diff --git a/core/src/main/kotlin/org/jetbrains/jewel/LazyTree.kt b/core/src/main/kotlin/org/jetbrains/jewel/LazyTree.kt index caba4a076..d582ffd74 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/LazyTree.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/LazyTree.kt @@ -1,17 +1,16 @@ package org.jetbrains.jewel -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.res.ResourceLoader import org.jetbrains.jewel.foundation.lazy.SelectableLazyItemScope import org.jetbrains.jewel.foundation.tree.BasicLazyTree import org.jetbrains.jewel.foundation.tree.DefaultTreeViewKeyActions -import org.jetbrains.jewel.foundation.tree.KeyBindingScopedActions +import org.jetbrains.jewel.foundation.tree.InitialNodeStatus +import org.jetbrains.jewel.foundation.tree.KeyBindingActions import org.jetbrains.jewel.foundation.tree.Tree import org.jetbrains.jewel.foundation.tree.TreeElementState import org.jetbrains.jewel.foundation.tree.TreeState @@ -22,20 +21,22 @@ import org.jetbrains.jewel.styling.LazyTreeStyle @Composable fun LazyTree( tree: Tree, + initialNodeStatus: InitialNodeStatus = InitialNodeStatus.Close(), resourceLoader: ResourceLoader, modifier: Modifier = Modifier, onElementClick: (Tree.Element) -> Unit, treeState: TreeState = rememberTreeState(), - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - onElementDoubleClick: (Tree.Element) -> Unit = { }, - keyActions: KeyBindingScopedActions = DefaultTreeViewKeyActions(treeState), + onElementDoubleClick: (Tree.Element) -> Unit = {}, + onSelectionChange: (List>) -> Unit = {}, + keyActions: KeyBindingActions = DefaultTreeViewKeyActions(treeState), style: LazyTreeStyle = IntelliJTheme.treeStyle, - nodeContent: @Composable SelectableLazyItemScope.(Tree.Element) -> Unit, + nodeContent: @Composable (SelectableLazyItemScope.(Tree.Element) -> Unit), ) { val colors = style.colors val metrics = style.metrics BasicLazyTree( tree = tree, + initialNodeStatus = initialNodeStatus, onElementClick = onElementClick, elementBackgroundFocused = colors.elementBackgroundFocused, elementBackgroundSelectedFocused = colors.elementBackgroundSelectedFocused, @@ -49,26 +50,25 @@ fun LazyTree( treeState = treeState, modifier = modifier, onElementDoubleClick = onElementDoubleClick, - interactionSource = interactionSource, + onSelectionChange = onSelectionChange, keyActions = keyActions, chevronContent = { elementState -> val painterProvider = style.icons.nodeChevron(elementState.isExpanded) val painter by painterProvider.getPainter(elementState, resourceLoader) Icon(painter = painter, contentDescription = null) }, - nodeContent = { - CompositionLocalProvider( - LocalContentColor provides ( - style.colors.contentFor( - TreeElementState.of( - isFocused, - isSelected, - false, - ), - ).value - .takeOrElse { LocalContentColor.current } + ) { + CompositionLocalProvider( + LocalContentColor provides ( + style.colors.contentFor( + TreeElementState.of( + focused = isActive, + selected = isSelected, + expanded = false, ), - ) { nodeContent(it) } - }, - ) + ).value + .takeOrElse { LocalContentColor.current } + ), + ) { nodeContent(it) } + } } diff --git a/core/src/main/kotlin/org/jetbrains/jewel/Scrollbars.kt b/core/src/main/kotlin/org/jetbrains/jewel/Scrollbars.kt index b2dd2e993..e6e9b8131 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/Scrollbars.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/Scrollbars.kt @@ -89,7 +89,7 @@ fun TabStripHorizontalScrollbar( style: ScrollbarStyle = IntelliJTheme.scrollbarStyle, ) { val shape by remember { mutableStateOf(RoundedCornerShape(style.metrics.thumbCornerSize)) } - val hoverDurationMillis by remember { mutableStateOf(style.hoverDuration.toInt(DurationUnit.MILLISECONDS)) } + val hoverDurationMillis by remember { mutableStateOf(style.hoverDuration.inWholeMilliseconds.toInt()) } CompositionLocalProvider( LocalScrollbarStyle provides ComposeScrollbarStyle( diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableColumnOnKeyEvent.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableColumnOnKeyEvent.kt index e748ac3a0..fc28c5f29 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableColumnOnKeyEvent.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableColumnOnKeyEvent.kt @@ -1,5 +1,6 @@ package org.jetbrains.jewel.foundation.lazy +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListKey.Selectable import kotlin.math.max import kotlin.math.min @@ -10,226 +11,205 @@ interface SelectableColumnOnKeyEvent { /** * Select First Node */ - suspend fun onSelectFirstItem() + fun onSelectFirstItem(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 + } + } /** * Extend Selection to First Node inherited from Move Caret to Text Start with Selection */ - suspend fun onExtendSelectionToFirst(currentIndex: Int) + fun onExtendSelectionToFirst(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) } + } + } + } /** * Select Last Node inherited from Move Caret to Text End */ - suspend fun onSelectLastItem() + fun onSelectLastItem(keys: List, state: SelectableLazyListState) { + keys.withIndex() + .lastOrNull { it.value is Selectable } + ?.let { + state.selectedKeys = listOf(it) + state.lastActiveItemIndex = it.index + } + } /** * Extend Selection to Last Node inherited from Move Caret to Text End with Selection */ - suspend fun onExtendSelectionToLastItem(currentIndex: Int) + fun onExtendSelectionToLastItem(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 + } + state.selectedKeys = list + } + } /** * Select Previous Node inherited from Up */ - suspend fun onSelectPreviousItem(currentIndex: Int) + fun onSelectPreviousItem(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 + } + } + } /** * Extend Selection with Previous Node inherited from Up with Selection */ - suspend fun onExtendSelectionWithPreviousItem(currentIndex: Int) + fun onExtendSelectionWithPreviousItem(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 + } + } + } /** * Select Next Node inherited from Down */ - suspend fun onSelectNextItem(currentIndex: Int) + fun onSelectNextItem(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 + } + } + } /** * Extend Selection with Next Node inherited from Down with Selection */ - suspend fun onExtendSelectionWithNextItem(currentIndex: Int) + fun onExtendSelectionWithNextItem(keys: List, 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 + } + } + } /** * Scroll Page Up and Select Node inherited from Page Up */ - suspend fun onScrollPageUpAndSelectItem(currentIndex: Int) + fun onScrollPageUpAndSelectItem(keys: List, state: SelectableLazyListState) { + val visibleSize = state.layoutInfo.visibleItemsInfo.size + val targetIndex = max((state.lastActiveItemIndex ?: 0) - visibleSize, 0) + state.selectedKeys = listOf(keys[targetIndex].key) + state.lastActiveItemIndex = targetIndex + } /** * Scroll Page Up and Extend Selection inherited from Page Up with Selection */ - suspend fun onScrollPageUpAndExtendSelection(currentIndex: Int) + fun onScrollPageUpAndExtendSelection(keys: List, state: SelectableLazyListState) { + val visibleSize = state.layoutInfo.visibleItemsInfo.size + val targetIndex = max((state.lastActiveItemIndex ?: 0) - visibleSize, 0) + val newSelectionList = + keys.subList(targetIndex, (state.lastActiveItemIndex ?: 0)) + .withIndex() + .filter { it.value is Selectable } + .let { + state.selectedKeys + it.map { selectableKey -> selectableKey.value.key } + } + state.selectedKeys = newSelectionList + state.lastActiveItemIndex = targetIndex + } /** * Scroll Page Down and Select Node inherited from Page Down */ - suspend fun onScrollPageDownAndSelectItem(currentIndex: Int) + fun onScrollPageDownAndSelectItem(keys: List, state: SelectableLazyListState) { + val visibleSize = state.layoutInfo.visibleItemsInfo.size + val targetIndex = min((state.lastActiveItemIndex ?: 0) + visibleSize, keys.lastIndex) + state.selectedKeys = listOf(keys[targetIndex].key) + state.lastActiveItemIndex = targetIndex + } /** * Scroll Page Down and Extend Selection inherited from Page Down with Selection */ - suspend fun onScrollPageDownAndExtendSelection(currentIndex: Int) + fun onScrollPageDownAndExtendSelection(keys: List, state: SelectableLazyListState) { + val visibleSize = state.layoutInfo.visibleItemsInfo.size + val targetIndex = min((state.lastActiveItemIndex ?: 0) + visibleSize, keys.lastIndex) + val newSelectionList = + keys.subList(state.lastActiveItemIndex ?: 0, targetIndex) + .filter { it is Selectable } + .let { + state.selectedKeys + it.map { selectableKey -> selectableKey.key } + } + state.selectedKeys = newSelectionList + state.lastActiveItemIndex = targetIndex + } /** * Edit In Item */ - suspend fun onEdit(currentIndex: Int) + fun onEdit() { + // ij with this shortcut just focus the first element with issue + // unavailable here + } } open class DefaultSelectableOnKeyEvent( override val keybindings: SelectableColumnKeybindings, - private val selectableState: SelectableLazyListState, ) : SelectableColumnOnKeyEvent { - override suspend fun onSelectFirstItem() { - val firstSelectable = selectableState.keys.indexOfFirst { it.selectable } - if (firstSelectable >= 0) selectableState.selectSingleItem(firstSelectable) - } - - override suspend fun onExtendSelectionToFirst(currentIndex: Int) { - if (selectableState.keys.isNotEmpty()) { - buildList { - for (i in currentIndex downTo 0) { - if (selectableState.keys[i].selectable) add(i) - } - }.let { - selectableState.addElementsToSelection(it, it.last()) - } - } - } - - override suspend fun onSelectLastItem() { - val lastSelectable = selectableState.keys.indexOfLast { it.selectable } - if (lastSelectable >= 0) selectableState.selectSingleItem(lastSelectable) - } - - override suspend fun onExtendSelectionToLastItem(currentIndex: Int) { - if (selectableState.keys.isNotEmpty()) { - val lastKey = selectableState.keys.lastIndex - val keys = buildList { - for (i in currentIndex..lastKey) { - if (selectableState.keys[i].selectable) { - add(element = i) - } - } - } - selectableState.addElementsToSelection(keys) - } - } - - override suspend fun onSelectPreviousItem(currentIndex: Int) { - if (currentIndex - 1 >= 0) { - for (i in currentIndex - 1 downTo 0) { - if (selectableState.keys[i].selectable) { - selectableState.selectSingleItem(i) - break - } - } - } - } - - override suspend fun onExtendSelectionWithPreviousItem(currentIndex: Int) { - if (currentIndex - 1 >= 0) { - val prevIndex = selectableState.indexOfPreviousSelectable(currentIndex) ?: return - if (selectableState.lastKeyEventUsedMouse) { - selectableState.selectedIdsMap.contains(selectableState.keys[currentIndex]) - if (selectableState.selectedIdsMap.contains(selectableState.keys[prevIndex])) { - selectableState.selectedIdsMap.remove(selectableState.keys[currentIndex]) - selectableState.focusItem(prevIndex, animateScroll = false, 0) - } else { - selectableState.addElementToSelection(prevIndex) - } - } else { - selectableState.deselectAll() - selectableState.addElementsToSelection( - listOf( - currentIndex, - prevIndex, - ), - ) - selectableState.lastKeyEventUsedMouse = true - } - } - } - - override suspend fun onSelectNextItem(currentIndex: Int) { - selectableState.indexOfNextSelectable(currentIndex)?.let { - selectableState.selectSingleItem(it) - } - } - - override suspend fun onExtendSelectionWithNextItem(currentIndex: Int) { - val nextSelectableIndex = selectableState.indexOfNextSelectable(currentIndex) - if (nextSelectableIndex != null) { - if (selectableState.lastKeyEventUsedMouse) { - if (selectableState.selectedIdsMap.contains(selectableState.keys[nextSelectableIndex])) { - selectableState.selectedIdsMap.remove(selectableState.keys[currentIndex]) - selectableState.focusItem(nextSelectableIndex, false, 0) - } else { - selectableState.addElementToSelection(nextSelectableIndex) - } - } else { - selectableState.deselectAll() - selectableState.addElementsToSelection( - listOf( - currentIndex, - nextSelectableIndex, - ), - ) - selectableState.lastKeyEventUsedMouse = true - } - } - } - - override suspend fun onScrollPageUpAndSelectItem(currentIndex: Int) { - val visibleSize = selectableState.layoutInfo.visibleItemsInfo.size - val targetIndex = max(currentIndex - visibleSize, 0) - if (!selectableState.keys[targetIndex].selectable) { - selectableState.indexOfPreviousSelectable(currentIndex) ?: selectableState.indexOfNextSelectable(currentIndex)?.let { - selectableState.selectSingleItem(it) - } - } else { - selectableState.selectSingleItem(targetIndex) - } - } - - override suspend fun onScrollPageUpAndExtendSelection(currentIndex: Int) { - val visibleSize = selectableState.layoutInfo.visibleItemsInfo.size - val targetIndex = max(currentIndex - visibleSize, 0) - val indexList = - selectableState.keys.subList(targetIndex, currentIndex) - .withIndex() - .filter { it.value.selectable } - .map { currentIndex - it.index } - .filter { it >= 0 } - selectableState.addElementsToSelection(indexList, targetIndex) - } - - override suspend fun onScrollPageDownAndSelectItem(currentIndex: Int) { - val firstVisible = selectableState.firstVisibleItemIndex - val visibleSize = selectableState.layoutInfo.visibleItemsInfo.size - val targetIndex = min(firstVisible + visibleSize, selectableState.keys.lastIndex) - if (!selectableState.keys[targetIndex].selectable) { - selectableState.indexOfNextSelectable(currentIndex) ?: selectableState.indexOfPreviousSelectable(currentIndex)?.let { - selectableState.selectSingleItem(it) - } - } else { - selectableState.selectSingleItem(targetIndex) - } - } - - override suspend fun onScrollPageDownAndExtendSelection(currentIndex: Int) { - val visibleSize = selectableState.layoutInfo.visibleItemsInfo.size - val targetIndex = min(currentIndex + visibleSize, selectableState.keys.lastIndex) - val indexList = - selectableState.keys.subList(currentIndex, targetIndex) - .withIndex() - .filter { it.value.selectable } - .map { currentIndex + it.index } - .filter { it <= selectableState.keys.lastIndex } - .toList() - selectableState.addElementsToSelection(indexList) - } - - override suspend fun onEdit(currentIndex: Int) { - // ij with this shortcut just focus the first element with issue - // unavailable here - } + companion object : DefaultSelectableOnKeyEvent(DefaultSelectableColumnKeybindings) } diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt index fa926ca6e..a98a4360f 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt @@ -5,93 +5,84 @@ import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListScopeContainer.Entry +import org.jetbrains.jewel.foundation.tree.DefaultSelectableLazyColumnEventAction import org.jetbrains.jewel.foundation.tree.DefaultSelectableLazyColumnKeyActions -import org.jetbrains.jewel.foundation.tree.DefaultSelectableLazyColumnPointerEventAction -import org.jetbrains.jewel.foundation.tree.KeyBindingScopedActions -import org.jetbrains.jewel.foundation.tree.PointerEventScopedActions -import java.util.UUID +import org.jetbrains.jewel.foundation.tree.KeyBindingActions +import org.jetbrains.jewel.foundation.tree.PointerEventActions /** * A composable that displays a scrollable and selectable list of items in a column arrangement. * - * @param modifier The modifier to apply to this layout. - * @param state The state object that holds the state information for the selectable lazy column. - * @param contentPadding The padding to be applied to the content of the column. - * @param reverseLayout Whether the items should be laid out in reverse order. - * @param verticalArrangement The vertical arrangement strategy for laying out the items. - * @param horizontalAlignment The horizontal alignment strategy for laying out the items. - * @param flingBehavior The fling behavior for scrolling. - * @param interactionSource The interaction source for handling user input events. - * @param keyActions The key binding actions for handling keyboard events. - * @param pointerHandlingScopedActions The pointer event actions for handling pointer events. - * @param content The content of the selectable lazy column, specified as a lambda function - * with a [SelectableLazyListScope] receiver. */ @Composable fun SelectableLazyColumn( modifier: Modifier = Modifier, - state: SelectableLazyListState = rememberSelectableLazyListState(selectionMode = SelectionMode.Multiple), + selectionMode: SelectionMode = SelectionMode.Multiple, + state: SelectableLazyListState = rememberSelectableLazyListState(), contentPadding: PaddingValues = PaddingValues(0.dp), reverseLayout: Boolean = false, + onSelectedIndexesChanged: (List) -> Unit = {}, verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, horizontalAlignment: Alignment.Horizontal = Alignment.Start, flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + keyActions: KeyBindingActions = DefaultSelectableLazyColumnKeyActions(), + pointerEventActions: PointerEventActions = DefaultSelectableLazyColumnEventAction(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - keyActions: KeyBindingScopedActions = DefaultSelectableLazyColumnKeyActions(state), - pointerHandlingScopedActions: PointerEventScopedActions = DefaultSelectableLazyColumnPointerEventAction(state), content: SelectableLazyListScope.() -> Unit, ) { - DisposableEffect(keyActions) { - state.attachKeybindings(keyActions) - onDispose { } + val scope = rememberCoroutineScope() + + val container = remember(content) { + SelectableLazyListScopeContainer().apply(content) + } + + val keys = remember(container) { + container.getKeys() + } + + var isActive by remember { mutableStateOf(false) } + + remember(state.selectedKeys) { + onSelectedIndexesChanged(state.selectedKeys.map { selected -> keys.indexOfFirst { it.key == selected } }) } - BaseSelectableLazyColumn( - modifier = modifier, - state = state, - contentPadding = contentPadding, - reverseLayout = reverseLayout, - verticalArrangement = verticalArrangement, - horizontalAlignment = horizontalAlignment, - flingBehavior = flingBehavior, - interactionSource = interactionSource, - keyActions = keyActions.handleOnKeyEvent(rememberCoroutineScope()), - pointerHandlingScopedActions = pointerHandlingScopedActions, - content = content, - ) -} -@Composable -internal fun BaseSelectableLazyColumn( - modifier: Modifier = Modifier, - state: SelectableLazyListState = rememberSelectableLazyListState(), - contentPadding: PaddingValues = PaddingValues(0.dp), - reverseLayout: Boolean = false, - verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, - horizontalAlignment: Alignment.Horizontal = Alignment.Start, - flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - keyActions: KeyEvent.(Int) -> Boolean = { false }, - pointerHandlingScopedActions: PointerEventScopedActions, - content: SelectableLazyListScope.() -> Unit, -) { - val uuid = remember { UUID.randomUUID().toString() } - state.checkUUID(uuid) LazyColumn( modifier = modifier - .onPreviewKeyEvent { event -> state.lastFocusedIndex?.let { keyActions.invoke(event, it) } ?: false } - .focusable(interactionSource = interactionSource), + .focusable(interactionSource = interactionSource) + .onFocusChanged { + isActive = it.hasFocus + } + .onPreviewKeyEvent { event -> + state.lastActiveItemIndex?.let { _ -> + keyActions.handleOnKeyEvent(event, keys, state, selectionMode).invoke(event) + scope.launch { state.lastActiveItemIndex?.let { state.scrollToItem(it) } } + } ?: run { + val index = keys.indexOfFirst { it is SelectableLazyListKey.Selectable } + if (index >= 0) { + state.lastActiveItemIndex = index + state.selectedKeys = state.selectedKeys.toMutableList().also { it.add(keys[index].key) } + } + } + true + }, state = state.lazyListState, contentPadding = contentPadding, reverseLayout = reverseLayout, @@ -99,18 +90,102 @@ internal fun BaseSelectableLazyColumn( horizontalAlignment = horizontalAlignment, flingBehavior = flingBehavior, ) { - state.clearKeys() - SelectableLazyListScopeContainer(state, pointerHandlingScopedActions).apply(content) + container.getEntries().forEach { entry -> + when (entry) { + is Entry.Item -> item(entry.key, entry.contentType) { + val itemScope = SelectableLazyItemScope( + isSelected = entry.key in state.selectedKeys, + isActive = isActive, + ) + if (keys.any { it.key == entry.key && it is SelectableLazyListKey.Selectable }) { + Box( + modifier = Modifier.selectable( + keybindings = keyActions.keybindings, + actionHandler = pointerEventActions, + selectionMode = selectionMode, + selectableState = state, + allKeys = keys, + itemKey = entry.key, + ), + ) { + entry.content.invoke(itemScope) + } + } else { + entry.content.invoke(itemScope) + } + } + + is Entry.Items -> items( + entry.count, + { entry.key(it) }, + { entry.contentType(it) }, + ) { index -> + val itemScope = SelectableLazyItemScope(entry.key(index) in state.selectedKeys, isActive) + if (keys.any { it.key == entry.key(index) && it is SelectableLazyListKey.Selectable }) { + Box( + modifier = Modifier.selectable( + keybindings = keyActions.keybindings, + actionHandler = pointerEventActions, + selectionMode = selectionMode, + selectableState = state, + allKeys = keys, + itemKey = entry.key(index), + ), + ) { + entry.itemContent.invoke(itemScope, index) + } + } else { + entry.itemContent.invoke(itemScope, index) + } + } + + is Entry.StickyHeader -> stickyHeader(entry.key, entry.contentType) { + val itemScope = SelectableLazyItemScope(entry.key in state.selectedKeys, isActive) + if (keys.any { it.key == entry.key && it is SelectableLazyListKey.Selectable }) { + Box( + modifier = Modifier.selectable( + keybindings = keyActions.keybindings, + actionHandler = pointerEventActions, + selectionMode = selectionMode, + selectableState = state, + allKeys = keys, + itemKey = entry.key, + ), + ) { + entry.content.invoke(itemScope) + } + } else { + SelectableLazyItemScope(entry.key in state.selectedKeys, isActive).apply { + entry.content.invoke(itemScope) + } + } + } + } + } } } -/** - * Creates a container for a selectable lazy list scope within a lazy list scope. - * - * @param state The state object for the selectable lazy list. - * @param pointerHandlingScopedActions The pointer event scoped actions for handling pointer events. - * - * @return A [SelectableLazyListScopeContainer] object that encapsulates the selectable lazy list scope. - */ -internal fun LazyListScope.SelectableLazyListScopeContainer(state: SelectableLazyListState, pointerHandlingScopedActions: PointerEventScopedActions) = - SelectableLazyListScopeContainer(this, state, pointerHandlingScopedActions) +private fun Modifier.selectable( + keybindings: SelectableColumnKeybindings, + actionHandler: PointerEventActions, + selectionMode: SelectionMode, + selectableState: SelectableLazyListState, + allKeys: List, + itemKey: Any, +) = this.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + when (event.type) { + PointerEventType.Press -> actionHandler.handlePointerEventPress( + event, + keybindings, + selectableState, + selectionMode, + allKeys, + itemKey, + ) + } + } + } +} diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListScope.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListScope.kt index 6477617b1..d004e240d 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListScope.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListScope.kt @@ -1,23 +1,10 @@ package org.jetbrains.jewel.foundation.lazy import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import kotlinx.coroutines.CoroutineScope -import org.jetbrains.jewel.foundation.tree.PointerEventScopedActions -import org.jetbrains.jewel.foundation.utils.Log +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListKey.NotSelectable +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListKey.Selectable /** * Interface defining the scope for building a selectable lazy list. @@ -25,203 +12,136 @@ import org.jetbrains.jewel.foundation.utils.Log interface SelectableLazyListScope { /** - * Adds an item to the selectable lazy list. + * Represents an item in a selectable lazy list. * - * @param key The key that uniquely identifies the item. - * @param contentType The content type of the item. - * @param focusable Whether the item is focusable or not. - * @param selectable Whether the item is selectable or not. - * @param content The content of the item, specified as a lambda function with a [SelectableLazyItemScope] receiver. + * @param key The unique identifier for the item. + * @param contentType The type of content displayed in the item. + * @param selectable Determines if the item is selectable. Default is `true`. + * @param content The content of the item as a composable function. */ fun item( key: Any, contentType: Any? = null, - focusable: Boolean = true, selectable: Boolean = true, content: @Composable SelectableLazyItemScope.() -> Unit, ) /** - * Adds multiple items to the selectable lazy list. + * Represents a list of items based on the provided parameters. * - * @param count The number of items to add. - * @param key A lambda function that provides the key for each item based on the index. - * @param contentType A lambda function that provides the content type for each item based on the index. - * @param focusable A lambda function that determines whether each item is focusable based on the index. - * @param selectable A lambda function that determines whether each item is selectable based on the index. - * @param itemContent The content of each item, specified as a lambda function with a [SelectableLazyItemScope] receiver and an index parameter. + * @param count The number of items in the list. + * @param key A function that generates a unique key for each item based on its index. + * @param contentType A function that returns the content type of an item based on its index. Defaults to `null`. + * @param selectable A function that determines if an item is selectable based on its index. Defaults to `true`. + * @param itemContent The content of each individual item, specified as a composable function that takes the item's index as a parameter. */ fun items( count: Int, key: (index: Int) -> Any, contentType: (index: Int) -> Any? = { null }, - focusable: (index: Int) -> Boolean = { true }, selectable: (index: Int) -> Boolean = { true }, itemContent: @Composable SelectableLazyItemScope.(index: Int) -> Unit, ) /** - * Adds a sticky header to the selectable lazy list. + * A method that enables sticky header behavior in a list or grid view. * - * @param key The key that uniquely identifies the sticky header. - * @param contentType The content type of the sticky header. - * @param focusable Whether the sticky header is focusable or not. - * @param selectable Whether the sticky header is selectable or not. - * @param content The content of the sticky header, specified as a lambda function with a [SelectableLazyItemScope] receiver. + * @param key The unique identifier for the sticky header. + * @param contentType The type of content in the sticky header. + * @param selectable Specifies whether the sticky header is selectable. + * @param content The content to be displayed in the sticky header, provided as a composable function */ fun stickyHeader( key: Any, contentType: Any? = null, - focusable: Boolean = false, selectable: Boolean = false, content: @Composable SelectableLazyItemScope.() -> Unit, ) } -internal class SelectableLazyListScopeContainer( - private val delegate: LazyListScope, - private val state: SelectableLazyListState, - private val pointerEventScopedActions: PointerEventScopedActions, -) : SelectableLazyListScope { +internal class SelectableLazyListScopeContainer : SelectableLazyListScope { - @Composable - private fun Modifier.focusable(key: SelectableKey.Focusable, isFocused: Boolean) = - focusRequester(key.focusRequester) - .onFocusChanged { - if (it.hasFocus) { - state.lastFocusedKeyState.value = SelectableLazyListState.LastFocusedKeyContainer.Set(key) - state.lastFocusedIndexState.value = state.keys.indexOf(key) - } - } - .focusable() - .pointerInput(key) { - awaitPointerEventScope { - while (true) { - awaitFirstDown(false) - if (!isFocused) { - key.focusRequester - .runCatching { requestFocus() }.onSuccess { - Log.d("focus requested with success on single item-> ${state.keys.indexOf(key)}") - }.onFailure { - Log.d("focus requested with failure on single item-> ${state.keys.indexOf(key)}") - } - } - } - } - } + private var entriesCount = 0 - @Composable - private fun Modifier.selectable(selectableKey: SelectableKey, scope: CoroutineScope = rememberCoroutineScope()) = - onPointerEvent(PointerEventType.Press) { - pointerEventScopedActions.handlePointerEventPress(it, state.keybindings, scope, selectableKey.key) - } + private val keys = mutableListOf() + private val entries = mutableListOf() + fun getEntries() = entries.toList() + fun getKeys() = keys.toList() + + internal sealed interface Entry { + data class Item( + val key: Any, + val contentType: Any?, + val content: @Composable (SelectableLazyItemScope.() -> Unit), + val index: Int, + ) : Entry + data class Items( + val count: Int, + val key: (index: Int) -> Any, + val contentType: (index: Int) -> Any?, + val itemContent: @Composable (SelectableLazyItemScope.(index: Int) -> Unit), + val startIndex: Int, + ) : Entry + data class StickyHeader( + val key: Any, + val contentType: Any?, + val content: @Composable (SelectableLazyItemScope.() -> Unit), + val index: Int, + ) : Entry + } override fun item( key: Any, contentType: Any?, - focusable: Boolean, selectable: Boolean, - content: @Composable SelectableLazyItemScope.() -> Unit, - ) { - val focusRequester = FocusRequester() - val selectableKey = if (focusable) { - SelectableKey.Focusable(focusRequester, key, selectable) - } else { - SelectableKey.NotFocusable(key, selectable) - } - state.attachKey(selectableKey) - delegate.item(selectableKey, contentType) { - singleItem(selectableKey, key, selectable, focusable, content) - } - } - - @Composable - private fun LazyItemScope.singleItem( - selectableKey: SelectableKey, - key: Any, - selectable: Boolean, - focusable: Boolean, content: @Composable (SelectableLazyItemScope.() -> Unit), ) { - val isFocused = state.lastFocusedIndex == state.keys.indexOf(selectableKey) - val isSelected = key in state.selectedIdsMap - val scope = rememberCoroutineScope() - Box( - Modifier - .then(if (selectable) Modifier.selectable(selectableKey, scope) else Modifier) - .then(if (focusable) Modifier.focusable(selectableKey as SelectableKey.Focusable, isFocused) else Modifier), - ) { - content(SelectableLazyItemScope(isSelected, isFocused)) - } + keys.add(if (selectable) Selectable(key) else NotSelectable(key)) + entries.add(Entry.Item(key, contentType, content, entriesCount)) + entriesCount++ } override fun items( count: Int, key: (index: Int) -> Any, contentType: (index: Int) -> Any?, - focusable: (index: Int) -> Boolean, selectable: (index: Int) -> Boolean, - itemContent: @Composable SelectableLazyItemScope.(index: Int) -> Unit, + itemContent: @Composable (SelectableLazyItemScope.(index: Int) -> Unit), ) { - val totalItems = state.keys.size - val selectableKeys: List = List(count) { - if (focusable(it)) { - SelectableKey.Focusable(FocusRequester(), key(it), selectable(it)) + val selectableKeys: List = List(count) { + if (selectable(it)) { + Selectable(key(it)) } else { - SelectableKey.NotFocusable( - key(it), - selectable(it), - ) + NotSelectable(key(it)) } } - state.attachKeys(selectableKeys) - Log.w("there are ${state.keys.size} keys") - Log.w(state.keys.map { it.key }.joinToString("\n")) - delegate.items( - count = count, - key = { selectableKeys[it] }, - itemContent = { index -> - if (selectableKeys[index] in state.selectedIdsMap) Log.e("i'm the element with index $index and i'm selected! ") - val isFocused = state.lastFocusedIndex == totalItems + index - val isSelected = selectableKeys[index] in state.selectedIdsMap - Box( - Modifier - .then(if (selectable(index)) Modifier.selectable(selectableKeys[index]) else Modifier) - .then(if (focusable(index)) Modifier.focusable(selectableKeys[index] as SelectableKey.Focusable, isFocused) else Modifier), - ) { - itemContent(SelectableLazyItemScope(isFocused, isSelected), index) - } - }, - ) + keys.addAll(selectableKeys) + entries.add(Entry.Items(count, key, contentType, itemContent, entriesCount)) + entriesCount = entriesCount + count } @ExperimentalFoundationApi override fun stickyHeader( key: Any, contentType: Any?, - focusable: Boolean, selectable: Boolean, - content: @Composable SelectableLazyItemScope.() -> Unit, + content: @Composable (SelectableLazyItemScope.() -> Unit), ) { - val focusRequester = FocusRequester() - val selectableKey = if (focusable) { - SelectableKey.Focusable(focusRequester, key, selectable) - } else { - SelectableKey.NotFocusable(key, selectable) - } - state.attachKey(selectableKey) - delegate.stickyHeader(selectableKey, contentType) { - singleItem(selectableKey, key, selectable, focusable, content) - } + keys.add(if (selectable) Selectable(key) else NotSelectable(key)) + entries.add(Entry.StickyHeader(key, contentType, content, entriesCount)) + entriesCount++ } } @Composable -fun LazyItemScope.SelectableLazyItemScope(isFocused: Boolean = false, isSelected: Boolean = false): SelectableLazyItemScope = - SelectableLazyItemScopeDelegate(this, isFocused, isSelected) +fun LazyItemScope.SelectableLazyItemScope( + isSelected: Boolean = false, + isActive: Boolean = false, +): SelectableLazyItemScope = + SelectableLazyItemScopeDelegate(this, isSelected, isActive) internal class SelectableLazyItemScopeDelegate( private val delegate: LazyItemScope, - override val isFocused: Boolean, override val isSelected: Boolean, + override val isActive: Boolean, ) : SelectableLazyItemScope, LazyItemScope by delegate diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListState.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListState.kt index 74ee19d78..706df272f 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListState.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListState.kt @@ -5,16 +5,11 @@ import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.focus.FocusRequester -import org.jetbrains.jewel.foundation.tree.KeyBindingScopedActions import kotlin.math.max -import kotlin.properties.Delegates @Suppress("unused") val LazyListState.visibleItemsRange @@ -27,108 +22,46 @@ val SelectableLazyListState.visibleItemsRange * State object for a selectable lazy list, which extends [ScrollableState]. * * @param lazyListState The state object for the underlying lazy list. - * @param selectionMode The selection mode for the list. */ class SelectableLazyListState( val lazyListState: LazyListState, - val selectionMode: SelectionMode = SelectionMode.None, ) : ScrollableState by lazyListState { - private val isMultiSelectionAllowed = selectionMode == SelectionMode.Multiple - internal var lastKeyEventUsedMouse: Boolean = false - internal val selectedIdsMap = mutableStateMapOf() - - internal val keys: List - get() = internalKeys - - private val internalKeys = mutableListOf() - - val selectedItemIndexes get() = selectedIdsMap.values.toList() - var lastSelectedIndex by mutableStateOf(null) - private var uuid: String? = null - - internal fun checkUUID(uuid: String) { - if (this.uuid == null) { - this.uuid = uuid - } else { - require(this.uuid == this.uuid) { - "Do not attach the same ${this::class.simpleName} to different SelectableLazyColumns." - } - } - } - - internal fun attachKeys(keys: List) { - internalKeys.removeAll(keys) - internalKeys.addAll(keys) - updateKeysIndexes() - } - internal fun attachKey(key: SelectableKey) { - internalKeys.remove(key) - internalKeys.add(key) - updateKeysIndexes() - } - - internal fun clearKeys() { - internalKeys.clear() - } - - internal fun updateKeysIndexes() { - keys.forEachIndexed { index, key -> - selectedIdsMap.computeIfPresent(key) { _, _ -> index } - } - } - - var keybindings: SelectableColumnKeybindings by Delegates.notNull() - internal fun attachKeybindings(keybindings: KeyBindingScopedActions) { - this.keybindings = keybindings.keybindings - } + var selectedKeys by mutableStateOf(emptyList()) + internal var lastActiveItemIndex: Int? = null /** - * Focuses on the item at the specified index within the lazy list. * * @param itemIndex The index of the item to focus on. * @param animateScroll Whether to animate the scroll to the focused item. * @param scrollOffset The scroll offset for the focused item. * @param skipScroll Whether to skip the scroll to the focused item. */ - suspend fun focusItem(itemIndex: Int, animateScroll: Boolean = false, scrollOffset: Int = 0, skipScroll: Boolean = false) { + suspend fun scrollToItem( + itemIndex: Int, + animateScroll: Boolean = false, + scrollOffset: Int = 0, + skipScroll: Boolean = false, + ) { val visibleRange = visibleItemsRange.drop(2).dropLast(4) if (!skipScroll && itemIndex !in visibleRange && visibleRange.isNotEmpty()) { when { - itemIndex < visibleRange.first() -> lazyListState.scrollToItem(max(0, itemIndex - 2), animateScroll, scrollOffset) + itemIndex < visibleRange.first() -> lazyListState.scrollToItem( + max(0, itemIndex - 2), + animateScroll, + scrollOffset, + ) + itemIndex > visibleRange.last() -> { lazyListState.scrollToItem(max(itemIndex - (visibleRange.size + 1), 0), animateScroll, 0) } } } - lastFocusedIndexState.value = itemIndex - focusVisibleItem(itemIndex) + lastActiveItemIndex = itemIndex } - private fun focusVisibleItem(itemIndex: Int) { - layoutInfo.visibleItemsInfo - .find { it.index == itemIndex } - ?.key - ?.let { it as? SelectableKey.Focusable } - ?.focusRequester - ?.runCatching { requestFocus() } // another recomposition may be launched, so we are not interested to focus item while we are recomposing - } - - internal sealed interface LastFocusedKeyContainer { - object NotSet : LastFocusedKeyContainer - - @JvmInline - value class Set(val key: Any?) : LastFocusedKeyContainer - } - - internal val lastFocusedKeyState: MutableState = mutableStateOf(LastFocusedKeyContainer.NotSet) - internal val lastFocusedIndexState: MutableState = mutableStateOf(null) - - val lastFocusedIndex - get() = lastFocusedIndexState.value - val layoutInfo get() = lazyListState.layoutInfo @@ -153,150 +86,150 @@ class SelectableLazyListState( get() = lazyListState.interactionSource // selection handling - fun indexOfNextSelectable(currentIndex: Int): Int? { - if (currentIndex + 1 > keys.lastIndex) return null - for (i in currentIndex + 1..keys.lastIndex) { - if (keys[i].selectable) return i - } - return null - } - - fun indexOfPreviousSelectable(currentIndex: Int): Int? { - if (currentIndex - 1 < 0) return null - for (i in currentIndex - 1 downTo 0) { - if (keys[i].selectable) return i - } - return null - } - - /** - * Selects a single item at the specified index within the lazy list. - * - * @param itemIndex The index of the item to select. - * @param changeFocus Whether to change the focus to the selected item. - * @param skipScroll Whether to skip the scroll to the selected item. - */ - suspend fun selectSingleItem(itemIndex: Int, changeFocus: Boolean = true, skipScroll: Boolean = false) { - if (changeFocus) focusItem(itemIndex, skipScroll = skipScroll) - selectedIdsMap.clear() - selectedIdsMap[keys[itemIndex]] = itemIndex - lastSelectedIndex = itemIndex - } - - /** - * Selects a single item with the specified key within the lazy list. - * - * @param key The key of the item to select. - * @param changeFocus Whether to change the focus to the selected item. - * @param skipScroll Whether to skip the scroll to the selected item. - */ - suspend fun selectSingleKey(key: Any, changeFocus: Boolean = true, skipScroll: Boolean = false) { - val index = keys.indexOfFirst { it.key == key } - if (index >= 0 && keys[index].selectable) selectSingleItem(index, changeFocus, skipScroll = skipScroll) - lastSelectedIndex = index - } - - suspend fun deselectSingleElement(itemIndex: Int, changeFocus: Boolean = true, skipScroll: Boolean = false) { - if (changeFocus) focusItem(itemIndex, skipScroll = skipScroll) - selectedIdsMap.remove(keys[itemIndex]) - } - - suspend fun toggleSelection(itemIndex: Int, skipScroll: Boolean = false) { - if (selectionMode == SelectionMode.None) return - selectedIdsMap[keys[itemIndex]]?.let { - deselectSingleElement(itemIndex) - } ?: if (!isMultiSelectionAllowed) { - selectSingleItem(itemIndex, skipScroll = skipScroll) - } else { - addElementToSelection(itemIndex, skipScroll = skipScroll) - } - } - - suspend fun toggleSelectionKey(key: Any, skipScroll: Boolean = false) { - if (selectionMode == SelectionMode.None) return - val index = keys.indexOfFirst { it.key == key } - if (index > 0 && keys[index].selectable) toggleSelection(index, skipScroll = skipScroll) - lastSelectedIndex = index - } - - suspend fun onExtendSelectionToIndex(itemIndex: Int, changeFocus: Boolean = true, skipScroll: Boolean = false) { - if (selectionMode == SelectionMode.None) return - if (!isMultiSelectionAllowed) { - selectSingleItem(itemIndex, skipScroll = skipScroll) - } else { - val lastFocussed = lastSelectedIndex ?: itemIndex - val indexInterval = if (itemIndex > lastFocussed) { - lastFocussed..itemIndex - } else { - lastFocussed downTo itemIndex - } - addElementsToSelection(indexInterval.toList()) - if (changeFocus) focusItem(itemIndex, skipScroll = skipScroll) - } - } - - @Suppress("unused") - internal fun addKeyToSelectionMap(keyIndex: Int) { - if (selectionMode == SelectionMode.None) return - if (keys[keyIndex].selectable) { - selectedIdsMap[keys[keyIndex]] = keyIndex - } - } - - suspend fun addElementToSelection(itemIndex: Int, changeFocus: Boolean = true, skipScroll: Boolean = false) { - if (selectionMode == SelectionMode.None) return - if (!isMultiSelectionAllowed) { - selectSingleItem(itemIndex, false) - } else { - selectedIdsMap[keys[itemIndex]] = itemIndex - } - if (changeFocus) focusItem(itemIndex, skipScroll = skipScroll) - lastSelectedIndex = itemIndex - } - - fun deselectAll() { - if (selectionMode == SelectionMode.None) return - selectedIdsMap.clear() - lastSelectedIndex = null - } - - suspend fun addElementsToSelection(itemIndexes: List, itemToFocus: Int? = itemIndexes.lastOrNull()) { - if (selectionMode == SelectionMode.None) return - if (!isMultiSelectionAllowed) { - itemIndexes.lastOrNull()?.let { selectSingleItem(it) } - } else { - itemIndexes.forEach { - selectedIdsMap[keys[it]] = it - } - itemToFocus?.let { focusItem(it) } - lastSelectedIndex = itemIndexes.lastOrNull() - } - } - - @Suppress("unused") - suspend fun removeElementsToSelection(itemIndexes: List, itemToFocus: Int? = itemIndexes.lastOrNull()) { - if (selectionMode == SelectionMode.None) return - itemIndexes.forEach { - selectedIdsMap.remove(keys[it]) - } - itemToFocus?.let { focusItem(it) } - } - - @Suppress("Unused") - suspend fun toggleElementsToSelection(itemIndexes: List, itemToFocus: Int? = itemIndexes.lastOrNull()) { - if (selectionMode == SelectionMode.None) return - if (!isMultiSelectionAllowed) { - toggleSelection(itemIndexes.last()) - } else { - itemIndexes.forEach { index -> - selectedIdsMap[keys[index]]?.let { - selectedIdsMap.remove(keys[index]) - } ?: { selectedIdsMap[keys[index]] = index } - } - itemToFocus?.let { focusItem(it) } - lastSelectedIndex = itemIndexes.lastOrNull() - } - } +// fun indexOfNextSelectable(currentIndex: Int): Int? { +// if (currentIndex + 1 > internalKeys.lastIndex) return null +// for (i in currentIndex + 1..internalKeys.lastIndex) { // todo iterate with instanceOF +// if (internalKeys[i] is Key.Selectable) return i +// } +// return null +// } +// +// fun indexOfPreviousSelectable(currentIndex: Int): Int? { +// if (currentIndex - 1 < 0) return null +// for (i in currentIndex - 1 downTo 0) { +// if (internalKeys[i] is Key.Selectable) return i +// } +// return null +// } +// +// /** +// * Selects a single item at the specified index within the lazy list. +// * +// * @param itemIndex The index of the item to select. +// * @param changeFocus Whether to change the focus to the selected item. +// * @param skipScroll Whether to skip the scroll to the selected item. +// */ +// suspend fun selectSingleItem(itemIndex: Int, changeFocus: Boolean = true, skipScroll: Boolean = false) { +// if (changeFocus) scrollToItem(itemIndex, skipScroll = skipScroll) +// selectedIdsMap.clear() +// selectedIdsMap[keys[itemIndex]] = itemIndex +// lastSelectedIndex = itemIndex +// } +// +// /** +// * Selects a single item with the specified key within the lazy list. +// * +// * @param key The key of the item to select. +// * @param changeFocus Whether to change the focus to the selected item. +// * @param skipScroll Whether to skip the scroll to the selected item. +// */ +// suspend fun selectSingleKey(key: Any, changeFocus: Boolean = true, skipScroll: Boolean = false) { +// val index = internalKeys.indexOfFirst { it.key == key } +// if (index >= 0 && internalKeys[index] is Key.Selectable) selectSingleItem(index, changeFocus, skipScroll = skipScroll) +// lastSelectedIndex = index +// } +// +// suspend fun deselectSingleElement(itemIndex: Int, changeFocus: Boolean = true, skipScroll: Boolean = false) { +// if (changeFocus) scrollToItem(itemIndex, skipScroll = skipScroll) +// selectedIdsMap.remove(keys[itemIndex]) +// } +// +// suspend fun toggleSelection(itemIndex: Int, skipScroll: Boolean = false) { +// if (selectionMode == SelectionMode.None) return +// selectedIdsMap[keys[itemIndex]]?.let { +// deselectSingleElement(itemIndex) +// } ?: if (!isMultiSelectionAllowed) { +// selectSingleItem(itemIndex, skipScroll = skipScroll) +// } else { +// addElementToSelection(itemIndex, skipScroll = skipScroll) +// } +// } +// +// suspend fun toggleSelectionKey(key: Any, skipScroll: Boolean = false) { +// if (selectionMode == SelectionMode.None) return +// val index = internalKeys.indexOfFirst { it.key == key } +// if (index > 0 && internalKeys[index] is Key.Selectable) toggleSelection(index, skipScroll = skipScroll) +// lastSelectedIndex = index +// } +// +// suspend fun onExtendSelectionToIndex(itemIndex: Int, changeFocus: Boolean = true, skipScroll: Boolean = false) { +// if (selectionMode == SelectionMode.None) return +// if (!isMultiSelectionAllowed) { +// selectSingleItem(itemIndex, skipScroll = skipScroll) +// } else { +// val lastFocussed = lastSelectedIndex ?: itemIndex +// val indexInterval = if (itemIndex > lastFocussed) { +// lastFocussed..itemIndex +// } else { +// lastFocussed downTo itemIndex +// } +// addElementsToSelection(indexInterval.toList()) +// if (changeFocus) scrollToItem(itemIndex, skipScroll = skipScroll) +// } +// } +// +// @Suppress("unused") +// internal fun addKeyToSelectionMap(keyIndex: Int) { +// if (selectionMode == SelectionMode.None) return +// if (internalKeys[keyIndex] is Key.Selectable) { +// selectedIdsMap[keys[keyIndex]] = keyIndex +// } +// } +// +// suspend fun addElementToSelection(itemIndex: Int, changeFocus: Boolean = true, skipScroll: Boolean = false) { +// if (selectionMode == SelectionMode.None) return +// if (!isMultiSelectionAllowed) { +// selectSingleItem(itemIndex, false) +// } else { +// selectedIdsMap[keys[itemIndex]] = itemIndex +// } +// if (changeFocus) scrollToItem(itemIndex, skipScroll = skipScroll) +// lastSelectedIndex = itemIndex +// } +// +// fun deselectAll() { +// if (selectionMode == SelectionMode.None) return +// selectedIdsMap.clear() +// lastSelectedIndex = null +// } +// +// suspend fun addElementsToSelection(itemIndexes: List, itemToFocus: Int? = itemIndexes.lastOrNull()) { +// if (selectionMode == SelectionMode.None) return +// if (!isMultiSelectionAllowed) { +// itemIndexes.lastOrNull()?.let { selectSingleItem(it) } +// } else { +// itemIndexes.forEach { +// selectedIdsMap[keys[it]] = it +// } +// itemToFocus?.let { scrollToItem(it) } +// lastSelectedIndex = itemIndexes.lastOrNull() +// } +// } +// +// @Suppress("unused") +// suspend fun removeElementsToSelection(itemIndexes: List, itemToFocus: Int? = itemIndexes.lastOrNull()) { +// if (selectionMode == SelectionMode.None) return +// itemIndexes.forEach { +// selectedIdsMap.remove(keys[it]) +// } +// itemToFocus?.let { scrollToItem(it) } +// } +// +// @Suppress("Unused") +// suspend fun toggleElementsToSelection(itemIndexes: List, itemToFocus: Int? = itemIndexes.lastOrNull()) { +// if (selectionMode == SelectionMode.None) return +// if (!isMultiSelectionAllowed) { +// toggleSelection(itemIndexes.last()) +// } else { +// itemIndexes.forEach { index -> +// selectedIdsMap[keys[index]]?.let { +// selectedIdsMap.remove(keys[index]) +// } ?: { selectedIdsMap[keys[index]] = index } +// } +// itemToFocus?.let { scrollToItem(it) } +// lastSelectedIndex = itemIndexes.lastOrNull() +// } +// } } private suspend fun LazyListState.scrollToItem(index: Int, animate: Boolean, scrollOffset: Int = 0) { @@ -310,7 +243,7 @@ private suspend fun LazyListState.scrollToItem(index: Int, animate: Boolean, scr /** * Represents a selectable key used in a selectable lazy list. */ -internal sealed class SelectableKey { +sealed class SelectableLazyListKey { /** * The key associated with the item. @@ -318,39 +251,28 @@ internal sealed class SelectableKey { abstract val key: Any /** - * Determines if the item is selectable. - */ - abstract val selectable: Boolean - - /** - * Represents a focusable item key. + * Represents a selectable item key. * - * @param focusRequester The focus requester for the item. * @param key The key associated with the item. - * @param selectable Whether the item is selectable. */ - internal class Focusable( - internal val focusRequester: FocusRequester, + class Selectable( override val key: Any, - override val selectable: Boolean, - ) : SelectableKey() + ) : SelectableLazyListKey() /** - * Represents a non-focusable item key. + * Represents a non-selectable item key. * * @param key The key associated with the item. - * @param selectable Whether the item is selectable. */ - internal class NotFocusable( + class NotSelectable( override val key: Any, - override val selectable: Boolean, - ) : SelectableKey() + ) : SelectableLazyListKey() override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - other as SelectableKey + other as SelectableLazyListKey return key == other.key } @@ -361,7 +283,7 @@ internal sealed class SelectableKey { interface SelectableLazyItemScope : LazyItemScope { val isSelected: Boolean - val isFocused: Boolean + val isActive: Boolean } /** @@ -391,12 +313,17 @@ enum class SelectionMode { * * @param firstVisibleItemIndex The index of the first visible item. * @param firstVisibleItemScrollOffset The scroll offset of the first visible item. - * @param selectionMode The selection mode for the list. * @return The remembered state of the selectable lazy list. */ @Composable fun rememberSelectableLazyListState( firstVisibleItemIndex: Int = 0, firstVisibleItemScrollOffset: Int = 0, - selectionMode: SelectionMode = SelectionMode.Multiple, -) = remember { SelectableLazyListState(LazyListState(firstVisibleItemIndex, firstVisibleItemScrollOffset), selectionMode) } +) = remember { + SelectableLazyListState( + LazyListState( + firstVisibleItemIndex, + firstVisibleItemScrollOffset, + ), + ) +} diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/BasicLazyTree.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/BasicLazyTree.kt index 31feeffac..f460d8661 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/BasicLazyTree.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/BasicLazyTree.kt @@ -2,7 +2,6 @@ package org.jetbrains.jewel.foundation.tree import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -15,17 +14,14 @@ import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -40,36 +36,46 @@ import org.jetbrains.jewel.InteractiveComponentState import org.jetbrains.jewel.SelectableComponentState import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn import org.jetbrains.jewel.foundation.lazy.SelectableLazyItemScope +import org.jetbrains.jewel.foundation.lazy.SelectionMode import org.jetbrains.jewel.foundation.utils.Log import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds /** - * A composable that displays a tree-like structure of elements in a hierarchical manner. + * Renders a lazy tree view based on the provided tree data structure. * - * @param modifier The modifier to apply to this layout. - * @param tree The tree structure to be displayed. - * @param treeState The state object that holds the state information for the tree view. - * @param onElementClick The callback to be invoked when an element is clicked. - * @param onElementDoubleClick The callback to be invoked when an element is double-clicked. - * @param interactionSource The interaction source for handling user input events. - * @param indentSize The depth of indentation for nested elements in the tree view, in density-independent pixels. - * @param elementBackgroundFocused The background color to be applied to the focused, but not selected, element. - * @param elementBackgroundSelectedFocused The background color to be applied to the focused and selected element. - * @param elementBackgroundSelected The background color to be applied to the selected, but not focused, element. - * @param platformDoubleClickDelay The delay duration in milliseconds for registering a double-click event based on the platform's behavior. - * @param keyActions The key binding actions for handling keyboard events. - * @param pointerEventScopedActions The pointer event actions for handling pointer events. - * @param chevronContent The content to be displayed for the expand/collapse arrow of each tree node - * @param nodeContent The content to be displayed for each tree element - * with a [SelectableLazyItemScope] receiver and a `Tree.Element` parameter. + * @param tree The tree structure to be rendered. + * @param initialNodeStatus The initial status of the tree nodes (opened or closed). + * @param selectionMode The selection mode for the tree nodes. + * @param onElementClick Callback function triggered when a tree node is clicked. + * @param elementBackgroundFocused The background color of a tree node when focused. + * @param elementBackgroundSelectedFocused The background color of a selected tree node when focused. + * @param elementBackgroundSelected The background color of a selected tree node. + * @param indentSize The size of the indent for each level of the tree node. + * @param elementBackgroundCornerSize The corner size of the background shape of a tree node. + * @param elementPadding The padding for the entire tree node. + * @param elementContentPadding The padding for the content within a tree node. + * @param elementMinHeight The minimum height of a tree node. + * @param chevronContentGap The gap between the chevron icon and the node content. + * @param treeState The state object for managing the tree view state. + * @param modifier Optional modifier for styling or positioning the tree view. + * @param onElementDoubleClick Callback function triggered when a tree node is double-clicked. + * @param onSelectionChange Callback function triggered when the selected tree nodes change. + * @param platformDoubleClickDelay The duration between two consecutive clicks to be considered a double-click. + * @param keyActions The key binding actions for the tree view. + * @param pointerEventScopedActions The pointer event actions for the tree view. + * @param chevronContent The composable function responsible for rendering the chevron icon. + * @param nodeContent The composable function responsible for rendering the content of a tree node. * - * @param T The type of data held by each tree element. + * @suppress("UNCHECKED_CAST") + * @Composable */ @Suppress("UNCHECKED_CAST") @Composable fun BasicLazyTree( tree: Tree, + initialNodeStatus: InitialNodeStatus, + selectionMode: SelectionMode = SelectionMode.Multiple, onElementClick: (Tree.Element) -> Unit, elementBackgroundFocused: Color, elementBackgroundSelectedFocused: Color, @@ -82,94 +88,111 @@ fun BasicLazyTree( chevronContentGap: Dp, treeState: TreeState = rememberTreeState(), modifier: Modifier = Modifier, - onElementDoubleClick: (Tree.Element) -> Unit = { }, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onElementDoubleClick: (Tree.Element) -> Unit, + onSelectionChange: (List>) -> Unit, platformDoubleClickDelay: Duration = 500.milliseconds, - keyActions: KeyBindingScopedActions = DefaultTreeViewKeyActions(treeState), - pointerEventScopedActions: PointerEventScopedActions = remember { + keyActions: KeyBindingActions = DefaultTreeViewKeyActions(treeState), + pointerEventScopedActions: PointerEventActions = remember { DefaultTreeViewPointerEventAction(treeState) }, chevronContent: @Composable (nodeState: TreeElementState) -> Unit, - nodeContent: @Composable SelectableLazyItemScope.(Tree.Element) -> Unit, + nodeContent: @Composable (SelectableLazyItemScope.(Tree.Element) -> Unit), ) { - var flattenedTree by remember { mutableStateOf(emptyList>()) } + val scope = rememberCoroutineScope() - LaunchedEffect(tree, treeState.openNodes.size) { - // refresh flattenTree - flattenedTree = tree.roots.flatMap { flattenTree(it, treeState.openNodes, treeState.allNodes) } - treeState.delegate.updateKeysIndexes() + val flattenedTree = remember(tree, treeState.openNodes.size) { + val flattenTree = tree.roots.flatMap { flattenTree(it, treeState.openNodes, treeState.allNodes) } + flattenTree } - val scope = rememberCoroutineScope() - var isTreeFocused by remember { mutableStateOf(false) } + remember(tree) { // if tree changes we need to update selection changes + flattenedTree.filter { + it.idPath() in treeState.delegate.selectedKeys + }.let { + onSelectionChange(it.map { element -> element as Tree.Element }) + } + } + + remember(tree) { + if (initialNodeStatus is InitialNodeStatus.Open) { + treeState.openNodes.clear() + treeState.openNodes.addAll(treeState.allNodes.map { it.first }) + } + } SelectableLazyColumn( - modifier.focusable().onFocusChanged { isTreeFocused = it.hasFocus }, + modifier = modifier, state = treeState.delegate, + selectionMode = selectionMode, keyActions = keyActions, - interactionSource = interactionSource, - pointerHandlingScopedActions = pointerEventScopedActions, - ) { - items( - count = flattenedTree.size, - key = { - val idPath = flattenedTree[it].idPath() - idPath - }, - contentType = { flattenedTree[it].data }, - ) { itemIndex -> - val element = flattenedTree[itemIndex] - val elementState = TreeElementState.of( - focused = isFocused, - selected = isSelected, - expanded = (element as? Tree.Element.Node)?.let { it.idPath() in treeState.openNodes } ?: false, - ) + pointerEventActions = pointerEventScopedActions, + onSelectedIndexesChanged = { + onSelectionChange(it.map { element -> flattenedTree[element] as Tree.Element }) + }, + content = { + items( + count = flattenedTree.size, + key = { + val idPath = flattenedTree[it].idPath() + idPath + }, + contentType = { flattenedTree[it].data }, + ) { itemIndex -> + val element = flattenedTree[itemIndex] + val elementState = TreeElementState.of( + active = isActive, + selected = isSelected, + expanded = (element as? Tree.Element.Node)?.let { it.idPath() in treeState.openNodes } ?: false, + ) - val backgroundShape by remember { mutableStateOf(RoundedCornerShape(elementBackgroundCornerSize)) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.defaultMinSize(minHeight = elementMinHeight) - .padding(elementPadding) - .elementBackground( - elementState, - elementBackgroundSelectedFocused, - elementBackgroundFocused, - elementBackgroundSelected, - backgroundShape, - ) - .padding(elementContentPadding) - .padding(start = (element.depth * indentSize.value).dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - (pointerEventScopedActions as? DefaultTreeViewPointerEventAction)?.notifyItemClicked( - item = flattenedTree[itemIndex] as Tree.Element, - scope = scope, - doubleClickTimeDelayMillis = platformDoubleClickDelay.inWholeMilliseconds, - onElementClick = onElementClick, - onElementDoubleClick = onElementDoubleClick, + val backgroundShape by remember { mutableStateOf(RoundedCornerShape(elementBackgroundCornerSize)) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.defaultMinSize(minHeight = elementMinHeight) + .padding(elementPadding) + .elementBackground( + state = elementState, + selectedFocused = elementBackgroundSelectedFocused, + focused = elementBackgroundFocused, + selected = elementBackgroundSelected, + backgroundShape = backgroundShape, ) - }, - ) { - if (element is Tree.Element.Node) { - Box( - modifier = Modifier.clickable( + .padding(elementContentPadding) + .padding(start = (element.depth * indentSize.value).dp) + .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { - treeState.toggleNode(element.idPath()) - onElementDoubleClick(element as Tree.Element) + (pointerEventScopedActions as? DefaultTreeViewPointerEventAction)?.notifyItemClicked( + item = flattenedTree[itemIndex] as Tree.Element, + scope = scope, + doubleClickTimeDelayMillis = platformDoubleClickDelay.inWholeMilliseconds, + onElementClick = onElementClick, + onElementDoubleClick = onElementDoubleClick, + ) + treeState.delegate.lastActiveItemIndex = itemIndex }, - ) { - chevronContent(elementState) + ) { + if (element is Tree.Element.Node) { + Box( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + treeState.toggleNode(element.idPath()) + onElementDoubleClick(element as Tree.Element) + }, + ) { + chevronContent(elementState) + } + Spacer(Modifier.width(chevronContentGap)) } - Spacer(Modifier.width(chevronContentGap)) + nodeContent(element as Tree.Element) } - nodeContent(element as Tree.Element) } - } - } + }, + interactionSource = remember { MutableInteractionSource() }, + ) } private fun Modifier.elementBackground( @@ -181,9 +204,9 @@ private fun Modifier.elementBackground( ) = background( color = when { - state.isFocused && state.isSelected -> selectedFocused - state.isFocused && !state.isSelected -> focused - state.isSelected && !state.isFocused -> selected + state.isActive && state.isSelected -> selectedFocused + state.isActive && !state.isSelected -> focused + state.isSelected && !state.isActive -> selected else -> Color.Unspecified }, shape = backgroundShape, @@ -272,12 +295,12 @@ value class TreeElementState(val state: ULong) : InteractiveComponentState, Sele private fun flattenTree( element: Tree.Element<*>, openNodes: SnapshotStateList, - allNodes: SnapshotStateList, + allNodes: SnapshotStateList>, ): MutableList> { val orderedChildren = mutableListOf>() when (element) { is Tree.Element.Node<*> -> { - if (element.idPath() !in allNodes) allNodes.add(element.idPath()) + if (element.idPath() !in allNodes.map { it.first }) allNodes.add(element.idPath() to element.depth) orderedChildren.add(element) if (element.idPath() !in openNodes) { return orderedChildren.also { @@ -313,3 +336,11 @@ private infix fun MutableList.getAllSubNodes(node: Tree.Element.Node<*>) { this@getAllSubNodes getAllSubNodes (it) } } + +sealed class InitialNodeStatus { + @Stable + object Open : InitialNodeStatus() + + @Stable + class Close : InitialNodeStatus() +} diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/DefaultTreeViewOnKeyEvent.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/DefaultTreeViewOnKeyEvent.kt index d53666e74..be23f6538 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/DefaultTreeViewOnKeyEvent.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/DefaultTreeViewOnKeyEvent.kt @@ -1,154 +1,75 @@ package org.jetbrains.jewel.foundation.tree -import org.jetbrains.jewel.foundation.utils.Log -import kotlin.math.max -import kotlin.math.min +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListKey +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState open class DefaultTreeViewOnKeyEvent( override val keybindings: TreeViewKeybindings, private val treeState: TreeState, - private val animate: Boolean = false, - private val scrollOffset: Int = 0, ) : TreeViewOnKeyEvent { - override suspend fun onSelectFirstItem() { - Log.e(treeState.toString()) - if (treeState.delegate.keys.isNotEmpty()) treeState.selectSingleElement(0) - } + override fun onSelectParent(keys: List, state: SelectableLazyListState) { + val currentKey = keys[state.lastActiveItemIndex ?: 0].key + val keyNodeList = treeState.allNodes.map { it.first } - override suspend fun onExtendSelectionToFirst(currentIndex: Int) { - if (treeState.delegate.keys.isNotEmpty()) { - treeState.addElementsToSelection((0..currentIndex).toList().reversed()) - } - } - - override suspend fun onSelectLastItem() { - treeState.delegate.keys.lastIndex.takeIf { it >= 0 }?.let { - treeState.selectSingleElement(it) + if (currentKey !in keyNodeList) { + handleLeafCase(keys, currentKey, keyNodeList, state) + } else { + handleNodeCase(currentKey, keys, state) } } - override suspend fun onExtendSelectionToLastItem(currentIndex: Int) { - if (treeState.delegate.keys.isNotEmpty()) { - treeState.addElementsToSelection((currentIndex..treeState.delegate.keys.lastIndex).toList()) + private fun handleNodeCase( + currentKey: Any, + keys: List, + state: SelectableLazyListState, + ) { + if (treeState.openNodes.contains(currentKey)) { + treeState.toggleNode(currentKey) + return } - } - - override suspend fun onSelectPreviousItem(currentIndex: Int) { - if (treeState.delegate.keys.getOrNull(currentIndex - 1) == null) return - - treeState.selectSingleElement(currentIndex - 1) - } - - override suspend fun onExtendSelectionWithPreviousItem(currentIndex: Int) { - val prevIndex = currentIndex - 1 - - if (treeState.delegate.keys.isNotEmpty() && prevIndex >= 0) { - if (treeState.lastKeyEventUsedMouse) { - if (treeState.delegate.selectedItemIndexes.contains(prevIndex)) { - // we are are changing direction so we needs just deselect the current element - treeState.deselectElement(currentIndex, false) - } else { - treeState.addElementToSelection(prevIndex, false) + treeState.allNodes.first { it.first == currentKey }.let { currentNode -> + treeState.allNodes + .subList(0, treeState.allNodes.indexOf(currentNode)) + .reversed() + .firstOrNull { it.second < currentNode.second } + ?.let { (parentNodeKey, _) -> + keys.first { it.key == parentNodeKey } + .takeIf { it is SelectableLazyListKey.Selectable } + ?.let { + state.lastActiveItemIndex = + keys.indexOfFirst { selectableKey -> selectableKey.key == parentNodeKey } + state.selectedKeys = listOf(parentNodeKey) + } } - } else { - treeState.deselectAll() - treeState.addElementsToSelection( - listOf( - currentIndex, - prevIndex, - ), - null, - ) - treeState.lastKeyEventUsedMouse = true - } } - if (prevIndex >= 0) treeState.delegate.focusItem(prevIndex) } - override suspend fun onSelectNextItem(currentIndex: Int) { - if (treeState.delegate.keys.size > currentIndex + 1) { - treeState.selectSingleElement(currentIndex + 1) - } - } - - override suspend fun onExtendSelectionWithNextItem(currentIndex: Int) { - val nextFlattenIndex = currentIndex + 1 - - if (treeState.delegate.keys.isNotEmpty() && nextFlattenIndex <= treeState.delegate.keys.lastIndex) { - if (treeState.lastKeyEventUsedMouse) { - if (treeState.delegate.selectedItemIndexes.contains(nextFlattenIndex)) { - // we are are changing direction so we needs just deselect the current element - treeState.deselectElement(currentIndex) - } else { - treeState.addElementToSelection(nextFlattenIndex, false) + private fun handleLeafCase( + keys: List, + currentKey: Any, + keyNodeList: List, + state: SelectableLazyListState, + ) { + val index = keys.indexOf(currentKey) + if (index < 0) return + for (i in index downTo 0) { + if (keys[i].key in keyNodeList) { + if (keys[i] is SelectableLazyListKey.Selectable) { + state.lastActiveItemIndex = i + state.selectedKeys = listOf(keys[i].key) } - } else { - treeState.deselectAll() - treeState.addElementsToSelection( - listOf( - currentIndex, - nextFlattenIndex, - ), - null, - ) - treeState.lastKeyEventUsedMouse = true + break } - treeState.delegate.focusItem(nextFlattenIndex) } } - override suspend fun onSelectParent(flattenedIndex: Int) { - val currentKey = treeState.delegate.keys[flattenedIndex].key - - if (currentKey in treeState.allNodes && currentKey in treeState.openNodes) { + override fun onSelectChild(keys: List, state: SelectableLazyListState) { + val currentKey = keys[state.lastActiveItemIndex ?: 0].key + if (currentKey in treeState.allNodes.map { it.first } && currentKey !in treeState.openNodes) { treeState.toggleNode(currentKey) } else { - onSelectPreviousItem(flattenedIndex) + super.onSelectNextItem(keys, state) } } - - override suspend fun onSelectChild(flattenedIndex: Int) { - val currentKey = treeState.delegate.keys[flattenedIndex].key - if (currentKey in treeState.allNodes && currentKey !in treeState.openNodes) { - treeState.toggleNode(currentKey) - } else { - onSelectNextItem(flattenedIndex) - } - } - - override suspend fun onScrollPageUpAndSelectItem(currentIndex: Int) { - val visibleSize = treeState.delegate.layoutInfo.visibleItemsInfo.size - val targetIndex = max(currentIndex - visibleSize, 0) - treeState.selectSingleElement(targetIndex) - } - - override suspend fun onScrollPageUpAndExtendSelection(currentIndex: Int) { - val visibleSize = treeState.delegate.layoutInfo.visibleItemsInfo.size - val targetIndex = max(currentIndex - visibleSize, 0) - for (i in targetIndex..currentIndex) treeState.addElementToSelection(i) - treeState.delegate.focusItem(targetIndex, animate, scrollOffset) - } - - override suspend fun onScrollPageDownAndSelectItem(currentIndex: Int) { - val firstVisible = treeState.delegate.firstVisibleItemIndex - val visibleSize = treeState.delegate.layoutInfo.visibleItemsInfo.size - val targetIndex = min(firstVisible + visibleSize, treeState.delegate.keys.lastIndex) - treeState.selectSingleElement(targetIndex) - } - - override suspend fun onScrollPageDownAndExtendSelection(currentIndex: Int) { - val firstVisible = treeState.delegate.firstVisibleItemIndex - val visibleSize = treeState.delegate.layoutInfo.visibleItemsInfo.size - val targetIndex = min(firstVisible + visibleSize, treeState.delegate.keys.lastIndex) - - treeState.addElementsToSelection((currentIndex..targetIndex).toList(), targetIndex) - - treeState.delegate.focusItem(targetIndex, animate, scrollOffset) - } - - override suspend fun onEdit(currentIndex: Int) { - // ij with this shortcut just focus the first element with issue - // unavailable here - } } diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/KeyBindingActions.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/KeyBindingActions.kt new file mode 100644 index 000000000..2364c00ad --- /dev/null +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/KeyBindingActions.kt @@ -0,0 +1,290 @@ +package org.jetbrains.jewel.foundation.tree + +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.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.isCtrlPressed +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.jewel.foundation.lazy.DefaultSelectableColumnKeybindings +import org.jetbrains.jewel.foundation.lazy.DefaultSelectableOnKeyEvent +import org.jetbrains.jewel.foundation.lazy.SelectableColumnKeybindings +import org.jetbrains.jewel.foundation.lazy.SelectableColumnOnKeyEvent +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListKey +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState +import org.jetbrains.jewel.foundation.lazy.SelectionMode +import org.jetbrains.jewel.foundation.utils.Log + +interface KeyBindingActions { + + val keybindings: SelectableColumnKeybindings + val actions: SelectableColumnOnKeyEvent + + fun handleOnKeyEvent( + event: KeyEvent, + keys: List, + state: SelectableLazyListState, + selectionMode: SelectionMode, + ): KeyEvent.() -> Boolean +} + +interface PointerEventActions { + + fun handlePointerEventPress( + pointerEvent: PointerEvent, + keyBindings: SelectableColumnKeybindings, + selectableLazyListState: SelectableLazyListState, + selectionMode: SelectionMode, + allKeys: List, + key: Any, + ) { + with(keyBindings) { + when { + pointerEvent.keyboardModifiers.isKeyboardMultiSelectionKeyPressed && pointerEvent.keyboardModifiers.isCtrlPressed -> { + Log.i("ctrl and shift pressed on click") + // do nothing + } + + pointerEvent.keyboardModifiers.isKeyboardMultiSelectionKeyPressed -> { + Log.i("shift pressed on click") + onExtendSelectionToKey(key, allKeys, selectableLazyListState, selectionMode) + } + + pointerEvent.keyboardModifiers.isCtrlPressed -> { + Log.i("ctrl pressed on click") + toggleKeySelection(key, allKeys, selectableLazyListState) + } + + else -> { + Log.i("single click") + selectableLazyListState.selectedKeys = listOf(key) + selectableLazyListState.lastActiveItemIndex = allKeys.indexOfFirst { it.key == key } + } + } + } + } + + fun toggleKeySelection( + key: Any, + allKeys: List, + selectableLazyListState: SelectableLazyListState, + ) { + if (selectableLazyListState.selectedKeys.contains(key)) { + selectableLazyListState.selectedKeys = + selectableLazyListState.selectedKeys.toMutableList().also { it.remove(key) } + } else { + selectableLazyListState.selectedKeys = + selectableLazyListState.selectedKeys.toMutableList().also { it.add(key) } + } + selectableLazyListState.lastActiveItemIndex = allKeys.indexOfFirst { it == key } + } + + fun onExtendSelectionToKey( + key: Any, + allKeys: List, + state: SelectableLazyListState, + selectionMode: SelectionMode, + ) { + if (selectionMode == SelectionMode.None) return + if (selectionMode == SelectionMode.Single) { + state.selectedKeys = listOf(key) + } else { + val currentIndex = allKeys.indexOfFirst { it.key == key }.coerceAtLeast(0) + val lastFocussed = state.lastActiveItemIndex ?: currentIndex + val indexInterval = if (currentIndex > lastFocussed) { + lastFocussed..currentIndex + } else { + lastFocussed downTo currentIndex + } + val keys = buildList { + for (i in indexInterval) { + val currentKey = allKeys[i] + if (currentKey is SelectableLazyListKey.Selectable && !state.selectedKeys.contains(allKeys[i].key)) { + add(currentKey.key) + } + } + } + state.selectedKeys = state.selectedKeys.toMutableList().also { it.addAll(keys) } + state.lastActiveItemIndex = allKeys.indexOfFirst { it.key == key } + } + } +} + +class DefaultSelectableLazyColumnEventAction : PointerEventActions + +class DefaultTreeViewPointerEventAction( + private val treeState: TreeState, +) : PointerEventActions { + + override fun handlePointerEventPress( + pointerEvent: PointerEvent, + keyBindings: SelectableColumnKeybindings, + selectableLazyListState: SelectableLazyListState, + selectionMode: SelectionMode, + allKeys: List, + key: Any, + ) { + with(keyBindings) { + when { + pointerEvent.keyboardModifiers.isKeyboardMultiSelectionKeyPressed && pointerEvent.keyboardModifiers.isCtrlPressed -> { + Log.t("ctrl and shift pressed on click") + } + + pointerEvent.keyboardModifiers.isKeyboardMultiSelectionKeyPressed -> { + super.onExtendSelectionToKey(key, allKeys, selectableLazyListState, selectionMode) + } + + pointerEvent.keyboardModifiers.isCtrlPressed -> { + Log.t("control pressed") + selectableLazyListState.lastKeyEventUsedMouse = false + super.toggleKeySelection(key, allKeys, selectableLazyListState) + } + + else -> { + selectableLazyListState.selectedKeys = listOf(key) + } + } + } + } + + // todo warning: move this away from here + // for item click that lose focus and fail to match if a operation is a double-click + private var elementClickedTmpHolder: List? = null + internal fun notifyItemClicked( + item: Tree.Element, + scope: CoroutineScope, + doubleClickTimeDelayMillis: Long, + onElementClick: (Tree.Element) -> Unit, + onElementDoubleClick: (Tree.Element) -> Unit, + ) { + if (elementClickedTmpHolder == item.idPath()) { + // is a double click + if (item is Tree.Element.Node) { + treeState.toggleNode(item.idPath()) + } + onElementDoubleClick(item) + elementClickedTmpHolder = null + Log.d("doubleClicked!") + } else { + elementClickedTmpHolder = item.idPath() + // is a single click + onElementClick(item) + scope.launch { + delay(doubleClickTimeDelayMillis) + if (elementClickedTmpHolder == item.idPath()) elementClickedTmpHolder = null + } + + Log.d("singleClicked!") + } + } +} + +class DefaultTreeViewKeyActions(treeState: TreeState) : DefaultSelectableLazyColumnKeyActions() { + + override val keybindings: TreeViewKeybindings = DefaultTreeViewKeybindings + override val actions: DefaultTreeViewOnKeyEvent = DefaultTreeViewOnKeyEvent(keybindings, treeState = treeState) + + override fun handleOnKeyEvent( + event: KeyEvent, + keys: List, + state: SelectableLazyListState, + selectionMode: SelectionMode, + ): KeyEvent.() -> Boolean = lambda@{ + if (type == KeyEventType.KeyUp) return@lambda false + val keyEvent = this + with(keybindings) { + with(actions) { + Log.d(keyEvent.key.keyCode.toString()) + if (selectionMode == SelectionMode.None) return@lambda false + when { + selectParent() ?: false -> onSelectParent(keys, state) + selectChild() ?: false -> onSelectChild(keys, state) + super.handleOnKeyEvent(event, keys, state, selectionMode) + .invoke(keyEvent) -> return@lambda true + + else -> return@lambda false + } + } + } + return@lambda true + } +} + +open class DefaultSelectableLazyColumnKeyActions : KeyBindingActions { + + override val keybindings: SelectableColumnKeybindings + get() = DefaultSelectableColumnKeybindings + + override val actions: SelectableColumnOnKeyEvent + get() = DefaultSelectableOnKeyEvent(keybindings) + + override fun handleOnKeyEvent( + event: KeyEvent, + keys: List, + state: SelectableLazyListState, + selectionMode: SelectionMode, + ): KeyEvent.() -> Boolean = + lambda@{ + if (type == KeyEventType.KeyUp || selectionMode == SelectionMode.None) return@lambda false + with(keybindings) { + with(actions) { + execute(keys, state, selectionMode) + } + } + } + + context(SelectableColumnKeybindings, SelectableColumnOnKeyEvent) + private fun KeyEvent.execute( + keys: List, + state: SelectableLazyListState, + selectionMode: SelectionMode, + ): Boolean { + when { + selectNextItem() ?: false -> { + onSelectNextItem(keys, state) + } + + selectPreviousItem() ?: false -> onSelectPreviousItem(keys, state) + selectFirstItem() ?: false -> onSelectFirstItem(keys, state) + selectLastItem() ?: false -> onSelectLastItem(keys, state) + edit() ?: false -> onEdit() + extendSelectionToFirstItem() ?: false -> { + if (selectionMode == SelectionMode.Multiple) onExtendSelectionToFirst(keys, state) + } + + extendSelectionToLastItem() ?: false -> { + if (selectionMode == SelectionMode.Multiple) onExtendSelectionToLastItem(keys, state) + } + + extendSelectionWithNextItem() ?: false -> { + if (selectionMode == SelectionMode.Multiple) onExtendSelectionWithNextItem(keys, state) + } + + extendSelectionWithPreviousItem() ?: false -> { + if (selectionMode == SelectionMode.Multiple) onExtendSelectionWithPreviousItem(keys, state) + } + + scrollPageDownAndExtendSelection() ?: false -> { + if (selectionMode == SelectionMode.Multiple) onScrollPageDownAndExtendSelection(keys, state) + } + + scrollPageDownAndSelectItem() ?: false -> { + if (selectionMode == SelectionMode.Multiple) onScrollPageDownAndSelectItem(keys, state) + } + + scrollPageUpAndExtendSelection() ?: false -> { + if (selectionMode == SelectionMode.Multiple) onScrollPageUpAndExtendSelection(keys, state) + } + + scrollPageUpAndSelectItem() ?: false -> { + if (selectionMode == SelectionMode.Multiple) onScrollPageUpAndSelectItem(keys, state) + } + + else -> return false + } + return true + } +} diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/KeyBindingScopedActions.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/KeyBindingScopedActions.kt deleted file mode 100644 index 6ea8bb388..000000000 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/KeyBindingScopedActions.kt +++ /dev/null @@ -1,213 +0,0 @@ -package org.jetbrains.jewel.foundation.tree - -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.input.pointer.PointerEvent -import androidx.compose.ui.input.pointer.isCtrlPressed -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.jetbrains.jewel.foundation.lazy.DefaultSelectableColumnKeybindings -import org.jetbrains.jewel.foundation.lazy.DefaultSelectableOnKeyEvent -import org.jetbrains.jewel.foundation.lazy.SelectableColumnKeybindings -import org.jetbrains.jewel.foundation.lazy.SelectableColumnOnKeyEvent -import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState -import org.jetbrains.jewel.foundation.utils.Log - -interface KeyBindingScopedActions { - - val keybindings: SelectableColumnKeybindings - val actions: SelectableColumnOnKeyEvent - - fun handleOnKeyEvent(coroutineScope: CoroutineScope): KeyEvent.(Int) -> Boolean -} - -interface PointerEventScopedActions { - - fun handlePointerEventPress( - pointerEvent: PointerEvent, - keyBindings: SelectableColumnKeybindings, - scope: CoroutineScope, - key: Any, - ) -} - -class DefaultSelectableLazyColumnPointerEventAction(private val state: SelectableLazyListState) : PointerEventScopedActions { - - override fun handlePointerEventPress( - pointerEvent: PointerEvent, - keyBindings: SelectableColumnKeybindings, - scope: CoroutineScope, - key: Any, - ) { - with(keyBindings) { - when { - pointerEvent.keyboardModifiers.isKeyboardMultiSelectionKeyPressed && pointerEvent.keyboardModifiers.isCtrlPressed -> { - Log.i("ctrl and shift pressed on click") - // do nothing - } - - pointerEvent.keyboardModifiers.isKeyboardMultiSelectionKeyPressed -> { - Log.i("shift pressed on click") - scope.launch { - state.onExtendSelectionToIndex(state.keys.indexOfFirst { it.key == key }, skipScroll = true) - } - } - - pointerEvent.keyboardModifiers.isCtrlPressed -> { - Log.i("ctrl pressed on click") - state.lastKeyEventUsedMouse = false - scope.launch { - state.toggleSelectionKey(key, skipScroll = true) - } - } - - else -> { - Log.i("single click") - scope.launch { - state.selectSingleKey(key, skipScroll = true) - } - } - } - } - } -} - -class DefaultTreeViewPointerEventAction( - private val treeState: TreeState, -) : PointerEventScopedActions { - - override fun handlePointerEventPress( - pointerEvent: PointerEvent, - keyBindings: SelectableColumnKeybindings, - scope: CoroutineScope, - key: Any, - ) { - with(keyBindings) { - when { - pointerEvent.keyboardModifiers.isKeyboardMultiSelectionKeyPressed && pointerEvent.keyboardModifiers.isCtrlPressed -> { - Log.t("ctrl and shift pressed on click") - } - - pointerEvent.keyboardModifiers.isKeyboardMultiSelectionKeyPressed -> { - Log.t("ShiftClicked ") - scope.launch { - treeState.delegate.onExtendSelectionToIndex(treeState.delegate.keys.indexOfFirst { it.key == key }) - } - } - - pointerEvent.keyboardModifiers.isCtrlPressed -> { - Log.t("control pressed") - treeState.lastKeyEventUsedMouse = false - scope.launch { - treeState.toggleElementSelection(treeState.delegate.keys.indexOfFirst { it.key == key }) - } - } - - else -> { - scope.launch { - treeState.delegate.selectSingleKey(key, skipScroll = true) - } - } - } - } - } - - // todo warning: this is ugly workaround - // for item click that lose focus and fail to match if a operation is a double-click - private var elementClickedTmpHolder: List? = null - internal fun notifyItemClicked( - item: Tree.Element, - scope: CoroutineScope, - doubleClickTimeDelayMillis: Long, - onElementClick: (Tree.Element) -> Unit, - onElementDoubleClick: (Tree.Element) -> Unit, - ) { - if (elementClickedTmpHolder == item.idPath()) { - // is a double click - if (item is Tree.Element.Node) { - treeState.toggleNode(item.idPath()) - } - onElementDoubleClick(item) - elementClickedTmpHolder = null - Log.d("doubleClicked!") - } else { - elementClickedTmpHolder = item.idPath() - // is a single click - onElementClick(item) - scope.launch { - delay(doubleClickTimeDelayMillis) - if (elementClickedTmpHolder == item.idPath()) elementClickedTmpHolder = null - } - - Log.d("singleClicked!") - } - } -} - -class DefaultTreeViewKeyActions(treeState: TreeState) : DefaultSelectableLazyColumnKeyActions(treeState.delegate) { - - override val keybindings: TreeViewKeybindings = DefaultTreeViewKeybindings - override val actions: DefaultTreeViewOnKeyEvent = DefaultTreeViewOnKeyEvent(keybindings, treeState = treeState) - - override fun handleOnKeyEvent(coroutineScope: CoroutineScope): KeyEvent.(Int) -> Boolean = lambda@{ focusedIndex -> - if (type == KeyEventType.KeyUp) return@lambda false - val keyEvent = this - with(keybindings) { - with(actions) { - Log.d(keyEvent.key.keyCode.toString()) - when { - selectParent() ?: false -> coroutineScope.launch { onSelectParent(focusedIndex) } - selectChild() ?: false -> coroutineScope.launch { onSelectChild(focusedIndex) } - super.handleOnKeyEvent(coroutineScope).invoke(keyEvent, focusedIndex) -> return@lambda true - else -> return@lambda false - } - } - } - return@lambda true - } -} - -open class DefaultSelectableLazyColumnKeyActions(val selectableState: SelectableLazyListState) : KeyBindingScopedActions { - - override val keybindings: SelectableColumnKeybindings - get() = DefaultSelectableColumnKeybindings - - override val actions: SelectableColumnOnKeyEvent - get() = DefaultSelectableOnKeyEvent(keybindings, selectableState) - - override fun handleOnKeyEvent(coroutineScope: CoroutineScope): KeyEvent.(Int) -> Boolean = - lambda@{ focusedIndex -> - if (type == KeyEventType.KeyUp) return@lambda false - with(keybindings) { - with(actions) { - with(coroutineScope) { - execute(focusedIndex) - } - } - } - } - - context(CoroutineScope, SelectableColumnKeybindings, SelectableColumnOnKeyEvent) - private fun KeyEvent.execute(focusedIndex: Int): Boolean { - when { - selectNextItem() ?: false -> launch { onSelectNextItem(focusedIndex) } - selectPreviousItem() ?: false -> launch { onSelectPreviousItem(focusedIndex) } - selectFirstItem() ?: false -> launch { onSelectFirstItem() } - selectLastItem() ?: false -> launch { onSelectLastItem() } - edit() ?: false -> launch { onEdit(focusedIndex) } - extendSelectionToFirstItem() ?: false -> launch { onExtendSelectionToFirst(focusedIndex) } - extendSelectionToLastItem() ?: false -> launch { onExtendSelectionToLastItem(focusedIndex) } - extendSelectionWithNextItem() ?: false -> launch { onExtendSelectionWithNextItem(focusedIndex) } - extendSelectionWithPreviousItem() ?: false -> launch { onExtendSelectionWithPreviousItem(focusedIndex) } - scrollPageDownAndExtendSelection() ?: false -> launch { onScrollPageDownAndExtendSelection(focusedIndex) } - scrollPageDownAndSelectItem() ?: false -> launch { onScrollPageDownAndSelectItem(focusedIndex) } - scrollPageUpAndExtendSelection() ?: false -> launch { onScrollPageUpAndExtendSelection(focusedIndex) } - scrollPageUpAndSelectItem() ?: false -> launch { onScrollPageUpAndSelectItem(focusedIndex) } - else -> return false - } - return true - } -} diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/Tree.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/Tree.kt index 84b8f5c2c..65b46f051 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/Tree.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/Tree.kt @@ -1,8 +1,6 @@ package org.jetbrains.jewel.foundation.tree -class Tree internal constructor(val roots: List>) : Iterable> { - - override fun iterator(): Iterator> = elementIterator(roots.firstOrNull()) { it.next } +class Tree internal constructor(internal val roots: List>) { sealed interface Element { diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/TreeState.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/TreeState.kt index 1dab02653..1846495c2 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/TreeState.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/TreeState.kt @@ -5,58 +5,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState -import org.jetbrains.jewel.foundation.lazy.SelectionMode import org.jetbrains.jewel.foundation.utils.Log @Composable -fun rememberTreeState(selectionMode: SelectionMode = SelectionMode.Single) = remember { - TreeState( - SelectableLazyListState( - LazyListState(), - selectionMode, - ), - ) +fun rememberTreeState() = remember { + TreeState(SelectableLazyListState(LazyListState())) } class TreeState( internal val delegate: SelectableLazyListState, ) { - val lazyListState get() = delegate.lazyListState - - val lastFocusedIndex get() = delegate.lastFocusedIndex - val selectedItemIndexes get() = delegate.selectedItemIndexes - val selectedKeys get() = delegate.selectedIdsMap.keys.map { it.key } - internal val allNodes = mutableStateListOf() + internal val allNodes = mutableStateListOf>() internal val openNodes = mutableStateListOf() - internal var lastKeyEventUsedMouse = false - - suspend fun selectSingleElement(elementIndex: Int, changeFocus: Boolean = true): Boolean { - delegate.selectSingleItem(elementIndex, changeFocus) - return true - } - - suspend fun addElementsToSelection(indexList: List, itemToFocus: Int? = indexList.lastOrNull()) { - delegate.addElementsToSelection(indexList, itemToFocus) - } - - suspend fun addElementToSelection(elementIndex: Int, changeFocus: Boolean = true) { - delegate.addElementToSelection(elementIndex, changeFocus) - } - - suspend fun toggleElementSelection(flattenIndex: Int) { - delegate.toggleSelection(flattenIndex) - } - - suspend fun deselectElement(itemIndex: Int, changeFocus: Boolean = true) { - delegate.deselectSingleElement(itemIndex, changeFocus) - } - - fun deselectAll() { - delegate.deselectAll() - } - fun toggleNode(nodeId: Any) { Log.d("toggleNode $nodeId") if (nodeId in openNodes) { diff --git a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/TreeViewOnKeyEvent.kt b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/TreeViewOnKeyEvent.kt index e60169ff7..56eb3912e 100644 --- a/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/TreeViewOnKeyEvent.kt +++ b/core/src/main/kotlin/org/jetbrains/jewel/foundation/tree/TreeViewOnKeyEvent.kt @@ -1,16 +1,18 @@ package org.jetbrains.jewel.foundation.tree import org.jetbrains.jewel.foundation.lazy.SelectableColumnOnKeyEvent +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListKey +import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState interface TreeViewOnKeyEvent : SelectableColumnOnKeyEvent { /** * Select Parent Node */ - suspend fun onSelectParent(flattenedIndex: Int) + fun onSelectParent(keys: List, state: SelectableLazyListState) /** * Select Child Node inherited from Right */ - suspend fun onSelectChild(flattenedIndex: Int) + fun onSelectChild(keys: List, state: SelectableLazyListState) } diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/components/ChipsAndTree.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/components/ChipsAndTree.kt index bd6a1d426..0f8b33e1c 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/components/ChipsAndTree.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/components/ChipsAndTree.kt @@ -1,5 +1,9 @@ package org.jetbrains.jewel.samples.standalone.components +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +18,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.jetbrains.jewel.Chip import org.jetbrains.jewel.GroupHeader @@ -22,6 +27,8 @@ import org.jetbrains.jewel.LocalResourceLoader import org.jetbrains.jewel.RadioButtonChip import org.jetbrains.jewel.Text import org.jetbrains.jewel.ToggleableChip +import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn +import org.jetbrains.jewel.foundation.tree.InitialNodeStatus import org.jetbrains.jewel.foundation.tree.buildTree @Composable @@ -35,9 +42,50 @@ fun ChipsAndTree() { GroupHeader("Tree", modifier = Modifier.width(300.dp)) TreeSample() } + + Column { + GroupHeader("SelectableLazyColumn", modifier = Modifier.width(300.dp)) + SelectableLazyColumnSample() + } } } +@Composable +fun SelectableLazyColumnSample() { + val listOfItems = remember { + (5000..10000).random().let { size -> + List(size) { "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 + }, + ).clickable { + println("click on $index") + }, + ) + } + }, + interactionSource = remember { MutableInteractionSource() }, + ) +} + @Composable fun ChipsRow(modifier: Modifier = Modifier) { Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -95,7 +143,6 @@ fun ChipsRow(modifier: Modifier = Modifier) { } } -@Suppress("OPT_IN_USAGE") @Composable fun TreeSample(modifier: Modifier = Modifier) { val tree = remember { @@ -119,11 +166,12 @@ fun TreeSample(modifier: Modifier = Modifier) { } val resourceLoader = LocalResourceLoader.current LazyTree( + tree = tree, + initialNodeStatus = InitialNodeStatus.Open, + resourceLoader = resourceLoader, modifier = Modifier.size(200.dp, 200.dp).then(modifier), onElementClick = {}, onElementDoubleClick = {}, - tree = tree, - resourceLoader = resourceLoader, ) { element -> Box(Modifier.fillMaxWidth()) { Text(element.data, modifier.padding(2.dp))