diff --git a/platform/jewel/foundation/api-dump.txt b/platform/jewel/foundation/api-dump.txt index c2b75157cb941..e4f5a515b10a1 100644 --- a/platform/jewel/foundation/api-dump.txt +++ b/platform/jewel/foundation/api-dump.txt @@ -233,6 +233,34 @@ c:org.jetbrains.jewel.foundation.lazy.DefaultSelectableOnKeyEvent - getKeybindings():org.jetbrains.jewel.foundation.lazy.SelectableColumnKeybindings f:org.jetbrains.jewel.foundation.lazy.DefaultSelectableOnKeyEvent$Companion - org.jetbrains.jewel.foundation.lazy.DefaultSelectableOnKeyEvent +f:org.jetbrains.jewel.foundation.lazy.MultiSelectionLazyListState +- androidx.compose.foundation.gestures.ScrollableState +- org.jetbrains.jewel.foundation.lazy.SelectableScope +- sf:$stable:I +- (androidx.compose.foundation.lazy.LazyListState,java.util.Set):V +- b:(androidx.compose.foundation.lazy.LazyListState,java.util.Set,I,kotlin.jvm.internal.DefaultConstructorMarker):V +- dispatchRawDelta(F):F +- getCanScrollBackward():Z +- getCanScrollForward():Z +- f:getFirstVisibleItemIndex():I +- f:getFirstVisibleItemScrollOffset():I +- f:getInteractionSource():androidx.compose.foundation.interaction.InteractionSource +- f:getLastActiveItemIndex():java.lang.Integer +- getLastScrolledBackward():Z +- getLastScrolledForward():Z +- f:getLayoutInfo():androidx.compose.foundation.lazy.LazyListLayoutInfo +- f:getLazyListState():androidx.compose.foundation.lazy.LazyListState +- getScrollIndicatorState():androidx.compose.foundation.ScrollIndicatorState +- getSelectedKeys():java.util.Set +- f:getSelectionMode():org.jetbrains.jewel.foundation.lazy.SelectionMode +- f:isKeyboardNavigating():Z +- isScrollInProgress():Z +- scroll(androidx.compose.foundation.MutatePriority,kotlin.jvm.functions.Function2,kotlin.coroutines.Continuation):java.lang.Object +- f:scrollToItem(I,Z,I,kotlin.coroutines.Continuation):java.lang.Object +- bs:scrollToItem$default(org.jetbrains.jewel.foundation.lazy.MultiSelectionLazyListState,I,Z,I,kotlin.coroutines.Continuation,I,java.lang.Object):java.lang.Object +- f:setKeyboardNavigating(Z):V +- f:setLastActiveItemIndex(java.lang.Integer):V +- setSelectedKeys(java.util.Set):V org.jetbrains.jewel.foundation.lazy.SelectableColumnKeybindings - a:isContiguousSelectionKeyPressed-5xRPYO0(I):Z - a:isContiguousSelectionKeyPressed-ZmokQxo(java.lang.Object):Z @@ -269,8 +297,10 @@ org.jetbrains.jewel.foundation.lazy.SelectableColumnOnKeyEvent - onSelectNextItem(java.util.List,org.jetbrains.jewel.foundation.lazy.SelectableLazyListState):V - onSelectPreviousItem(java.util.List,org.jetbrains.jewel.foundation.lazy.SelectableLazyListState):V f:org.jetbrains.jewel.foundation.lazy.SelectableLazyColumnKt +- sf:MultiSelectionLazyColumn(androidx.compose.ui.Modifier,org.jetbrains.jewel.foundation.lazy.MultiSelectionLazyListState,androidx.compose.foundation.layout.PaddingValues,Z,kotlin.jvm.functions.Function1,androidx.compose.foundation.layout.Arrangement$Vertical,androidx.compose.ui.Alignment$Horizontal,androidx.compose.foundation.gestures.FlingBehavior,org.jetbrains.jewel.foundation.lazy.tree.KeyActions,org.jetbrains.jewel.foundation.lazy.tree.PointerEventActions,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.jvm.functions.Function1,androidx.compose.runtime.Composer,I,I,I):V - sf:SelectableLazyColumn(androidx.compose.ui.Modifier,org.jetbrains.jewel.foundation.lazy.SelectionMode,org.jetbrains.jewel.foundation.lazy.SelectableLazyListState,androidx.compose.foundation.layout.PaddingValues,Z,kotlin.jvm.functions.Function1,androidx.compose.foundation.layout.Arrangement$Vertical,androidx.compose.ui.Alignment$Horizontal,androidx.compose.foundation.gestures.FlingBehavior,org.jetbrains.jewel.foundation.lazy.tree.KeyActions,org.jetbrains.jewel.foundation.lazy.tree.PointerEventActions,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.jvm.functions.Function1,androidx.compose.runtime.Composer,I,I,I):V - bsf:SelectableLazyColumn(androidx.compose.ui.Modifier,org.jetbrains.jewel.foundation.lazy.SelectionMode,org.jetbrains.jewel.foundation.lazy.SelectableLazyListState,androidx.compose.foundation.layout.PaddingValues,Z,kotlin.jvm.functions.Function1,androidx.compose.foundation.layout.Arrangement$Vertical,androidx.compose.ui.Alignment$Horizontal,androidx.compose.foundation.gestures.FlingBehavior,org.jetbrains.jewel.foundation.lazy.tree.KeyActions,org.jetbrains.jewel.foundation.lazy.tree.PointerEventActions,kotlin.jvm.functions.Function1,androidx.compose.runtime.Composer,I,I,I):V +- sf:SingleSelectionLazyColumn(androidx.compose.ui.Modifier,org.jetbrains.jewel.foundation.lazy.SingleSelectionLazyListState,androidx.compose.foundation.layout.PaddingValues,Z,kotlin.jvm.functions.Function1,androidx.compose.foundation.layout.Arrangement$Vertical,androidx.compose.ui.Alignment$Horizontal,androidx.compose.foundation.gestures.FlingBehavior,org.jetbrains.jewel.foundation.lazy.tree.KeyActions,org.jetbrains.jewel.foundation.lazy.tree.PointerEventActions,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.jvm.functions.Function1,androidx.compose.runtime.Composer,I,I,I):V org.jetbrains.jewel.foundation.lazy.SelectableLazyItemScope - androidx.compose.foundation.lazy.LazyItemScope - a:isActive():Z @@ -307,7 +337,9 @@ f:org.jetbrains.jewel.foundation.lazy.SelectableLazyListState - androidx.compose.foundation.gestures.ScrollableState - org.jetbrains.jewel.foundation.lazy.SelectableScope - sf:$stable:I -- (androidx.compose.foundation.lazy.LazyListState):V +- b:(androidx.compose.foundation.lazy.LazyListState):V +- (androidx.compose.foundation.lazy.LazyListState,org.jetbrains.jewel.foundation.lazy.SelectionMode,java.util.Set):V +- b:(androidx.compose.foundation.lazy.LazyListState,org.jetbrains.jewel.foundation.lazy.SelectionMode,java.util.Set,I,kotlin.jvm.internal.DefaultConstructorMarker):V - dispatchRawDelta(F):F - getCanScrollBackward():Z - getCanScrollForward():Z @@ -321,6 +353,7 @@ f:org.jetbrains.jewel.foundation.lazy.SelectableLazyListState - f:getLazyListState():androidx.compose.foundation.lazy.LazyListState - getScrollIndicatorState():androidx.compose.foundation.ScrollIndicatorState - getSelectedKeys():java.util.Set +- f:getSelectionMode():org.jetbrains.jewel.foundation.lazy.SelectionMode - f:isKeyboardNavigating():Z - isScrollInProgress():Z - scroll(androidx.compose.foundation.MutatePriority,kotlin.jvm.functions.Function2,kotlin.coroutines.Continuation):java.lang.Object @@ -332,7 +365,10 @@ f:org.jetbrains.jewel.foundation.lazy.SelectableLazyListState f:org.jetbrains.jewel.foundation.lazy.SelectableLazyListStateKt - sf:getVisibleItemsRange(androidx.compose.foundation.lazy.LazyListState):kotlin.ranges.IntRange - sf:getVisibleItemsRange(org.jetbrains.jewel.foundation.lazy.SelectableLazyListState):kotlin.ranges.IntRange -- sf:rememberSelectableLazyListState(I,I,androidx.compose.runtime.Composer,I,I):org.jetbrains.jewel.foundation.lazy.SelectableLazyListState +- sf:rememberMultiSelectionLazyListState(I,I,java.util.Set,androidx.compose.runtime.Composer,I,I):org.jetbrains.jewel.foundation.lazy.MultiSelectionLazyListState +- bsf:rememberSelectableLazyListState(I,I,androidx.compose.runtime.Composer,I,I):org.jetbrains.jewel.foundation.lazy.SelectableLazyListState +- sf:rememberSelectableLazyListState(I,I,org.jetbrains.jewel.foundation.lazy.SelectionMode,java.util.List,androidx.compose.runtime.Composer,I,I):org.jetbrains.jewel.foundation.lazy.SelectableLazyListState +- sf:rememberSingleSelectionLazyListState(I,I,java.lang.Object,androidx.compose.runtime.Composer,I,I):org.jetbrains.jewel.foundation.lazy.SingleSelectionLazyListState org.jetbrains.jewel.foundation.lazy.SelectableScope - a:getSelectedKeys():java.util.Set - a:setSelectedKeys(java.util.Set):V @@ -344,6 +380,34 @@ e:org.jetbrains.jewel.foundation.lazy.SelectionMode - s:getEntries():kotlin.enums.EnumEntries - s:valueOf(java.lang.String):org.jetbrains.jewel.foundation.lazy.SelectionMode - s:values():org.jetbrains.jewel.foundation.lazy.SelectionMode[] +f:org.jetbrains.jewel.foundation.lazy.SingleSelectionLazyListState +- androidx.compose.foundation.gestures.ScrollableState +- org.jetbrains.jewel.foundation.lazy.SelectableScope +- sf:$stable:I +- (androidx.compose.foundation.lazy.LazyListState,java.lang.Object):V +- b:(androidx.compose.foundation.lazy.LazyListState,java.lang.Object,I,kotlin.jvm.internal.DefaultConstructorMarker):V +- dispatchRawDelta(F):F +- getCanScrollBackward():Z +- getCanScrollForward():Z +- f:getFirstVisibleItemIndex():I +- f:getFirstVisibleItemScrollOffset():I +- f:getInteractionSource():androidx.compose.foundation.interaction.InteractionSource +- f:getLastActiveItemIndex():java.lang.Integer +- getLastScrolledBackward():Z +- getLastScrolledForward():Z +- f:getLayoutInfo():androidx.compose.foundation.lazy.LazyListLayoutInfo +- f:getLazyListState():androidx.compose.foundation.lazy.LazyListState +- getScrollIndicatorState():androidx.compose.foundation.ScrollIndicatorState +- getSelectedKeys():java.util.Set +- f:getSelectionMode():org.jetbrains.jewel.foundation.lazy.SelectionMode +- f:isKeyboardNavigating():Z +- isScrollInProgress():Z +- scroll(androidx.compose.foundation.MutatePriority,kotlin.jvm.functions.Function2,kotlin.coroutines.Continuation):java.lang.Object +- f:scrollToItem(I,Z,I,kotlin.coroutines.Continuation):java.lang.Object +- bs:scrollToItem$default(org.jetbrains.jewel.foundation.lazy.SingleSelectionLazyListState,I,Z,I,kotlin.coroutines.Continuation,I,java.lang.Object):java.lang.Object +- f:setKeyboardNavigating(Z):V +- f:setLastActiveItemIndex(java.lang.Integer):V +- setSelectedKeys(java.util.Set):V f:org.jetbrains.jewel.foundation.lazy.tree.BasicLazyTreeKt - sf:BasicLazyTree-X48TzrA(org.jetbrains.jewel.foundation.lazy.tree.Tree,J,J,J,F,androidx.compose.foundation.shape.CornerSize,androidx.compose.foundation.layout.PaddingValues,androidx.compose.foundation.layout.PaddingValues,F,F,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function3,androidx.compose.ui.Modifier,org.jetbrains.jewel.foundation.lazy.SelectionMode,org.jetbrains.jewel.foundation.lazy.tree.TreeState,J,org.jetbrains.jewel.foundation.lazy.tree.KeyActions,org.jetbrains.jewel.foundation.lazy.tree.PointerEventActions,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.jvm.functions.Function4,androidx.compose.runtime.Composer,I,I,I,I):V - bsf:BasicLazyTree-orM9XXQ(org.jetbrains.jewel.foundation.lazy.tree.Tree,org.jetbrains.jewel.foundation.lazy.SelectionMode,kotlin.jvm.functions.Function1,J,J,J,F,androidx.compose.foundation.shape.CornerSize,androidx.compose.foundation.layout.PaddingValues,androidx.compose.foundation.layout.PaddingValues,F,F,org.jetbrains.jewel.foundation.lazy.tree.TreeState,androidx.compose.ui.Modifier,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function1,J,org.jetbrains.jewel.foundation.lazy.tree.KeyActions,org.jetbrains.jewel.foundation.lazy.tree.PointerEventActions,kotlin.jvm.functions.Function3,kotlin.jvm.functions.Function4,androidx.compose.runtime.Composer,I,I,I,I):V diff --git a/platform/jewel/foundation/metalava/foundation-baseline-current.txt b/platform/jewel/foundation/metalava/foundation-baseline-current.txt index 9a97707763f11..cc43f63119f73 100644 --- a/platform/jewel/foundation/metalava/foundation-baseline-current.txt +++ b/platform/jewel/foundation/metalava/foundation-baseline-current.txt @@ -1 +1,3 @@ // Baseline format: 1.0 +RemovedMethod: org.jetbrains.jewel.foundation.lazy.SelectableLazyListState#SelectableLazyListState(androidx.compose.foundation.lazy.LazyListState): + Binary breaking change: Removed constructor org.jetbrains.jewel.foundation.lazy.SelectableLazyListState(androidx.compose.foundation.lazy.LazyListState) diff --git a/platform/jewel/foundation/metalava/foundation-baseline-stable-current.txt b/platform/jewel/foundation/metalava/foundation-baseline-stable-current.txt index 9a97707763f11..cc43f63119f73 100644 --- a/platform/jewel/foundation/metalava/foundation-baseline-stable-current.txt +++ b/platform/jewel/foundation/metalava/foundation-baseline-stable-current.txt @@ -1 +1,3 @@ // Baseline format: 1.0 +RemovedMethod: org.jetbrains.jewel.foundation.lazy.SelectableLazyListState#SelectableLazyListState(androidx.compose.foundation.lazy.LazyListState): + Binary breaking change: Removed constructor org.jetbrains.jewel.foundation.lazy.SelectableLazyListState(androidx.compose.foundation.lazy.LazyListState) diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt index d4749e2889130..3f88b243d57e6 100644 --- a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.focused @@ -43,13 +44,164 @@ import org.jetbrains.jewel.foundation.lazy.tree.DefaultSelectableLazyColumnEvent import org.jetbrains.jewel.foundation.lazy.tree.DefaultSelectableLazyColumnKeyActions import org.jetbrains.jewel.foundation.lazy.tree.KeyActions import org.jetbrains.jewel.foundation.lazy.tree.PointerEventActions +import org.jetbrains.jewel.foundation.util.JewelLogger + +private val logger = JewelLogger.getInstance("org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn") + +/** + * Displays a lazily composed column with single-selection behavior. + * + * Use this API with [SingleSelectionLazyListState], usually created via [rememberSingleSelectionLazyListState]. + * + * @param modifier The [Modifier] applied to the underlying [LazyColumn] container. + * @param state The single-selection state holder for scroll and selection behavior. + * @param contentPadding Inner padding applied around the content. + * @param reverseLayout Whether items are laid out in reverse order (bottom-to-top). + * @param onSelectedIndexesChange Callback invoked when selected indices change after initial composition. + * @param verticalArrangement Vertical arrangement of items. Defaults to [Arrangement.Top] unless [reverseLayout] is + * `true`, in which case it defaults to [Arrangement.Bottom]. + * @param horizontalAlignment Horizontal alignment for items in the column. + * @param flingBehavior Fling behavior used by the underlying [LazyColumn]. + * @param keyActions Keyboard interaction handlers used for selection/navigation behavior. + * @param pointerEventActions Pointer interaction handlers used for selection behavior. + * @param interactionSource Optional [MutableInteractionSource] to observe focus/interaction state. If `null`, an + * internal source is created. + * @param content The list content declared in [SelectableLazyListScope]. + */ +@Composable +public fun SingleSelectionLazyColumn( + modifier: Modifier = Modifier, + state: SingleSelectionLazyListState = rememberSingleSelectionLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + onSelectedIndexesChange: (List) -> Unit = {}, + verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + keyActions: KeyActions = DefaultSelectableLazyColumnKeyActions, + pointerEventActions: PointerEventActions = DefaultSelectableLazyColumnEventAction(), + interactionSource: MutableInteractionSource? = null, + content: SelectableLazyListScope.() -> Unit, +) { + SelectableLazyColumnImpl( + modifier = modifier, + selectionMode = SelectionMode.Single, + state = state.delegate, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + onSelectedIndexesChange = onSelectedIndexesChange, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + keyActions = keyActions, + pointerEventActions = pointerEventActions, + interactionSource = interactionSource, + content = content, + ) +} + +/** + * Displays a lazily composed column with multiple-selection behavior. + * + * Use this API with [MultiSelectionLazyListState], usually created via [rememberMultiSelectionLazyListState]. + * + * @param modifier The [Modifier] applied to the underlying [LazyColumn] container. + * @param state The multi-selection state holder for scroll and selection behavior. + * @param contentPadding Inner padding applied around the content. + * @param reverseLayout Whether items are laid out in reverse order (bottom-to-top). + * @param onSelectedIndexesChange Callback invoked when selected indices change after initial composition. + * @param verticalArrangement Vertical arrangement of items. Defaults to [Arrangement.Top] unless [reverseLayout] is + * `true`, in which case it defaults to [Arrangement.Bottom]. + * @param horizontalAlignment Horizontal alignment for items in the column. + * @param flingBehavior Fling behavior used by the underlying [LazyColumn]. + * @param keyActions Keyboard interaction handlers used for selection/navigation behavior. + * @param pointerEventActions Pointer interaction handlers used for selection behavior. + * @param interactionSource Optional [MutableInteractionSource] to observe focus/interaction state. If `null`, an + * internal source is created. + * @param content The list content declared in [SelectableLazyListScope]. + */ +@Composable +public fun MultiSelectionLazyColumn( + modifier: Modifier = Modifier, + state: MultiSelectionLazyListState = rememberMultiSelectionLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + onSelectedIndexesChange: (List) -> Unit = {}, + verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + keyActions: KeyActions = DefaultSelectableLazyColumnKeyActions, + pointerEventActions: PointerEventActions = DefaultSelectableLazyColumnEventAction(), + interactionSource: MutableInteractionSource? = null, + content: SelectableLazyListScope.() -> Unit, +) { + SelectableLazyColumnImpl( + modifier = modifier, + selectionMode = SelectionMode.Multiple, + state = state.delegate, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + onSelectedIndexesChange = onSelectedIndexesChange, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + keyActions = keyActions, + pointerEventActions = pointerEventActions, + interactionSource = interactionSource, + content = content, + ) +} + +/** A composable that displays a scrollable and selectable list of items in a column arrangement. */ +@Composable +@Deprecated( + message = + "Migrate to SingleSelectionLazyColumn or MultiSelectionLazyColumn and use the matching " + + "rememberSingleSelectionLazyListState(...) or rememberMultiSelectionLazyListState(...)." +) +public fun SelectableLazyColumn( + modifier: Modifier = Modifier, + selectionMode: SelectionMode = SelectionMode.Multiple, + state: SelectableLazyListState = rememberSelectableLazyListState(selectionMode = selectionMode), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + onSelectedIndexesChange: (List) -> Unit = {}, + verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + keyActions: KeyActions = DefaultSelectableLazyColumnKeyActions, + pointerEventActions: PointerEventActions = DefaultSelectableLazyColumnEventAction(), + interactionSource: MutableInteractionSource? = null, + content: SelectableLazyListScope.() -> Unit, +) { + SelectableLazyColumnImpl( + modifier = modifier, + selectionMode = selectionMode, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + onSelectedIndexesChange = onSelectedIndexesChange, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + keyActions = keyActions, + pointerEventActions = pointerEventActions, + interactionSource = interactionSource, + content = content, + ) +} @Composable -@Deprecated("Use SelectableLazyColumn with 'interactionSource' parameter instead", level = DeprecationLevel.HIDDEN) +@Deprecated( + message = + "Migrate to SingleSelectionLazyColumn or MultiSelectionLazyColumn and use the matching " + + "rememberSingleSelectionLazyListState(...) or rememberMultiSelectionLazyListState(...).", + level = DeprecationLevel.HIDDEN, +) public fun SelectableLazyColumn( modifier: Modifier = Modifier, selectionMode: SelectionMode = SelectionMode.Multiple, - state: SelectableLazyListState = rememberSelectableLazyListState(), + state: SelectableLazyListState = rememberSelectableLazyListState(selectionMode = selectionMode), contentPadding: PaddingValues = PaddingValues(0.dp), reverseLayout: Boolean = false, onSelectedIndexesChange: (List) -> Unit = {}, @@ -77,12 +229,39 @@ public fun SelectableLazyColumn( ) } -/** A composable that displays a scrollable and selectable list of items in a column arrangement. */ +/** + * A composable function that implements a lazy column with selection capabilities. This method allows multiple or + * single item selection based on the specified selection mode and offers advanced configuration like content alignment, + * key action handling, pointer event handling, and state management. + * + * @param modifier A [Modifier] that will be applied to the lazy column. + * @param selectionMode The mode of selection, which can be [SelectionMode.Multiple] for multi-selection or + * [SelectionMode.None] to disable selection. + * @param state The state object defining the current selection and configuration of the lazy column. Use + * [rememberSelectableLazyListState] to create or remember the default state if none is provided. + * @param contentPadding The padding values for the content displayed inside the lazy column. + * @param reverseLayout A boolean indicating whether to reverse the layout of the column (i.e., bottom-to-top). + * @param onSelectedIndexesChange A callback invoked when the selected indices change. It provides the list of currently + * selected indices. + * @param verticalArrangement Specifies the spacing and arrangement of items in the vertical direction. Defaults to + * [Arrangement.Top] for non-reversed layouts or [Arrangement.Bottom] for reversed layouts. + * @param horizontalAlignment Aligns the horizontal position of items within the column. Defaults to [Alignment.Start]. + * @param flingBehavior The behavior that determines how the lazy column will respond to fling gestures. Defaults to + * [ScrollableDefaults.flingBehavior]. + * @param keyActions Key bindings for handling keyboard interactions and selection behaviors in the lazy column. + * Defaults to [DefaultSelectableLazyColumnKeyActions]. + * @param pointerEventActions Event handlers for pointer-based interactions (e.g., mouse or touch input). Defaults to + * [DefaultSelectableLazyColumnEventAction]. + * @param interactionSource Optional [MutableInteractionSource] that stores interaction state and events, such as hover + * or focus. If none is provided, a new one will be remembered and managed internally. + * @param content The lambda defining the content to be displayed within the lazy column. Use the + * [SelectableLazyListScope] receiver to define items and content behavior. + */ @Composable -public fun SelectableLazyColumn( +private fun SelectableLazyColumnImpl( modifier: Modifier = Modifier, selectionMode: SelectionMode = SelectionMode.Multiple, - state: SelectableLazyListState = rememberSelectableLazyListState(), + state: SelectableLazyListState = rememberSelectableLazyListState(selectionMode = selectionMode), contentPadding: PaddingValues = PaddingValues(0.dp), reverseLayout: Boolean = false, onSelectedIndexesChange: (List) -> Unit = {}, @@ -94,6 +273,19 @@ public fun SelectableLazyColumn( interactionSource: MutableInteractionSource? = null, content: SelectableLazyListScope.() -> Unit, ) { + // Legacy compatibility contract: + // `SelectableLazyColumn` historically accepts both `selectionMode` and `state`, so callers can pass values + // that disagree (for example: selectionMode=Single, state.selectionMode=Multiple). + // + // In this legacy API, the explicit `selectionMode` argument remains authoritative for interaction behavior + // (keyboard/pointer handlers). We intentionally do not rewrite state during composition. + // Instead, we warn and preserve current state values for rendering/callback baseline to avoid unnecessary + // recompositions and side-effects. + // + // The typed entry points (`SingleSelectionLazyColumn` and `MultiSelectionLazyColumn`) avoid this ambiguity. + LogLegacySelectionModeWarnings(selectionMode = selectionMode, state = state) + + val effectiveSelectionMode = selectionMode val intSource = interactionSource ?: remember { MutableInteractionSource() } val scope = rememberCoroutineScope() @@ -106,8 +298,13 @@ public fun SelectableLazyColumn( val keys = remember(container) { container.getKeys() } val isFocused by intSource.collectIsFocusedAsState() - /** Tracks the last emitted indices to avoid duplicate emissions from both commit-time and effect-driven updates. */ - var lastEmittedIndices by remember { mutableStateOf>(emptyList()) } + // Tracks last emitted indices to dedupe commit-time/effect-driven updates. + // Seed once from the composition-time selection snapshot: + // - prevents initial preselected keys from emitting on first composition + // - still allows early programmatic selection changes (after composition starts) to emit + var lastEmittedIndices by remember { + mutableStateOf(state.selectedKeys.mapNotNull { key -> container.getKeyIndex(key) }) + } // Keep the latest callback reference to avoid capturing a stale lambda inside effects val latestOnSelectedIndexesChange = rememberUpdatedState(onSelectedIndexesChange) @@ -133,10 +330,13 @@ public fun SelectableLazyColumn( if (indices != lastEmittedIndices) { lastEmittedIndices = indices - // Keep keyboard navigation gate in sync after key→index remaps, including through empty→non‑empty - // transitions. + // Keep the keyboard navigation gate in sync after key→index remaps, including through empty→non‑empty + // transitions. Preserve an existing active index when it is still selected; otherwise fall back to + // the first selected index. if (indices.isNotEmpty()) { - state.lastActiveItemIndex = indices.first() + val activeIndex = state.lastActiveItemIndex + state.lastActiveItemIndex = + if (activeIndex != null && activeIndex in indices) activeIndex else indices.first() } // Notify using the latest callback reference to avoid capturing a stale lambda. @@ -145,7 +345,7 @@ public fun SelectableLazyColumn( } LaunchedEffect(isFocused) { - if (!isFocused || selectionMode == SelectionMode.None) return@LaunchedEffect + if (!isFocused || effectiveSelectionMode == SelectionMode.None) return@LaunchedEffect with(state) { if (lastActiveItemIndex == null && selectedKeys.isEmpty()) { keyActions.actions.onSelectFirstItem(keys, this) @@ -174,7 +374,7 @@ public fun SelectableLazyColumn( } override fun handlePointerEventPress( - pointerEvent: androidx.compose.ui.input.pointer.PointerEvent, + pointerEvent: PointerEvent, keybindings: SelectableColumnKeybindings, selectableLazyListState: SelectableLazyListState, selectionMode: SelectionMode, @@ -281,7 +481,7 @@ public fun SelectableLazyColumn( } val actionHandled = - notifyingKeyActions.handleOnKeyEvent(event, keys, state, selectionMode).invoke(event) + notifyingKeyActions.handleOnKeyEvent(event, keys, state, effectiveSelectionMode).invoke(event) if (actionHandled) { scope.launch { state.lastActiveItemIndex?.let { state.scrollToItem(it) } } } @@ -303,7 +503,7 @@ public fun SelectableLazyColumn( focusRequester, notifyingKeyActions, notifyingPointerEventActions, - selectionMode, + effectiveSelectionMode, container::isKeySelectable, ) } @@ -451,3 +651,50 @@ private fun SelectableLazyListState.selectedIndicesIfChanged( } return null } + +/** + * Logs mismatch warnings for legacy `SelectableLazyColumn` mode/state inputs. + * + * This helper isolates deprecation-compatibility diagnostics from core selection logic. + * + * Emitted warnings: + * - Generic mismatch: `selectionMode != state.selectionMode` (legacy precedence applies). + * - Edge-case mismatch: + * - `selectionMode = None` while state already contains selected keys. + * - `selectionMode = Single` while state contains multiple selected keys. + * + * We only warn; we do not mutate state here. That keeps composition side-effect free with respect to selection data and + * avoids recomposition churn from compatibility normalization. + */ +@Composable +private fun LogLegacySelectionModeWarnings(selectionMode: SelectionMode, state: SelectableLazyListState) { + val modeMismatch = selectionMode != state.selectionMode + + LaunchedEffect(selectionMode, state.selectionMode) { + if (!modeMismatch) return@LaunchedEffect + logger.warn( + "SelectableLazyColumn: selectionMode=$selectionMode does not match state.selectionMode=" + + "${state.selectionMode}. selectionMode will be used." + ) + } + + LaunchedEffect(selectionMode, state.selectionMode, state.selectedKeys.size) { + if (!modeMismatch) return@LaunchedEffect + when { + selectionMode == SelectionMode.None && state.selectedKeys.isNotEmpty() -> { + logger.warn( + "SelectableLazyColumn: selectionMode=${SelectionMode.None} " + + "while state has ${state.selectedKeys.size} selected key(s). " + + "Initial selection may appear inconsistent until user interaction." + ) + } + selectionMode == SelectionMode.Single && state.selectedKeys.size > 1 -> { + logger.warn( + "SelectableLazyColumn: selectionMode=${SelectionMode.Single} " + + "while state has ${state.selectedKeys.size} selected key(s). " + + "Initial selection may appear inconsistent until user interaction." + ) + } + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListState.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListState.kt index d51389e4e01da..2806c99ec58a5 100644 --- a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListState.kt +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListState.kt @@ -11,6 +11,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import kotlin.math.max +import org.jetbrains.jewel.foundation.util.JewelLogger + +private val logger = JewelLogger.getInstance(SelectableLazyListState::class.java) @Suppress("unused") public val LazyListState.visibleItemsRange: IntRange @@ -24,18 +27,43 @@ public interface SelectableScope { } /** - * State object for a selectable lazy list, which extends [ScrollableState]. + * Legacy Selectable list state used for backward compatibility. + * + * New code should prefer [SingleSelectionLazyListState] or [MultiSelectionLazyListState] together with + * [SingleSelectionLazyColumn] or [MultiSelectionLazyColumn]. * - * @param lazyListState The state object for the underlying lazy list. + * Pre-selecting keys does **not** trigger [SelectableLazyColumn]'s `onSelectedIndexesChange` on the first composition, + * because callback emission is seeded from initial selection and emitted only on later changes. + * + * @param lazyListState Underlying [LazyListState] used for scroll/layout state. + * @param selectionMode Legacy selection mode metadata kept for API compatibility and legacy diagnostics. + * @param initialSelectedKeys Initially selected keys assigned to [selectedKeys] at construction time. This constructor + * does not normalize keys against [selectionMode]. When this state is constructed manually, keys should be normalized + * for the chosen mode (for example, at most one key for [SelectionMode.Single], and no keys for + * [SelectionMode.None]). [rememberSelectableLazyListState] provides built-in legacy normalization from a [List]. */ -public class SelectableLazyListState(public val lazyListState: LazyListState) : - ScrollableState by lazyListState, SelectableScope { +public class SelectableLazyListState +public constructor( + public val lazyListState: LazyListState, + public val selectionMode: SelectionMode = SelectionMode.Multiple, + initialSelectedKeys: Set = emptySet(), +) : ScrollableState by lazyListState, SelectableScope { + /** + * Compatibility overload retained for binary compatibility only. Use the primary constructor with [selectionMode] + * and [initialSelectedKeys] instead. + */ + @Deprecated( + message = "Use SelectableLazyListState(lazyListState, selectionMode, initialSelectedKeys) instead.", + level = DeprecationLevel.HIDDEN, + ) + public constructor(lazyListState: LazyListState) : this(lazyListState, SelectionMode.Multiple, emptySet()) + internal var lastKeyEventUsedMouse: Boolean = false /** Flag indicating whether the user is currently navigating via keyboard */ public var isKeyboardNavigating: Boolean by mutableStateOf(false) - override var selectedKeys: Set by mutableStateOf(emptySet()) + override var selectedKeys: Set by mutableStateOf(initialSelectedKeys) public var lastActiveItemIndex: Int? = null @@ -85,6 +113,146 @@ public class SelectableLazyListState(public val lazyListState: LazyListState) : get() = lazyListState.interactionSource } +public class SingleSelectionLazyListState internal constructor(internal val delegate: SelectableLazyListState) : + ScrollableState by delegate, SelectableScope { + /** + * Type-safe state holder for single-selection lazy lists. + * + * This state guarantees single-selection semantics: + * - [selectionMode] is always [SelectionMode.Single]. + * - assigning multiple keys to [selectedKeys] keeps only the first key. + * + * Use with [SingleSelectionLazyColumn]. + * + * @param lazyListState Underlying [LazyListState] used for scroll/layout state. + * @param initialSelectedKey Optional initial selected key applied once at creation. + */ + public constructor( + lazyListState: LazyListState, + initialSelectedKey: Any? = null, + ) : this( + SelectableLazyListState( + lazyListState = lazyListState, + selectionMode = SelectionMode.Single, + initialSelectedKeys = initialSelectedKey?.let(::setOf).orEmpty(), + ) + ) + + public val lazyListState: LazyListState + get() = delegate.lazyListState + + public val selectionMode: SelectionMode + get() = SelectionMode.Single + + override var selectedKeys: Set + get() = delegate.selectedKeys + set(value) { + if (value.size > 1) { + logger.warn( + "SingleSelectionLazyListState: ${value.size} selectedKeys provided. " + + "Keeping only the first key." + ) + } + delegate.selectedKeys = if (value.isEmpty()) emptySet() else setOf(value.first()) + } + + public var isKeyboardNavigating: Boolean + get() = delegate.isKeyboardNavigating + set(value) { + delegate.isKeyboardNavigating = value + } + + public var lastActiveItemIndex: Int? + get() = delegate.lastActiveItemIndex + set(value) { + delegate.lastActiveItemIndex = value + } + + public suspend fun scrollToItem(itemIndex: Int, animateScroll: Boolean = false, scrollOffset: Int = 0) { + delegate.scrollToItem(itemIndex, animateScroll, scrollOffset) + } + + public val layoutInfo: LazyListLayoutInfo + get() = delegate.layoutInfo + + public val firstVisibleItemIndex: Int + get() = delegate.firstVisibleItemIndex + + @Suppress("unused") + public val firstVisibleItemScrollOffset: Int + get() = delegate.firstVisibleItemScrollOffset + + public val interactionSource: InteractionSource + get() = delegate.interactionSource +} + +public class MultiSelectionLazyListState internal constructor(internal val delegate: SelectableLazyListState) : + ScrollableState by delegate, SelectableScope { + /** + * Type-safe state holder for multi-selection lazy lists. + * + * This state guarantees multi-selection semantics: + * - [selectionMode] is always [SelectionMode.Multiple]. + * - [selectedKeys] accepts any number of keys. + * + * Use with [MultiSelectionLazyColumn]. + * + * @param lazyListState Underlying [LazyListState] used for scroll/layout state. + * @param initialSelectedKeys Optional initial selected keys applied once at creation. + */ + public constructor( + lazyListState: LazyListState, + initialSelectedKeys: Set = emptySet(), + ) : this( + SelectableLazyListState( + lazyListState = lazyListState, + selectionMode = SelectionMode.Multiple, + initialSelectedKeys = initialSelectedKeys, + ) + ) + + public val lazyListState: LazyListState + get() = delegate.lazyListState + + public val selectionMode: SelectionMode + get() = SelectionMode.Multiple + + override var selectedKeys: Set + get() = delegate.selectedKeys + set(value) { + delegate.selectedKeys = value + } + + public var isKeyboardNavigating: Boolean + get() = delegate.isKeyboardNavigating + set(value) { + delegate.isKeyboardNavigating = value + } + + public var lastActiveItemIndex: Int? + get() = delegate.lastActiveItemIndex + set(value) { + delegate.lastActiveItemIndex = value + } + + public suspend fun scrollToItem(itemIndex: Int, animateScroll: Boolean = false, scrollOffset: Int = 0) { + delegate.scrollToItem(itemIndex, animateScroll, scrollOffset) + } + + public val layoutInfo: LazyListLayoutInfo + get() = delegate.layoutInfo + + public val firstVisibleItemIndex: Int + get() = delegate.firstVisibleItemIndex + + @Suppress("unused") + public val firstVisibleItemScrollOffset: Int + get() = delegate.firstVisibleItemScrollOffset + + public val interactionSource: InteractionSource + get() = delegate.interactionSource +} + private suspend fun LazyListState.scrollToItem(index: Int, animate: Boolean, scrollOffset: Int = 0) { if (animate) { animateScrollToItem(index, scrollOffset) @@ -142,16 +310,162 @@ public enum class SelectionMode { } /** - * Remembers the state of a selectable lazy list. + * Legacy convenience overload for remembering a [SelectableLazyListState]. + * + * This overload exists for source/binary compatibility and is equivalent to: + * `rememberSelectableLazyListState(initialFirstVisibleItemIndex = firstVisibleItemIndex,` + * `initialFirstVisibleItemScrollOffset = firstVisibleItemScrollOffset,` `selectionMode = SelectionMode.Multiple, + * initialSelectedKeys = emptyList())`. * - * @param firstVisibleItemIndex The index of the first visible item. - * @param firstVisibleItemScrollOffset The scroll offset of the first visible item. + * Prefer the 4-parameter overload to control selection mode and initial selection explicitly. + * + * @param firstVisibleItemIndex Initial index of the first visible item, used only at creation time. + * @param firstVisibleItemScrollOffset Initial scroll offset of the first visible item, used only at creation time. * @return The remembered state of the selectable lazy list. */ +@Deprecated( + message = + "Use rememberSelectableLazyListState(" + + "initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset, " + + "selectionMode, initialSelectedKeys) instead.", + replaceWith = + ReplaceWith( + "rememberSelectableLazyListState(" + + "initialFirstVisibleItemIndex = firstVisibleItemIndex, " + + "initialFirstVisibleItemScrollOffset = firstVisibleItemScrollOffset, " + + "selectionMode = SelectionMode.Multiple, " + + "initialSelectedKeys = emptyList())" + ), + level = DeprecationLevel.HIDDEN, +) @Composable public fun rememberSelectableLazyListState( firstVisibleItemIndex: Int = 0, firstVisibleItemScrollOffset: Int = 0, +): SelectableLazyListState = + rememberSelectableLazyListState( + initialFirstVisibleItemIndex = firstVisibleItemIndex, + initialFirstVisibleItemScrollOffset = firstVisibleItemScrollOffset, + selectionMode = SelectionMode.Multiple, + initialSelectedKeys = emptyList(), + ) + +/** + * Remembers a legacy [SelectableLazyListState]. + * + * The underlying [LazyListState] and [SelectableLazyListState] are created once and reused across recompositions. + * Parameters are treated as initial values and are not reapplied after the first composition. + * + * [initialSelectedKeys] are normalized once at creation time using [normalizeFor]: + * - [SelectionMode.None]: all keys are ignored. + * - [SelectionMode.Single]: only the first key is kept. + * - [SelectionMode.Multiple]: all keys are kept (deduplicated in insertion order). + * + * Initial key application does **not** trigger [SelectableLazyColumn]'s `onSelectedIndexesChange` on the first + * composition. + * + * @param initialFirstVisibleItemIndex Initial index of the first visible item, used only at creation time. + * @param initialFirstVisibleItemScrollOffset Initial scroll offset of the first visible item, used only at creation + * time. + * @param selectionMode Legacy selection mode metadata and normalization mode used at creation time. + * @param initialSelectedKeys Keys that should be selected before the first user interaction. This is a [List] (not a + * [Set]) so order is preserved for [SelectionMode.Single], where the first key wins. Used only at creation time; + * ignored on subsequent recompositions. + */ +@Composable +public fun rememberSelectableLazyListState( + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0, + selectionMode: SelectionMode = SelectionMode.Multiple, + initialSelectedKeys: List = emptyList(), ): SelectableLazyListState = remember { - SelectableLazyListState(LazyListState(firstVisibleItemIndex, firstVisibleItemScrollOffset)) + SelectableLazyListState( + lazyListState = LazyListState(initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset), + selectionMode = selectionMode, + initialSelectedKeys = initialSelectedKeys.normalizeFor(selectionMode), + ) } + +@Composable +/** + * Remembers a [SingleSelectionLazyListState]. + * + * The underlying [LazyListState] is created once and reused across recompositions. [initialSelectedKey] is applied only + * at creation time and does not trigger [SelectableLazyColumn]'s `onSelectedIndexesChange` on first composition. + * + * @param initialFirstVisibleItemIndex Initial first visible item index, used only at creation time. + * @param initialFirstVisibleItemScrollOffset Initial first visible item scroll offset, used only at creation time. + * @param initialSelectedKey Optional preselected key for the initial composition. + */ +public fun rememberSingleSelectionLazyListState( + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0, + initialSelectedKey: Any? = null, +): SingleSelectionLazyListState = remember { + SingleSelectionLazyListState( + lazyListState = LazyListState(initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset), + initialSelectedKey = initialSelectedKey, + ) +} + +@Composable +/** + * Remembers a [MultiSelectionLazyListState]. + * + * The underlying [LazyListState] is created once and reused across recompositions. [initialSelectedKeys] are applied + * only at creation time and do not trigger [SelectableLazyColumn]'s `onSelectedIndexesChange` on first composition. + * + * @param initialFirstVisibleItemIndex Initial first visible item index, used only at creation time. + * @param initialFirstVisibleItemScrollOffset Initial first visible item scroll offset, used only at creation time. + * @param initialSelectedKeys Optional preselected keys for the initial composition. + */ +public fun rememberMultiSelectionLazyListState( + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0, + initialSelectedKeys: Set = emptySet(), +): MultiSelectionLazyListState = remember { + MultiSelectionLazyListState( + lazyListState = LazyListState(initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset), + initialSelectedKeys = initialSelectedKeys, + ) +} + +/** + * Normalizes legacy initial selection input to the internal [Set]-based representation. + * + * The input is a [List] to preserve insertion order for deterministic legacy behavior. In [SelectionMode.Single], only + * the first key in the list is kept. + * + * Behavior by [mode]: + * - [SelectionMode.None]: returns an empty set and logs a warning if the list is not empty. + * - [SelectionMode.Single]: returns a single-element set containing the first key; logs a warning when extra keys are + * provided. + * - [SelectionMode.Multiple]: returns a [LinkedHashSet] preserving encounter order and removing duplicates. + */ +private fun List.normalizeFor(mode: SelectionMode): Set = + when (mode) { + SelectionMode.None -> { + if (isNotEmpty()) { + logger.warn( + "SelectableLazyListState: $size initialSelectedKeys provided but " + + "SelectionMode.None disallows selection. All keys will be ignored: $this" + ) + } + emptySet() + } + + SelectionMode.Single -> { + if (size > 1) { + val kept = first() + val dropped = drop(1) + logger.warn( + "SelectableLazyListState: $size initialSelectedKeys provided but " + + "SelectionMode.Single allows only one. Keeping '$kept', " + + "ignoring ${dropped.size} key(s): $dropped." + ) + } + if (isEmpty()) emptySet() else setOf(first()) + } + + SelectionMode.Multiple -> LinkedHashSet(this) + } diff --git a/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnLegacyCompatibilityTest.kt b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnLegacyCompatibilityTest.kt new file mode 100644 index 0000000000000..bcbc76d3f308a --- /dev/null +++ b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnLegacyCompatibilityTest.kt @@ -0,0 +1,90 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.foundation.lazy + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.text.BasicText +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@Suppress("ImplicitUnitReturnType") +internal class SelectableLazyColumnLegacyCompatibilityTest { + @get:Rule val composeRule = createComposeRule() + + private val items = listOf("A", "B", "C") + + @Test + fun `legacy mismatch SelectionMode Single vs SelectionMode Multiple applies Single on interaction`() = runTest { + lateinit var state: SelectableLazyListState + val callbackInvocations = mutableListOf>() + composeRule.setContent { + state = + rememberSelectableLazyListState( + selectionMode = SelectionMode.Multiple, + initialSelectedKeys = listOf("A", "B"), + ) + Box(modifier = Modifier.requiredHeight(300.dp)) { + SelectableLazyColumn( + modifier = Modifier.testTag("list"), + selectionMode = SelectionMode.Single, + state = state, + onSelectedIndexesChange = { callbackInvocations += it }, + ) { + items(items.size, key = { items[it] }) { index -> + BasicText(items[index], modifier = Modifier.testTag(items[index])) + } + } + } + } + + composeRule.awaitIdle() + assertEquals(setOf("A", "B"), state.selectedKeys) + assertEquals(emptyList>(), callbackInvocations) + + composeRule.onNodeWithTag("C", useUnmergedTree = true).performClick() + composeRule.awaitIdle() + + assertEquals(setOf("C"), state.selectedKeys) + assertEquals(listOf(listOf(2)), callbackInvocations) + } + + @Test + fun `legacy mismatch SelectionMode None vs SelectionMode Single suppresses new click selection`() = runTest { + lateinit var state: SelectableLazyListState + val callbackInvocations = mutableListOf>() + composeRule.setContent { + state = + rememberSelectableLazyListState(selectionMode = SelectionMode.Single, initialSelectedKeys = listOf("A")) + Box(modifier = Modifier.requiredHeight(300.dp)) { + SelectableLazyColumn( + modifier = Modifier.testTag("list"), + selectionMode = SelectionMode.None, + state = state, + onSelectedIndexesChange = { callbackInvocations += it }, + ) { + items(items.size, key = { items[it] }) { index -> + BasicText(items[index], modifier = Modifier.testTag(items[index])) + } + } + } + } + + composeRule.awaitIdle() + assertEquals(setOf("A"), state.selectedKeys) + assertEquals(emptyList>(), callbackInvocations) + + composeRule.onNodeWithTag("B", useUnmergedTree = true).performClick() + composeRule.awaitIdle() + + assertEquals(setOf("A"), state.selectedKeys) + assertEquals(emptyList>(), callbackInvocations) + } +} diff --git a/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnSelectionModeNoneTest.kt b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnSelectionModeNoneTest.kt index 86e7e0640b0eb..315eac220db69 100644 --- a/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnSelectionModeNoneTest.kt +++ b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnSelectionModeNoneTest.kt @@ -44,7 +44,7 @@ internal class SelectableLazyColumnSelectionModeNoneTest { } @Test - fun `clicking an item in SelectionMode-None mode does not produce any selection`() = runTest { + fun `clicking an item in SelectionMode None mode does not produce any selection`() = runTest { lateinit var state: SelectableLazyListState composeRule.setContent { state = rememberSelectableLazyListState() @@ -64,7 +64,7 @@ internal class SelectableLazyColumnSelectionModeNoneTest { } @Test - fun `clicking an item in SelectionMode-None mode does not trigger onSelectedIndexesChange`() = runTest { + fun `clicking an item in SelectionMode None mode does not trigger onSelectedIndexesChange`() = runTest { val callbackInvocations = mutableListOf>() composeRule.setContent { val state = rememberSelectableLazyListState() @@ -84,7 +84,7 @@ internal class SelectableLazyColumnSelectionModeNoneTest { } @Test - fun `focusing the list in SelectionMode-None mode does not auto-select the first item`() = runTest { + fun `focusing the list in SelectionMode None mode does not auto-select the first item`() = runTest { lateinit var state: SelectableLazyListState composeRule.setContent { state = rememberSelectableLazyListState() diff --git a/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SingleAndMultiSelectionLazyColumnTest.kt b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SingleAndMultiSelectionLazyColumnTest.kt new file mode 100644 index 0000000000000..6a8270a8b8ae0 --- /dev/null +++ b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SingleAndMultiSelectionLazyColumnTest.kt @@ -0,0 +1,140 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.foundation.lazy + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.text.BasicText +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@Suppress("ImplicitUnitReturnType") +internal class SingleAndMultiSelectionLazyColumnTest { + @get:Rule val composeRule = createComposeRule() + + private val items = listOf("A", "B", "C", "D") + + @Test + fun `SingleSelectionLazyColumn keeps one selected key and emits single index`() = runTest { + lateinit var state: SingleSelectionLazyListState + val callbackInvocations = mutableListOf>() + + composeRule.setContent { + state = rememberSingleSelectionLazyListState(initialSelectedKey = "A") + Box(modifier = Modifier.requiredHeight(300.dp)) { + SingleSelectionLazyColumn( + modifier = Modifier.testTag("list"), + state = state, + onSelectedIndexesChange = { callbackInvocations += it }, + ) { + items(items.size, key = { items[it] }) { index -> + BasicText(items[index], modifier = Modifier.testTag(items[index])) + } + } + } + } + + composeRule.awaitIdle() + assertEquals(setOf("A"), state.selectedKeys) + assertTrue(callbackInvocations.isEmpty()) + + composeRule.onNodeWithTag("C", useUnmergedTree = true).performClick() + composeRule.awaitIdle() + assertEquals(setOf("C"), state.selectedKeys) + assertEquals(listOf(listOf(2)), callbackInvocations) + + composeRule.onNodeWithTag("B", useUnmergedTree = true).performClick() + composeRule.awaitIdle() + assertEquals(setOf("B"), state.selectedKeys) + assertEquals(listOf(listOf(2), listOf(1)), callbackInvocations) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `MultiSelectionLazyColumn extends selection with shift arrow and emits updated indices`() = runTest { + lateinit var state: MultiSelectionLazyListState + val callbackInvocations = mutableListOf>() + + composeRule.setContent { + state = rememberMultiSelectionLazyListState() + Box(modifier = Modifier.requiredHeight(300.dp)) { + MultiSelectionLazyColumn( + modifier = Modifier.testTag("list"), + state = state, + onSelectedIndexesChange = { callbackInvocations += it }, + ) { + items(items.size, key = { items[it] }) { index -> + BasicText(items[index], modifier = Modifier.testTag(items[index])) + } + } + } + } + + composeRule.awaitIdle() + assertTrue(callbackInvocations.isEmpty()) + + composeRule.onNodeWithTag("B", useUnmergedTree = true).performClick() + composeRule.awaitIdle() + assertEquals(setOf("B"), state.selectedKeys) + assertEquals(listOf(listOf(1)), callbackInvocations) + + composeRule.onNodeWithTag("list").performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionDown) } } + composeRule.awaitIdle() + + assertEquals(setOf("B", "C"), state.selectedKeys) + assertEquals(listOf(listOf(1), listOf(1, 2)), callbackInvocations) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `MultiSelectionLazyColumn keeps non-contiguous picks and adds next item on shift down`() = runTest { + val localItems = listOf("1", "2", "3", "4", "5") + lateinit var state: MultiSelectionLazyListState + val callbackInvocations = mutableListOf>() + + composeRule.setContent { + state = rememberMultiSelectionLazyListState() + Box(modifier = Modifier.requiredHeight(300.dp)) { + MultiSelectionLazyColumn( + modifier = Modifier.testTag("list"), + state = state, + onSelectedIndexesChange = { callbackInvocations += it }, + ) { + items(localItems.size, key = { localItems[it] }) { index -> + BasicText(localItems[index], modifier = Modifier.testTag(localItems[index])) + } + } + } + } + + composeRule.awaitIdle() + // Ensure the list has keyboard focus before sending key events. + composeRule.onNodeWithTag("3", useUnmergedTree = true).performClick() + composeRule.awaitIdle() + composeRule.runOnIdle { + state.selectedKeys = setOf("1", "3") + state.lastActiveItemIndex = 2 + } + composeRule.awaitIdle() + callbackInvocations.clear() + + composeRule.onNodeWithTag("list").performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.DirectionDown) } } + composeRule.awaitIdle() + + assertEquals(setOf("1", "3", "4"), state.selectedKeys) + assertEquals(listOf(listOf(0, 2, 3)), callbackInvocations) + } +} diff --git a/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SingleAndMultiSelectionLazyListStateTest.kt b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SingleAndMultiSelectionLazyListStateTest.kt new file mode 100644 index 0000000000000..8f526d02a28af --- /dev/null +++ b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SingleAndMultiSelectionLazyListStateTest.kt @@ -0,0 +1,154 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.foundation.lazy + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@Suppress("ImplicitUnitReturnType") +internal class SingleAndMultiSelectionLazyListStateTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun `SingleSelectionLazyListState starts empty when no initial key is provided`() { + val state = SingleSelectionLazyListState(lazyListState = LazyListState()) + + assertEquals(SelectionMode.Single, state.selectionMode) + assertEquals(emptySet(), state.selectedKeys) + } + + @Test + fun `SingleSelectionLazyListState keeps only first key when assigning multiple keys`() { + val state = SingleSelectionLazyListState(lazyListState = LazyListState()) + + state.selectedKeys = linkedSetOf("A", "B", "C") + + assertEquals(setOf("A"), state.selectedKeys) + } + + @Test + fun `SingleSelectionLazyListState can be cleared`() { + val state = SingleSelectionLazyListState(lazyListState = LazyListState(), initialSelectedKey = "A") + + state.selectedKeys = emptySet() + + assertEquals(emptySet(), state.selectedKeys) + } + + @Test + fun `MultiSelectionLazyListState preserves all selected keys`() { + val state = MultiSelectionLazyListState(lazyListState = LazyListState(), initialSelectedKeys = setOf("A", "C")) + + assertEquals(SelectionMode.Multiple, state.selectionMode) + assertEquals(setOf("A", "C"), state.selectedKeys) + } + + @Test + fun `MultiSelectionLazyListState updates and clears selected keys`() { + val state = MultiSelectionLazyListState(lazyListState = LazyListState()) + + state.selectedKeys = linkedSetOf("A", "B") + assertEquals(setOf("A", "B"), state.selectedKeys) + + state.selectedKeys = emptySet() + assertEquals(emptySet(), state.selectedKeys) + } + + @Test + fun `rememberSingleSelectionLazyListState applies initial key only once`() { + var initialSelectedKey: Any? by mutableStateOf("A") + lateinit var state: SingleSelectionLazyListState + + composeRule.setContent { state = rememberSingleSelectionLazyListState(initialSelectedKey = initialSelectedKey) } + composeRule.waitForIdle() + assertEquals(setOf("A"), state.selectedKeys) + + composeRule.runOnIdle { initialSelectedKey = "B" } + composeRule.waitForIdle() + + assertEquals(setOf("A"), state.selectedKeys) + } + + @Test + fun `rememberMultiSelectionLazyListState applies initial keys only once`() { + var initialSelectedKeys by mutableStateOf(listOf("A")) + lateinit var state: MultiSelectionLazyListState + + composeRule.setContent { + state = rememberMultiSelectionLazyListState(initialSelectedKeys = initialSelectedKeys.toSet()) + } + composeRule.waitForIdle() + assertEquals(setOf("A"), state.selectedKeys) + + composeRule.runOnIdle { initialSelectedKeys = listOf("B", "C") } + composeRule.waitForIdle() + + assertEquals(setOf("A"), state.selectedKeys) + } + + @Test + fun `rememberSelectableLazyListState with SelectionMode Single keeps only first initial key`() { + lateinit var state: SelectableLazyListState + + composeRule.setContent { + state = + rememberSelectableLazyListState( + selectionMode = SelectionMode.Single, + initialSelectedKeys = listOf("A", "B", "C"), + ) + } + composeRule.waitForIdle() + + assertEquals(setOf("A"), state.selectedKeys) + } + + @Test + fun `rememberSelectableLazyListState with SelectionMode None drops all initial keys`() { + lateinit var state: SelectableLazyListState + + composeRule.setContent { + state = + rememberSelectableLazyListState( + selectionMode = SelectionMode.None, + initialSelectedKeys = listOf("A", "B"), + ) + } + composeRule.waitForIdle() + + assertEquals(emptySet(), state.selectedKeys) + } + + @Test + fun `rememberSelectableLazyListState with SelectionMode Multiple deduplicates while preserving insertion order`() { + lateinit var state: SelectableLazyListState + + composeRule.setContent { + state = + rememberSelectableLazyListState( + selectionMode = SelectionMode.Multiple, + initialSelectedKeys = listOf("B", "A", "B", "C", "A"), + ) + } + composeRule.waitForIdle() + + assertEquals(listOf("B", "A", "C"), state.selectedKeys.toList()) + } + + @Test + fun `rememberSelectableLazyListState legacy two-arg overload delegates to four-arg defaults with SelectionMode Multiple`() { + lateinit var state: SelectableLazyListState + + composeRule.setContent { state = rememberSelectableLazyListState(3, 7) } + composeRule.waitForIdle() + + assertEquals(SelectionMode.Multiple, state.selectionMode) + assertEquals(emptySet(), state.selectedKeys) + assertEquals(3, state.firstVisibleItemIndex) + assertEquals(7, state.firstVisibleItemScrollOffset) + } +}