From b132c43964e3a4af5250830b09b2d59e6df2e87d Mon Sep 17 00:00:00 2001 From: James Rose Date: Wed, 21 May 2025 14:40:17 -0700 Subject: [PATCH] Merge table from https://github.com/devkanro/jewel/tree/table --- platform/jewel/foundation/build.gradle.kts | 1 + .../jetbrains/jewel/foundation/OverflowBox.kt | 52 ++ .../foundation/lazy/draggable/Draggable.kt | 91 ++++ .../lazy/draggable/LazyLayoutDraggingState.kt | 88 +++ .../lazy/selectable/SelectionEvent.kt | 3 + .../lazy/selectable/SelectionManager.kt | 54 ++ .../lazy/selectable/SelectionMode.kt | 13 + .../lazy/selectable/SelectionType.kt | 7 + .../lazy/table/AwaitFirstLayoutModifier.kt | 29 + .../foundation/lazy/table/LazyTable.View.kt | 150 +++++ .../jewel/foundation/lazy/table/LazyTable.kt | 252 +++++++++ .../lazy/table/LazyTableAnimateScroll.kt | 345 ++++++++++++ .../lazy/table/LazyTableCellContainer.kt | 15 + .../foundation/lazy/table/LazyTableContent.kt | 30 + .../lazy/table/LazyTableDimensionScope.kt | 22 + .../lazy/table/LazyTableIntervalContent.kt | 141 +++++ .../lazy/table/LazyTableItemInfo.kt | 21 + .../lazy/table/LazyTableItemKeyPositionMap.kt | 81 +++ .../lazy/table/LazyTableItemProvider.kt | 125 +++++ .../lazy/table/LazyTableItemScope.kt | 23 + .../lazy/table/LazyTableLayoutInfo.kt | 73 +++ .../lazy/table/LazyTableLayoutScope.kt | 17 + .../foundation/lazy/table/LazyTableMeasure.kt | 513 ++++++++++++++++++ .../lazy/table/LazyTableMeasureResult.kt | 36 ++ .../lazy/table/LazyTableMeasuredItem.kt | 62 +++ .../table/LazyTableMeasuredItemProvider.kt | 105 ++++ .../lazy/table/LazyTableNearestRangeState.kt | 51 ++ .../foundation/lazy/table/LazyTableScope.kt | 43 ++ .../lazy/table/LazyTableScrollPosition.kt | 69 +++ .../lazy/table/LazyTableScrollbarAdapter.kt | 191 +++++++ .../foundation/lazy/table/LazyTableState.kt | 294 ++++++++++ .../foundation/lazy/table/LazyTableStyle.kt | 54 ++ .../lazy/table/draggable/Draggable.kt | 51 ++ .../draggable/LazyTableDraggableState.kt | 120 ++++ .../table/selectable/SelectChangedElement.kt | 72 +++ .../lazy/table/selectable/Selectable.kt | 15 + .../table/selectable/SelectableCellElement.kt | 121 +++++ .../selectable/SingleCellSelectionManager.kt | 57 ++ .../selectable/SingleRowSelectionManager.kt | 48 ++ .../table/selectable/TableSelectionEvent.kt | 13 + .../table/selectable/TableSelectionManager.kt | 38 ++ .../table/selectable/TableSelectionUnit.kt | 15 + .../foundation/lazy/table/view/ColumnView.kt | 38 ++ .../lazy/table/view/DelegatedTableView.kt | 38 ++ .../lazy/table/view/InMemoryTableView.kt | 191 +++++++ .../lazy/table/view/LazyTableViewContent.kt | 70 +++ .../lazy/table/view/SortableTableView.kt | 44 ++ .../foundation/lazy/table/view/TableView.kt | 85 +++ .../table/view/TableViewKeyPositionMap.kt | 19 + .../lazy/table/view/TableViewWithHeader.kt | 56 ++ .../jewel/bridge/theme/IntUiBridge.kt | 1 + .../bridge/theme/IntUiBridgeLazyTable.kt | 25 + .../api/int-ui-standalone.api | 8 + .../standalone/styling/IntUiTableStyling.kt | 72 +++ .../intui/standalone/theme/IntUiTheme.kt | 5 + ...tellij.platform.jewel.samples.showcase.iml | 1 + .../samples/showcase/components/Tables.kt | 266 +++++++++ .../showcase/views/ComponentsViewModel.kt | 4 + .../standalone/view/component/Debug.kt | 25 + .../standalone/view/component/LongList.kt | 81 +++ .../standalone/view/component/TableView.kt | 235 ++++++++ .../view/component/package-info.java | 0 ...llij.platform.jewel.samples.standalone.iml | 1 + .../samples/standalone/view/TitleBarView.kt | 3 + .../standalone/view/component/FpsCounter.kt | 140 +++++ .../resources/icons/components/dataTables.svg | 6 + .../icons/components/dataTables_dark.svg | 13 + .../main/resources/icons/components/debug.svg | 11 + .../icons/components/debug@20x20.svg | 11 + .../icons/components/debug@20x20_dark.svg | 11 + .../resources/icons/components/debug_dark.svg | 11 + platform/jewel/ui/api/ui.api | 52 ++ .../jewel/ui/DefaultComponentStyling.kt | 4 + .../org/jetbrains/jewel/ui/component/Table.kt | 109 ++++ .../ui/component/styling/TableStyling.kt | 150 +++++ .../jetbrains/jewel/ui/theme/JewelTheme.kt | 8 + 76 files changed, 5395 insertions(+) create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/OverflowBox.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/Draggable.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/LazyLayoutDraggingState.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionEvent.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionManager.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionMode.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionType.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/AwaitFirstLayoutModifier.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.View.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableAnimateScroll.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableCellContainer.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableContent.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableDimensionScope.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableIntervalContent.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemInfo.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemKeyPositionMap.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemProvider.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemScope.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutScope.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItem.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItemProvider.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableNearestRangeState.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScope.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableStyle.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/Draggable.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/LazyTableDraggableState.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectChangedElement.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/Selectable.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectableCellElement.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleCellSelectionManager.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleRowSelectionManager.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionEvent.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionManager.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionUnit.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/ColumnView.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/DelegatedTableView.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/InMemoryTableView.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/LazyTableViewContent.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/SortableTableView.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableView.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewKeyPositionMap.kt create mode 100644 platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewWithHeader.kt create mode 100644 platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeLazyTable.kt create mode 100644 platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/styling/IntUiTableStyling.kt create mode 100644 platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/Tables.kt create mode 100644 platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Debug.kt create mode 100644 platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/LongList.kt create mode 100644 platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/TableView.kt rename platform/jewel/samples/{standalone => showcase}/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/package-info.java (100%) create mode 100644 platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/FpsCounter.kt create mode 100644 platform/jewel/samples/standalone/src/main/resources/icons/components/dataTables.svg create mode 100644 platform/jewel/samples/standalone/src/main/resources/icons/components/dataTables_dark.svg create mode 100644 platform/jewel/samples/standalone/src/main/resources/icons/components/debug.svg create mode 100644 platform/jewel/samples/standalone/src/main/resources/icons/components/debug@20x20.svg create mode 100644 platform/jewel/samples/standalone/src/main/resources/icons/components/debug@20x20_dark.svg create mode 100644 platform/jewel/samples/standalone/src/main/resources/icons/components/debug_dark.svg create mode 100644 platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Table.kt create mode 100644 platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/styling/TableStyling.kt diff --git a/platform/jewel/foundation/build.gradle.kts b/platform/jewel/foundation/build.gradle.kts index cd91c579ca8c9..714b065ac959f 100644 --- a/platform/jewel/foundation/build.gradle.kts +++ b/platform/jewel/foundation/build.gradle.kts @@ -13,6 +13,7 @@ private val composeVersion dependencies { api("org.jetbrains.compose.foundation:foundation-desktop:$composeVersion") + implementation("org.jetbrains.compose.ui:ui-util-desktop:$composeVersion") testImplementation(compose.desktop.uiTestJUnit4) testImplementation(compose.desktop.currentOs) { exclude(group = "org.jetbrains.compose.material") } diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/OverflowBox.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/OverflowBox.kt new file mode 100644 index 0000000000000..a0ef02265316f --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/OverflowBox.kt @@ -0,0 +1,52 @@ +package org.jetbrains.jewel.foundation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import org.jetbrains.jewel.foundation.modifier.onHover + +@ExperimentalJewelApi +@Composable +public fun OverflowBox( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + overflowZIndex: Float = 1f, + content: @Composable BoxScope.() -> Unit, +) { + var showOverflow by remember { mutableStateOf(false) } + + Box( + Modifier.layout { measurable, constraints -> + val predictWidth = measurable.maxIntrinsicWidth(constraints.maxHeight) + val constraintWith = constraints.maxWidth + + val overflowing = showOverflow && predictWidth > constraintWith + + val targetConstraints = + if (overflowing) { + constraints.copy(minWidth = 0, maxWidth = Constraints.Infinity) + } else { + constraints + } + val zIndex = if (overflowing) overflowZIndex else 0f + + // Trick for overflow layout, I don't know why cell align center without offset. + val offset = if (overflowing) (predictWidth - constraintWith) / 2 else 0 + + val placements = measurable.measure(targetConstraints) + layout(placements.width, placements.height) { placements.placeRelative(offset, 0, zIndex) } + } + .onHover { showOverflow = it } + .then(modifier), + contentAlignment = contentAlignment, + content = content, + ) +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/Draggable.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/Draggable.kt new file mode 100644 index 0000000000000..b8b940071d012 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/Draggable.kt @@ -0,0 +1,91 @@ +package org.jetbrains.jewel.foundation.lazy.draggable + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.modifier.ModifierLocal +import androidx.compose.ui.modifier.modifierLocalConsumer +import androidx.compose.ui.modifier.modifierLocalOf +import androidx.compose.ui.modifier.modifierLocalProvider +import androidx.compose.ui.zIndex + +internal val ModifierLocalDraggableLayoutOffset = modifierLocalOf { Offset.Zero } + +internal fun Modifier.draggableLayout(): Modifier = composed { + var offset by remember { mutableStateOf(Offset.Zero) } + + this.onGloballyPositioned { offset = it.positionInRoot() } + .modifierLocalProvider(ModifierLocalDraggableLayoutOffset) { offset } +} + +internal fun Modifier.draggingGestures(stateLocal: ModifierLocal>, key: Any?): Modifier = + composed { + var state by remember { mutableStateOf?>(null) } + var itemOffset by remember { mutableStateOf(Offset.Zero) } + var layoutOffset by remember { mutableStateOf(Offset.Zero) } + + modifierLocalConsumer { + state = stateLocal.current + layoutOffset = ModifierLocalDraggableLayoutOffset.current + } + .then( + if (state != null) { + Modifier.onGloballyPositioned { itemOffset = it.positionInRoot() } + .pointerInput(Unit) { + detectDragGestures( + onDrag = { change, offset -> + change.consume() + state?.onDrag(offset) + }, + onDragStart = { state?.onDragStart(key, it + itemOffset - layoutOffset) }, + onDragEnd = { state?.onDragInterrupted() }, + onDragCancel = { state?.onDragInterrupted() }, + ) + } + } else { + Modifier + } + ) + } + +internal fun Modifier.draggingOffset( + stateLocal: ModifierLocal>, + key: Any?, + orientation: Orientation? = null, +): Modifier = composed { + var state by remember { mutableStateOf?>(null) } + val dragging = state?.draggingItemKey == key + + this.modifierLocalConsumer { state = stateLocal.current } + .then( + if (state != null && dragging) { + Modifier.zIndex(2f) + .graphicsLayer( + translationX = + if (orientation == Orientation.Vertical) { + 0f + } else { + state?.draggingItemOffsetTransformX ?: 0f + }, + translationY = + if (orientation == Orientation.Horizontal) { + 0f + } else { + state?.draggingItemOffsetTransformY ?: 0f + }, + ) + } else { + Modifier + } + ) +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/LazyLayoutDraggingState.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/LazyLayoutDraggingState.kt new file mode 100644 index 0000000000000..afec90eb3ae71 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/LazyLayoutDraggingState.kt @@ -0,0 +1,88 @@ +package org.jetbrains.jewel.foundation.lazy.draggable + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size + +public abstract class LazyLayoutDraggingState { + public var draggingItemOffsetTransformX: Float by mutableStateOf(0f) + + public var draggingItemOffsetTransformY: Float by mutableStateOf(0f) + + public var draggingItemKey: Any? by mutableStateOf(null) + + public var initialOffset: Offset = Offset.Zero + + public var draggingOffset: Offset = Offset.Zero + + internal val interactionSource: MutableInteractionSource = MutableInteractionSource() + + public fun onDragStart(key: Any?, offset: Offset) { + draggingItemKey = key + initialOffset = offset + draggingOffset = Offset.Zero + draggingItemOffsetTransformX = 0f + draggingItemOffsetTransformY = 0f + + println("Drag start with '$key' at '$offset'") + } + + public fun onDrag(offset: Offset) { + draggingItemOffsetTransformX += offset.x + draggingItemOffsetTransformY += offset.y + draggingOffset += offset + + val draggingItem = getItemWithKey(draggingItemKey ?: return) ?: return + val hoverItem = getReplacingItem(draggingItem) + + if (hoverItem != null && draggingItem.key != hoverItem.key) { + val targetOffset = + if (draggingItem.index < hoverItem.index) { + val maxOffset = hoverItem.offset + Offset(hoverItem.size.width, hoverItem.size.height) + maxOffset - Offset(draggingItem.size.width, draggingItem.size.height) + } else { + hoverItem.offset + } + + val changedOffset = draggingItem.offset - targetOffset + + println("Drag '${draggingItem.key}(${draggingItem.size})' at ${draggingItem.offset}") + println("Over '${hoverItem.key}(${hoverItem.size})' at ${hoverItem.offset}") + println("Into $targetOffset With $changedOffset") + + if (moveItem(draggingItem.key, hoverItem.key)) { + draggingItemOffsetTransformX += changedOffset.x + draggingItemOffsetTransformY += changedOffset.y + } + } + } + + public fun onDragInterrupted() { + println("Drag end") + + draggingItemKey = null + initialOffset = Offset.Zero + draggingOffset = Offset.Zero + draggingItemOffsetTransformX = 0f + draggingItemOffsetTransformY = 0f + } + + public abstract fun canMove(key: Any?): Boolean + + public abstract fun moveItem(from: Any?, to: Any?): Boolean + + public abstract fun getReplacingItem(draggingItem: T): T? + + public abstract fun getItemWithKey(key: Any): T? + + public abstract val T.offset: Offset + + public abstract val T.size: Size + + public abstract val T.index: Int + + public abstract val T.key: Any? +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionEvent.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionEvent.kt new file mode 100644 index 0000000000000..c727716692c4b --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionEvent.kt @@ -0,0 +1,3 @@ +package org.jetbrains.jewel.foundation.lazy.selectable + +public interface SelectionEvent diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionManager.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionManager.kt new file mode 100644 index 0000000000000..d04364b04206a --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionManager.kt @@ -0,0 +1,54 @@ +package org.jetbrains.jewel.foundation.lazy.selectable + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.input.pointer.isCtrlPressed +import androidx.compose.ui.input.pointer.isMetaPressed +import androidx.compose.ui.input.pointer.isShiftPressed +import androidx.compose.ui.modifier.modifierLocalConsumer +import androidx.compose.ui.modifier.modifierLocalOf +import androidx.compose.ui.modifier.modifierLocalProvider + +public interface SelectionManager { + public val interactionSource: MutableInteractionSource + + public fun isSelectable(itemKey: Any?): Boolean + + public fun isSelected(itemKey: Any?): Boolean + + public fun handleEvent(event: SelectionEvent) + + public fun clearSelection() +} + +internal val ModifierLocalSelectionManager = modifierLocalOf { null } + +public fun Modifier.selectionManager(manager: SelectionManager): Modifier = + focusable(interactionSource = manager.interactionSource).focusGroup().modifierLocalProvider( + ModifierLocalSelectionManager + ) { + manager + } + +public fun Modifier.selectionManagerConsumer(factory: @Composable (SelectionManager) -> Modifier): Modifier = composed { + var manager by remember { mutableStateOf(null) } + + this.modifierLocalConsumer { manager = ModifierLocalSelectionManager.current } + .then(if (manager != null) factory(manager!!) else Modifier) +} + +internal fun PointerKeyboardModifiers.selectionType(): SelectionType = + when { + this.isCtrlPressed || this.isMetaPressed -> SelectionType.Multi + this.isShiftPressed -> SelectionType.Contiguous + else -> SelectionType.Normal + } diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionMode.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionMode.kt new file mode 100644 index 0000000000000..f9067f2509952 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionMode.kt @@ -0,0 +1,13 @@ +package org.jetbrains.jewel.foundation.lazy.selectable + +/** Specifies the selection mode for a selectable lazy list. */ +public enum class SelectionMode { + /** No selection is allowed. */ + None, + + /** Only a single cell can be selected. */ + Single, + + /** Multiple cells can be selected. */ + Multiple, +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionType.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionType.kt new file mode 100644 index 0000000000000..a96ff81f8fd49 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionType.kt @@ -0,0 +1,7 @@ +package org.jetbrains.jewel.foundation.lazy.selectable + +public enum class SelectionType { + Normal, + Contiguous, + Multi, +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/AwaitFirstLayoutModifier.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/AwaitFirstLayoutModifier.kt new file mode 100644 index 0000000000000..878d90b1aa7e0 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/AwaitFirstLayoutModifier.kt @@ -0,0 +1,29 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.OnGloballyPositionedModifier +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal class AwaitFirstLayoutModifier : OnGloballyPositionedModifier { + + private var wasPositioned = false + private var continuation: Continuation? = null + + suspend fun waitForFirstLayout() { + if (!wasPositioned) { + val oldContinuation = continuation + suspendCoroutine { continuation = it } + oldContinuation?.resume(Unit) + } + } + + override fun onGloballyPositioned(coordinates: LayoutCoordinates) { + if (!wasPositioned) { + wasPositioned = true + continuation?.resume(Unit) + continuation = null + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.View.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.View.kt new file mode 100644 index 0000000000000..feef31316eda8 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.View.kt @@ -0,0 +1,150 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.layout.LazyLayout +import androidx.compose.foundation.overscroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.modifier.modifierLocalProvider +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.lazy.draggable.draggableLayout +import org.jetbrains.jewel.foundation.lazy.table.draggable.LazyTableColumnDraggingState +import org.jetbrains.jewel.foundation.lazy.table.draggable.LazyTableRowDraggingState +import org.jetbrains.jewel.foundation.lazy.table.draggable.ModifierLocalLazyTableColumnDraggingState +import org.jetbrains.jewel.foundation.lazy.table.draggable.ModifierLocalLazyTableRowDraggingState +import org.jetbrains.jewel.foundation.lazy.table.selectable.selectionManager +import org.jetbrains.jewel.foundation.lazy.table.view.LazyTableViewContent +import org.jetbrains.jewel.foundation.lazy.table.view.SortableTableView +import org.jetbrains.jewel.foundation.lazy.table.view.TableView +import org.jetbrains.jewel.foundation.lazy.table.view.TableViewKeyPositionMap + +@Composable +public fun LazyTable( + modifier: Modifier = Modifier, + state: LazyTableState = rememberLazyTableState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + beyondBoundsItemCount: Int = 0, + horizontalArrangement: Arrangement.Horizontal? = null, + verticalArrangement: Arrangement.Vertical? = null, + style: LazyTableStyle = LazyTableStyle, + view: TableView, +) { + println("Recompose Table") + val itemProviderLambda = rememberLazyTableItemProviderLambda(state, style, view) + + val measurePolicy = + rememberLazyTabletMeasurePolicy( + itemProviderLambda = itemProviderLambda, + state = state, + pinnedColumns = view.pinnedColumns(), + pinnedRows = view.pinnedRows(), + contentPadding = contentPadding, + beyondBoundsItemCount = beyondBoundsItemCount, + horizontalArrangement = horizontalArrangement, + verticalArrangement = verticalArrangement, + ) + + val overscrollEffect = ScrollableDefaults.overscrollEffect() + + LazyLayout( + modifier = + modifier + .draggableTable(state, view) + .selectionManager(view) + .then(state.remeasurementModifier) + .then(state.awaitLayoutModifier) + .overscroll(overscrollEffect) + .scrollable( + orientation = Orientation.Vertical, + interactionSource = state.internalInteractionSource, + reverseDirection = + ScrollableDefaults.reverseDirection(LocalLayoutDirection.current, Orientation.Vertical, false), + flingBehavior = flingBehavior, + state = state.verticalScrollableState, + overscrollEffect = overscrollEffect, + enabled = userScrollEnabled, + ) + .scrollable( + orientation = Orientation.Horizontal, + interactionSource = state.internalInteractionSource, + reverseDirection = + ScrollableDefaults.reverseDirection( + LocalLayoutDirection.current, + Orientation.Horizontal, + false, + ), + flingBehavior = flingBehavior, + state = state.horizontalScrollableState, + overscrollEffect = overscrollEffect, + enabled = userScrollEnabled, + ) + .clip(RectangleShape), + prefetchState = state.prefetchState, + measurePolicy = measurePolicy, + itemProvider = itemProviderLambda, + ) +} + +@Composable +internal fun rememberLazyTableItemProviderLambda( + state: LazyTableState, + style: LazyTableStyle, + view: TableView, +): () -> LazyTableItemProvider { + val itemProvider = + remember(state, style) { + val scope = LazyTableItemScopeImpl() + + val intervalContent = LazyTableViewContent(state, style, view) + + val map = TableViewKeyPositionMap(view) + + LazyTableItemProviderImpl(state = state, content = intervalContent, itemScope = scope, keyPositionMap = map) + } + + return { itemProvider } +} + +@Composable +private fun Modifier.draggableTable(state: LazyTableState, view: TableView) = composed { + if (view !is SortableTableView) return@composed this + if (!view.supportColumnSorting() && !view.supportRowSorting()) return@composed this + + val columnDraggingModifier = + if (view.supportColumnSorting()) { + val columnDraggingState = + remember(state, view) { + LazyTableColumnDraggingState(state, { false }) { fromKey, toKey -> view.moveColumn(fromKey, toKey) } + } + + Modifier.modifierLocalProvider(ModifierLocalLazyTableColumnDraggingState) { columnDraggingState } + } else { + Modifier + } + + val rowDraggingModifier = + if (view.supportRowSorting()) { + val rowDraggingState = + remember(state, view) { + LazyTableRowDraggingState(state, { false }) { fromKey, toKey -> view.moveRow(fromKey, toKey) } + } + + Modifier.modifierLocalProvider(ModifierLocalLazyTableRowDraggingState) { rowDraggingState } + } else { + Modifier + } + + this@composed.draggableLayout().then(columnDraggingModifier).then(rowDraggingModifier) +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt new file mode 100644 index 0000000000000..d6463f121ba68 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt @@ -0,0 +1,252 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.lazy.layout.LazyLayout +import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope +import androidx.compose.foundation.overscroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset + +@Composable +public fun LazyTable( + modifier: Modifier = Modifier, + state: LazyTableState = rememberLazyTableState(), + pinnedColumns: Int = 0, + pinnedRows: Int = 0, + contentPadding: PaddingValues = PaddingValues(0.dp), + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + beyondBoundsItemCount: Int = 0, + horizontalArrangement: Arrangement.Horizontal? = null, + verticalArrangement: Arrangement.Vertical? = null, + style: LazyTableStyle = LazyTableStyle, + content: LazyTableScope.() -> LazyTableCells, +) { + println("Recompose Table") + val itemProviderLambda = rememberLazyTableItemProviderLambda(state, style, pinnedColumns, pinnedRows, content) + + val measurePolicy = + rememberLazyTabletMeasurePolicy( + itemProviderLambda = itemProviderLambda, + state = state, + pinnedColumns = pinnedColumns, + pinnedRows = pinnedRows, + contentPadding = contentPadding, + beyondBoundsItemCount = beyondBoundsItemCount, + horizontalArrangement = horizontalArrangement, + verticalArrangement = verticalArrangement, + ) + + val overscrollEffect = ScrollableDefaults.overscrollEffect() + + LazyLayout( + modifier = + modifier + .then(state.remeasurementModifier) + .then(state.awaitLayoutModifier) + .overscroll(overscrollEffect) + .scrollable( + orientation = Orientation.Vertical, + interactionSource = state.internalInteractionSource, + reverseDirection = + ScrollableDefaults.reverseDirection(LocalLayoutDirection.current, Orientation.Vertical, false), + flingBehavior = flingBehavior, + state = state.verticalScrollableState, + overscrollEffect = overscrollEffect, + enabled = userScrollEnabled, + ) + .scrollable( + orientation = Orientation.Horizontal, + interactionSource = state.internalInteractionSource, + reverseDirection = + ScrollableDefaults.reverseDirection( + LocalLayoutDirection.current, + Orientation.Horizontal, + false, + ), + flingBehavior = flingBehavior, + state = state.horizontalScrollableState, + overscrollEffect = overscrollEffect, + enabled = userScrollEnabled, + ) + .clip(RectangleShape), + prefetchState = state.prefetchState, + measurePolicy = measurePolicy, + itemProvider = itemProviderLambda, + ) +} + +@Composable +internal fun rememberLazyTabletMeasurePolicy( + itemProviderLambda: () -> LazyTableItemProvider, + state: LazyTableState, + pinnedColumns: Int, + pinnedRows: Int, + contentPadding: PaddingValues, + beyondBoundsItemCount: Int, + horizontalArrangement: Arrangement.Horizontal? = null, + verticalArrangement: Arrangement.Vertical? = null, +): LazyLayoutMeasureScope.(Constraints) -> MeasureResult = + remember MeasureResult>( + itemProviderLambda, + state, + pinnedColumns, + pinnedRows, + contentPadding, + beyondBoundsItemCount, + ) { + { containerConstraints -> + check( + containerConstraints.maxHeight != Constraints.Infinity && + containerConstraints.maxWidth != Constraints.Infinity + ) { + "LazyTable does not support infinite constraints." + } + + state.density = this + + val startPadding = contentPadding.calculateStartPadding(layoutDirection).roundToPx() + val endPadding = contentPadding.calculateEndPadding(layoutDirection).roundToPx() + val topPadding = contentPadding.calculateTopPadding().roundToPx() + val bottomPadding = contentPadding.calculateBottomPadding().roundToPx() + + val totalVerticalPadding = topPadding + bottomPadding + val totalHorizontalPadding = startPadding + endPadding + + val contentConstraints = containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) + + val availableSize = + IntSize( + containerConstraints.maxWidth - totalHorizontalPadding, + containerConstraints.maxHeight - totalVerticalPadding, + ) + + val visualItemOffset = IntOffset(startPadding, topPadding) + + val horizontalSpacing = horizontalArrangement?.spacing?.roundToPx() ?: 0 + val verticalSpacing = verticalArrangement?.spacing?.roundToPx() ?: 0 + + val itemProvider = itemProviderLambda() + val measuredItemProvider = + object : + LazyTableMeasuredItemProvider( + availableSize = availableSize, + rows = itemProvider.rowCount, + columns = itemProvider.columnCount, + horizontalSpacing = horizontalSpacing, + verticalSpacing = verticalSpacing, + itemProvider = itemProvider, + measureScope = this, + density = this, + ) { + override fun createItem( + column: Int, + row: Int, + size: IntSize, + key: Any, + contentType: Any?, + placeables: List, + ): LazyTableMeasuredItem { + // we add spaceBetweenItems as an extra spacing for all items apart from the last one so + // the lazy list measuring logic will take it into account. + val coordinate = IntOffset(column, row) + val index = itemProvider.getIndex(coordinate) + return LazyTableMeasuredItem( + index = index, + row = row, + column = column, + size = size, + placeables = placeables, + alignment = Alignment.TopStart, + layoutDirection = layoutDirection, + visualOffset = visualItemOffset, + key = key, + contentType = contentType, + ) + } + } + + val firstVisibleItemCoordinate: IntOffset + val firstVisibleScrollOffset: IntOffset + + Snapshot.withoutReadObservation { + firstVisibleItemCoordinate = IntOffset(state.firstVisibleColumnIndex, state.firstVisibleRowIndex) + firstVisibleScrollOffset = + IntOffset(state.firstVisibleItemHorizontalScrollOffset, state.firstVisibleItemVerticalScrollOffset) + } + + state.applyTableInfo( + LazyTableInfo( + columns = itemProvider.columnCount, + rows = itemProvider.rowCount, + pinnedColumns = pinnedColumns, + pinnedRows = pinnedRows, + ) + ) + + measureLazyTable( + constraints = contentConstraints, + availableSize = availableSize, + rows = itemProvider.rowCount, + columns = itemProvider.columnCount, + pinnedColumns = minOf(pinnedColumns, itemProvider.columnCount), + pinnedRows = minOf(pinnedRows, itemProvider.rowCount), + measuredItemProvider = measuredItemProvider, + horizontalSpacing = horizontalSpacing, + verticalSpacing = verticalSpacing, + firstVisibleCellPosition = firstVisibleItemCoordinate, + firstVisibleCellScrollOffset = firstVisibleScrollOffset, + scrollToBeConsumed = Offset(state.scrollToBeConsumedHorizontal, state.scrollToBeConsumedVertical), + density = this, + beyondBoundsItemCount = beyondBoundsItemCount, + layout = { width, height, placement -> + layout( + containerConstraints.constrainWidth(width + totalHorizontalPadding), + containerConstraints.constrainHeight(height + totalVerticalPadding), + emptyMap(), + placement, + ) + }, + ) + .also { state.applyMeasureResult(it) } + } + } + +public interface LazyTableRowScope { + public fun column(column: Int, content: @Composable (LazyTableCellState) -> Unit) +} + +public interface LazyTableColumnScope { + public fun row(row: Int, content: @Composable (LazyTableCellState) -> Unit) +} + +public interface LazyTableCellState { + public val row: Int + + public val column: Int + + public val selected: Boolean +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableAnimateScroll.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableAnimateScroll.kt new file mode 100644 index 0000000000000..978ff5e2cb77f --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableAnimateScroll.kt @@ -0,0 +1,345 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.copy +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMaxOfOrNull +import androidx.compose.ui.util.fastSumBy +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs +import org.jetbrains.jewel.foundation.util.myLogger + +internal interface LazyTableAnimateScrollScope { + val density: Density + + val firstVisibleLineIndex: Int + + val firstVisibleLineScrollOffset: Int + + val lastVisibleLineIndex: Int + + val lineCount: Int + + fun getTargetLineOffset(index: Int): Int? + + fun getStartLineOffset(): Int + + fun ScrollScope.snapToLine(index: Int, scrollOffset: Int) + + fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float + + /** defines min number of items that forces scroll to snap if animation did not reach it */ + val numOfLinesForTeleport: Int + + suspend fun scroll(block: suspend ScrollScope.() -> Unit) +} + +private val TargetDistance = 2500.dp +private val BoundDistance = 1500.dp +private val MinimumDistance = 50.dp + +private class LineFoundInScroll(val lineOffset: Int, val previousAnimation: AnimationState) : + CancellationException() + +internal suspend fun LazyTableAnimateScrollScope.animateScrollToItem(index: Int, scrollOffset: Int) { + scroll { + require(index >= 0f) { "Index should be non-negative ($index)" } + try { + val targetDistancePx = with(density) { TargetDistance.toPx() } + val boundDistancePx = with(density) { BoundDistance.toPx() } + val minDistancePx = with(density) { MinimumDistance.toPx() } + var loop = true + var anim = AnimationState(0f) + val targetItemInitialOffset = getTargetLineOffset(index) + if (targetItemInitialOffset != null) { + // It's already visible, just animate directly + throw LineFoundInScroll(targetItemInitialOffset, anim) + } + val forward = index > firstVisibleLineIndex + + fun isOvershot(): Boolean { + // Did we scroll past the item? + @Suppress("RedundantIf") // It's way easier to understand the logic this way + return if (forward) { + if (firstVisibleLineIndex > index) { + true + } else if (firstVisibleLineIndex == index && firstVisibleLineScrollOffset > scrollOffset) { + true + } else { + false + } + } else { // backward + if (firstVisibleLineIndex < index) { + true + } else if (firstVisibleLineIndex == index && firstVisibleLineScrollOffset < scrollOffset) { + true + } else { + false + } + } + } + + var loops = 1 + while (loop && lineCount > 0) { + val expectedDistance = expectedDistanceTo(index, scrollOffset) + val target = + if (abs(expectedDistance) < targetDistancePx) { + val absTargetPx = maxOf(abs(expectedDistance), minDistancePx) + if (forward) absTargetPx else -absTargetPx + } else { + if (forward) targetDistancePx else -targetDistancePx + } + + myLogger() + .debug( + "Scrolling to index=$index offset=$scrollOffset from " + + "index=$firstVisibleLineIndex offset=$firstVisibleLineScrollOffset with " + + " calculated target=$target" + ) + + anim = anim.copy(value = 0f) + var prevValue = 0f + anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) { + // If we haven't found the item yet, check if it's visible. + var targetItemOffset = getTargetLineOffset(index) + + if (targetItemOffset == null) { + // Springs can overshoot their target, clamp to the desired range + val coercedValue = + if (target > 0) { + value.coerceAtMost(target) + } else { + value.coerceAtLeast(target) + } + val delta = coercedValue - prevValue + myLogger().debug("Scrolling by $delta (target: $target, coercedValue: $coercedValue)") + + val consumed = scrollBy(delta) + targetItemOffset = getTargetLineOffset(index) + if (targetItemOffset != null) { + // debugLog { "Found the item after performing scrollBy()" + // } + } else if (!isOvershot()) { + if (delta != consumed) { + myLogger().debug("Hit end without finding the item") + cancelAnimation() + loop = false + return@animateTo + } + prevValue += delta + if (forward) { + if (value > boundDistancePx) { + myLogger().debug("Struck bound going forward") + cancelAnimation() + } + } else { + if (value < -boundDistancePx) { + myLogger().debug("Struck bound going backward") + cancelAnimation() + } + } + + if (forward) { + if (loops >= 2 && index - lastVisibleLineIndex > numOfLinesForTeleport) { + // Teleport + myLogger().debug("Teleport forward") + snapToLine(index = index - numOfLinesForTeleport, scrollOffset = 0) + } + } else { + if (loops >= 2 && firstVisibleLineIndex - index > numOfLinesForTeleport) { + // Teleport + myLogger().debug("Teleport backward") + snapToLine(index = index + numOfLinesForTeleport, scrollOffset = 0) + } + } + } + } + + // We don't throw LineFoundInScroll when we snap, because once we've snapped to + // the final position, there's no need to animate to it. + if (isOvershot()) { + myLogger() + .debug( + "Overshot, " + + "item $firstVisibleLineIndex at $firstVisibleLineScrollOffset, " + + "target is $scrollOffset" + ) + snapToLine(index = index, scrollOffset = scrollOffset) + loop = false + cancelAnimation() + return@animateTo + } else if (targetItemOffset != null) { + myLogger().debug("Found item") + throw LineFoundInScroll(targetItemOffset, anim) + } + } + + loops++ + } + } catch (itemFound: LineFoundInScroll) { + // We found it, animate to it + // Bring to the requested position - will be automatically stopped if not possible + val anim = itemFound.previousAnimation.copy(value = 0f) + val target = (itemFound.lineOffset + scrollOffset - getStartLineOffset()).toFloat() + var prevValue = 0f + myLogger().debug("Seeking by $target at velocity ${itemFound.previousAnimation.velocity}") + anim.animateTo(target, sequentialAnimation = (anim.velocity != 0f)) { + // Springs can overshoot their target, clamp to the desired range + val coercedValue = + when { + target > 0 -> { + value.coerceAtMost(target) + } + target < 0 -> { + value.coerceAtLeast(target) + } + else -> { + myLogger().debug("WARNING: somehow ended up seeking 0px, this shouldn't happen") + 0f + } + } + val delta = coercedValue - prevValue + myLogger().debug("Seeking by $delta (coercedValue = $coercedValue)") + val consumed = scrollBy(delta) + if ( + delta != consumed /* hit the end, stop */ || coercedValue != value // would have overshot, stop + ) { + cancelAnimation() + } + prevValue += delta + } + // Once we're finished the animation, snap to the exact position to account for + // rounding error (otherwise we tend to end up with the previous item scrolled the + // tiniest bit onscreen) + // TODO: prevent temporarily scrolling *past* the item + snapToLine(index = index, scrollOffset = scrollOffset) + } + } +} + +internal class LazyTableAnimateHorizontalScrollScope(private val state: LazyTableState) : LazyTableAnimateScrollScope { + override val density: Density + get() = state.density + + override val firstVisibleLineIndex: Int + get() = state.firstVisibleColumnIndex + + override val firstVisibleLineScrollOffset: Int + get() = state.firstVisibleItemHorizontalScrollOffset + + override val lastVisibleLineIndex: Int + get() = + state.layoutInfo.pinnedRowsInfo.lastOrNull()?.column + ?: state.layoutInfo.floatingItemsInfo.lastOrNull()?.column + ?: 0 + + override val lineCount: Int + get() = state.layoutInfo.columns + + override val numOfLinesForTeleport: Int = 100 + + override fun getTargetLineOffset(index: Int): Int? { + if (state.layoutInfo.pinnedColumns > index) { + return 0 + } + + state.layoutInfo.floatingItemsInfo.fastForEach { + if (it.column == index) { + return it.offset.x + } + } + return null + } + + override fun getStartLineOffset(): Int = + (state.layoutInfo.pinnedItemsInfo.fastMaxOfOrNull { it.offset.x + it.size.width } ?: 0) + + state.layoutInfo.horizontalSpacing + + override fun ScrollScope.snapToLine(index: Int, scrollOffset: Int) { + state.snapToColumnInternal(index, scrollOffset) + } + + override fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float { + if (state.layoutInfo.pinnedColumns > index) { + return 0f + } + + val layoutInfo = state.layoutInfo + val visibleItems = layoutInfo.floatingItemsInfo + val averageSize = visibleItems.fastSumBy { it.size.width } / visibleItems.size + layoutInfo.horizontalSpacing + val indexesDiff = index - firstVisibleLineIndex + var coercedOffset = minOf(abs(targetScrollOffset), averageSize) + if (targetScrollOffset < 0) coercedOffset *= -1 + return (averageSize * indexesDiff).toFloat() + coercedOffset - firstVisibleLineScrollOffset + } + + override suspend fun scroll(block: suspend ScrollScope.() -> Unit) { + state.horizontalScroll(block = block) + } +} + +internal class LazyTableAnimateVerticalScrollScope(private val state: LazyTableState) : LazyTableAnimateScrollScope { + override val density: Density + get() = state.density + + override val firstVisibleLineIndex: Int + get() = state.firstVisibleRowIndex + + override val firstVisibleLineScrollOffset: Int + get() = state.firstVisibleItemVerticalScrollOffset + + override val lastVisibleLineIndex: Int + get() = + state.layoutInfo.pinnedColumnsInfo.lastOrNull()?.row + ?: state.layoutInfo.floatingItemsInfo.lastOrNull()?.row + ?: 0 + + override val lineCount: Int + get() = state.layoutInfo.rows + + override val numOfLinesForTeleport: Int = 100 + + override fun getTargetLineOffset(index: Int): Int? { + if (state.layoutInfo.pinnedRows > index) { + return 0 + } + + state.layoutInfo.floatingItemsInfo.fastForEach { + if (it.row == index) { + return it.offset.y + } + } + return null + } + + override fun getStartLineOffset(): Int = + (state.layoutInfo.pinnedItemsInfo.fastMaxOfOrNull { it.offset.y + it.size.height } ?: 0) + + state.layoutInfo.verticalSpacing + + override fun ScrollScope.snapToLine(index: Int, scrollOffset: Int) { + state.snapToRowInternal(index, scrollOffset) + } + + override fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float { + if (state.layoutInfo.pinnedColumns > index) { + return 0f + } + + val layoutInfo = state.layoutInfo + val visibleItems = layoutInfo.floatingItemsInfo + val averageSize = visibleItems.fastSumBy { it.size.height } / visibleItems.size + layoutInfo.verticalSpacing + val indexesDiff = index - firstVisibleLineIndex + var coercedOffset = minOf(abs(targetScrollOffset), averageSize) + if (targetScrollOffset < 0) coercedOffset *= -1 + return (averageSize * indexesDiff).toFloat() + coercedOffset - firstVisibleLineScrollOffset + } + + override suspend fun scroll(block: suspend ScrollScope.() -> Unit) { + state.verticalScroll(block = block) + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableCellContainer.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableCellContainer.kt new file mode 100644 index 0000000000000..fcf3d5b3f808d --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableCellContainer.kt @@ -0,0 +1,15 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.jetbrains.jewel.foundation.OverflowBox + +@Composable +public fun LazyTableCellContainer( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + content: @Composable () -> Unit, +) { + OverflowBox(modifier = modifier, contentAlignment = contentAlignment) { content() } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableContent.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableContent.kt new file mode 100644 index 0000000000000..db4010d75a40d --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableContent.kt @@ -0,0 +1,30 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset + +public interface LazyTableContent { + + public val columnCount: Int + + public val rowCount: Int + + public fun getKey(position: IntOffset): Pair + + public fun getKey(index: Int): Pair + + public fun getContentType(position: IntOffset): Any? + + public fun getContentType(index: Int): Any? + + public fun getPosition(index: Int): IntOffset + + public fun getIndex(position: IntOffset): Int + + public fun LazyTableLayoutScope.getColumnConstraints(column: Int): Constraints? + + public fun LazyTableLayoutScope.getRowConstraints(row: Int): Constraints? + + @Composable public fun Item(scope: LazyTableItemScope, index: Int) +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableDimensionScope.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableDimensionScope.kt new file mode 100644 index 0000000000000..bf30a183f6f72 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableDimensionScope.kt @@ -0,0 +1,22 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.unit.Constraints +import org.jetbrains.jewel.foundation.GenerateDataFunctions + +@GenerateDataFunctions +public class LazyTableDimensionDefinition(public val key: Any, public val constraints: Constraints) + +public interface LazyTableDimensionScope { + + public infix fun Any.with(constraints: Constraints): LazyTableDimensionDefinition + + public val noConstraints: Constraints +} + +internal object LazyTableDimensionScopeImpl : LazyTableDimensionScope { + + override val noConstraints: Constraints = Constraints() + + override fun Any.with(constraints: Constraints): LazyTableDimensionDefinition = + LazyTableDimensionDefinition(this, constraints) +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableIntervalContent.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableIntervalContent.kt new file mode 100644 index 0000000000000..79131d308e690 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableIntervalContent.kt @@ -0,0 +1,141 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.lazy.layout.IntervalList +import androidx.compose.foundation.lazy.layout.MutableIntervalList +import androidx.compose.foundation.lazy.layout.getDefaultLazyLayoutKey +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import org.jetbrains.jewel.foundation.lazy.table.LazyTableStyle.Default.container + +internal class LazyTableIntervalContent( + content: LazyTableScope.() -> LazyTableCells, + private val state: LazyTableState, + private val style: LazyTableStyle, +) : LazyTableScope, LazyTableContent { + private val columnIntervals: MutableIntervalList = MutableIntervalList() + + private val rowIntervals: MutableIntervalList = MutableIntervalList() + + private val cells: LazyTableCells = content() + + override val columnCount: Int + get() = columnIntervals.size + + override val rowCount: Int + get() = rowIntervals.size + + override fun columnDefinitions( + count: Int, + key: ((index: Int) -> Any)?, + constraints: (LazyTableLayoutScope.(index: Int) -> Constraints)?, + ) { + columnIntervals.addInterval(count, LazyTableDimensionInterval(key, constraints)) + } + + override fun columnDefinition(key: Any?, constraints: (LazyTableLayoutScope.() -> Constraints)?) { + columnIntervals.addInterval( + size = 1, + LazyTableDimensionInterval( + key = if (key != null) { _: Int -> key } else null, + constraints = if (constraints != null) { _: Int -> constraints() } else null, + ), + ) + } + + override fun rowDefinitions( + count: Int, + key: ((index: Int) -> Any)?, + constraints: (LazyTableLayoutScope.(index: Int) -> Constraints)?, + ) { + rowIntervals.addInterval(count, LazyTableDimensionInterval(key, constraints)) + } + + override fun rowDefinition(key: Any?, constraints: (LazyTableLayoutScope.() -> Constraints)?) { + rowIntervals.addInterval( + size = 1, + LazyTableDimensionInterval( + key = if (key != null) { _: Int -> key } else null, + constraints = if (constraints != null) { _: Int -> constraints() } else null, + ), + ) + } + + private inline fun IntervalList.withInterval( + globalIndex: Int, + block: (localIntervalIndex: Int, content: IntervalList.Interval) -> R, + ): R { + val interval = this[globalIndex] + val localIntervalIndex = globalIndex - interval.startIndex + return block(localIntervalIndex, interval) + } + + override fun cells( + type: (columnKey: Any, rowKey: Any) -> Any?, + content: @Composable LazyTableItemScope.(columnKey: Any, rowKey: Any) -> Unit, + ): LazyTableCells = + object : LazyTableCells { + override fun type(columnKey: Any, rowKey: Any): Any? = type(columnKey, rowKey) + + @Composable + override fun LazyTableItemScope.content(columnKey: Any, rowKey: Any) { + content(columnKey, rowKey) + } + } + + override fun getKey(position: IntOffset): Pair { + val columnKey = + columnIntervals.withInterval(position.x) { localIntervalIndex, content -> + content.value.key?.invoke(localIntervalIndex) ?: getDefaultLazyLayoutKey(position.x) + } + val rowKey = + rowIntervals.withInterval(position.y) { localIntervalIndex, content -> + content.value.key?.invoke(localIntervalIndex) ?: getDefaultLazyLayoutKey(position.y) + } + + return columnKey to rowKey + } + + override fun getKey(index: Int): Pair = getKey(getPosition(index)) + + override fun LazyTableLayoutScope.getColumnConstraints(column: Int): Constraints? = + columnIntervals.withInterval(column) { localIntervalIndex, content -> + content.value.constraints?.invoke(this, localIntervalIndex) + } + + override fun LazyTableLayoutScope.getRowConstraints(row: Int): Constraints? = + rowIntervals.withInterval(row) { localIntervalIndex, content -> + content.value.constraints?.invoke(this, localIntervalIndex) + } + + override fun getContentType(position: IntOffset): Any? { + val (columnKey, rowKey) = getKey(position) + + return cells.type(columnKey, rowKey) + } + + override fun getContentType(index: Int): Any? { + val (columnKey, rowKey) = getKey(index) + + return cells.type(columnKey, rowKey) + } + + override fun getPosition(index: Int): IntOffset { + val row = index / columnIntervals.size + val column = index % columnIntervals.size + return IntOffset(column, row) + } + + override fun getIndex(position: IntOffset): Int = position.y * columnIntervals.size + position.x + + @Composable + override fun Item(scope: LazyTableItemScope, index: Int) { + val position = getPosition(index) + val (columnKey, rowKey) = getKey(position) + with(style) { + state.container(position.x, position.y, columnKey, rowKey) { + with(cells) { scope.content(columnKey, rowKey) } + } + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemInfo.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemInfo.kt new file mode 100644 index 0000000000000..ef49b19bb19d1 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemInfo.kt @@ -0,0 +1,21 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize + +public interface LazyTableItemInfo { + public val index: Int + + public val key: Any + + public val row: Int + + public val column: Int + + public val offset: IntOffset + + public val size: IntSize + + public val contentType: Any? + get() = null +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemKeyPositionMap.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemKeyPositionMap.kt new file mode 100644 index 0000000000000..bcaa7131fdfe7 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemKeyPositionMap.kt @@ -0,0 +1,81 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.unit.IntOffset + +internal interface LazyTableItemKeyPositionMap { + + fun getPosition(key: Any): IntOffset? + + fun getKey(coordinate: IntOffset): Any? + + companion object Empty : LazyTableItemKeyPositionMap { + + override fun getPosition(key: Any): IntOffset? = null + + override fun getKey(coordinate: IntOffset): Any? = null + } +} + +internal class NearestRangeKeyPositionMap( + rowRange: IntRange, + columnRange: IntRange, + pinnedColumns: Int, + pinnedRows: Int, + content: LazyTableContent, +) : LazyTableItemKeyPositionMap { + + private val map: Map + private val keys: Map + + init { + val firstRow = rowRange.first + val lastRow = minOf(rowRange.last, content.rowCount - 1) + val rowCount = lastRow - firstRow + 1 + pinnedRows + + val firstColumn = columnRange.first + val lastColumn = minOf(columnRange.last, content.columnCount - 1) + val columnCount = lastColumn - firstColumn + 1 + pinnedColumns + + val pinnedRows = minOf(pinnedRows, content.rowCount) + val pinnedColumns = minOf(pinnedColumns, content.columnCount) + + if (lastRow < firstRow || lastColumn < firstColumn) { + map = emptyMap() + keys = emptyMap() + } else { + keys = HashMap(rowCount * columnCount) + map = HashMap(rowCount * columnCount) + + fun initCell(column: Int, row: Int) { + val position = IntOffset(column, row) + if (position in keys) return + + val key = content.getKey(position) + map[key] = position + keys[position] = key + } + + repeat(pinnedRows) { + for (column in firstColumn..lastColumn) { + initCell(column, it) + } + } + + repeat(pinnedColumns) { + for (row in firstRow..lastRow) { + initCell(it, row) + } + } + + for (row in firstRow..lastRow) { + for (column in firstColumn..lastColumn) { + initCell(column, row) + } + } + } + } + + override fun getPosition(key: Any): IntOffset? = map[key] + + override fun getKey(coordinate: IntOffset): Any? = keys[coordinate] +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemProvider.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemProvider.kt new file mode 100644 index 0000000000000..7dfba33521aeb --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemProvider.kt @@ -0,0 +1,125 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider +import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem +import androidx.compose.foundation.lazy.layout.getDefaultLazyLayoutKey +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.referentialEqualityPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset + +internal interface LazyTableItemProvider : LazyLayoutItemProvider { + val rowCount: Int + + val columnCount: Int + + val keyPositionMap: LazyTableItemKeyPositionMap + + fun getContentType(position: IntOffset): Any? + + fun getIndex(position: IntOffset): Int + + fun getKey(position: IntOffset): Any + + fun LazyTableLayoutScope.getColumnConstraints(column: Int): Constraints? + + fun LazyTableLayoutScope.getRowConstraints(column: Int): Constraints? +} + +@Composable +internal fun rememberLazyTableItemProviderLambda( + state: LazyTableState, + style: LazyTableStyle, + pinnedColumns: Int, + pinnedRows: Int, + content: LazyTableScope.() -> LazyTableCells, +): () -> LazyTableItemProvider { + val latestContent = rememberUpdatedState(content) + + return remember(state, style, pinnedColumns, pinnedRows) { + val scope = LazyTableItemScopeImpl() + + val intervalContentState = + derivedStateOf(referentialEqualityPolicy()) { LazyTableIntervalContent(latestContent.value, state, style) } + + val itemProvider = + derivedStateOf(referentialEqualityPolicy()) { + val intervalContent = intervalContentState.value + + val map = + NearestRangeKeyPositionMap( + rowRange = state.nearestRowRange, + columnRange = state.nearestColumnRange, + pinnedColumns = pinnedColumns, + pinnedRows = pinnedRows, + content = intervalContent, + ) + LazyTableItemProviderImpl( + state = state, + content = intervalContent, + itemScope = scope, + keyPositionMap = map, + ) + } + itemProvider::value + } +} + +internal class LazyTableItemProviderImpl( + private val state: LazyTableState, + private val content: LazyTableContent?, + private val itemScope: LazyTableItemScope, + override val keyPositionMap: LazyTableItemKeyPositionMap, +) : LazyTableItemProvider { + override val rowCount: Int + get() = content?.rowCount ?: 0 + + override val columnCount: Int + get() = content?.columnCount ?: 0 + + override val itemCount: Int + get() = rowCount * columnCount + + @Composable + override fun Item(index: Int, key: Any) { + if (index < 0) return + content ?: return + + LazyLayoutPinnableItem(key, index, state.pinnedItems) { content.Item(itemScope, index) } + } + + override fun getContentType(index: Int): Any? { + val coordinate = content?.getPosition(index) ?: return null + return content.getContentType(coordinate) + } + + override fun getContentType(position: IntOffset): Any? = content?.getContentType(position) + + override fun getIndex(key: Any): Int { + val position = keyPositionMap.getPosition(key) ?: return -1 + return content?.getIndex(position) ?: -1 + } + + override fun getIndex(position: IntOffset): Int = content?.getIndex(position) ?: -1 + + override fun getKey(index: Int): Any { + val coordinate = content?.getPosition(index) ?: return getDefaultLazyLayoutKey(index) + return content.getKey(coordinate) + } + + override fun getKey(position: IntOffset): Any = + keyPositionMap.getKey(position) ?: content?.getKey(position) ?: getDefaultLazyLayoutKey(getIndex(position)) + + override fun LazyTableLayoutScope.getColumnConstraints(column: Int): Constraints? { + content ?: return null + return with(content) { this@getColumnConstraints.getColumnConstraints(column) } + } + + override fun LazyTableLayoutScope.getRowConstraints(row: Int): Constraints? { + content ?: return null + return with(content) { this@getRowConstraints.getRowConstraints(row) } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemScope.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemScope.kt new file mode 100644 index 0000000000000..7d42d1b818a58 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemScope.kt @@ -0,0 +1,23 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset + +public interface LazyTableItemScope { + + @ExperimentalFoundationApi + public fun Modifier.animateItemPlacement( + animationSpec: FiniteAnimationSpec = + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntOffset.VisibilityThreshold) + ): Modifier +} + +internal class LazyTableItemScopeImpl : LazyTableItemScope { + + override fun Modifier.animateItemPlacement(animationSpec: FiniteAnimationSpec): Modifier = this +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt new file mode 100644 index 0000000000000..2cc3723204d64 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt @@ -0,0 +1,73 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.jetbrains.jewel.foundation.GenerateDataFunctions + +public interface LazyTableLayoutInfo { + public val floatingItemsInfo: List + + public val pinnedColumnsInfo: List + + public val pinnedRowsInfo: List + + public val pinnedItemsInfo: List + + public val viewportStartOffset: IntOffset + + public val viewportEndOffset: IntOffset + + public val viewportSize: IntSize + get() = IntSize.Zero + + public val viewportCellSize: IntSize + get() = IntSize.Zero + + public val columns: Int + + public val rows: Int + + public val pinnedColumns: Int + + public val pinnedRows: Int + + public val pinnedColumnsWidth: Int + + public val pinnedRowsHeight: Int + + public val totalItemsCount: Int + get() = columns * rows + + public val horizontalSpacing: Int + get() = 0 + + public val verticalSpacing: Int + get() = 0 +} + +@GenerateDataFunctions +public class LazyTableInfo( + public val columns: Int = 0, + public val rows: Int = 0, + public val pinnedColumns: Int = 0, + public val pinnedRows: Int = 0, +) { + public companion object { + public val Empty: LazyTableInfo = LazyTableInfo() + } +} + +internal object EmptyLazyTableLayoutInfo : LazyTableLayoutInfo { + override val floatingItemsInfo: List = emptyList() + override val pinnedColumnsInfo: List = emptyList() + override val pinnedRowsInfo: List = emptyList() + override val pinnedItemsInfo: List = emptyList() + override val viewportStartOffset: IntOffset = IntOffset.Zero + override val viewportEndOffset: IntOffset = IntOffset.Zero + override val columns: Int = 0 + override val rows: Int = 0 + override val pinnedColumns: Int = 0 + override val pinnedRows: Int = 0 + override val pinnedColumnsWidth: Int = 0 + override val pinnedRowsHeight: Int = 0 +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutScope.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutScope.kt new file mode 100644 index 0000000000000..eb9bac4c333bc --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutScope.kt @@ -0,0 +1,17 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize + +public interface LazyTableLayoutScope : Density { + + public val availableSize: IntSize + + public val columns: Int + + public val rows: Int + + public val horizontalSpacing: Int + + public val verticalSpacing: Int +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt new file mode 100644 index 0000000000000..8d27a9b67ea69 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt @@ -0,0 +1,513 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastForEach +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sign + +internal fun measureLazyTable( + constraints: Constraints, + availableSize: IntSize, + rows: Int, + columns: Int, + pinnedColumns: Int, + pinnedRows: Int, + measuredItemProvider: LazyTableMeasuredItemProvider, + horizontalSpacing: Int, + verticalSpacing: Int, + firstVisibleCellPosition: IntOffset, + firstVisibleCellScrollOffset: IntOffset, + scrollToBeConsumed: Offset, + beyondBoundsItemCount: Int, + density: Density, + layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult, +): LazyTableMeasureResult { + if (rows * columns <= 0) { + return LazyTableMeasureResult( + firstFloatingCell = null, + firstFloatingCellScrollOffset = IntOffset.Zero, + canVerticalScrollForward = false, + canVerticalScrollBackward = false, + consumedVerticalScroll = 0f, + canHorizontalScrollForward = false, + canHorizontalScrollBackward = false, + consumedHorizontalScroll = 0f, + measureResult = layout(constraints.minWidth, constraints.minHeight) {}, + floatingItemsInfo = emptyList(), + pinnedColumnsInfo = emptyList(), + pinnedRowsInfo = emptyList(), + pinnedItemsInfo = emptyList(), + viewportStartOffset = IntOffset.Zero, + viewportEndOffset = IntOffset.Zero, + viewportCellSize = IntSize.Zero, + columns = columns, + rows = rows, + pinnedColumns = 0, + pinnedRows = 0, + pinnedColumnsWidth = 0, + pinnedRowsHeight = 0, + horizontalSpacing = horizontalSpacing, + verticalSpacing = verticalSpacing, + ) + } else { + var scrollDeltaX = scrollToBeConsumed.x.roundToInt() + var scrollDeltaY = scrollToBeConsumed.y.roundToInt() + + var currentFirstFloatingColumn = firstVisibleCellPosition.x + var currentFirstFloatingRow = firstVisibleCellPosition.y + + var currentFirstFloatingColumnScrollOffset = firstVisibleCellScrollOffset.x + var currentFirstFloatingRowScrollOffset = firstVisibleCellScrollOffset.y + + if (currentFirstFloatingColumn >= columns) { + currentFirstFloatingColumn = columns - 1 + currentFirstFloatingColumnScrollOffset = 0 + } + + if (currentFirstFloatingColumn < pinnedColumns) { + currentFirstFloatingColumn = pinnedColumns + currentFirstFloatingColumnScrollOffset = 0 + } + + if (currentFirstFloatingRow >= rows) { + currentFirstFloatingRow = rows - 1 + currentFirstFloatingRowScrollOffset = 0 + } + + if (currentFirstFloatingRow < pinnedRows) { + currentFirstFloatingRow = pinnedRows + currentFirstFloatingRowScrollOffset = 0 + } + + currentFirstFloatingColumnScrollOffset -= scrollDeltaX + currentFirstFloatingRowScrollOffset -= scrollDeltaY + + if (currentFirstFloatingColumn == pinnedColumns && currentFirstFloatingColumnScrollOffset < 0) { + scrollDeltaX += currentFirstFloatingColumnScrollOffset + currentFirstFloatingColumnScrollOffset = 0 + } + + if (currentFirstFloatingRow == pinnedRows && currentFirstFloatingRowScrollOffset < 0) { + scrollDeltaY += currentFirstFloatingRowScrollOffset + currentFirstFloatingRowScrollOffset = 0 + } + + // measuredItemProvider.getAndMeasure(0, 0) + + val minOffsetX = 0 + var pinnedColumnsWidth = 0 + repeat(pinnedColumns) { + val columnWidth = measuredItemProvider.getColumnWidth(it) + pinnedColumnsWidth += columnWidth + horizontalSpacing + } + val maxOffsetX = availableSize.width - pinnedColumnsWidth + + val minOffsetY = 0 + var pinnedRowsHeight = 0 + repeat(pinnedRows) { + val rowHeight = measuredItemProvider.getRowHeight(it) + pinnedRowsHeight += rowHeight + verticalSpacing + } + val maxOffsetY = availableSize.height - pinnedRowsHeight + + do { + val needPreviousColumn = + currentFirstFloatingColumnScrollOffset < 0 && currentFirstFloatingColumn > pinnedColumns + val needPreviousRow = currentFirstFloatingRowScrollOffset < 0 && currentFirstFloatingRow > pinnedRows + + if (needPreviousColumn) { + val column = currentFirstFloatingColumn - 1 + val columnWidth = measuredItemProvider.getColumnWidth(column) + + currentFirstFloatingColumnScrollOffset += columnWidth + horizontalSpacing + currentFirstFloatingColumn = column + } + + if (needPreviousRow) { + val row = currentFirstFloatingRow - 1 + val rowHeight = measuredItemProvider.getRowHeight(row) + + currentFirstFloatingRowScrollOffset += rowHeight + verticalSpacing + currentFirstFloatingRow = row + } + } while (needPreviousColumn || needPreviousRow) + + if (currentFirstFloatingColumnScrollOffset < minOffsetX) { + scrollDeltaX -= (minOffsetX - currentFirstFloatingColumnScrollOffset) + currentFirstFloatingColumnScrollOffset = minOffsetX + } + + if (currentFirstFloatingRowScrollOffset < minOffsetY) { + scrollDeltaY -= (minOffsetY - currentFirstFloatingRowScrollOffset) + currentFirstFloatingRowScrollOffset = minOffsetY + } + + var currentLastFloatingColumn = currentFirstFloatingColumn + var currentLastFloatingRow = currentFirstFloatingRow + + val maxCellAxisX = maxOffsetX + val maxCellAxisY = maxOffsetY + + var currentCellAxisOffsetX = -currentFirstFloatingColumnScrollOffset + var currentCellAxisOffsetY = -currentFirstFloatingRowScrollOffset + + do { + val needNextColumn = + currentLastFloatingColumn < columns && + (currentCellAxisOffsetX < maxCellAxisX || currentCellAxisOffsetX <= 0) + val needNextRow = + currentLastFloatingRow < rows && (currentCellAxisOffsetY < maxCellAxisY || currentCellAxisOffsetY <= 0) + + if (needNextColumn) { + val columnWidth = measuredItemProvider.getColumnWidth(currentLastFloatingColumn) + currentCellAxisOffsetX += columnWidth + horizontalSpacing + currentLastFloatingColumn++ + + if (currentLastFloatingColumn >= columns) { + currentCellAxisOffsetX -= horizontalSpacing + } + + if (currentCellAxisOffsetX <= minOffsetX && currentLastFloatingColumn < columns - 1) { + currentFirstFloatingColumn = currentLastFloatingColumn + currentFirstFloatingColumnScrollOffset -= (columnWidth + horizontalSpacing) + } + } + + if (needNextRow) { + val rowHeight = measuredItemProvider.getRowHeight(currentLastFloatingRow) + currentCellAxisOffsetY += rowHeight + verticalSpacing + currentLastFloatingRow++ + + if (currentLastFloatingRow >= rows) { + currentCellAxisOffsetY -= verticalSpacing + } + + if (currentCellAxisOffsetY <= minOffsetY && currentLastFloatingRow < rows - 1) { + currentFirstFloatingRow = currentLastFloatingRow + currentFirstFloatingRowScrollOffset -= (rowHeight + verticalSpacing) + } + } + } while (needNextColumn || needNextRow) + + if (currentCellAxisOffsetX < maxCellAxisX) { + val toScrollBack = maxCellAxisX - currentCellAxisOffsetX + currentFirstFloatingColumnScrollOffset -= toScrollBack + currentCellAxisOffsetX += toScrollBack + + while (currentFirstFloatingColumnScrollOffset < 0 && currentFirstFloatingColumn > pinnedColumns) { + val column = currentFirstFloatingColumn - 1 + val columnWidth = measuredItemProvider.getColumnWidth(column) + + currentFirstFloatingColumnScrollOffset += columnWidth + horizontalSpacing + currentFirstFloatingColumn = column + } + scrollDeltaX += toScrollBack + if (currentFirstFloatingColumnScrollOffset < 0) { + scrollDeltaX += currentFirstFloatingColumnScrollOffset + currentCellAxisOffsetX += currentFirstFloatingColumnScrollOffset + currentFirstFloatingColumnScrollOffset = 0 + } + } + + if (currentCellAxisOffsetY < maxCellAxisY) { + val toScrollBack = maxCellAxisY - currentCellAxisOffsetY + currentFirstFloatingRowScrollOffset -= toScrollBack + currentCellAxisOffsetY += toScrollBack + + while (currentFirstFloatingRowScrollOffset < 0 && currentFirstFloatingRow > pinnedRows) { + val row = currentFirstFloatingRow - 1 + val rowHeight = measuredItemProvider.getRowHeight(row) + + currentFirstFloatingRowScrollOffset += rowHeight + verticalSpacing + currentFirstFloatingRow = row + } + scrollDeltaY += toScrollBack + if (currentFirstFloatingRowScrollOffset < 0) { + scrollDeltaY += currentFirstFloatingRowScrollOffset + currentCellAxisOffsetY += currentFirstFloatingRowScrollOffset + currentFirstFloatingRowScrollOffset = 0 + } + } + + val consumedScrollX = + if ( + scrollToBeConsumed.x.roundToInt().sign == scrollDeltaX.sign && + abs(scrollToBeConsumed.x.roundToInt()) >= abs(scrollDeltaX) + ) { + scrollDeltaX.toFloat() + } else { + scrollToBeConsumed.x + } + + val consumedScrollY = + if ( + scrollToBeConsumed.y.roundToInt().sign == scrollDeltaY.sign && + abs(scrollToBeConsumed.y.roundToInt()) >= abs(scrollDeltaY) + ) { + scrollDeltaY.toFloat() + } else { + scrollToBeConsumed.y + } + + require(currentFirstFloatingColumnScrollOffset >= 0 && currentFirstFloatingRowScrollOffset >= 0) { + "Invalid scroll offset: $currentFirstFloatingColumnScrollOffset, $currentFirstFloatingRowScrollOffset" + } + + val startCellPositionX = max(pinnedColumns, currentFirstFloatingColumn - beyondBoundsItemCount) + val endCellPositionX = min(columns, currentLastFloatingColumn + beyondBoundsItemCount) + + val startCellPositionY = max(pinnedRows, currentFirstFloatingRow - beyondBoundsItemCount) + val endCellPositionY = min(rows, currentLastFloatingRow + beyondBoundsItemCount) + + val visibleRows = currentFirstFloatingRow until currentLastFloatingRow + val visibleColumns = currentFirstFloatingColumn until currentLastFloatingColumn + + val visibleRowCount = currentLastFloatingRow - currentFirstFloatingRow + val visibleColumnCount = currentLastFloatingColumn - currentFirstFloatingColumn + + val extraRows = startCellPositionY until endCellPositionY + val extraColumns = startCellPositionX until endCellPositionX + + val extraRowCount = endCellPositionY - startCellPositionY + val extraColumnCount = endCellPositionX - startCellPositionX + + // floating items + val floatingItemsInfo = ArrayList(extraRowCount * extraColumnCount) + // floating rows but pinned columns + val pinnedColumnsInfo = ArrayList(extraRowCount * pinnedColumns) + // floating columns but pinned rows + val pinnedRowsInfo = ArrayList(extraColumnCount * pinnedRows) + // pinned items + val pinnedItemsInfo = ArrayList(pinnedColumns * pinnedRows) + + for (row in extraRows) { + for (column in extraColumns) { + val isPinned = row < pinnedRows || column < pinnedColumns + if (!isPinned) { + floatingItemsInfo += measuredItemProvider.getAndMeasure(column, row) + } + } + } + + repeat(pinnedRows) { row -> + for (column in extraColumns) { + val isColumnPinned = column < pinnedColumns + + if (!isColumnPinned) { + pinnedRowsInfo += measuredItemProvider.getAndMeasure(column, row) + } + } + } + + repeat(pinnedColumns) { column -> + for (row in extraRows) { + val isRowPinned = row < pinnedRows + + if (!isRowPinned) { + pinnedColumnsInfo += measuredItemProvider.getAndMeasure(column, row) + } + } + } + + repeat(pinnedRows) { row -> + repeat(pinnedColumns) { column -> pinnedItemsInfo += measuredItemProvider.getAndMeasure(column, row) } + } + + calculateItemsOffsets( + measuredItemProvider = measuredItemProvider, + firstVisibleCellPosition = IntOffset(currentFirstFloatingColumn, currentFirstFloatingRow), + cellsScrollOffset = + IntOffset(-currentFirstFloatingColumnScrollOffset, -currentFirstFloatingRowScrollOffset), + pinnedColumns = pinnedColumns, + pinnedRows = pinnedRows, + pinnedColumnsWidth = pinnedColumnsWidth, + pinnedRowsHeight = pinnedRowsHeight, + extraColumns = extraColumns, + extraRows = extraRows, + horizontalSpacing = horizontalSpacing, + verticalSpacing = verticalSpacing, + ) + + return LazyTableMeasureResult( + firstFloatingCell = + measuredItemProvider.getAndMeasureOrNull(currentFirstFloatingColumn, currentFirstFloatingRow), + firstFloatingCellScrollOffset = + IntOffset(currentFirstFloatingColumnScrollOffset, currentFirstFloatingRowScrollOffset), + canVerticalScrollForward = currentLastFloatingRow < rows || currentCellAxisOffsetY > maxOffsetY, + canVerticalScrollBackward = currentFirstFloatingRowScrollOffset > 0 || currentFirstFloatingRow > 0, + consumedVerticalScroll = consumedScrollY, + canHorizontalScrollForward = currentLastFloatingColumn < columns || currentCellAxisOffsetX > maxOffsetX, + canHorizontalScrollBackward = currentFirstFloatingColumnScrollOffset > 0 || currentFirstFloatingColumn > 0, + consumedHorizontalScroll = consumedScrollX, + measureResult = + layout(constraints.maxWidth, constraints.maxHeight) { + floatingItemsInfo.fastForEach { it.place(this) } + + pinnedColumnsInfo.fastForEach { it.place(this, 1f) } + + pinnedRowsInfo.fastForEach { it.place(this, 1f) } + + pinnedItemsInfo.fastForEach { it.place(this, 1f) } + }, + floatingItemsInfo = floatingItemsInfo, + pinnedColumnsInfo = pinnedColumnsInfo, + pinnedRowsInfo = pinnedRowsInfo, + pinnedItemsInfo = pinnedItemsInfo, + viewportStartOffset = IntOffset.Zero, + viewportEndOffset = IntOffset(maxOffsetX, maxOffsetY), + viewportCellSize = IntSize(visibleColumnCount, visibleRowCount), + columns = columns, + rows = rows, + pinnedColumns = pinnedColumns, + pinnedRows = pinnedRows, + pinnedColumnsWidth = pinnedColumnsWidth, + pinnedRowsHeight = pinnedRowsHeight, + horizontalSpacing = horizontalSpacing, + verticalSpacing = verticalSpacing, + ) + } +} + +private fun calculateItemsOffsets( + measuredItemProvider: LazyTableMeasuredItemProvider, + firstVisibleCellPosition: IntOffset, + cellsScrollOffset: IntOffset, + pinnedRows: Int, + pinnedColumns: Int, + pinnedColumnsWidth: Int, + pinnedRowsHeight: Int, + extraRows: IntRange, + extraColumns: IntRange, + horizontalSpacing: Int, + verticalSpacing: Int, +) { + val positionedColumns = HashMap(extraColumns.last - extraColumns.first + 1 + pinnedColumns) + val positionedRows = HashMap(extraRows.last - extraRows.first + 1 + pinnedRows) + val positionedPinnedColumns = HashMap(pinnedColumns) + val positionedPinnedRows = HashMap(pinnedRows) + + fun getCellScrollOffsetX(column: Int): Int = + positionedColumns.getOrPut(column) { + if (column > firstVisibleCellPosition.x) { + val previousColumn = column - 1 + val previousColumnWidth = measuredItemProvider.getColumnWidth(previousColumn) + val previousColumnScrollOffset = getCellScrollOffsetX(previousColumn) + + previousColumnScrollOffset + previousColumnWidth + horizontalSpacing + } else if (column < firstVisibleCellPosition.x) { + val currentWidth = measuredItemProvider.getColumnWidth(column) + val nextColumnScrollOffset = getCellScrollOffsetX(column + 1) + + nextColumnScrollOffset - currentWidth - horizontalSpacing + } else { + cellsScrollOffset.x + pinnedColumnsWidth + } + } + + fun getCellScrollOffsetY(row: Int): Int = + positionedRows.getOrPut(row) { + if (row > firstVisibleCellPosition.y) { + val previousRow = row - 1 + val previousRowHeight = measuredItemProvider.getRowHeight(previousRow) + val previousRowScrollOffset = getCellScrollOffsetY(previousRow) + + previousRowScrollOffset + previousRowHeight + verticalSpacing + } else if (row < firstVisibleCellPosition.y) { + val currentHeight = measuredItemProvider.getRowHeight(row) + val nextRowScrollOffset = getCellScrollOffsetY(row + 1) + + nextRowScrollOffset - currentHeight - verticalSpacing + } else { + cellsScrollOffset.y + pinnedRowsHeight + } + } + + fun getPinnedCellScrollOffsetX(column: Int): Int = + positionedPinnedColumns.getOrPut(column) { + if (column > 0) { + val previousColumn = column - 1 + val previousColumnWidth = measuredItemProvider.getColumnWidth(previousColumn) + val previousColumnScrollOffset = getPinnedCellScrollOffsetX(previousColumn) + + previousColumnScrollOffset + previousColumnWidth + horizontalSpacing + } else { + 0 + } + } + + fun getPinnedCellScrollOffsetY(row: Int): Int = + positionedPinnedRows.getOrPut(row) { + if (row > 0) { + val previousRow = row - 1 + val previousRowHeight = measuredItemProvider.getRowHeight(previousRow) + val previousRowScrollOffset = getPinnedCellScrollOffsetY(previousRow) + + previousRowScrollOffset + previousRowHeight + verticalSpacing + } else { + 0 + } + } + + fun getScrollOffsetX(column: Int): Int = + if (column < pinnedColumns) { + getPinnedCellScrollOffsetX(column) + } else { + getCellScrollOffsetX(column) + } + + fun getScrollOffsetY(row: Int): Int = + if (row < pinnedRows) { + getPinnedCellScrollOffsetY(row) + } else { + getCellScrollOffsetY(row) + } + + for (row in extraRows) { + for (column in extraColumns) { + val item = measuredItemProvider.getAndMeasure(column, row) + val offsetX = getScrollOffsetX(column) + val offsetY = getScrollOffsetY(row) + + item.position(IntOffset(offsetX, offsetY)) + } + } + + repeat(pinnedRows) { row -> + repeat(pinnedColumns) { column -> + val item = measuredItemProvider.getAndMeasure(column, row) + val offsetX = getScrollOffsetX(column) + val offsetY = getScrollOffsetY(row) + + item.position(IntOffset(offsetX, offsetY)) + } + } + + repeat(pinnedRows) { row -> + for (column in extraColumns) { + val item = measuredItemProvider.getAndMeasure(column, row) + val offsetX = getScrollOffsetX(column) + val offsetY = getScrollOffsetY(row) + + item.position(IntOffset(offsetX, offsetY)) + } + } + + repeat(pinnedColumns) { column -> + for (row in extraRows) { + val item = measuredItemProvider.getAndMeasure(column, row) + val offsetX = getScrollOffsetX(column) + val offsetY = getScrollOffsetY(row) + + item.position(IntOffset(offsetX, offsetY)) + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt new file mode 100644 index 0000000000000..f04cdc6f6f39a --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt @@ -0,0 +1,36 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize + +internal class LazyTableMeasureResult( + val firstFloatingCell: LazyTableMeasuredItem?, + val firstFloatingCellScrollOffset: IntOffset, + val canVerticalScrollForward: Boolean, + val canVerticalScrollBackward: Boolean, + val consumedVerticalScroll: Float, + val canHorizontalScrollForward: Boolean, + val canHorizontalScrollBackward: Boolean, + val consumedHorizontalScroll: Float, + measureResult: MeasureResult, + override val floatingItemsInfo: List, + override val pinnedColumnsInfo: List, + override val pinnedRowsInfo: List, + override val pinnedItemsInfo: List, + override val viewportStartOffset: IntOffset, + override val viewportEndOffset: IntOffset, + override val viewportCellSize: IntSize, + override val columns: Int, + override val rows: Int, + override val pinnedColumns: Int, + override val pinnedRows: Int, + override val pinnedColumnsWidth: Int, + override val pinnedRowsHeight: Int, + override val horizontalSpacing: Int, + override val verticalSpacing: Int, +) : LazyTableLayoutInfo, MeasureResult by measureResult { + + override val viewportSize: IntSize + get() = IntSize(width, height) +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItem.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItem.kt new file mode 100644 index 0000000000000..ca0fcad609af6 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItem.kt @@ -0,0 +1,62 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.ui.Alignment +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection + +internal class LazyTableMeasuredItem( + override val index: Int, + override val row: Int, + override val column: Int, + override val size: IntSize, + private val placeables: List, + private val alignment: Alignment, + private val layoutDirection: LayoutDirection, + private val visualOffset: IntOffset, + override val key: Any, + override val contentType: Any?, +) : LazyTableItemInfo { + + private val placeableOffsets: IntArray = IntArray(placeables.size * 2) + + override var offset: IntOffset = IntOffset.Zero + private set + + fun position(offset: IntOffset) { + this.offset = offset + + for (index in placeables.indices) { + val placeable = placeables[index] + + val indexInArray = index * 2 + + val alignOffset = alignment.align(IntSize(placeable.width, placeable.height), size, layoutDirection) + + placeableOffsets[indexInArray] = offset.x + alignOffset.x + placeableOffsets[indexInArray + 1] = offset.y + alignOffset.y + } + } + + val placeablesCount: Int + get() = placeables.size + + fun getParentData(index: Int) = placeables[index].parentData + + internal fun getOffset(index: Int) = IntOffset(placeableOffsets[index * 2], placeableOffsets[index * 2 + 1]) + + fun place(scope: Placeable.PlacementScope, zIndex: Float = 0f) = + with(scope) { + require(offset != Unset) { "position() should be called first" } + + repeat(placeablesCount) { index -> + val placeable = placeables[index] + var offset = getOffset(index) + offset += visualOffset + placeable.placeRelativeWithLayer(offset, zIndex) + } + } +} + +private val Unset = IntOffset(Int.MAX_VALUE, Int.MIN_VALUE) diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItemProvider.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItemProvider.kt new file mode 100644 index 0000000000000..bc04ef770bbe7 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItemProvider.kt @@ -0,0 +1,105 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.max + +internal abstract class LazyTableMeasuredItemProvider( + override val availableSize: IntSize, + override val columns: Int, + override val rows: Int, + override val horizontalSpacing: Int, + override val verticalSpacing: Int, + private val itemProvider: LazyTableItemProvider, + private val measureScope: LazyLayoutMeasureScope, + density: Density, +) : LazyTableLayoutScope, Density by density { + private val cachedColumnConstraints = HashMap(100) + private val cachedRowConstraints = HashMap(100) + + private val measuredItems = HashMap(500) + + private fun getCellConstraints(column: Int, row: Int): Constraints = + with(itemProvider) { + val rowConstraints = + if (column == 0) { + cachedRowConstraints.getOrPut(row) { getRowConstraints(row) ?: Constraints() } + } else { + Constraints.fixedHeight(getRowHeight(row)) + } + val columnConstraints = + if (row == 0) { + cachedColumnConstraints.getOrPut(column) { getColumnConstraints(column) ?: Constraints() } + } else { + Constraints.fixedWidth(getColumnWidth(column)) + } + + Constraints( + minWidth = columnConstraints.minWidth, + maxWidth = columnConstraints.maxWidth, + minHeight = rowConstraints.minHeight, + maxHeight = rowConstraints.maxHeight, + ) + } + + private fun getOrMeasureColumnHeader(column: Int): Int = + measuredItems + .getOrPut(IntOffset(column, 0)) { getAndMeasure(column, 0, getCellConstraints(column, 0)) } + .size + .width + + private fun getOrMeasureRowHeader(row: Int): Int = + measuredItems.getOrPut(IntOffset(0, row)) { getAndMeasure(0, row, getCellConstraints(0, row)) }.size.height + + private fun getAndMeasure(column: Int, row: Int, constraints: Constraints): LazyTableMeasuredItem { + val coordinate = IntOffset(column, row) + val index = itemProvider.getIndex(coordinate) + val key = itemProvider.getKey(coordinate) + val contentType = itemProvider.getContentType(coordinate) + val placeables = measureScope.measure(index, constraints) + + var maxWidth = 0 + var maxHeight = 0 + + for (placeable in placeables) { + maxWidth = max(placeable.width, maxWidth) + maxHeight = max(placeable.height, maxHeight) + } + + return createItem(coordinate.x, coordinate.y, IntSize(maxWidth, maxHeight), key, contentType, placeables) + } + + fun getAndMeasure(column: Int, row: Int): LazyTableMeasuredItem { + val width = getOrMeasureColumnHeader(column) + val height = getOrMeasureRowHeader(row) + + return measuredItems.getOrPut(IntOffset(column, row)) { + getAndMeasure(column, row, Constraints.fixed(width, height)) + } + } + + fun getAndMeasureOrNull(column: Int, row: Int): LazyTableMeasuredItem? { + if (column >= columns - 1 || row >= rows - 1) { + return null + } + + return getAndMeasure(column, row) + } + + fun getRowHeight(row: Int): Int = getAndMeasure(0, row).size.height + + fun getColumnWidth(column: Int): Int = getAndMeasure(column, 0).size.width + + abstract fun createItem( + column: Int, + row: Int, + size: IntSize, + key: Any, + contentType: Any?, + placeables: List, + ): LazyTableMeasuredItem +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableNearestRangeState.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableNearestRangeState.kt new file mode 100644 index 0000000000000..e022c3da41dcb --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableNearestRangeState.kt @@ -0,0 +1,51 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.structuralEqualityPolicy + +internal class LazyTableNearestRangeState( + firstVisibleItem: Int, + lastVisibleItem: Int, + private val extraItemCount: Int, +) : State { + + override var value: IntRange by + mutableStateOf( + calculateNearestItemsRange(firstVisibleItem, lastVisibleItem, extraItemCount), + structuralEqualityPolicy(), + ) + private set + + private var lastFirstVisibleItem = firstVisibleItem + private var lastLastVisibleItem = lastVisibleItem + + fun update(firstVisibleItem: Int, lastVisibleItem: Int) { + val range = value + if (firstVisibleItem in range && lastVisibleItem in range) return + + lastFirstVisibleItem = firstVisibleItem + lastLastVisibleItem = lastVisibleItem + value = calculateNearestItemsRange(firstVisibleItem, lastVisibleItem, extraItemCount) + } + + private companion object { + + /** + * Returns a range of indexes which contains at least [extraItemCount] items near the first visible item. It is + * optimized to return the same range for small changes in the firstVisibleItem value so we do not regenerate + * the map on each scroll. + */ + private fun calculateNearestItemsRange( + firstVisibleItem: Int, + lastVisibleItem: Int, + extraItemCount: Int, + ): IntRange { + val start = maxOf(firstVisibleItem - extraItemCount, 0) + val end = lastVisibleItem + extraItemCount + return start until end + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScope.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScope.kt new file mode 100644 index 0000000000000..b54b95f6ee2aa --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScope.kt @@ -0,0 +1,43 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.layout.LazyLayoutIntervalContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Constraints + +public interface LazyTableScope { + public fun columnDefinitions( + count: Int, + key: ((index: Int) -> Any)?, + constraints: (LazyTableLayoutScope.(index: Int) -> Constraints)? = null, + ) + + public fun columnDefinition(key: Any?, constraints: (LazyTableLayoutScope.() -> Constraints)? = null) + + public fun rowDefinitions( + count: Int, + key: ((index: Int) -> Any)?, + constraints: (LazyTableLayoutScope.(index: Int) -> Constraints)? = null, + ) + + public fun rowDefinition(key: Any?, constraints: (LazyTableLayoutScope.() -> Constraints)? = null) + + public fun cells( + type: (columnKey: Any, rowKey: Any) -> Any? = { _, _ -> null }, + content: @Composable LazyTableItemScope.(columnKey: Any, rowKey: Any) -> Unit, + ): LazyTableCells +} + +public interface LazyTableCells { + public fun type(columnKey: Any, rowKey: Any): Any? + + @Composable public fun LazyTableItemScope.content(columnKey: Any, rowKey: Any) +} + +@OptIn(ExperimentalFoundationApi::class) +internal class LazyTableDimensionInterval( + override val key: ((index: Int) -> Any)?, + val constraints: (LazyTableLayoutScope.(index: Int) -> Constraints)?, +) : LazyLayoutIntervalContent.Interval { + override val type: (index: Int) -> Any? = { null } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt new file mode 100644 index 0000000000000..d0ffcd2e09874 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt @@ -0,0 +1,69 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize + +internal class LazyTableScrollPosition( + initialRowIndex: Int = 0, + initialColumnIndex: Int = 0, + initialVerticalScrollOffset: Int = 0, + initialHorizontalScrollOffset: Int = 0, +) { + + var row by mutableIntStateOf(initialRowIndex) + + var column by mutableIntStateOf(initialColumnIndex) + + var verticalScrollOffset by mutableIntStateOf(initialVerticalScrollOffset) + private set + + var horizontalScrollOffset by mutableIntStateOf(initialHorizontalScrollOffset) + private set + + private var hadFirstNotEmptyLayout = false + + private var lastKnownFirstItemKey: Any? = null + + val nearestRowRange = LazyTableNearestRangeState(initialRowIndex, initialRowIndex + 30, 100) + + val nearestColumnRange = LazyTableNearestRangeState(initialColumnIndex, initialColumnIndex + 30, 100) + + fun updateFromMeasureResult(measureResult: LazyTableMeasureResult) { + lastKnownFirstItemKey = measureResult.firstFloatingCell?.key + // we ignore the index and offset from measureResult until we get at least one + // measurement with real items. otherwise the initial index and scroll passed to the + // state would be lost and overridden with zeros. + if (hadFirstNotEmptyLayout || measureResult.totalItemsCount > 0) { + hadFirstNotEmptyLayout = true + val scrollOffset = measureResult.firstFloatingCellScrollOffset + check(scrollOffset.x >= 0f && scrollOffset.y >= 0f) { + "scrollOffset should be non-negative ($scrollOffset)" + } + + val firstRowIndex = measureResult.firstFloatingCell?.row ?: 0 + val firstColumnIndex = measureResult.firstFloatingCell?.column ?: 0 + update(firstColumnIndex, firstRowIndex, measureResult.viewportCellSize, scrollOffset) + } + } + + private fun update(column: Int, row: Int, visibleCellSize: IntSize, scrollOffset: IntOffset) { + require(row >= 0f && column >= 0f) { "Coordinate should be non-negative ($row, $column)" } + this.row = row + this.column = column + nearestColumnRange.update(column, column + visibleCellSize.width) + nearestRowRange.update(row, row + visibleCellSize.height) + this.horizontalScrollOffset = scrollOffset.x + this.verticalScrollOffset = scrollOffset.y + } + + fun requestColumn(column: Int, scrollOffset: Int) { + update(column, row, IntSize(50, 50), IntOffset(scrollOffset, verticalScrollOffset)) + } + + fun requestRow(row: Int, scrollOffset: Int) { + update(column, row, IntSize(50, 50), IntOffset(horizontalScrollOffset, scrollOffset)) + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt new file mode 100644 index 0000000000000..f0568dce147cb --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt @@ -0,0 +1,191 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.v2.ScrollbarAdapter +import androidx.compose.foundation.v2.maxScrollOffset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import kotlin.math.abs + +internal abstract class LazyTableScrollbarAdapter : ScrollbarAdapter { + // Implement the adapter in terms of "lines", which means either rows, + // (for a vertically scrollable widget) or columns (for a horizontally + // scrollable one). + // For LazyList this translates directly to items; for LazyGrid, it + // translates to rows/columns of items. + + class VisibleLine(val index: Int, val offset: Int) + + /** Return the first visible line, if any. */ + protected abstract fun firstVisibleLine(): VisibleLine? + + /** Return the total number of lines. */ + protected abstract fun totalLineCount(): Int + + /** The sum of content padding (before+after) on the scrollable axis. */ + protected abstract fun contentPadding(): Int + + /** Scroll immediately to the given line, and offset it by [scrollOffset] pixels. */ + protected abstract suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) + + /** Scroll from the current position by the given amount of pixels. */ + protected abstract suspend fun scrollBy(value: Float) + + /** Return the average size (on the scrollable axis) of the visible lines. */ + protected abstract fun averageVisibleLineSize(): Double + + /** The spacing between lines. */ + protected abstract val lineSpacing: Int + + private val averageVisibleLineSize by derivedStateOf { + if (totalLineCount() == 0) { + 0.0 + } else { + averageVisibleLineSize() + } + } + + private val averageVisibleLineSizeWithSpacing + get() = averageVisibleLineSize + lineSpacing + + override val scrollOffset: Double + get() { + val firstVisibleLine = firstVisibleLine() + return if (firstVisibleLine == null) { + 0.0 + } else { + firstVisibleLine.index * averageVisibleLineSizeWithSpacing - firstVisibleLine.offset + } + } + + override val contentSize: Double + get() { + val totalLineCount = totalLineCount() + return averageVisibleLineSize * totalLineCount + + lineSpacing * (totalLineCount - 1).coerceAtLeast(0) + + contentPadding() + } + + override suspend fun scrollTo(scrollOffset: Double) { + val distance = scrollOffset - this@LazyTableScrollbarAdapter.scrollOffset + + // if we scroll less than viewport we need to use scrollBy function to avoid + // undesirable scroll jumps (when an item size is different) + // + // if we scroll more than viewport we should immediately jump to this position + // without recreating all items between the current and the new position + if (abs(distance) <= viewportSize) { + scrollBy(distance.toFloat()) + } else { + snapTo(scrollOffset) + } + } + + private suspend fun snapTo(scrollOffset: Double) { + val scrollOffsetCoerced = scrollOffset.coerceIn(0.0, maxScrollOffset) + + val index = + (scrollOffsetCoerced / averageVisibleLineSizeWithSpacing) + .toInt() + .coerceAtLeast(0) + .coerceAtMost(totalLineCount() - 1) + + val offset = (scrollOffsetCoerced - index * averageVisibleLineSizeWithSpacing).toInt().coerceAtLeast(0) + + snapToLine(lineIndex = index, scrollOffset = offset) + } +} + +internal class LazyTableHorizontalScrollbarAdapter(private val scrollState: LazyTableState) : + LazyTableScrollbarAdapter() { + override val viewportSize: Double + get() = with(scrollState.layoutInfo) { viewportSize.width - pinnedColumnsWidth }.toDouble() + + override fun firstVisibleLine(): VisibleLine? { + val item = scrollState.layoutInfo.floatingItemsInfo.firstOrNull() ?: return null + return VisibleLine( + index = item.column - scrollState.layoutInfo.pinnedColumns, + offset = item.offset.x - scrollState.layoutInfo.pinnedColumnsWidth, + ) + } + + override fun totalLineCount() = + if (scrollState.layoutInfo.rows > 0) scrollState.layoutInfo.columns - scrollState.layoutInfo.pinnedColumns + else 0 + + override fun contentPadding() = with(scrollState.layoutInfo) { 0 } + + override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) { + scrollState.scrollToColumn(lineIndex, scrollOffset) + } + + override suspend fun scrollBy(value: Float) { + scrollState.horizontalScrollableState.scrollBy(value) + } + + override fun averageVisibleLineSize(): Double { + val first = scrollState.layoutInfo.floatingItemsInfo.firstOrNull() ?: return 0.0 + val last = scrollState.layoutInfo.floatingItemsInfo.lastOrNull() ?: return 0.0 + val count = last.column - first.column + 1 + + return (last.offset.x + last.size.width - first.offset.x - (count - 1) * lineSpacing).toDouble() / count + } + + override val lineSpacing + get() = scrollState.layoutInfo.horizontalSpacing +} + +internal class LazyTableVerticalScrollbarAdapter(private val scrollState: LazyTableState) : + LazyTableScrollbarAdapter() { + override val viewportSize: Double + get() = with(scrollState.layoutInfo) { viewportSize.height - pinnedRowsHeight }.toDouble() + + override fun firstVisibleLine(): VisibleLine? { + val item = scrollState.layoutInfo.floatingItemsInfo.firstOrNull() ?: return null + return VisibleLine( + index = item.row - scrollState.layoutInfo.pinnedRows, + offset = item.offset.y - scrollState.layoutInfo.pinnedRowsHeight, + ) + } + + override fun totalLineCount() = + if (scrollState.layoutInfo.columns > 0) scrollState.layoutInfo.rows - scrollState.layoutInfo.pinnedRows else 0 + + override fun contentPadding() = with(scrollState.layoutInfo) { 0 } + + override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) { + scrollState.scrollToRow(lineIndex, scrollOffset) + } + + override suspend fun scrollBy(value: Float) { + scrollState.verticalScrollableState.scrollBy(value) + } + + override fun averageVisibleLineSize(): Double { + val first = scrollState.layoutInfo.floatingItemsInfo.firstOrNull() ?: return 0.0 + val last = scrollState.layoutInfo.floatingItemsInfo.lastOrNull() ?: return 0.0 + + val count = last.row - first.row + 1 + + return (last.offset.y + last.size.height - first.offset.y - (count - 1) * lineSpacing).toDouble() / count + } + + override val lineSpacing + get() = scrollState.layoutInfo.verticalSpacing +} + +public fun tableHorizontalScrollbarAdapter(scrollState: LazyTableState): ScrollbarAdapter = + LazyTableHorizontalScrollbarAdapter(scrollState) + +@Composable +public fun rememberTableHorizontalScrollbarAdapter(scrollState: LazyTableState): ScrollbarAdapter = + remember(scrollState) { tableHorizontalScrollbarAdapter(scrollState) } + +public fun tableVerticalScrollbarAdapter(scrollState: LazyTableState): ScrollbarAdapter = + LazyTableVerticalScrollbarAdapter(scrollState) + +@Composable +public fun rememberTableVerticalScrollbarAdapter(scrollState: LazyTableState): ScrollbarAdapter = + remember(scrollState) { tableVerticalScrollbarAdapter(scrollState) } diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt new file mode 100644 index 0000000000000..3d13b82e6210d --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt @@ -0,0 +1,294 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList +import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.Remeasurement +import androidx.compose.ui.layout.RemeasurementModifier +import androidx.compose.ui.unit.Density +import kotlin.math.abs + +@Composable +public fun rememberLazyTableState( + firstVisibleRowIndex: Int = 0, + firstVisibleColumnIndex: Int = 0, + firstVisibleRowScrollOffset: Int = 0, + firstVisibleColumnScrollOffset: Int = 0, +): LazyTableState = + rememberSaveable(saver = LazyTableState.Saver) { + LazyTableState( + firstVisibleRowIndex = firstVisibleRowIndex, + firstVisibleColumnIndex = firstVisibleColumnIndex, + firstVisibleRowScrollOffset = firstVisibleRowScrollOffset, + firstVisibleColumnScrollOffset = firstVisibleColumnScrollOffset, + ) + } + +public class LazyTableState( + firstVisibleRowIndex: Int = 0, + firstVisibleColumnIndex: Int = 0, + firstVisibleRowScrollOffset: Int = 0, + firstVisibleColumnScrollOffset: Int = 0, +) { + private val scrollPosition = + LazyTableScrollPosition( + firstVisibleRowIndex, + firstVisibleColumnIndex, + firstVisibleRowScrollOffset, + firstVisibleColumnScrollOffset, + ) + + public val firstVisibleRowIndex: Int + get() = scrollPosition.row + + public val firstVisibleColumnIndex: Int + get() = scrollPosition.column + + public val firstVisibleItemVerticalScrollOffset: Int + get() = scrollPosition.verticalScrollOffset + + public val firstVisibleItemHorizontalScrollOffset: Int + get() = scrollPosition.horizontalScrollOffset + + internal val pinnedItems = LazyLayoutPinnedItemList() + + internal val awaitLayoutModifier = AwaitFirstLayoutModifier() + + internal val nearestRowRange: IntRange by scrollPosition.nearestRowRange + + internal val nearestColumnRange: IntRange by scrollPosition.nearestColumnRange + + internal var prefetchingEnabled: Boolean = true + + internal val prefetchState = LazyLayoutPrefetchState() + + internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource() + + internal var remeasurement: Remeasurement? = null + private set + + private val layoutInfoState = mutableStateOf(EmptyLazyTableLayoutInfo) + + private val tableInfoState = mutableStateOf(LazyTableInfo.Empty) + + internal var numMeasurePasses: Int = 0 + private set + + internal val remeasurementModifier = + object : RemeasurementModifier { + override fun onRemeasurementAvailable(remeasurement: Remeasurement) { + this@LazyTableState.remeasurement = remeasurement + } + } + + internal fun applyTableInfo(tableInfo: LazyTableInfo) { + tableInfoState.value = tableInfo + } + + internal fun applyMeasureResult(result: LazyTableMeasureResult) { + scrollPosition.updateFromMeasureResult(result) + scrollToBeConsumedHorizontal -= result.consumedHorizontalScroll + scrollToBeConsumedVertical -= result.consumedVerticalScroll + + layoutInfoState.value = result + + canHorizontalScrollBackward = result.canHorizontalScrollBackward + canHorizontalScrollForward = result.canHorizontalScrollForward + canVerticalScrollBackward = result.canVerticalScrollBackward + canVerticalScrollForward = result.canVerticalScrollForward + + numMeasurePasses++ + } + + public val isScrollInProgress: Boolean + get() = horizontalScrollableState.isScrollInProgress || verticalScrollableState.isScrollInProgress + + /* + * Horizontal scroll + */ + + internal var scrollToBeConsumedHorizontal = 0f + private set + + public val horizontalScrollableState: ScrollableState = ScrollableState { -onHorizontalScroll(-it) } + + public suspend fun horizontalScroll( + scrollPriority: MutatePriority = MutatePriority.Default, + block: suspend ScrollScope.() -> Unit, + ) { + awaitLayoutModifier.waitForFirstLayout() + horizontalScrollableState.scroll(scrollPriority, block) + } + + internal fun onHorizontalScroll(distance: Float): Float { + if (distance < 0 && !canHorizontalScrollForward || distance > 0 && !canHorizontalScrollBackward) { + return 0f + } + check(abs(scrollToBeConsumedHorizontal) <= 0.5f) { + "entered drag with non-zero pending scroll: $scrollToBeConsumedHorizontal" + } + scrollToBeConsumedHorizontal += distance + + // scrollToBeConsumed will be consumed synchronously during the forceRemeasure invocation + // inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if + // we have less than 0.5 pixels + if (abs(scrollToBeConsumedHorizontal) > 0.5f) { + val preScrollToBeConsumed = scrollToBeConsumedHorizontal + remeasurement?.forceRemeasure() + } + + // here scrollToBeConsumed is already consumed during the forceRemeasure invocation + if (abs(scrollToBeConsumedHorizontal) <= 0.5f) { + // We consumed all of it - we'll hold onto the fractional scroll for later, so report + // that we consumed the whole thing + return distance + } else { + val scrollConsumed = distance - scrollToBeConsumedHorizontal + // We did not consume all of it - return the rest to be consumed elsewhere (e.g., + // nested scrolling) + scrollToBeConsumedHorizontal = 0f // We're not consuming the rest, give it back + return scrollConsumed + } + } + + public var canHorizontalScrollForward: Boolean by mutableStateOf(false) + private set + + public var canHorizontalScrollBackward: Boolean by mutableStateOf(false) + private set + + /* + * Vertical scroll + */ + + internal var scrollToBeConsumedVertical = 0f + private set + + public val verticalScrollableState: ScrollableState = ScrollableState { -onVerticalScroll(-it) } + + public suspend fun verticalScroll( + scrollPriority: MutatePriority = MutatePriority.Default, + block: suspend ScrollScope.() -> Unit, + ) { + awaitLayoutModifier.waitForFirstLayout() + verticalScrollableState.scroll(scrollPriority, block) + } + + internal fun onVerticalScroll(distance: Float): Float { + if (distance < 0 && !canVerticalScrollForward || distance > 0 && !canVerticalScrollBackward) { + return 0f + } + check(abs(scrollToBeConsumedVertical) <= 0.5f) { + "entered drag with non-zero pending scroll: $scrollToBeConsumedVertical" + } + scrollToBeConsumedVertical += distance + + // scrollToBeConsumed will be consumed synchronously during the forceRemeasure invocation + // inside measuring we do scrollToBeConsumed.roundToInt() so there will be no scroll if + // we have less than 0.5 pixels + if (abs(scrollToBeConsumedVertical) > 0.5f) { + val preScrollToBeConsumed = scrollToBeConsumedVertical + remeasurement?.forceRemeasure() + if (prefetchingEnabled) { + // notifyPrefetch(preScrollToBeConsumed - scrollToBeConsumedVertical) + } + } + + // here scrollToBeConsumed is already consumed during the forceRemeasure invocation + if (abs(scrollToBeConsumedVertical) <= 0.5f) { + // We consumed all of it - we'll hold onto the fractional scroll for later, so report + // that we consumed the whole thing + return distance + } else { + val scrollConsumed = distance - scrollToBeConsumedVertical + // We did not consume all of it - return the rest to be consumed elsewhere (e.g., + // nested scrolling) + scrollToBeConsumedVertical = 0f // We're not consuming the rest, give it back + return scrollConsumed + } + } + + public var canVerticalScrollForward: Boolean by mutableStateOf(false) + private set + + public var canVerticalScrollBackward: Boolean by mutableStateOf(false) + private set + + public val layoutInfo: LazyTableLayoutInfo + get() = layoutInfoState.value + + public val tableInfo: LazyTableInfo + get() = tableInfoState.value + + public suspend fun scrollToColumn(column: Int, scrollOffset: Int = 0) { + horizontalScroll { snapToColumnInternal(column, scrollOffset) } + } + + internal fun snapToColumnInternal(column: Int, scrollOffset: Int) { + scrollPosition.requestColumn(column, scrollOffset) + // placement animation is not needed because we snap into a new position. + // placementAnimator.reset() + remeasurement?.forceRemeasure() + } + + public suspend fun scrollToRow(row: Int, scrollOffset: Int = 0) { + verticalScroll { snapToRowInternal(row, scrollOffset) } + } + + internal fun snapToRowInternal(row: Int, scrollOffset: Int) { + scrollPosition.requestRow(row, scrollOffset) + // placement animation is not needed because we snap into a new position. + // placementAnimator.reset() + remeasurement?.forceRemeasure() + } + + internal var density: Density = Density(1f, 1f) + + private val animateHorizontalScrollScope = LazyTableAnimateHorizontalScrollScope(this) + private val animateVerticalScrollScope = LazyTableAnimateVerticalScrollScope(this) + + public suspend fun animateScrollToRow(row: Int, scrollOffset: Int = 0) { + animateVerticalScrollScope.animateScrollToItem(row, scrollOffset) + } + + public suspend fun animateScrollToColumn(column: Int, scrollOffset: Int = 0) { + animateHorizontalScrollScope.animateScrollToItem(column, scrollOffset) + } + + public companion object { + public val Saver: Saver = + listSaver( + save = { + listOf( + it.firstVisibleRowIndex, + it.firstVisibleColumnIndex, + it.firstVisibleItemVerticalScrollOffset, + it.firstVisibleItemHorizontalScrollOffset, + ) + }, + restore = { + LazyTableState( + firstVisibleRowIndex = it[0], + firstVisibleColumnIndex = it[1], + firstVisibleRowScrollOffset = it[2], + firstVisibleColumnScrollOffset = it[3], + ) + }, + ) + } +} + +public interface TableScrollScope { + public fun scrollBy(pixels: Size): Size +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableStyle.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableStyle.kt new file mode 100644 index 0000000000000..f3663742c1345 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableStyle.kt @@ -0,0 +1,54 @@ +package org.jetbrains.jewel.foundation.lazy.table + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.jewel.foundation.lazy.table.draggable.lazyTableCellDraggingOffset +import org.jetbrains.jewel.foundation.lazy.table.draggable.lazyTableDraggableColumnHeader +import org.jetbrains.jewel.foundation.lazy.table.draggable.lazyTableDraggableRowHeader +import org.jetbrains.jewel.foundation.lazy.table.selectable.TableSelectionUnit +import org.jetbrains.jewel.foundation.lazy.table.selectable.selectableCell + +public interface LazyTableStyle { + @Composable + public fun LazyTableState.container( + columnIndex: Int, + rowIndex: Int, + columnKey: Any?, + rowKey: Any?, + content: @Composable () -> Unit, + ) + + public companion object Default : LazyTableStyle { + @Composable + override fun LazyTableState.container( + columnIndex: Int, + rowIndex: Int, + columnKey: Any?, + rowKey: Any?, + content: @Composable () -> Unit, + ) { + val isPinnedColumn = columnIndex < layoutInfo.pinnedColumns + val isPinnedRow = rowIndex < layoutInfo.pinnedRows + + val modifier = + when { + (isPinnedColumn == isPinnedRow) && isPinnedRow -> { + Modifier.selectableCell(columnKey, rowKey, TableSelectionUnit.All) + } + (isPinnedColumn == isPinnedRow) && !isPinnedRow -> { + Modifier.lazyTableCellDraggingOffset(columnKey, rowKey).selectableCell(columnKey, rowKey) + } + isPinnedColumn -> { + Modifier.lazyTableDraggableRowHeader(rowKey) + .selectableCell(columnKey, rowKey, TableSelectionUnit.Row) + } + else -> { + Modifier.lazyTableDraggableColumnHeader(columnKey) + .selectableCell(columnKey, rowKey, TableSelectionUnit.Column) + } + } + + LazyTableCellContainer(modifier, content = content) + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/Draggable.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/Draggable.kt new file mode 100644 index 0000000000000..351c407c6b0e0 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/Draggable.kt @@ -0,0 +1,51 @@ +package org.jetbrains.jewel.foundation.lazy.table.draggable + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.ui.Modifier +import androidx.compose.ui.modifier.ModifierLocal +import androidx.compose.ui.modifier.modifierLocalOf +import androidx.compose.ui.modifier.modifierLocalProvider +import org.jetbrains.jewel.foundation.lazy.draggable.LazyLayoutDraggingState +import org.jetbrains.jewel.foundation.lazy.draggable.draggableLayout +import org.jetbrains.jewel.foundation.lazy.draggable.draggingGestures +import org.jetbrains.jewel.foundation.lazy.draggable.draggingOffset + +internal val ModifierLocalLazyTableRowDraggingState = modifierLocalOf { null } + +internal val ModifierLocalLazyTableColumnDraggingState = modifierLocalOf { null } + +public fun Modifier.lazyTableDraggable( + rowDraggingState: LazyTableRowDraggingState?, + columnDraggingState: LazyTableColumnDraggingState?, +): Modifier = + draggableLayout() + .modifierLocalProvider(ModifierLocalLazyTableRowDraggingState) { rowDraggingState } + .modifierLocalProvider(ModifierLocalLazyTableColumnDraggingState) { columnDraggingState } + +public fun Modifier.lazyTableCellDraggingOffset(columnKey: Any?, rowKey: Any?): Modifier = + draggingOffset( + ModifierLocalLazyTableRowDraggingState as ModifierLocal>, + rowKey, + Orientation.Vertical, + ) + .draggingOffset( + ModifierLocalLazyTableColumnDraggingState as ModifierLocal>, + columnKey, + Orientation.Horizontal, + ) + +public fun Modifier.lazyTableDraggableRowHeader(key: Any?): Modifier = + draggingGestures(ModifierLocalLazyTableRowDraggingState as ModifierLocal>, key) + .draggingOffset( + ModifierLocalLazyTableRowDraggingState as ModifierLocal>, + key, + Orientation.Vertical, + ) + +public fun Modifier.lazyTableDraggableColumnHeader(key: Any?): Modifier = + draggingGestures(ModifierLocalLazyTableColumnDraggingState as ModifierLocal>, key) + .draggingOffset( + ModifierLocalLazyTableColumnDraggingState as ModifierLocal>, + key, + Orientation.Horizontal, + ) diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/LazyTableDraggableState.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/LazyTableDraggableState.kt new file mode 100644 index 0000000000000..75b6373397332 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/LazyTableDraggableState.kt @@ -0,0 +1,120 @@ +package org.jetbrains.jewel.foundation.lazy.table.draggable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.util.fastFirstOrNull +import org.jetbrains.jewel.foundation.lazy.draggable.LazyLayoutDraggingState +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemInfo +import org.jetbrains.jewel.foundation.lazy.table.LazyTableState + +@Composable +public fun rememberLazyTableRowDraggingState( + tableState: LazyTableState, + itemCanMove: (Any?) -> Boolean, + onMove: (Any?, Any?) -> Boolean, +): LazyTableRowDraggingState = + remember(tableState, onMove) { LazyTableRowDraggingState(tableState, itemCanMove, onMove) } + +@Composable +public fun rememberLazyTableColumnDraggingState( + tableState: LazyTableState, + itemCanMove: (Any?) -> Boolean, + onMove: (Any?, Any?) -> Boolean, +): LazyTableColumnDraggingState = + remember(tableState, onMove) { LazyTableColumnDraggingState(tableState, itemCanMove, onMove) } + +public abstract class LazyTableDraggableState( + public val tableState: LazyTableState, + public val itemCanMove: (Any?) -> Boolean, + public val onMove: (Any?, Any?) -> Boolean, +) : LazyLayoutDraggingState() { + protected fun getItemAt(offset: Offset): LazyTableItemInfo? = + tableState.layoutInfo.pinnedColumnsInfo.fastFirstOrNull { + offset in Rect(it.offset.toOffset(), it.size.toSize()) + } + ?: tableState.layoutInfo.pinnedRowsInfo.fastFirstOrNull { + offset in Rect(it.offset.toOffset(), it.size.toSize()) + } + ?: tableState.layoutInfo.pinnedItemsInfo.fastFirstOrNull { + offset in Rect(it.offset.toOffset(), it.size.toSize()) + } + + override val LazyTableItemInfo.size: Size + get() = this.size.toSize() + + override val LazyTableItemInfo.offset: Offset + get() = this.offset.toOffset() + + override fun canMove(key: Any?): Boolean = itemCanMove(key) + + override fun moveItem(from: Any?, to: Any?): Boolean = onMove(from, to) +} + +public class LazyTableRowDraggingState( + tableState: LazyTableState, + itemCanMove: (Any?) -> Boolean, + onMove: (Any?, Any?) -> Boolean, +) : LazyTableDraggableState(tableState, itemCanMove, onMove) { + override val LazyTableItemInfo.index: Int + get() = this.row + + override val LazyTableItemInfo.key: Any? + get() = (this.key as Pair?)?.second + + override fun getItemWithKey(key: Any): LazyTableItemInfo? = + tableState.layoutInfo.pinnedColumnsInfo.fastFirstOrNull { (it.key as Pair?)?.second == key } + ?: tableState.layoutInfo.pinnedRowsInfo.fastFirstOrNull { (it.key as Pair?)?.second == key } + ?: tableState.layoutInfo.pinnedItemsInfo.fastFirstOrNull { (it.key as Pair?)?.second == key } + + override fun getReplacingItem(draggingItem: LazyTableItemInfo): LazyTableItemInfo? { + if (draggingItemOffsetTransformY > 0) { + val bottomBorder = draggingItem.offset.y + draggingItem.size.height + draggingItemOffsetTransformY + val replacingItem = getItemAt(initialOffset.copy(y = bottomBorder)) ?: return null + val topBorder = replacingItem.offset.y + (replacingItem.size.height / 2) + if (bottomBorder >= topBorder) return replacingItem + } else { + val topBorder = draggingItem.offset.y + draggingItemOffsetTransformY + val replacingItem = getItemAt(initialOffset.copy(y = topBorder)) ?: return null + val bottomBorder = replacingItem.offset.y + (replacingItem.size.height / 2) + if (bottomBorder >= topBorder) return replacingItem + } + return null + } +} + +public class LazyTableColumnDraggingState( + tableState: LazyTableState, + itemCanMove: (Any?) -> Boolean, + onMove: (Any?, Any?) -> Boolean, +) : LazyTableDraggableState(tableState, itemCanMove, onMove) { + override val LazyTableItemInfo.index: Int + get() = this.column + + override val LazyTableItemInfo.key: Any? + get() = (this.key as Pair?)?.first + + override fun getItemWithKey(key: Any): LazyTableItemInfo? = + tableState.layoutInfo.pinnedColumnsInfo.fastFirstOrNull { (it.key as Pair?)?.first == key } + ?: tableState.layoutInfo.pinnedRowsInfo.fastFirstOrNull { (it.key as Pair?)?.first == key } + ?: tableState.layoutInfo.pinnedItemsInfo.fastFirstOrNull { (it.key as Pair?)?.first == key } + + override fun getReplacingItem(draggingItem: LazyTableItemInfo): LazyTableItemInfo? { + if (draggingItemOffsetTransformX > 0) { + val rightBorder = draggingItem.offset.x + draggingItem.size.width + draggingItemOffsetTransformX + val replacingItem = getItemAt(initialOffset.copy(x = rightBorder)) ?: return null + val leftBorder = replacingItem.offset.x + (replacingItem.size.width / 2) + if (rightBorder >= leftBorder) return replacingItem + } else { + val leftBorder = draggingItem.offset.x + draggingItemOffsetTransformX + val replacingItem = getItemAt(initialOffset.copy(x = leftBorder)) ?: return null + val rightBorder = replacingItem.offset.x + (replacingItem.size.width / 2) + if (rightBorder >= leftBorder) return replacingItem + } + return null + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectChangedElement.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectChangedElement.kt new file mode 100644 index 0000000000000..023fee2a1d0ce --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectChangedElement.kt @@ -0,0 +1,72 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +import androidx.compose.ui.Modifier +import androidx.compose.ui.modifier.ModifierLocalModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.observeReads +import org.jetbrains.jewel.foundation.lazy.selectable.ModifierLocalSelectionManager + +public fun Modifier.onSelectChanged(columnKey: Any?, rowKey: Any?, onSelectChanged: (Boolean) -> Unit): Modifier = + then(SelectChangedElement(columnKey, rowKey, onSelectChanged)) + +internal class SelectChangedElement( + private var columnKey: Any?, + private var rowKey: Any?, + private var onSelectChanged: (Boolean) -> Unit, +) : ModifierNodeElement() { + override fun create(): SelectChangedNode = SelectChangedNode(columnKey, rowKey, onSelectChanged) + + override fun update(node: SelectChangedNode) { + node.update(columnKey, rowKey, onSelectChanged) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SelectChangedElement) return false + + if (columnKey != other.columnKey) return false + if (rowKey != other.rowKey) return false + if (onSelectChanged != other.onSelectChanged) return false + + return true + } + + override fun hashCode(): Int { + var result = columnKey?.hashCode() ?: 0 + result = 31 * result + (rowKey?.hashCode() ?: 0) + result = 31 * result + onSelectChanged.hashCode() + return result + } +} + +internal class SelectChangedNode( + private var columnKey: Any?, + private var rowKey: Any?, + private var onSelectChanged: (Boolean) -> Unit, +) : Modifier.Node(), ModifierLocalModifierNode, ObserverModifierNode { + var isSelected: Boolean = false + + fun update(columnKey: Any?, rowKey: Any?, onSelectChanged: (Boolean) -> Unit) { + this.columnKey = columnKey + this.rowKey = rowKey + this.onSelectChanged = onSelectChanged + this.isSelected = false + onObservedReadsChanged() + } + + override fun onAttach() { + onObservedReadsChanged() + } + + override fun onObservedReadsChanged() { + observeReads { + val manager = ModifierLocalSelectionManager.current as? TableSelectionManager ?: return@observeReads + val newValue = manager.isSelected(columnKey, rowKey) + if (newValue != isSelected) { + isSelected = newValue + onSelectChanged(newValue) + } + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/Selectable.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/Selectable.kt new file mode 100644 index 0000000000000..d2c0cd993004f --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/Selectable.kt @@ -0,0 +1,15 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +import androidx.compose.ui.Modifier +import org.jetbrains.jewel.foundation.lazy.selectable.selectionManager +import org.jetbrains.jewel.foundation.lazy.table.view.DelegatedTableView +import org.jetbrains.jewel.foundation.lazy.table.view.TableView + +internal fun Modifier.selectionManager(view: TableView): Modifier = + then( + when (view) { + is TableSelectionManager -> Modifier.selectionManager(view as TableSelectionManager) + is DelegatedTableView -> Modifier.selectionManager(view.delegate) + else -> Modifier + } + ) diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectableCellElement.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectableCellElement.kt new file mode 100644 index 0000000000000..317ab05571e2c --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectableCellElement.kt @@ -0,0 +1,121 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.modifier.ModifierLocalModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.IntSize +import org.jetbrains.jewel.foundation.lazy.selectable.ModifierLocalSelectionManager +import org.jetbrains.jewel.foundation.lazy.selectable.selectionType + +public fun Modifier.selectableCell( + columnKey: Any?, + rowKey: Any?, + selectionUnit: TableSelectionUnit = TableSelectionUnit.Cell, +): Modifier = then(SelectableCellElement(columnKey, rowKey, selectionUnit)) + +private class SelectableCellElement( + private val columnKey: Any?, + private val rowKey: Any?, + private val selectionUnit: TableSelectionUnit, +) : ModifierNodeElement() { + override fun create(): SelectableCellNode = SelectableCellNode(columnKey, rowKey, selectionUnit) + + override fun update(node: SelectableCellNode) { + node.update(columnKey, rowKey, selectionUnit) + } + + override fun InspectorInfo.inspectableProperties() { + name = "SelectableCell" + properties["columnKey"] = columnKey + properties["rowKey"] = rowKey + properties["selectionUnit"] = selectionUnit + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SelectableCellElement) return false + + if (columnKey != other.columnKey) return false + if (rowKey != other.rowKey) return false + if (selectionUnit != other.selectionUnit) return false + + return true + } + + override fun hashCode(): Int { + var result = columnKey?.hashCode() ?: 0 + result = 31 * result + (rowKey?.hashCode() ?: 0) + result = 31 * result + selectionUnit.hashCode() + return result + } +} + +internal class SelectableCellNode( + private var columnKey: Any?, + private var rowKey: Any?, + private var selectionUnit: TableSelectionUnit, +) : DelegatingNode(), ModifierLocalModifierNode { + private var pointerInputNode: SelectableCellPointerInputNode? = null + + fun update(columnKey: Any?, rowKey: Any?, selectionUnit: TableSelectionUnit) { + this.columnKey = columnKey + this.rowKey = rowKey + this.selectionUnit = selectionUnit + pointerInputNode?.update(columnKey, rowKey, selectionUnit) + } + + override fun onAttach() { + val selectionManager = ModifierLocalSelectionManager.current as? TableSelectionManager ?: return + + if (pointerInputNode == null) { + pointerInputNode = SelectableCellPointerInputNode(columnKey, rowKey, selectionUnit) + } + + val pointerInputNode = pointerInputNode ?: return + + pointerInputNode.selectionManager = selectionManager + + if (!pointerInputNode.isAttached) { + delegate(pointerInputNode) + } + } +} + +private class SelectableCellPointerInputNode( + private var columnKey: Any?, + private var rowKey: Any?, + private var selectionUnit: TableSelectionUnit, +) : Modifier.Node(), PointerInputModifierNode { + var selectionManager: TableSelectionManager? = null + + fun update(columnKey: Any?, rowKey: Any?, selectionUnit: TableSelectionUnit) { + this.columnKey = columnKey + this.rowKey = rowKey + this.selectionUnit = selectionUnit + } + + override fun onCancelPointerInput() {} + + override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) { + val manager = selectionManager ?: return + + when (pointerEvent.type) { + PointerEventType.Press -> { + manager.handleEvent( + TableSelectionEvent( + columnKey, + rowKey, + selectionUnit, + pointerEvent.keyboardModifiers.selectionType(), + ) + ) + } + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleCellSelectionManager.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleCellSelectionManager.kt new file mode 100644 index 0000000000000..b04011c750f31 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleCellSelectionManager.kt @@ -0,0 +1,57 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.jetbrains.jewel.foundation.lazy.selectable.SelectionEvent +import org.jetbrains.jewel.foundation.lazy.selectable.SelectionType + +/** A [SelectionManager] that support single cell selection. */ +public open class SingleCellSelectionManager(initialColumnKey: Any?, initialRowKey: Any?) : TableSelectionManager { + private var selectedRowKey: Any? by mutableStateOf(initialRowKey) + private var selectedColumnKey: Any? by mutableStateOf(initialColumnKey) + + override val interactionSource: MutableInteractionSource = MutableInteractionSource() + + override fun isSelectable(columnKey: Any?, rowKey: Any?): Boolean = true + + override fun isSelected(columnKey: Any?, rowKey: Any?): Boolean = + selectedRowKey == rowKey && selectedColumnKey == columnKey + + override fun handleEvent(event: SelectionEvent) { + if (event !is TableSelectionEvent) { + clearSelection() + return + } + + if (event.rowKey == null || event.columnKey == null) { + clearSelection() + return + } + + if (event.type == SelectionType.Multi && isSelected(event.columnKey, event.rowKey)) { + clearSelection() + return + } + select(event.columnKey, event.rowKey) + } + + private fun select(columnKey: Any?, rowKey: Any?) { + if (isSelected(columnKey, rowKey)) { + return + } + + selectedRowKey = rowKey + selectedColumnKey = columnKey + } + + override fun clearSelection() { + if (selectedRowKey == null && selectedColumnKey == null) { + return + } + + selectedRowKey = null + selectedColumnKey = null + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleRowSelectionManager.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleRowSelectionManager.kt new file mode 100644 index 0000000000000..14429b1975f60 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleRowSelectionManager.kt @@ -0,0 +1,48 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.jetbrains.jewel.foundation.lazy.selectable.SelectionEvent +import org.jetbrains.jewel.foundation.lazy.selectable.SelectionType + +public class SingleRowSelectionManager(initialRowKey: Any?) : TableSelectionManager { + private var selectedRowKey: Any? by mutableStateOf(initialRowKey) + + override val interactionSource: MutableInteractionSource = MutableInteractionSource() + + override fun isSelectable(columnKey: Any?, rowKey: Any?): Boolean = true + + override fun isSelected(columnKey: Any?, rowKey: Any?): Boolean = rowKey == selectedRowKey + + override fun handleEvent(event: SelectionEvent) { + if (event !is TableSelectionEvent) { + clearSelection() + return + } + + if (event.rowKey == null) { + clearSelection() + return + } + + if (event.type == SelectionType.Multi && isSelected(null, event.rowKey)) { + clearSelection() + return + } + select(event.rowKey) + } + + private fun select(rowKey: Any?) { + if (selectedRowKey == rowKey) { + return + } + + selectedRowKey = rowKey + } + + override fun clearSelection() { + selectedRowKey = null + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionEvent.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionEvent.kt new file mode 100644 index 0000000000000..8fafa3045e3f6 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionEvent.kt @@ -0,0 +1,13 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +import org.jetbrains.jewel.foundation.GenerateDataFunctions +import org.jetbrains.jewel.foundation.lazy.selectable.SelectionEvent +import org.jetbrains.jewel.foundation.lazy.selectable.SelectionType + +@GenerateDataFunctions +public class TableSelectionEvent( + public val columnKey: Any? = null, + public val rowKey: Any? = null, + public val selectionUnit: TableSelectionUnit = TableSelectionUnit.Cell, + public val type: SelectionType = SelectionType.Normal, +) : SelectionEvent diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionManager.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionManager.kt new file mode 100644 index 0000000000000..61e61127e2b9c --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionManager.kt @@ -0,0 +1,38 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.jetbrains.jewel.foundation.lazy.selectable.SelectionManager + +public interface TableSelectionManager : SelectionManager { + public fun isSelectable(columnKey: Any?, rowKey: Any?): Boolean + + override fun isSelectable(itemKey: Any?): Boolean { + if (itemKey is Pair<*, *>) { + return isSelectable(itemKey.first, itemKey.second) + } + + return true + } + + public fun isSelected(columnKey: Any?, rowKey: Any?): Boolean + + override fun isSelected(itemKey: Any?): Boolean { + if (itemKey is Pair<*, *>) { + return isSelected(itemKey.first, itemKey.second) + } + + return false + } +} + +@Composable +public fun rememberSingleCellSelectionManager( + initialColumnKey: Any? = null, + initialRowKey: Any? = null, +): TableSelectionManager = remember { SingleCellSelectionManager(initialColumnKey, initialRowKey) } + +@Composable +public fun rememberSingleRowSelectionManager(initialRowKey: Any? = null): TableSelectionManager = remember { + SingleRowSelectionManager(initialRowKey) +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionUnit.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionUnit.kt new file mode 100644 index 0000000000000..984c489de0eb4 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionUnit.kt @@ -0,0 +1,15 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +public enum class TableSelectionUnit { + /** Selects a single cell. */ + Cell, + + /** Selects a row. */ + Row, + + /** Selects a column. */ + Column, + + /** Selects all cells. */ + All, +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/ColumnView.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/ColumnView.kt new file mode 100644 index 0000000000000..f91122d8f9f63 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/ColumnView.kt @@ -0,0 +1,38 @@ +package org.jetbrains.jewel.foundation.lazy.table.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Constraints +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemScope +import org.jetbrains.jewel.foundation.lazy.table.LazyTableLayoutScope + +public interface ColumnView { + public fun columns(): Int + + public fun pinnedColumns(): Int + + public fun columnKey(column: Int): Any? + + public fun columnIndex(key: Any?): Int + + public fun LazyTableLayoutScope.columnConstraints(columnKey: Any?): Constraints? + + @Composable public fun LazyTableItemScope.cell(item: T, columnKey: Any?) + + @Composable public fun LazyTableItemScope.header(rowKey: Any?, columnKey: Any?) {} + + @Composable public fun supportColumnSorting(): Boolean = false + + /** + * Check if the column with the given key can be moved. + * + * @param key the key of the column to check + * @return `true` if the column can be moved, `false` otherwise + */ + public fun canMoveColumn(key: Any?): Boolean = false + + public fun moveColumn(fromKey: Any?, toKey: Any?): Boolean = false + + public fun cellContentType(item: T, columnKey: Any?): Any? + + public fun headerContentType(rowKey: Any?, columnKey: Any?): Any? = null +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/DelegatedTableView.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/DelegatedTableView.kt new file mode 100644 index 0000000000000..aeca2e4b5aec6 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/DelegatedTableView.kt @@ -0,0 +1,38 @@ +package org.jetbrains.jewel.foundation.lazy.table.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Constraints +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemScope +import org.jetbrains.jewel.foundation.lazy.table.LazyTableLayoutScope + +/** A wrapper of a table view that delegates all the methods to the given table view. */ +public abstract class DelegatedTableView(public val delegate: TableView) : TableView { + override fun rows(): Int = delegate.rows() + + override fun pinnedRows(): Int = delegate.pinnedRows() + + override fun columns(): Int = delegate.columns() + + override fun pinnedColumns(): Int = delegate.pinnedColumns() + + override fun rowKey(row: Int): Any? = delegate.rowKey(row) + + override fun columnKey(column: Int): Any? = delegate.columnKey(column) + + override fun rowIndex(key: Any?): Int = delegate.rowIndex(key) + + override fun columnIndex(key: Any?): Int = delegate.columnIndex(key) + + override fun LazyTableLayoutScope.rowConstraints(rowKey: Any?): Constraints? = + with(delegate) { rowConstraints(rowKey) } + + override fun LazyTableLayoutScope.columnConstraints(columnKey: Any?): Constraints? = + with(delegate) { columnConstraints(columnKey) } + + @Composable + override fun LazyTableItemScope.cell(rowKey: Any?, columnKey: Any?) { + with(delegate) { cell(rowKey, columnKey) } + } + + override fun cellContentType(rowKey: Any?, columnKey: Any?): Any? = delegate.cellContentType(rowKey, columnKey) +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/InMemoryTableView.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/InMemoryTableView.kt new file mode 100644 index 0000000000000..e233cd6eae444 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/InMemoryTableView.kt @@ -0,0 +1,191 @@ +package org.jetbrains.jewel.foundation.lazy.table.view + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.unit.Constraints +import kotlin.math.abs +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemScope +import org.jetbrains.jewel.foundation.lazy.table.LazyTableLayoutScope + +/** + * A table view that stores its content in memory. + * + * @param T the type of the content + * @param initContent the initial content of the table + * @param columnView the column accessor for the table + * @param constraints the constraints for the rows + */ +public open class InMemoryTableView( + initContent: Collection = listOf(), + private val columnView: ColumnView, + private val rowKey: (T) -> Any = { it }, + private val constraints: LazyTableLayoutScope.(Any?) -> Constraints?, +) : SortableTableView { + private val items = initContent.toMutableStateList() + + private val reversedKeyMapping = mutableMapOf() + + private val reversedIndexMapping = mutableMapOf() + + protected fun generateRowKey(index: Int): Any { + val item = items[index] + val key = rowKey(item) + reversedKeyMapping[key] = item + reversedIndexMapping[key] = index + return key + } + + public val size: Int + get() = items.size + + public fun clear() { + reversedKeyMapping.clear() + reversedIndexMapping.clear() + items.clear() + } + + public operator fun get(index: Int): T = items[index] + + public operator fun set(index: Int, element: T): T { + val item = items[index] + val oldKey = rowKey(item) + reversedKeyMapping.remove(oldKey) + reversedIndexMapping.remove(oldKey) + + items[index] = element + return element + } + + public fun isEmpty(): Boolean = items.isEmpty() + + public fun iterator(): MutableIterator = + object : MutableIterator { + private var index = 0 + + override fun hasNext(): Boolean = index < items.size + + override fun next(): T = get(index++) + + override fun remove() { + removeAt(index) + } + } + + public fun removeAt(index: Int): T { + val key = rowKey(items[index]) + reversedIndexMapping.remove(key) + reversedKeyMapping.remove(key) + + return items.removeAt(index) + } + + public fun remove(element: T): Boolean { + val key = rowKey(element) + reversedIndexMapping.remove(key) + reversedKeyMapping.remove(key) + + return items.remove(element) + } + + public fun contains(element: T): Boolean = items.contains(element) + + public fun add(index: Int, element: T) { + items.add(index, element) + } + + public fun add(element: T): Boolean = items.add(element) + + override fun rows(): Int = items.size + + override fun columns(): Int = columnView.columns() + + override fun pinnedColumns(): Int = columnView.pinnedColumns() + + override fun rowKey(row: Int): Any? = generateRowKey(row) + + override fun columnKey(column: Int): Any? = columnView.columnKey(column) + + override fun rowIndex(key: Any?): Int = reversedIndexMapping[key] ?: -1 + + override fun columnIndex(key: Any?): Int = columnView.columnIndex(key) + + override fun LazyTableLayoutScope.rowConstraints(rowKey: Any?): Constraints? = this.constraints(rowKey) + + override fun LazyTableLayoutScope.columnConstraints(columnKey: Any?): Constraints? = + with(columnView) { columnConstraints(columnKey) } + + @Composable + override fun LazyTableItemScope.cell(rowKey: Any?, columnKey: Any?) { + with(columnView) { + val item = reversedKeyMapping[rowKey] + if (item != null) { + cell(item, columnKey) + } else { + header(rowKey, columnKey) + } + } + } + + override fun cellContentType(rowKey: Any?, columnKey: Any?): Any? { + val item = reversedKeyMapping[rowKey] + return if (item != null) { + columnView.cellContentType(item, columnKey) + } else { + columnView.headerContentType(rowKey, columnKey) + } + } + + @Composable override fun supportColumnSorting(): Boolean = columnView.supportColumnSorting() + + @Composable override fun supportRowSorting(): Boolean = true + + override fun canMoveColumn(key: Any?): Boolean = columnView.canMoveColumn(key) + + override fun canMoveRow(key: Any?): Boolean = true + + override fun moveColumn(fromKey: Any?, toKey: Any?): Boolean = columnView.moveColumn(fromKey, toKey) + + override fun moveRow(fromKey: Any?, toKey: Any?): Boolean { + val fromIndex = reversedIndexMapping[fromKey] ?: return false + val toIndex = reversedIndexMapping[toKey] ?: return false + + items.add(toIndex, items.removeAt(fromIndex)) + + if (abs(fromIndex - toIndex) == 1) { + reversedIndexMapping.swap(fromKey!!, toKey!!) + } else { + val step = if (fromIndex > toIndex) -1 else 1 + + var from = fromIndex + + while (from != toIndex) { + val key = rowKey(items[from]) + reversedIndexMapping[key] = from + from += step + } + } + + return true + } +} + +private fun MutableMap.swap(key1: K, key2: K) { + val value1 = this[key1] ?: return + val value2 = this[key2] ?: return + + this[key1] = value2 + this[key2] = value1 +} + +private fun MutableList.swap(index1: Int, index2: Int) { + val value1 = this[index2] + val value2 = this[index2] + + this[index1] = value2 + this[index2] = value1 +} + +public fun Collection.toTableView( + columnView: ColumnView, + rowConstraints: LazyTableLayoutScope.(Any?) -> Constraints? = { null }, +): InMemoryTableView = InMemoryTableView(this, columnView, { it }, rowConstraints) diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/LazyTableViewContent.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/LazyTableViewContent.kt new file mode 100644 index 0000000000000..8781e3bcebc31 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/LazyTableViewContent.kt @@ -0,0 +1,70 @@ +package org.jetbrains.jewel.foundation.lazy.table.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import org.jetbrains.jewel.foundation.lazy.table.LazyTableContent +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemScope +import org.jetbrains.jewel.foundation.lazy.table.LazyTableLayoutScope +import org.jetbrains.jewel.foundation.lazy.table.LazyTableState +import org.jetbrains.jewel.foundation.lazy.table.LazyTableStyle + +internal class LazyTableViewContent( + private val state: LazyTableState, + private val style: LazyTableStyle, + private val tableView: TableView, +) : LazyTableContent { + override val columnCount: Int + get() = tableView.columns() + + override val rowCount: Int + get() = tableView.rows() + + override fun getKey(position: IntOffset): Pair { + val columnKey = tableView.columnKey(position.x)!! + val rowKey = tableView.rowKey(position.y)!! + + return columnKey to rowKey + } + + override fun getKey(index: Int): Pair = getKey(getPosition(index)) + + override fun LazyTableLayoutScope.getColumnConstraints(column: Int): Constraints? = + with(tableView) { columnConstraints(tableView.columnKey(column)) } + + override fun LazyTableLayoutScope.getRowConstraints(row: Int): Constraints? = + with(tableView) { rowConstraints(tableView.rowKey(row)) } + + override fun getContentType(position: IntOffset): Any? { + val (columnKey, rowKey) = getKey(position) + return tableView.cellContentType(rowKey, columnKey) + } + + override fun getContentType(index: Int): Any? { + val (columnKey, rowKey) = getKey(index) + + return tableView.cellContentType(rowKey, columnKey) + } + + override fun getPosition(index: Int): IntOffset { + val row = index / columnCount + val column = index % columnCount + return IntOffset(column, row) + } + + override fun getIndex(position: IntOffset): Int = position.y * columnCount + position.x + + @Composable + override fun Item(scope: LazyTableItemScope, index: Int) { + val position = getPosition(index) + if (position.x >= columnCount) return + if (position.y >= rowCount) return + + val (columnKey, rowKey) = getKey(position) + with(style) { + state.container(position.x, position.y, columnKey, rowKey) { + with(tableView) { scope.cell(rowKey, columnKey) } + } + } + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/SortableTableView.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/SortableTableView.kt new file mode 100644 index 0000000000000..ef73287eb36ee --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/SortableTableView.kt @@ -0,0 +1,44 @@ +package org.jetbrains.jewel.foundation.lazy.table.view + +import androidx.compose.runtime.Composable + +/** A table view that provides the drag-and-drop to sort table content functionality. */ +public interface SortableTableView : TableView { + @Composable public fun supportColumnSorting(): Boolean + + @Composable public fun supportRowSorting(): Boolean + + /** + * Check if the column with the given key can be moved. + * + * @param key the key of the column to check + * @return `true` if the column can be moved, `false` otherwise + */ + public fun canMoveColumn(key: Any?): Boolean + + /** + * Check if the row with the given key can be moved. + * + * @param key the key of the row to check + * @return `true` if the row can be moved, `false` otherwise + */ + public fun canMoveRow(key: Any?): Boolean + + /** + * Move the column with the given key to the new position. + * + * @param fromKey the key of the column to move + * @param toKey the key of the column to move to + * @return `true` if the column is moved successfully, `false` otherwise + */ + public fun moveColumn(fromKey: Any?, toKey: Any?): Boolean + + /** + * Move the row with the given key to the new position. + * + * @param fromKey the key of the row to move + * @param toKey the key of the row to move to + * @return `true` if the row is moved successfully, `false` otherwise + */ + public fun moveRow(fromKey: Any?, toKey: Any?): Boolean +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableView.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableView.kt new file mode 100644 index 0000000000000..c99d2da68b46c --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableView.kt @@ -0,0 +1,85 @@ +package org.jetbrains.jewel.foundation.lazy.table.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Constraints +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemScope +import org.jetbrains.jewel.foundation.lazy.table.LazyTableLayoutScope + +/** A table view that provides the abstraction of a table content. */ +public interface TableView { + /** The number of rows in the table. */ + public fun rows(): Int + + /** The number of pinned rows in the table. */ + public fun pinnedRows(): Int = 0 + + /** The number of columns in the table. */ + public fun columns(): Int + + /** The number of pinned columns in the table. */ + public fun pinnedColumns(): Int = 0 + + /** + * The key of the row at the given index. + * + * @param row the index of the row + * @return the key of the row + */ + public fun rowKey(row: Int): Any? + + /** + * The key of the column at the given index. + * + * @param column the index of the column + * @return the key of the column + */ + public fun columnKey(column: Int): Any? + + /** + * The index of the row with the given key. + * + * @param key the key of the row + * @return the index of the row + */ + public fun rowIndex(key: Any?): Int + + /** + * The index of the column with the given key. + * + * @param key the key of the column + * @return the index of the column + */ + public fun columnIndex(key: Any?): Int + + /** + * The layout constraints of the row with the given key. + * + * @param rowKey the key of the row + * @return the constraints of the row + */ + public fun LazyTableLayoutScope.rowConstraints(rowKey: Any?): Constraints? = null + + /** + * The layout constraints of the column with the given key. + * + * @param columnKey the key of the column + * @return the constraints of the column + */ + public fun LazyTableLayoutScope.columnConstraints(columnKey: Any?): Constraints? = null + + /** + * The content of the cell at the given row and column. + * + * @param rowKey the key of the row + * @param columnKey the key of the column + */ + @Composable public fun LazyTableItemScope.cell(rowKey: Any?, columnKey: Any?) + + /** + * The content of the header cell at the given row and column. + * + * @param rowKey the key of the row + * @param columnKey the key of the column + */ + public fun cellContentType(rowKey: Any?, columnKey: Any?): Any? +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewKeyPositionMap.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewKeyPositionMap.kt new file mode 100644 index 0000000000000..01a3135a3751e --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewKeyPositionMap.kt @@ -0,0 +1,19 @@ +package org.jetbrains.jewel.foundation.lazy.table.view + +import androidx.compose.ui.unit.IntOffset +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemKeyPositionMap + +internal class TableViewKeyPositionMap(val view: TableView) : LazyTableItemKeyPositionMap { + override fun getPosition(key: Any): IntOffset? { + val (columnKey, rowKey) = key as Pair<*, *> + val column = view.columnIndex(columnKey) + val row = view.rowIndex(rowKey) + return IntOffset(column, row) + } + + override fun getKey(coordinate: IntOffset): Any? { + val columnKey = view.columnKey(coordinate.x) + val rowKey = view.rowKey(coordinate.y) + return columnKey to rowKey + } +} diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewWithHeader.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewWithHeader.kt new file mode 100644 index 0000000000000..424467318d2b7 --- /dev/null +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewWithHeader.kt @@ -0,0 +1,56 @@ +package org.jetbrains.jewel.foundation.lazy.table.view + +import androidx.compose.runtime.Composable + +/** A table view that provides table header functionality. */ +public open class TableViewWithHeader(delegateView: TableView, protected val headerKey: Any) : + DelegatedTableView(delegateView) { + override fun rows(): Int = super.rows() + 1 + + override fun pinnedRows(): Int = super.pinnedRows() + 1 + + override fun rowKey(row: Int): Any? = if (row == 0) headerKey else super.rowKey(row - 1) + + override fun rowIndex(key: Any?): Int = if (key == headerKey) 0 else super.rowIndex(key) + 1 +} + +public class SortableTableViewWithHeader(delegateView: TableView, headerKey: Any) : + TableViewWithHeader(delegateView, headerKey), SortableTableView { + @Composable + override fun supportColumnSorting(): Boolean { + if (delegate !is SortableTableView) return false + return delegate.supportColumnSorting() + } + + @Composable + override fun supportRowSorting(): Boolean { + if (delegate !is SortableTableView) return false + return delegate.supportRowSorting() + } + + override fun canMoveColumn(key: Any?): Boolean { + if (delegate !is SortableTableView) return false + return key != headerKey && delegate.canMoveColumn(key) + } + + override fun canMoveRow(key: Any?): Boolean { + if (delegate !is SortableTableView) return false + return key != headerKey && delegate.canMoveRow(key) + } + + override fun moveColumn(fromKey: Any?, toKey: Any?): Boolean { + if (delegate !is SortableTableView) return false + return fromKey != headerKey && toKey != headerKey && delegate.moveColumn(fromKey, toKey) + } + + override fun moveRow(fromKey: Any?, toKey: Any?): Boolean { + if (delegate !is SortableTableView) return false + return fromKey != headerKey && toKey != headerKey && delegate.moveRow(fromKey, toKey) + } +} + +public fun T.withHeader(headerKey: Any): TableView = + when (this) { + is SortableTableView -> SortableTableViewWithHeader(this, headerKey) + else -> TableViewWithHeader(this, headerKey) + } diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridge.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridge.kt index 48275763b84e0..bb650ab66a03c 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridge.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridge.kt @@ -98,6 +98,7 @@ internal fun createBridgeComponentStyling(theme: ThemeDefinition): ComponentStyl textAreaStyle = readTextAreaStyle(textFieldStyle.metrics), textFieldStyle = textFieldStyle, tooltipStyle = readTooltipStyle(), + tableStyle = readLazyTableStyle(), undecoratedDropdownStyle = readUndecoratedDropdownStyle(menuStyle), ) } diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeLazyTable.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeLazyTable.kt new file mode 100644 index 0000000000000..744964f18f190 --- /dev/null +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeLazyTable.kt @@ -0,0 +1,25 @@ +package org.jetbrains.jewel.bridge.theme + +import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified +import org.jetbrains.jewel.ui.component.styling.TableColors +import org.jetbrains.jewel.ui.component.styling.TableMetrics +import org.jetbrains.jewel.ui.component.styling.TableStyle + +internal fun readLazyTableStyle() = + TableStyle( + colors = + TableColors( + background = retrieveColorOrUnspecified("Table.background"), + backgroundSelected = retrieveColorOrUnspecified("Table.selectionBackground"), + backgroundInactiveSelected = retrieveColorOrUnspecified("Table.inactiveSelectionBackground"), + foreground = retrieveColorOrUnspecified("Table.foreground"), + foregroundSelected = retrieveColorOrUnspecified("Table.selectionForeground"), + foregroundInactiveSelected = retrieveColorOrUnspecified("Table.inactiveSelectionForeground"), + gridColor = retrieveColorOrUnspecified("Table.gridColor"), + stripeColor = retrieveColorOrUnspecified("Table.stripeColor"), + headerBackground = retrieveColorOrUnspecified("TableHeader.background"), + headerForeground = retrieveColorOrUnspecified("TableHeader.foreground"), + headerSeparatorColor = retrieveColorOrUnspecified("TableHeader.separatorColor"), + ), + metrics = TableMetrics(), + ) diff --git a/platform/jewel/int-ui/int-ui-standalone/api/int-ui-standalone.api b/platform/jewel/int-ui/int-ui-standalone/api/int-ui-standalone.api index 7d0874bae9f26..bcf4c25ff5eb6 100644 --- a/platform/jewel/int-ui/int-ui-standalone/api/int-ui-standalone.api +++ b/platform/jewel/int-ui/int-ui-standalone/api/int-ui-standalone.api @@ -393,6 +393,14 @@ public final class org/jetbrains/jewel/intui/standalone/styling/IntUiInlineWarni public static synthetic fun light$default (Lorg/jetbrains/jewel/intui/standalone/styling/IntUiInlineWarningBannerStyleFactory;Lorg/jetbrains/jewel/ui/component/styling/BannerColors;Lorg/jetbrains/jewel/ui/component/styling/BannerMetrics;ILjava/lang/Object;)Lorg/jetbrains/jewel/ui/component/styling/InlineBannerStyle; } +public final class org/jetbrains/jewel/intui/standalone/styling/IntUiLazyTableStylingKt { + public static final fun dark (Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle; + public static final fun dark-Zw1t1dA (Lorg/jetbrains/jewel/ui/component/styling/LazyTableColors$Companion;JJJJJJJJJJJLandroidx/compose/runtime/Composer;III)Lorg/jetbrains/jewel/ui/component/styling/LazyTableColors; + public static final fun defaults (Lorg/jetbrains/jewel/ui/component/styling/LazyTableMetrics$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/LazyTableMetrics; + public static final fun light (Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle; + public static final fun light-Zw1t1dA (Lorg/jetbrains/jewel/ui/component/styling/LazyTableColors$Companion;JJJJJJJJJJJLandroidx/compose/runtime/Composer;III)Lorg/jetbrains/jewel/ui/component/styling/LazyTableColors; +} + public final class org/jetbrains/jewel/intui/standalone/styling/IntUiLazyTreeStylingKt { public static final fun dark (Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle$Companion;Lorg/jetbrains/jewel/ui/component/styling/SimpleListItemColors;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeMetrics;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeIcons;)Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle; public static synthetic fun dark$default (Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle$Companion;Lorg/jetbrains/jewel/ui/component/styling/SimpleListItemColors;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeMetrics;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeIcons;ILjava/lang/Object;)Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle; diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/styling/IntUiTableStyling.kt b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/styling/IntUiTableStyling.kt new file mode 100644 index 0000000000000..86ef6317c8a48 --- /dev/null +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/styling/IntUiTableStyling.kt @@ -0,0 +1,72 @@ +package org.jetbrains.jewel.intui.standalone.styling + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import org.jetbrains.jewel.intui.core.theme.IntUiDarkTheme +import org.jetbrains.jewel.intui.core.theme.IntUiLightTheme +import org.jetbrains.jewel.ui.component.styling.TableColors +import org.jetbrains.jewel.ui.component.styling.TableMetrics +import org.jetbrains.jewel.ui.component.styling.TableStyle + +public fun TableStyle.Companion.light(): TableStyle = + TableStyle(colors = TableColors.light(), metrics = TableMetrics.defaults()) + +public fun TableStyle.Companion.dark(): TableStyle = + TableStyle(colors = TableColors.dark(), metrics = TableMetrics.defaults()) + +public fun TableColors.Companion.light( + background: Brush = SolidColor(IntUiLightTheme.colors.gray(13)), + backgroundSelected: Brush = SolidColor(IntUiLightTheme.colors.blue(11)), + backgroundInactiveSelected: Brush = SolidColor(IntUiLightTheme.colors.gray(11)), + foreground: Color = IntUiLightTheme.colors.gray(1), + foregroundSelected: Color = IntUiLightTheme.colors.gray(1), + foregroundInactiveSelected: Color = IntUiLightTheme.colors.gray(1), + gridColor: Color = IntUiLightTheme.colors.gray(12), + stripeBackground: Brush = SolidColor(IntUiLightTheme.colors.gray(13)), + headerBackground: Brush = SolidColor(IntUiLightTheme.colors.gray(13)), + headerForeground: Color = IntUiLightTheme.colors.gray(1), + headerSeparatorColor: Color = IntUiLightTheme.colors.gray(12), +): TableColors = + TableColors( + background = background, + backgroundSelected = backgroundSelected, + backgroundInactiveSelected = backgroundInactiveSelected, + foreground = foreground, + foregroundSelected = foregroundSelected, + foregroundInactiveSelected = foregroundInactiveSelected, + gridColor = gridColor, + stripeBackground = stripeBackground, + headerBackground = headerBackground, + headerForeground = headerForeground, + headerSeparatorColor = headerSeparatorColor, + ) + +public fun TableColors.Companion.dark( + background: Brush = SolidColor(IntUiDarkTheme.colors.gray(2)), + backgroundSelected: Brush = SolidColor(IntUiDarkTheme.colors.blue(2)), + backgroundInactiveSelected: Brush = SolidColor(IntUiDarkTheme.colors.gray(4)), + foreground: Color = IntUiDarkTheme.colors.gray(12), + foregroundSelected: Color = IntUiDarkTheme.colors.gray(12), + foregroundInactiveSelected: Color = IntUiDarkTheme.colors.gray(12), + gridColor: Color = IntUiDarkTheme.colors.gray(1), + stripeBackground: Brush = SolidColor(IntUiDarkTheme.colors.gray(2)), + headerBackground: Brush = SolidColor(IntUiDarkTheme.colors.gray(2)), + headerForeground: Color = IntUiDarkTheme.colors.gray(12), + headerSeparatorColor: Color = IntUiDarkTheme.colors.gray(1), +): TableColors = + TableColors( + background = background, + backgroundSelected = backgroundSelected, + backgroundInactiveSelected = backgroundInactiveSelected, + foreground = foreground, + foregroundSelected = foregroundSelected, + foregroundInactiveSelected = foregroundInactiveSelected, + gridColor = gridColor, + stripeBackground = stripeBackground, + headerBackground = headerBackground, + headerForeground = headerForeground, + headerSeparatorColor = headerSeparatorColor, + ) + +public fun TableMetrics.Companion.defaults(): TableMetrics = TableMetrics() diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt index 1f1fe8733a0b1..c312955f1eeff 100644 --- a/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt @@ -49,6 +49,7 @@ import org.jetbrains.jewel.ui.component.styling.SimpleListItemStyle import org.jetbrains.jewel.ui.component.styling.SliderStyle import org.jetbrains.jewel.ui.component.styling.SplitButtonStyle import org.jetbrains.jewel.ui.component.styling.TabStyle +import org.jetbrains.jewel.ui.component.styling.TableStyle import org.jetbrains.jewel.ui.component.styling.TextAreaStyle import org.jetbrains.jewel.ui.component.styling.TextFieldStyle import org.jetbrains.jewel.ui.component.styling.TooltipStyle @@ -167,6 +168,7 @@ public fun ComponentStyling.dark( selectableLazyColumnStyle: SelectableLazyColumnStyle = SelectableLazyColumnStyle.dark(), sliderStyle: SliderStyle = SliderStyle.dark(), simpleListItemStyle: SimpleListItemStyle = SimpleListItemStyle.dark(), + tableStyle: TableStyle = TableStyle.dark(), textAreaStyle: TextAreaStyle = TextAreaStyle.dark(), textFieldStyle: TextFieldStyle = TextFieldStyle.dark(), tooltipStyle: TooltipStyle = TooltipStyle.dark(), @@ -202,6 +204,7 @@ public fun ComponentStyling.dark( selectableLazyColumnStyle = selectableLazyColumnStyle, simpleListItemStyle = simpleListItemStyle, sliderStyle = sliderStyle, + tableStyle = tableStyle, textAreaStyle = textAreaStyle, textFieldStyle = textFieldStyle, tooltipStyle = tooltipStyle, @@ -238,6 +241,7 @@ public fun ComponentStyling.light( sliderStyle: SliderStyle = SliderStyle.light(), selectableLazyColumnStyle: SelectableLazyColumnStyle = SelectableLazyColumnStyle.light(), simpleListItemStyle: SimpleListItemStyle = SimpleListItemStyle.light(), + tableStyle: TableStyle = TableStyle.light(), textAreaStyle: TextAreaStyle = TextAreaStyle.light(), textFieldStyle: TextFieldStyle = TextFieldStyle.light(), tooltipStyle: TooltipStyle = TooltipStyle.light(), @@ -273,6 +277,7 @@ public fun ComponentStyling.light( selectableLazyColumnStyle = selectableLazyColumnStyle, sliderStyle = sliderStyle, simpleListItemStyle = simpleListItemStyle, + tableStyle = tableStyle, textAreaStyle = textAreaStyle, textFieldStyle = textFieldStyle, tooltipStyle = tooltipStyle, diff --git a/platform/jewel/samples/showcase/intellij.platform.jewel.samples.showcase.iml b/platform/jewel/samples/showcase/intellij.platform.jewel.samples.showcase.iml index 40502f41fc040..798356335490a 100644 --- a/platform/jewel/samples/showcase/intellij.platform.jewel.samples.showcase.iml +++ b/platform/jewel/samples/showcase/intellij.platform.jewel.samples.showcase.iml @@ -37,5 +37,6 @@ + \ No newline at end of file diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/Tables.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/Tables.kt new file mode 100644 index 0000000000000..ec58b11b1a5bd --- /dev/null +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/Tables.kt @@ -0,0 +1,266 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.samples.showcase.components + +import androidx.compose.foundation.HorizontalScrollbar +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import kotlin.random.Random +import kotlin.reflect.KProperty +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotations +import kotlinx.coroutines.launch +import org.jetbrains.jewel.foundation.lazy.selectable.selectionManager +import org.jetbrains.jewel.foundation.lazy.table.LazyTable +import org.jetbrains.jewel.foundation.lazy.table.draggable.lazyTableDraggable +import org.jetbrains.jewel.foundation.lazy.table.draggable.rememberLazyTableColumnDraggingState +import org.jetbrains.jewel.foundation.lazy.table.draggable.rememberLazyTableRowDraggingState +import org.jetbrains.jewel.foundation.lazy.table.rememberLazyTableState +import org.jetbrains.jewel.foundation.lazy.table.rememberTableHorizontalScrollbarAdapter +import org.jetbrains.jewel.foundation.lazy.table.rememberTableVerticalScrollbarAdapter +import org.jetbrains.jewel.foundation.lazy.table.selectable.rememberSingleRowSelectionManager +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.theme.defaultTableStyle + +@Target(AnnotationTarget.PROPERTY) +internal annotation class ColumnInfo( + val name: String = "", + val minWidth: Int = -1, + val maxWidth: Int = -1, + val order: Int = 0, +) + +internal data class Order( + @ColumnInfo(name = "ID", minWidth = 50, order = 0) val id: Int, + @ColumnInfo(name = "Transaction ID", minWidth = 120, order = 1) val transactionId: String, + @ColumnInfo(name = "User ID", minWidth = 120, order = 2) val uid: String, + @ColumnInfo(name = "User Name", minWidth = 120, maxWidth = 240, order = 3) val userName: String, + @ColumnInfo(name = "Product ID", minWidth = 80, order = 4) val productId: Int, + @ColumnInfo(name = "Product Name", minWidth = 100, maxWidth = 600, order = 5) val productName: String, + @ColumnInfo(name = "Price", minWidth = 80, order = 6) val price: String, + @ColumnInfo(name = "Shipping Address", minWidth = 400, order = 8) val shippingAddress: String, + @ColumnInfo(name = "Postal Code", minWidth = 120, order = 7) val postalCode: String, + @ColumnInfo(name = "Create Time", minWidth = 50, order = 9) val createTime: Int, + @ColumnInfo(name = "Update Time", minWidth = 50, order = 10) val updateTime: Int, +) { + companion object { + val random = Random.Default + + fun fake(id: Int): Order = + Order( + id = id, + transactionId = "T${random.nextInt().toString().take(8)}", + uid = "U${random.nextInt().toString().take(8)}", + userName = "", + productId = random.nextInt(65535), + productName = "Product $id abcdefgh $id 12345678 $id", + price = random.nextInt(10000).toString(), + shippingAddress = "1600 Amphitheatre Parkway", + postalCode = "94043", + createTime = 0, + updateTime = 0, + ) + } +} + +@Composable +internal fun Tables() { + val data = remember { mutableMapOf().apply { repeat(1000) { put(it, Order.fake(it)) } } } + + val rows = remember { data.keys.toMutableStateList() } + val columns = remember { + Order::class + .declaredMemberProperties + .sortedBy { it.findAnnotations().firstOrNull()?.order } + .toMutableStateList() + } + + val state = rememberLazyTableState() + val draggableColumnState = + rememberLazyTableColumnDraggingState( + state, + itemCanMove = { true }, + onMove = { from, to -> + val fromIndex = + columns.indexOf(from).takeIf { it >= 0 } ?: return@rememberLazyTableColumnDraggingState false + val toIndex = + columns.indexOf(to).takeIf { it >= 0 } ?: return@rememberLazyTableColumnDraggingState false + + columns.add(toIndex, columns.removeAt(fromIndex)) + true + }, + ) + val draggableRowState = + rememberLazyTableRowDraggingState( + state, + itemCanMove = { true }, + onMove = { from, to -> + val fromIndex = rows.indexOf(from).takeIf { it >= 0 } ?: return@rememberLazyTableRowDraggingState false + val toIndex = rows.indexOf(to).takeIf { it >= 0 } ?: return@rememberLazyTableRowDraggingState false + + rows.add(toIndex, rows.removeAt(fromIndex)) + true + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + val id = data.size + data[id] = Order.fake(id) + rows += id + } + ) { + Text("Add row") + } + + OutlinedButton( + onClick = { + if (rows.isNotEmpty()) { + data.remove(rows.removeLast()) + } + } + ) { + Text("Remove row") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + data.clear() + rows.clear() + } + ) { + Text("Clear") + } + + OutlinedButton( + onClick = { + data.clear() + rows.clear() + repeat(1000) { data[it] = Order.fake(it) } + rows += data.keys + } + ) { + Text("Init") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val column = TextFieldState() + val row = TextFieldState() + + TextField(column) + + TextField(row) + + val coroutine = rememberCoroutineScope() + + OutlinedButton( + onClick = { + coroutine.launch { + state.scrollToColumn(column.text.toString().toIntOrNull() ?: 0) + state.scrollToRow(row.text.toString().toIntOrNull() ?: 0) + } + } + ) { + Text("Goto") + } + + OutlinedButton( + onClick = { + coroutine.launch { state.animateScrollToColumn(column.text.toString().toIntOrNull() ?: 0) } + coroutine.launch { state.animateScrollToRow(row.text.toString().toIntOrNull() ?: 0) } + } + ) { + Text("Goto with Animation") + } + } + + println("Recompose View") + + Box(Modifier.fillMaxSize()) { + println("Recompose Box") + + LazyTable( + modifier = + Modifier.lazyTableDraggable(draggableRowState, draggableColumnState) + .selectionManager(rememberSingleRowSelectionManager()), + state = state, + verticalArrangement = Arrangement.spacedBy(1.dp), + horizontalArrangement = Arrangement.spacedBy(1.dp), + pinnedColumns = 1, + pinnedRows = 1, + style = JewelTheme.defaultTableStyle, + ) { + columnDefinitions(columns.size, { columns[it] }) { + val info = columns[it].findAnnotations().firstOrNull() + Constraints( + minWidth = info?.minWidth?.takeIf { it >= 0 }?.dp?.roundToPx() ?: 0, + maxWidth = info?.maxWidth?.takeIf { it >= 0 }?.dp?.roundToPx() ?: Constraints.Infinity, + ) + } + + rowDefinition("Header") { Constraints(minHeight = 24.dp.roundToPx()) } + + rowDefinitions(rows.size, { rows[it] }) { Constraints(minHeight = 24.dp.roundToPx()) } + + cells { columnKey, rowKey -> + val column = columnKey as KProperty<*> + if (rowKey == "Header") { + val info = column.findAnnotations().firstOrNull() + Text(info?.name ?: column.name, Modifier.padding(horizontal = 4.dp), maxLines = 1) + } else { + Text( + column.getter.call(data[rowKey as Int]).toString(), + Modifier.padding(horizontal = 4.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } + } + + // TODO: Use Jewel scrollbars + HorizontalScrollbar( + rememberTableHorizontalScrollbarAdapter(state), + Modifier.fillMaxWidth().align(Alignment.BottomStart), + ) + + VerticalScrollbar( + rememberTableVerticalScrollbarAdapter(state), + Modifier.fillMaxHeight().align(Alignment.TopEnd), + ) + } +} diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/views/ComponentsViewModel.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/views/ComponentsViewModel.kt index 66374983ee062..9ff0502f8f421 100644 --- a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/views/ComponentsViewModel.kt +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/views/ComponentsViewModel.kt @@ -22,10 +22,12 @@ import org.jetbrains.jewel.samples.showcase.components.SegmentedControls import org.jetbrains.jewel.samples.showcase.components.ShowcaseIcons import org.jetbrains.jewel.samples.showcase.components.Sliders import org.jetbrains.jewel.samples.showcase.components.SplitLayouts +import org.jetbrains.jewel.samples.showcase.components.Tables import org.jetbrains.jewel.samples.showcase.components.Tabs import org.jetbrains.jewel.samples.showcase.components.TextAreas import org.jetbrains.jewel.samples.showcase.components.TextFields import org.jetbrains.jewel.samples.showcase.components.Tooltips +import org.jetbrains.jewel.samples.standalone.view.component.TableView import org.jetbrains.jewel.ui.component.SplitLayoutState import org.jetbrains.jewel.ui.component.styling.ScrollbarVisibility @@ -64,6 +66,8 @@ public class ComponentsViewModel( content = { SegmentedControls() }, ), ViewInfo(title = "Sliders", iconKey = ShowcaseIcons.Components.slider, content = { Sliders() }), + ViewInfo(title = "Table", iconKey = ShowcaseIcons.Components.tree, content = { Tables() }), + ViewInfo(title = "TableView", iconKey = ShowcaseIcons.Components.tree, content = { TableView() }), ViewInfo(title = "Tabs", iconKey = ShowcaseIcons.Components.tabs, content = { Tabs() }), ViewInfo(title = "Tooltips", iconKey = ShowcaseIcons.Components.tooltip, content = { Tooltips() }), ViewInfo(title = "TextAreas", iconKey = ShowcaseIcons.Components.textArea, content = { TextAreas() }), diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Debug.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Debug.kt new file mode 100644 index 0000000000000..f7345eac1f6f6 --- /dev/null +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Debug.kt @@ -0,0 +1,25 @@ +package org.jetbrains.jewel.samples.standalone.view.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.OverflowBox +import org.jetbrains.jewel.ui.component.Text + +@Composable +internal fun Debug() { + Row(Modifier.fillMaxWidth().background(Color.Red)) { + Box(Modifier.size(24.dp, 24.dp).background(Color.Green)) { + OverflowBox(Modifier.fillMaxSize().background(Color.Blue.copy(alpha = 0.5f))) { + Text("Foo bar baz qux quux corge grault garply waldo fred plugh xyzzy thud", color = Color.White) + } + } + } +} diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/LongList.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/LongList.kt new file mode 100644 index 0000000000000..2c5e5afcc8f55 --- /dev/null +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/LongList.kt @@ -0,0 +1,81 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.samples.standalone.view.component + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn +import org.jetbrains.jewel.foundation.lazy.rememberSelectableLazyListState +import org.jetbrains.jewel.foundation.lazy.table.LazyTableCellContainer +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.samples.showcase.components.Order +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.theme.tableStyle +import org.jetbrains.jewel.ui.theme.treeStyle + +@Composable +internal fun LongList() { + val data = remember { (1 until 1000).map { Order.fake(it) } } + + Box(Modifier.fillMaxSize()) { + val listState = rememberSelectableLazyListState() + SelectableLazyColumn( + Modifier.fillMaxSize().border(1.dp, JewelTheme.globalColors.borders.normal), + state = listState, + ) { + items(data.size, key = { data[it].id }, selectable = { true }) { orderId -> + val order = data[orderId] + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Cell(isSelected, order.id.toString(), Modifier.weight(1f)) + Cell(isSelected, order.transactionId, Modifier.weight(1f)) + Cell(isSelected, order.uid, Modifier.weight(1f)) + Cell(isSelected, order.userName, Modifier.weight(1f)) + Cell(isSelected, order.productId.toString(), Modifier.weight(1f)) + Cell(isSelected, order.productName, Modifier.weight(1f)) + Cell(isSelected, order.price, Modifier.weight(1f)) + Cell(isSelected, order.postalCode, Modifier.weight(1f)) + Cell(isSelected, order.shippingAddress, Modifier.weight(1f)) + Cell(isSelected, order.createTime.toString(), Modifier.weight(1f)) + Cell(isSelected, order.updateTime.toString(), Modifier.weight(1f)) + } + } + } + + // TODO: Migrate to Jewel scrollbar + VerticalScrollbar( + rememberScrollbarAdapter(listState.lazyListState), + Modifier.align(Alignment.TopEnd).fillMaxHeight(), + ) + } +} + +@Composable +private fun Cell(isSelected: Boolean, text: String, modifier: Modifier = Modifier) { + LazyTableCellContainer( + modifier + .border(1.dp, JewelTheme.tableStyle.colors.gridColor) + .background( + if (isSelected) JewelTheme.tableStyle.colors.backgroundSelected + else JewelTheme.tableStyle.colors.background + ) + .height(JewelTheme.treeStyle.metrics.elementMinHeight) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text(text, overflow = TextOverflow.Ellipsis, maxLines = 1) + } +} diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/TableView.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/TableView.kt new file mode 100644 index 0000000000000..9a1214a70f1c3 --- /dev/null +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/TableView.kt @@ -0,0 +1,235 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.samples.standalone.view.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +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.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotations +import kotlinx.coroutines.launch +import org.jetbrains.jewel.foundation.lazy.selectable.selectionManager +import org.jetbrains.jewel.foundation.lazy.table.LazyTable +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemScope +import org.jetbrains.jewel.foundation.lazy.table.LazyTableLayoutScope +import org.jetbrains.jewel.foundation.lazy.table.rememberLazyTableState +import org.jetbrains.jewel.foundation.lazy.table.selectable.rememberSingleRowSelectionManager +import org.jetbrains.jewel.foundation.lazy.table.view.ColumnView +import org.jetbrains.jewel.foundation.lazy.table.view.toTableView +import org.jetbrains.jewel.foundation.lazy.table.view.withHeader +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.samples.showcase.components.ColumnInfo +import org.jetbrains.jewel.samples.showcase.components.Order +import org.jetbrains.jewel.ui.component.HorizontalScrollbar +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.component.VerticalScrollbar +import org.jetbrains.jewel.ui.theme.defaultTableStyle + +@Composable +internal fun TableView() { + var id by remember { mutableStateOf(0) } + + Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp)) { + val view = remember { + buildList { + repeat(1000) { + add(Order.fake(id)) + id++ + } + } + .toTableView(OrderColumnView) { Constraints(minHeight = 24.dp.roundToPx()) } + } + + val viewWithHeader = remember(view) { view.withHeader("Header") } + + val state = rememberLazyTableState() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + view.add(Order.fake(id)) + id++ + } + ) { + Text("Add row") + } + + OutlinedButton( + onClick = { + if (view.size > 0) { + view.removeAt(view.size - 1) + } + } + ) { + Text("Remove row") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { view.clear() }) { Text("Clear") } + + OutlinedButton( + onClick = { + view.clear() + id = 0 + repeat(1000) { + view.add(Order.fake(id)) + id++ + } + } + ) { + Text("Init") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val column = TextFieldState() + val row = TextFieldState() + + TextField(column) + + TextField(row) + + val scope = rememberCoroutineScope() + + OutlinedButton( + onClick = { + scope.launch { + state.scrollToColumn(column.text.toString().toIntOrNull() ?: 0) + state.scrollToRow(row.text.toString().toIntOrNull() ?: 0) + } + } + ) { + Text("Goto") + } + + OutlinedButton( + onClick = { + scope.launch { state.animateScrollToColumn(column.text.toString().toIntOrNull() ?: 0) } + scope.launch { state.animateScrollToRow(row.text.toString().toIntOrNull() ?: 0) } + } + ) { + Text("Goto with Animation") + } + } + + Box(Modifier.weight(1f).fillMaxWidth().border(1.dp, JewelTheme.globalColors.borders.normal)) { + LazyTable( + modifier = Modifier.selectionManager(rememberSingleRowSelectionManager()), + state = state, + verticalArrangement = Arrangement.spacedBy(1.dp), + horizontalArrangement = Arrangement.spacedBy(1.dp), + view = viewWithHeader, + style = JewelTheme.defaultTableStyle, + ) + + // TODO: this probably needs to work with table state + HorizontalScrollbar( + rememberScrollState(), + // rememberTableHorizontalScrollbarAdapter(state), + Modifier.fillMaxWidth().align(Alignment.BottomStart), + ) + + VerticalScrollbar( + rememberScrollState(), + // rememberTableVerticalScrollbarAdapter(state), + Modifier.fillMaxHeight().align(Alignment.TopEnd), + ) + } + } +} + +internal object OrderColumnView : ColumnView { + val columns = + Order::class + .declaredMemberProperties + .sortedBy { it.findAnnotations().firstOrNull()?.order } + .toMutableStateList() + + override fun columns(): Int = columns.size + + override fun pinnedColumns(): Int = 1 + + override fun LazyTableLayoutScope.columnConstraints(columnKey: Any?): Constraints { + val info = (columnKey as KProperty<*>).findAnnotations().firstOrNull() + + return Constraints( + minWidth = info?.minWidth?.takeIf { it >= 0 }?.dp?.roundToPx() ?: 0, + maxWidth = info?.maxWidth?.takeIf { it >= 0 }?.dp?.roundToPx() ?: Constraints.Infinity, + ) + } + + override fun columnIndex(key: Any?): Int = columns.indexOf(key) + + override fun columnKey(column: Int): Any = columns[column] + + @Composable + override fun LazyTableItemScope.cell(item: Order, columnKey: Any?) { + @Suppress("UNCHECKED_CAST") val property = columnKey as KProperty1 + + Text( + property.get(item).toString(), + Modifier.padding(horizontal = 4.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + + @Composable + override fun LazyTableItemScope.header(rowKey: Any?, columnKey: Any?) { + @Suppress("UNCHECKED_CAST") val property = columnKey as KProperty1 + + Text(property.name, Modifier.padding(horizontal = 4.dp), overflow = TextOverflow.Ellipsis, maxLines = 1) + } + + override fun cellContentType(item: Order, columnKey: Any?): Any? = columnKey + + @Composable override fun supportColumnSorting(): Boolean = true + + override fun canMoveColumn(key: Any?): Boolean = true + + override fun moveColumn(fromKey: Any?, toKey: Any?): Boolean { + val fromIndex = columns.indexOf(fromKey) + val toIndex = columns.indexOf(toKey) + + if (fromIndex <= 0 || toIndex <= 0) return false + + columns.add(toIndex, columns.removeAt(fromIndex)) + return true + } +} diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/package-info.java b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/package-info.java similarity index 100% rename from platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/package-info.java rename to platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/package-info.java diff --git a/platform/jewel/samples/standalone/intellij.platform.jewel.samples.standalone.iml b/platform/jewel/samples/standalone/intellij.platform.jewel.samples.standalone.iml index 0e8492daccf9a..bf9fd14dfc0be 100644 --- a/platform/jewel/samples/standalone/intellij.platform.jewel.samples.standalone.iml +++ b/platform/jewel/samples/standalone/intellij.platform.jewel.samples.standalone.iml @@ -137,5 +137,6 @@ + \ No newline at end of file diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt index 0f0a92cddeab3..7e278b3bd0a01 100644 --- a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt +++ b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt @@ -15,6 +15,7 @@ import java.net.URI import org.jetbrains.jewel.samples.showcase.components.ShowcaseIcons import org.jetbrains.jewel.samples.showcase.views.forCurrentOs import org.jetbrains.jewel.samples.standalone.IntUiThemes +import org.jetbrains.jewel.samples.standalone.view.component.FpsCounter import org.jetbrains.jewel.samples.standalone.viewmodel.MainViewModel import org.jetbrains.jewel.ui.component.Dropdown import org.jetbrains.jewel.ui.component.Icon @@ -66,6 +67,8 @@ internal fun DecoratedWindowScope.TitleBarView() { } } + FpsCounter(Modifier.align(Alignment.Start)) + Text(title) Row(Modifier.align(Alignment.End)) { diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/FpsCounter.kt b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/FpsCounter.kt new file mode 100644 index 0000000000000..d045d5195d24c --- /dev/null +++ b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/FpsCounter.kt @@ -0,0 +1,140 @@ +package org.jetbrains.jewel.samples.standalone.view.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.jewel.ui.component.Text + +@Composable +internal fun FpsCounter(modifier: Modifier = Modifier) { + var displayedFPS by remember { mutableStateOf(0) } + var textContent by remember { mutableStateOf("FPS:") } + var minMaxContent by remember { mutableStateOf("") } + var fpsCountMethod by remember { mutableStateOf(FPSCountMethod.RealTime) } + var minFps by remember { mutableStateOf(240) } + var maxFps by remember { mutableStateOf(0) } + + val textColor by remember { + derivedStateOf { + when (fpsCountMethod) { + FPSCountMethod.RealTime -> { + textContent = "FPS(Realtime):$displayedFPS" + } + + FPSCountMethod.FixedInterval -> { + textContent = "FPS(latest ${fpsUpdDelay}ms):$displayedFPS" + } + + FPSCountMethod.FixedFrameCount -> { + textContent = "FPS(${frameCount}frame):$displayedFPS" + } + } + minMaxContent = "min:$minFps, max:$maxFps" + if (displayedFPS > greenFPS) Color.Green else Color.Red + } + } + + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = textContent, + modifier = + Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + fpsCountMethod = + when (fpsCountMethod) { + FPSCountMethod.FixedInterval -> FPSCountMethod.FixedFrameCount + FPSCountMethod.FixedFrameCount -> FPSCountMethod.RealTime + FPSCountMethod.RealTime -> FPSCountMethod.FixedInterval + } + minFps = 240 + maxFps = 0 + }, + color = textColor, + ) + + Text( + text = minMaxContent, + modifier = + Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + minFps = 240 + maxFps = 0 + }, + ) + } + + LaunchedEffect(Unit) { + launch(Dispatchers.Default) { + val fpsArray = FloatArray(frameCount) { 0f } + + var fpsCount = 0 + var avgFPS = 0 + var lastWriteIndex = 0 + + val countTask: suspend CoroutineScope.() -> Unit = { + var lastUpdTime = 0L + var writeIndex = 0 + while (true) { + withFrameMillis { frameTimeMillis -> + fpsCount++ + + fpsArray[writeIndex] = 1000f / (frameTimeMillis - lastUpdTime) + lastUpdTime = frameTimeMillis + + lastWriteIndex = writeIndex + + writeIndex++ + if (writeIndex >= fpsArray.size) { + avgFPS = fpsArray.average().roundToInt() + writeIndex = 0 + } + } + } + } + val updDataTask: suspend CoroutineScope.() -> Unit = { + while (true) { + delay(fpsUpdDelay) + + displayedFPS = + when (fpsCountMethod) { + FPSCountMethod.FixedInterval -> fpsCount * 1000 / fpsUpdDelay.toInt() + FPSCountMethod.FixedFrameCount -> avgFPS + FPSCountMethod.RealTime -> fpsArray[lastWriteIndex].roundToInt() + } + if (displayedFPS > 0) { + minFps = minOf(minFps, displayedFPS) + } + maxFps = maxOf(maxFps, displayedFPS) + fpsCount = 0 + } + } + launch(block = countTask) + launch(block = updDataTask) + } + } +} + +private const val fpsUpdDelay = 250L +private const val frameCount = 10 +private const val greenFPS = 57 + +internal enum class FPSCountMethod { + FixedInterval, + FixedFrameCount, + RealTime, +} diff --git a/platform/jewel/samples/standalone/src/main/resources/icons/components/dataTables.svg b/platform/jewel/samples/standalone/src/main/resources/icons/components/dataTables.svg new file mode 100644 index 0000000000000..c78377d04957e --- /dev/null +++ b/platform/jewel/samples/standalone/src/main/resources/icons/components/dataTables.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/platform/jewel/samples/standalone/src/main/resources/icons/components/dataTables_dark.svg b/platform/jewel/samples/standalone/src/main/resources/icons/components/dataTables_dark.svg new file mode 100644 index 0000000000000..64e102dfaeb1d --- /dev/null +++ b/platform/jewel/samples/standalone/src/main/resources/icons/components/dataTables_dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/platform/jewel/samples/standalone/src/main/resources/icons/components/debug.svg b/platform/jewel/samples/standalone/src/main/resources/icons/components/debug.svg new file mode 100644 index 0000000000000..415ad2dafc382 --- /dev/null +++ b/platform/jewel/samples/standalone/src/main/resources/icons/components/debug.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/platform/jewel/samples/standalone/src/main/resources/icons/components/debug@20x20.svg b/platform/jewel/samples/standalone/src/main/resources/icons/components/debug@20x20.svg new file mode 100644 index 0000000000000..c82ab9f0b8884 --- /dev/null +++ b/platform/jewel/samples/standalone/src/main/resources/icons/components/debug@20x20.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/platform/jewel/samples/standalone/src/main/resources/icons/components/debug@20x20_dark.svg b/platform/jewel/samples/standalone/src/main/resources/icons/components/debug@20x20_dark.svg new file mode 100644 index 0000000000000..5cd5d4601824e --- /dev/null +++ b/platform/jewel/samples/standalone/src/main/resources/icons/components/debug@20x20_dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/platform/jewel/samples/standalone/src/main/resources/icons/components/debug_dark.svg b/platform/jewel/samples/standalone/src/main/resources/icons/components/debug_dark.svg new file mode 100644 index 0000000000000..aa9df118b0ead --- /dev/null +++ b/platform/jewel/samples/standalone/src/main/resources/icons/components/debug_dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/platform/jewel/ui/api/ui.api b/platform/jewel/ui/api/ui.api index 8fb40341c1ef1..8270828015517 100644 --- a/platform/jewel/ui/api/ui.api +++ b/platform/jewel/ui/api/ui.api @@ -517,6 +517,11 @@ public final class org/jetbrains/jewel/ui/component/InputFieldState$Companion { public static synthetic fun of-raUdB0Y$default (Lorg/jetbrains/jewel/ui/component/InputFieldState$Companion;ZZZZZILjava/lang/Object;)J } +public final class org/jetbrains/jewel/ui/component/LazyTableKt { + public static final fun LazyTableCell (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun LazyTableHeader (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + public final class org/jetbrains/jewel/ui/component/LazyTreeKt { public static final fun LazyTree (Lorg/jetbrains/jewel/foundation/lazy/tree/Tree;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lorg/jetbrains/jewel/foundation/lazy/tree/TreeState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lorg/jetbrains/jewel/foundation/lazy/tree/KeyActions;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V } @@ -1878,6 +1883,52 @@ public abstract interface class org/jetbrains/jewel/ui/component/styling/InputFi public abstract fun getMetrics ()Lorg/jetbrains/jewel/ui/component/styling/InputFieldMetrics; } +public final class org/jetbrains/jewel/ui/component/styling/LazyTableColors { + public static final field $stable I + public static final field Companion Lorg/jetbrains/jewel/ui/component/styling/LazyTableColors$Companion; + public synthetic fun (JJJJJJJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getBackground-0d7_KjU ()J + public final fun getBackgroundInactiveSelected-0d7_KjU ()J + public final fun getBackgroundSelected-0d7_KjU ()J + public final fun getForeground-0d7_KjU ()J + public final fun getForegroundInactiveSelected-0d7_KjU ()J + public final fun getForegroundSelected-0d7_KjU ()J + public final fun getGridColor-0d7_KjU ()J + public final fun getHeaderBackground-0d7_KjU ()J + public final fun getHeaderForeground-0d7_KjU ()J + public final fun getHeaderSeparatorColor-0d7_KjU ()J + public final fun getStripeColor-0d7_KjU ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/jetbrains/jewel/ui/component/styling/LazyTableColors$Companion { +} + +public final class org/jetbrains/jewel/ui/component/styling/LazyTableMetrics { + public static final field $stable I + public static final field Companion Lorg/jetbrains/jewel/ui/component/styling/LazyTableMetrics$Companion; + public fun ()V +} + +public final class org/jetbrains/jewel/ui/component/styling/LazyTableMetrics$Companion { +} + +public final class org/jetbrains/jewel/ui/component/styling/LazyTableStyle { + public static final field $stable I + public static final field Companion Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle$Companion; + public fun (Lorg/jetbrains/jewel/ui/component/styling/LazyTableColors;Lorg/jetbrains/jewel/ui/component/styling/LazyTableMetrics;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getColors ()Lorg/jetbrains/jewel/ui/component/styling/LazyTableColors; + public final fun getMetrics ()Lorg/jetbrains/jewel/ui/component/styling/LazyTableMetrics; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/jetbrains/jewel/ui/component/styling/LazyTableStyle$Companion { +} + public final class org/jetbrains/jewel/ui/component/styling/LazyTreeIcons { public static final field $stable I public static final field Companion Lorg/jetbrains/jewel/ui/component/styling/LazyTreeIcons$Companion; @@ -4915,6 +4966,7 @@ public final class org/jetbrains/jewel/ui/theme/JewelThemeKt { public static final fun getSelectableLazyColumnStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/SelectableLazyColumnStyle; public static final fun getSimpleListItemStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/SimpleListItemStyle; public static final fun getSliderStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/SliderStyle; + public static final fun getTableStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle; public static final fun getTextAreaStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/TextAreaStyle; public static final fun getTextFieldStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/TextFieldStyle; public static final fun getTooltipStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/TooltipStyle; diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/DefaultComponentStyling.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/DefaultComponentStyling.kt index 9aa7f9ea926c9..b3726a2cedc0a 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/DefaultComponentStyling.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/DefaultComponentStyling.kt @@ -48,6 +48,7 @@ import org.jetbrains.jewel.ui.component.styling.LocalSegmentedControlStyle import org.jetbrains.jewel.ui.component.styling.LocalSelectableLazyColumnStyle import org.jetbrains.jewel.ui.component.styling.LocalSimpleListItemStyleStyle import org.jetbrains.jewel.ui.component.styling.LocalSliderStyle +import org.jetbrains.jewel.ui.component.styling.LocalTableStyle import org.jetbrains.jewel.ui.component.styling.LocalTextAreaStyle import org.jetbrains.jewel.ui.component.styling.LocalTextFieldStyle import org.jetbrains.jewel.ui.component.styling.LocalTooltipStyle @@ -63,6 +64,7 @@ import org.jetbrains.jewel.ui.component.styling.SimpleListItemStyle import org.jetbrains.jewel.ui.component.styling.SliderStyle import org.jetbrains.jewel.ui.component.styling.SplitButtonStyle import org.jetbrains.jewel.ui.component.styling.TabStyle +import org.jetbrains.jewel.ui.component.styling.TableStyle import org.jetbrains.jewel.ui.component.styling.TextAreaStyle import org.jetbrains.jewel.ui.component.styling.TextFieldStyle import org.jetbrains.jewel.ui.component.styling.TooltipStyle @@ -98,6 +100,7 @@ public class DefaultComponentStyling( public val selectableLazyColumnStyle: SelectableLazyColumnStyle, public val simpleListItemStyle: SimpleListItemStyle, public val sliderStyle: SliderStyle, + public val tableStyle: TableStyle, public val textAreaStyle: TextAreaStyle, public val textFieldStyle: TextFieldStyle, public val tooltipStyle: TooltipStyle, @@ -135,6 +138,7 @@ public class DefaultComponentStyling( LocalSelectableLazyColumnStyle provides selectableLazyColumnStyle, LocalSimpleListItemStyleStyle provides simpleListItemStyle, LocalSliderStyle provides sliderStyle, + LocalTableStyle provides tableStyle, LocalTextAreaStyle provides textAreaStyle, LocalTextFieldStyle provides textFieldStyle, LocalTooltipStyle provides tooltipStyle, diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Table.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Table.kt new file mode 100644 index 0000000000000..4d937f2dcd0a5 --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Table.kt @@ -0,0 +1,109 @@ +package org.jetbrains.jewel.ui.component + +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.Stroke +import org.jetbrains.jewel.foundation.lazy.table.LazyTableCellContainer +import org.jetbrains.jewel.foundation.lazy.table.LazyTableState +import org.jetbrains.jewel.foundation.modifier.border +import org.jetbrains.jewel.foundation.state.CommonStateBitMask.Active +import org.jetbrains.jewel.foundation.state.CommonStateBitMask.Enabled +import org.jetbrains.jewel.foundation.state.CommonStateBitMask.Focused +import org.jetbrains.jewel.foundation.state.CommonStateBitMask.Hovered +import org.jetbrains.jewel.foundation.state.CommonStateBitMask.Pressed +import org.jetbrains.jewel.foundation.state.CommonStateBitMask.Selected +import org.jetbrains.jewel.foundation.state.FocusableComponentState +import org.jetbrains.jewel.foundation.state.SelectableComponentState +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.foundation.theme.LocalContentColor +import org.jetbrains.jewel.ui.component.styling.TableStyle +import org.jetbrains.jewel.ui.theme.tableStyle + +@ExperimentalJewelApi +@Composable +public fun LazyTableState.TableCellContainer( + columnIndex: Int, + rowIndex: Int, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + style: TableStyle = JewelTheme.tableStyle, + content: @Composable () -> Unit, +) { + LazyTableCellContainer( + modifier = + modifier + .background(style.colors.headerBackground) + .border(alignment = Stroke.Alignment.Outside, width = 1.dp, color = style.colors.headerSeparatorColor), + contentAlignment = contentAlignment, + ) { + CompositionLocalProvider(LocalContentColor provides style.colors.headerForeground) { content() } + } +} + +@Immutable +@JvmInline +public value class TableCellState(public val state: ULong) : SelectableComponentState, FocusableComponentState { + override val isActive: Boolean + get() = state and Active != 0UL + + override val isEnabled: Boolean + get() = state and Enabled != 0UL + + override val isFocused: Boolean + get() = state and Focused != 0UL + + override val isHovered: Boolean + get() = state and Hovered != 0UL + + override val isPressed: Boolean + get() = state and Pressed != 0UL + + override val isSelected: Boolean + get() = state and Selected != 0UL + + public fun copy( + enabled: Boolean = isEnabled, + focused: Boolean = isFocused, + pressed: Boolean = isPressed, + hovered: Boolean = isHovered, + selected: Boolean = isSelected, + active: Boolean = isActive, + ): TableCellState = + of( + enabled = enabled, + focused = focused, + pressed = pressed, + hovered = hovered, + selected = selected, + active = active, + ) + + override fun toString(): String = + "${javaClass.simpleName}(isEnabled=$isEnabled, isFocused=$isFocused, isHovered=$isHovered, " + + "isPressed=$isPressed, isSelected=$isSelected, isActive=$isActive)" + + public companion object { + public fun of( + enabled: Boolean = true, + focused: Boolean = false, + pressed: Boolean = false, + hovered: Boolean = false, + selected: Boolean = false, + active: Boolean = true, + ): TableCellState = + TableCellState( + (if (enabled) Enabled else 0UL) or + (if (focused) Focused else 0UL) or + (if (hovered) Hovered else 0UL) or + (if (pressed) Pressed else 0UL) or + (if (selected) Selected else 0UL) or + (if (active) Active else 0UL) + ) + } +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/styling/TableStyling.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/styling/TableStyling.kt new file mode 100644 index 0000000000000..61a434a96a84e --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/styling/TableStyling.kt @@ -0,0 +1,150 @@ +package org.jetbrains.jewel.ui.component.styling + +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.GenerateDataFunctions +import org.jetbrains.jewel.foundation.Stroke +import org.jetbrains.jewel.foundation.lazy.table.LazyTableCellContainer +import org.jetbrains.jewel.foundation.lazy.table.LazyTableState +import org.jetbrains.jewel.foundation.lazy.table.LazyTableStyle +import org.jetbrains.jewel.foundation.lazy.table.draggable.lazyTableCellDraggingOffset +import org.jetbrains.jewel.foundation.lazy.table.draggable.lazyTableDraggableColumnHeader +import org.jetbrains.jewel.foundation.lazy.table.draggable.lazyTableDraggableRowHeader +import org.jetbrains.jewel.foundation.lazy.table.selectable.TableSelectionUnit +import org.jetbrains.jewel.foundation.lazy.table.selectable.onSelectChanged +import org.jetbrains.jewel.foundation.lazy.table.selectable.selectableCell +import org.jetbrains.jewel.foundation.modifier.border +import org.jetbrains.jewel.foundation.theme.LocalContentColor +import org.jetbrains.jewel.ui.component.TableCellState + +@Stable +@GenerateDataFunctions +public class TableStyle(public val colors: TableColors, public val metrics: TableMetrics) : LazyTableStyle { + @Composable + override fun LazyTableState.container( + columnIndex: Int, + rowIndex: Int, + columnKey: Any?, + rowKey: Any?, + content: @Composable () -> Unit, + ) { + var cellState by remember(content) { mutableStateOf(TableCellState.of()) } + + val isPinnedColumn = columnIndex < tableInfo.pinnedColumns + val isPinnedRow = rowIndex < tableInfo.pinnedRows + val isHeader = isPinnedRow || isPinnedColumn + val isStripe = (rowIndex - tableInfo.pinnedRows) % 2 == 1 + + val modifier = + when { + (isPinnedColumn == isPinnedRow) && isPinnedRow -> { + Modifier.selectableCell(columnKey, rowKey, TableSelectionUnit.All) + } + + (isPinnedColumn == isPinnedRow) && !isPinnedRow -> { + Modifier.lazyTableCellDraggingOffset(columnKey, rowKey).selectableCell(columnKey, rowKey) + } + + isPinnedColumn -> { + Modifier.lazyTableDraggableRowHeader(rowKey) + .selectableCell(columnKey, rowKey, TableSelectionUnit.Row) + } + + else -> { + Modifier.lazyTableDraggableColumnHeader(columnKey) + .selectableCell(columnKey, rowKey, TableSelectionUnit.Column) + } + } + + LazyTableCellContainer( + modifier + .onFocusChanged { cellState = cellState.copy(focused = it.hasFocus) } + .onSelectChanged(columnKey, rowKey) { cellState = cellState.copy(selected = it) } + .background(colors.backgroundFor(cellState, isHeader, isStripe).value) + .border(Stroke.Alignment.Outside, 1.dp, colors.borderFor(cellState, isHeader, isStripe).value), + contentAlignment = if (isHeader) Alignment.Center else Alignment.CenterStart, + ) { + val contentColor by colors.contentFor(cellState, isHeader, isStripe) + + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + } + } + + public companion object +} + +@Immutable +@GenerateDataFunctions +public class TableColors( + public val background: Brush, + public val backgroundSelected: Brush, + public val backgroundInactiveSelected: Brush, + public val foreground: Color, + public val foregroundSelected: Color, + public val foregroundInactiveSelected: Color, + public val gridColor: Color, + public val stripeBackground: Brush, + public val headerBackground: Brush, + public val headerForeground: Color, + public val headerSeparatorColor: Color, +) { + @Composable + public fun backgroundFor(state: TableCellState, isHeader: Boolean, isStripe: Boolean): State = + rememberUpdatedState( + when { + state.isSelected && !state.isActive -> backgroundInactiveSelected + state.isSelected -> backgroundSelected + isHeader -> headerBackground + isStripe -> stripeBackground + else -> background + } + ) + + @Composable + public fun contentFor(state: TableCellState, isHeader: Boolean, isStripe: Boolean): State = + rememberUpdatedState( + when { + state.isSelected && !state.isActive -> foregroundInactiveSelected + state.isSelected -> foregroundSelected + isHeader -> headerForeground + else -> foreground + } + ) + + @Composable + public fun borderFor(state: TableCellState, isHeader: Boolean, isStripe: Boolean): State = + rememberUpdatedState( + when { + isHeader -> headerSeparatorColor + else -> gridColor + } + ) + + public companion object +} + +@Immutable +public class TableMetrics { + public companion object +} + +internal val LocalTableStyle: ProvidableCompositionLocal = staticCompositionLocalOf { + error("No LazyTableStyle provided. Have you forgotten the theme?") +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/theme/JewelTheme.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/theme/JewelTheme.kt index 47c036758180b..df3d9c450971e 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/theme/JewelTheme.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/theme/JewelTheme.kt @@ -52,6 +52,7 @@ import org.jetbrains.jewel.ui.component.styling.LocalSegmentedControlStyle import org.jetbrains.jewel.ui.component.styling.LocalSelectableLazyColumnStyle import org.jetbrains.jewel.ui.component.styling.LocalSimpleListItemStyleStyle import org.jetbrains.jewel.ui.component.styling.LocalSliderStyle +import org.jetbrains.jewel.ui.component.styling.LocalTableStyle import org.jetbrains.jewel.ui.component.styling.LocalTextAreaStyle import org.jetbrains.jewel.ui.component.styling.LocalTextFieldStyle import org.jetbrains.jewel.ui.component.styling.LocalTooltipStyle @@ -66,6 +67,7 @@ import org.jetbrains.jewel.ui.component.styling.SimpleListItemStyle import org.jetbrains.jewel.ui.component.styling.SliderStyle import org.jetbrains.jewel.ui.component.styling.SplitButtonStyle import org.jetbrains.jewel.ui.component.styling.TabStyle +import org.jetbrains.jewel.ui.component.styling.TableStyle import org.jetbrains.jewel.ui.component.styling.TextAreaStyle import org.jetbrains.jewel.ui.component.styling.TextFieldStyle import org.jetbrains.jewel.ui.component.styling.TooltipStyle @@ -161,6 +163,9 @@ public val JewelTheme.Companion.defaultTabStyle: TabStyle public val JewelTheme.Companion.editorTabStyle: TabStyle @Composable @ReadOnlyComposable get() = LocalEditorTabStyle.current +public val JewelTheme.Companion.defaultTableStyle: TableStyle + @Composable @ReadOnlyComposable get() = LocalTableStyle.current + public val JewelTheme.Companion.circularProgressStyle: CircularProgressStyle @Composable @ReadOnlyComposable get() = LocalCircularProgressStyle.current @@ -173,6 +178,9 @@ public val JewelTheme.Companion.iconButtonStyle: IconButtonStyle public val JewelTheme.Companion.sliderStyle: SliderStyle @Composable @ReadOnlyComposable get() = LocalSliderStyle.current +public val JewelTheme.Companion.tableStyle: TableStyle + @Composable @ReadOnlyComposable get() = LocalTableStyle.current + @Composable public fun BaseJewelTheme(theme: ThemeDefinition, styling: ComponentStyling, content: @Composable () -> Unit) { BaseJewelTheme(theme, styling, swingCompatMode = false, content)