From c4fcde1692a942f63b1aeb3f11263ff419317a29 Mon Sep 17 00:00:00 2001 From: higan Date: Thu, 8 Aug 2024 01:09:57 +0800 Subject: [PATCH] Table --- foundation/build.gradle.kts | 1 + .../jetbrains/jewel/foundation/OverflowBox.kt | 58 ++ .../foundation/lazy/draggable/Draggable.kt | 105 ++++ .../lazy/draggable/LazyLayoutDraggingState.kt | 94 ++++ .../lazy/selectable/SelectionEvent.kt | 3 + .../lazy/selectable/SelectionManager.kt | 62 ++ .../lazy/selectable/SelectionMode.kt | 21 + .../lazy/selectable/SelectionType.kt | 7 + .../lazy/table/AwaitFirstLayoutModifier.kt | 29 + .../foundation/lazy/table/LazyTable.View.kt | 168 ++++++ .../jewel/foundation/lazy/table/LazyTable.kt | 264 +++++++++ .../lazy/table/LazyTableAnimateScroll.kt | 408 ++++++++++++++ .../lazy/table/LazyTableCellContainer.kt | 20 + .../foundation/lazy/table/LazyTableContent.kt | 31 + .../lazy/table/LazyTableDimensionScope.kt | 24 + .../lazy/table/LazyTableIntervalContent.kt | 159 ++++++ .../lazy/table/LazyTableItemInfo.kt | 20 + .../lazy/table/LazyTableItemKeyPositionMap.kt | 81 +++ .../lazy/table/LazyTableItemProvider.kt | 138 +++++ .../lazy/table/LazyTableItemScope.kt | 25 + .../lazy/table/LazyTableLayoutInfo.kt | 68 +++ .../lazy/table/LazyTableLayoutScope.kt | 17 + .../foundation/lazy/table/LazyTableMeasure.kt | 531 ++++++++++++++++++ .../lazy/table/LazyTableMeasureResult.kt | 36 ++ .../lazy/table/LazyTableMeasuredItem.kt | 67 +++ .../table/LazyTableMeasuredItemProvider.kt | 126 +++++ .../lazy/table/LazyTableNearestRangeState.kt | 50 ++ .../foundation/lazy/table/LazyTableScope.kt | 56 ++ .../lazy/table/LazyTableScrollPosition.kt | 75 +++ .../lazy/table/LazyTableScrollbarAdapter.kt | 242 ++++++++ .../foundation/lazy/table/LazyTableState.kt | 320 +++++++++++ .../foundation/lazy/table/LazyTableStyle.kt | 59 ++ .../lazy/table/draggable/Draggable.kt | 60 ++ .../draggable/LazyTableDraggableState.kt | 133 +++++ .../table/selectable/SelectChangedElement.kt | 81 +++ .../lazy/table/selectable/Selectable.kt | 15 + .../table/selectable/SelectableCellElement.kt | 135 +++++ .../selectable/SingleCellSelectionManager.kt | 67 +++ .../selectable/SingleRowSelectionManager.kt | 54 ++ .../table/selectable/TableSelectionEvent.kt | 13 + .../table/selectable/TableSelectionManager.kt | 31 + .../table/selectable/TableSelectionUnit.kt | 23 + .../lazy/table/view/ColumnAccessor.kt | 65 +++ .../lazy/table/view/DelegatedTableView.kt | 54 ++ .../lazy/table/view/InMemoryTableView.kt | 265 +++++++++ .../lazy/table/view/LazyTableViewContent.kt | 76 +++ .../lazy/table/view/SortableTableView.kt | 54 ++ .../foundation/lazy/table/view/TableView.kt | 102 ++++ .../table/view/TableViewKeyPositionMap.kt | 21 + .../lazy/table/view/TableViewWithHeader.kt | 69 +++ gradle/libs.versions.toml | 2 + .../jewel/bridge/theme/IntUiBridge.kt | 229 ++++++-- .../api/int-ui-standalone.api | 12 +- .../standalone/styling/IntUiTableStyling.kt | 84 +++ .../intui/standalone/theme/IntUiTheme.kt | 5 + samples/standalone/build.gradle.kts | 1 + .../standalone/StandaloneSampleIcons.kt | 2 + .../samples/standalone/view/TitleBarView.kt | 3 + .../standalone/view/component/Debug.kt | 25 + .../standalone/view/component/FpsCounter.kt | 142 +++++ .../standalone/view/component/LongList.kt | 87 +++ .../standalone/view/component/TableView.kt | 266 +++++++++ .../standalone/view/component/Tables.kt | 357 ++++++++++++ .../viewmodel/ComponentsViewModel.kt | 12 + .../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 + ui/api/ui.api | 55 +- .../jewel/ui/DefaultComponentStyling.kt | 4 + .../org/jetbrains/jewel/ui/component/Table.kt | 119 ++++ .../ui/component/styling/TableStyling.kt | 175 ++++++ .../jetbrains/jewel/ui/theme/JewelTheme.kt | 12 + 75 files changed, 6293 insertions(+), 45 deletions(-) create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/OverflowBox.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/Draggable.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/LazyLayoutDraggingState.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionEvent.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionManager.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionMode.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionType.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/AwaitFirstLayoutModifier.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.View.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableAnimateScroll.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableCellContainer.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableContent.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableDimensionScope.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableIntervalContent.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemInfo.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemKeyPositionMap.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemProvider.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemScope.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutScope.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItem.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItemProvider.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableNearestRangeState.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScope.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableStyle.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/Draggable.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/LazyTableDraggableState.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectChangedElement.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/Selectable.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectableCellElement.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleCellSelectionManager.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleRowSelectionManager.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionEvent.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionManager.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionUnit.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/ColumnAccessor.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/DelegatedTableView.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/InMemoryTableView.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/LazyTableViewContent.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/SortableTableView.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableView.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewKeyPositionMap.kt create mode 100644 foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewWithHeader.kt create mode 100644 int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/styling/IntUiTableStyling.kt create mode 100644 samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Debug.kt create mode 100644 samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/FpsCounter.kt create mode 100644 samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/LongList.kt create mode 100644 samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/TableView.kt create mode 100644 samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Tables.kt create mode 100644 samples/standalone/src/main/resources/icons/components/dataTables.svg create mode 100644 samples/standalone/src/main/resources/icons/components/dataTables_dark.svg create mode 100644 samples/standalone/src/main/resources/icons/components/debug.svg create mode 100644 samples/standalone/src/main/resources/icons/components/debug@20x20.svg create mode 100644 samples/standalone/src/main/resources/icons/components/debug@20x20_dark.svg create mode 100644 samples/standalone/src/main/resources/icons/components/debug_dark.svg create mode 100644 ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Table.kt create mode 100644 ui/src/main/kotlin/org/jetbrains/jewel/ui/component/styling/TableStyling.kt diff --git a/foundation/build.gradle.kts b/foundation/build.gradle.kts index 1e835ea52..3d63c0f95 100644 --- a/foundation/build.gradle.kts +++ b/foundation/build.gradle.kts @@ -12,6 +12,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) { diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/OverflowBox.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/OverflowBox.kt new file mode 100644 index 000000000..4e57f9f74 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/OverflowBox.kt @@ -0,0 +1,58 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/Draggable.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/Draggable.kt new file mode 100644 index 000000000..247044489 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/Draggable.kt @@ -0,0 +1,105 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/LazyLayoutDraggingState.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/LazyLayoutDraggingState.kt new file mode 100644 index 000000000..5d4a3471b --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/draggable/LazyLayoutDraggingState.kt @@ -0,0 +1,94 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionEvent.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionEvent.kt new file mode 100644 index 000000000..c72771669 --- /dev/null +++ b/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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionManager.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionManager.kt new file mode 100644 index 000000000..d3f36bf26 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionManager.kt @@ -0,0 +1,62 @@ +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.Stable +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.ModifierLocalModifierNode +import androidx.compose.ui.modifier.ModifierLocalReadScope +import androidx.compose.ui.modifier.modifierLocalConsumer +import androidx.compose.ui.modifier.modifierLocalOf +import androidx.compose.ui.modifier.modifierLocalProvider +import androidx.compose.ui.node.DelegatingNode + +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionMode.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionMode.kt new file mode 100644 index 000000000..654383943 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionMode.kt @@ -0,0 +1,21 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionType.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/selectable/SelectionType.kt new file mode 100644 index 000000000..a96ff81f8 --- /dev/null +++ b/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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/AwaitFirstLayoutModifier.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/AwaitFirstLayoutModifier.kt new file mode 100644 index 000000000..878d90b1a --- /dev/null +++ b/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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.View.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.View.kt new file mode 100644 index 000000000..a55a4dbe9 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.View.kt @@ -0,0 +1,168 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt new file mode 100644 index 000000000..03c493490 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTable.kt @@ -0,0 +1,264 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableAnimateScroll.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableAnimateScroll.kt new file mode 100644 index 000000000..4d3f206aa --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableAnimateScroll.kt @@ -0,0 +1,408 @@ +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 org.jetbrains.jewel.foundation.util.myLogger +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs + +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableCellContainer.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableCellContainer.kt new file mode 100644 index 000000000..473e985cb --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableCellContainer.kt @@ -0,0 +1,20 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableContent.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableContent.kt new file mode 100644 index 000000000..be347feb8 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableContent.kt @@ -0,0 +1,31 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableDimensionScope.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableDimensionScope.kt new file mode 100644 index 000000000..b3c6fc440 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableDimensionScope.kt @@ -0,0 +1,24 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableIntervalContent.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableIntervalContent.kt new file mode 100644 index 000000000..b14b04964 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableIntervalContent.kt @@ -0,0 +1,159 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemInfo.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemInfo.kt new file mode 100644 index 000000000..7120102fd --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemInfo.kt @@ -0,0 +1,20 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemKeyPositionMap.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemKeyPositionMap.kt new file mode 100644 index 000000000..bcaa7131f --- /dev/null +++ b/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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemProvider.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemProvider.kt new file mode 100644 index 000000000..e0bcfbc3a --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemProvider.kt @@ -0,0 +1,138 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemScope.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemScope.kt new file mode 100644 index 000000000..c03bb53dc --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableItemScope.kt @@ -0,0 +1,25 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt new file mode 100644 index 000000000..279a86393 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutInfo.kt @@ -0,0 +1,68 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutScope.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableLayoutScope.kt new file mode 100644 index 000000000..eb9bac4c3 --- /dev/null +++ b/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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt new file mode 100644 index 000000000..3dd4b7dd6 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasure.kt @@ -0,0 +1,531 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasureResult.kt new file mode 100644 index 000000000..f04cdc6f6 --- /dev/null +++ b/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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItem.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItem.kt new file mode 100644 index 000000000..f8e18e26d --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItem.kt @@ -0,0 +1,67 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItemProvider.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItemProvider.kt new file mode 100644 index 000000000..078f28180 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableMeasuredItemProvider.kt @@ -0,0 +1,126 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableNearestRangeState.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableNearestRangeState.kt new file mode 100644 index 000000000..b864d514e --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableNearestRangeState.kt @@ -0,0 +1,50 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScope.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScope.kt new file mode 100644 index 000000000..d4be3266d --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScope.kt @@ -0,0 +1,56 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt new file mode 100644 index 000000000..1020205cd --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollPosition.kt @@ -0,0 +1,75 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt new file mode 100644 index 000000000..bd24ec16a --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableScrollbarAdapter.kt @@ -0,0 +1,242 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt new file mode 100644 index 000000000..234cc7aea --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableState.kt @@ -0,0 +1,320 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableStyle.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableStyle.kt new file mode 100644 index 000000000..aff1785a0 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/LazyTableStyle.kt @@ -0,0 +1,59 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/Draggable.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/Draggable.kt new file mode 100644 index 000000000..10dfc0d16 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/Draggable.kt @@ -0,0 +1,60 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/LazyTableDraggableState.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/LazyTableDraggableState.kt new file mode 100644 index 000000000..a0f692915 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/draggable/LazyTableDraggableState.kt @@ -0,0 +1,133 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectChangedElement.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectChangedElement.kt new file mode 100644 index 000000000..78ee35875 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectChangedElement.kt @@ -0,0 +1,81 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/Selectable.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/Selectable.kt new file mode 100644 index 000000000..74b492c3d --- /dev/null +++ b/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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectableCellElement.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectableCellElement.kt new file mode 100644 index 000000000..6d5287da9 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SelectableCellElement.kt @@ -0,0 +1,135 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleCellSelectionManager.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleCellSelectionManager.kt new file mode 100644 index 000000000..983f5a6d0 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleCellSelectionManager.kt @@ -0,0 +1,67 @@ +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 : TableSelectionManager { + private var selectedRowKey: Any? by mutableStateOf(null) + private var selectedColumnKey: Any? by mutableStateOf(null) + + 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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleRowSelectionManager.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleRowSelectionManager.kt new file mode 100644 index 000000000..35ba36c83 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/SingleRowSelectionManager.kt @@ -0,0 +1,54 @@ +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 : TableSelectionManager { + private var selectedRowKey: Any? by mutableStateOf(null) + + 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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionEvent.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionEvent.kt new file mode 100644 index 000000000..8fafa3045 --- /dev/null +++ b/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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionManager.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionManager.kt new file mode 100644 index 000000000..b61abf9a2 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionManager.kt @@ -0,0 +1,31 @@ +package org.jetbrains.jewel.foundation.lazy.table.selectable + +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 + } +} diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionUnit.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionUnit.kt new file mode 100644 index 000000000..3ef38a3a0 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/selectable/TableSelectionUnit.kt @@ -0,0 +1,23 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/ColumnAccessor.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/ColumnAccessor.kt new file mode 100644 index 000000000..52504424e --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/ColumnAccessor.kt @@ -0,0 +1,65 @@ +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 ColumnAccessor { + 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 container( + rowKey: Any?, + columnKey: Any?, + content: @Composable () -> Unit, + ) { + } + + @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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/DelegatedTableView.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/DelegatedTableView.kt new file mode 100644 index 000000000..381935891 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/DelegatedTableView.kt @@ -0,0 +1,54 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/InMemoryTableView.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/InMemoryTableView.kt new file mode 100644 index 000000000..895a7e628 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/InMemoryTableView.kt @@ -0,0 +1,265 @@ +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 org.jetbrains.jewel.foundation.GenerateDataFunctions +import org.jetbrains.jewel.foundation.lazy.table.LazyTableItemScope +import org.jetbrains.jewel.foundation.lazy.table.LazyTableLayoutScope +import org.jetbrains.jewel.foundation.lazy.table.selectable.SingleCellSelectionManager +import org.jetbrains.jewel.foundation.lazy.table.selectable.SingleRowSelectionManager +import org.jetbrains.jewel.foundation.lazy.table.selectable.TableSelectionManager + +/** + * 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 columnAccessor the column accessor for the table + * @param constraints the constraints for the rows + */ +public open class InMemoryTableView( + initContent: Iterable = listOf(), + private val columnAccessor: ColumnAccessor, + private val constraints: LazyTableLayoutScope.(Any?) -> Constraints?, + private val selectionManager: TableSelectionManager = SingleRowSelectionManager(), +) : SortableTableView, + TableSelectionManager by selectionManager, + MutableList { + /** + * The content of the table, the key is the id of the content, it will be wrapped in [RowKey] when used as a key. + */ + private val contentMap = initContent.withIndex().associate { it.index to it.value }.toMutableMap() + + /** + * Auto-incremented Id for the content, when new content is added, the id will be incremented. + * When the content is removed, the id will not be reused except the all content be cleared. + */ + private var lastId = contentMap.size + + /** + * The index-id mapping of the content, it is used to keep the order of the content. + * It is a state list to make the table recompose when the content is changed. + */ + private val idMapping = contentMap.keys.toMutableStateList() + + override val size: Int get() = idMapping.size + + override fun clear() { + idMapping.clear() + contentMap.clear() + lastId = 0 + } + + override fun get(index: Int): T = contentMap[idMapping[index]] ?: throw IndexOutOfBoundsException() + + override fun isEmpty(): Boolean = idMapping.isEmpty() + + override fun iterator(): MutableIterator = + object : MutableIterator { + private var index = 0 + + override fun hasNext(): Boolean = index < idMapping.size + + override fun next(): T = get(index++) + + override fun remove() { + removeAt(index) + } + } + + override fun listIterator(): MutableListIterator = listIterator(0) + + override fun listIterator(index: Int): MutableListIterator = + object : MutableListIterator { + private var currentIndex = index + + override fun hasNext(): Boolean = currentIndex < idMapping.size + + override fun next(): T = get(currentIndex++) + + override fun hasPrevious(): Boolean = currentIndex > 0 + + override fun previous(): T = get(--currentIndex) + + override fun nextIndex(): Int = currentIndex + 1 + + override fun previousIndex(): Int = currentIndex - 1 + + override fun remove() { + removeAt(currentIndex) + } + + override fun set(element: T) { + set(currentIndex, element) + } + + override fun add(element: T) { + add(currentIndex, element) + } + } + + override fun removeAt(index: Int): T { + val key = idMapping.removeAt(index) + return contentMap.remove(key) ?: throw IndexOutOfBoundsException() + } + + override fun subList( + fromIndex: Int, + toIndex: Int, + ): MutableList = idMapping.subList(fromIndex, toIndex).map { contentMap[it]!! }.toMutableList() + + override fun set( + index: Int, + element: T, + ): T { + val key = idMapping[index] + contentMap[key] = element + return element + } + + override fun retainAll(elements: Collection): Boolean { + val keys = contentMap.keys.filter { key -> contentMap[key] in elements } + val newContentMap = keys.associateWith { key -> contentMap[key]!! } + idMapping.clear() + contentMap.clear() + contentMap.putAll(newContentMap) + idMapping.addAll(keys) + return true + } + + override fun removeAll(elements: Collection): Boolean { + val keys = contentMap.keys.filter { key -> contentMap[key] in elements } + keys.forEach { key -> + idMapping.remove(key) + contentMap.remove(key) + } + return true + } + + override fun remove(element: T): Boolean { + val key = contentMap.entries.find { it.value == element }?.key ?: return false + idMapping.remove(key) + contentMap.remove(key) + return true + } + + override fun lastIndexOf(element: T): Int = idMapping.indexOfLast { key -> contentMap[key] == element } + + override fun indexOf(element: T): Int = idMapping.indexOfFirst { key -> contentMap[key] == element } + + override fun containsAll(elements: Collection): Boolean = elements.all { element -> contentMap.values.contains(element) } + + override fun contains(element: T): Boolean = contentMap.values.contains(element) + + override fun addAll(elements: Collection): Boolean { + elements.forEach { add(it) } + return true + } + + override fun addAll( + index: Int, + elements: Collection, + ): Boolean { + elements.forEachIndexed { i, element -> add(index + i, element) } + return true + } + + override fun add( + index: Int, + element: T, + ) { + contentMap[lastId] = element + idMapping.add(index, lastId) + lastId++ + } + + override fun add(element: T): Boolean { + contentMap[lastId] = element + idMapping.add(lastId) + lastId++ + return true + } + + override fun rows(): Int = idMapping.size + + override fun columns(): Int = columnAccessor.columns() + + override fun pinnedColumns(): Int = columnAccessor.pinnedColumns() + + override fun rowKey(row: Int): Any? = RowKey(idMapping[row]) + + override fun columnKey(column: Int): Any? = columnAccessor.columnKey(column) + + override fun rowIndex(key: Any?): Int = idMapping.indexOf((key as RowKey).index) + + override fun columnIndex(key: Any?): Int = columnAccessor.columnIndex(key) + + override fun LazyTableLayoutScope.rowConstraints(rowKey: Any?): Constraints? = this.constraints(rowKey) + + override fun LazyTableLayoutScope.columnConstraints(columnKey: Any?): Constraints? = + with(columnAccessor) { + columnConstraints(columnKey) + } + + @Composable + override fun LazyTableItemScope.cell( + rowKey: Any?, + columnKey: Any?, + ) { + with(columnAccessor) { + if (rowKey is RowKey) { + cell(contentMap[rowKey.index]!!, columnKey) + } else { + header(rowKey, columnKey) + } + } + } + + override fun cellContentType( + rowKey: Any?, + columnKey: Any?, + ): Any? { + if (rowKey is RowKey) { + return columnAccessor.cellContentType(contentMap[rowKey.index]!!, columnKey) + } + return columnAccessor.headerContentType(rowKey, columnKey) + } + + @Composable + override fun supportColumnSorting(): Boolean = columnAccessor.supportColumnSorting() + + @Composable + override fun supportRowSorting(): Boolean = true + + override fun canMoveColumn(key: Any?): Boolean = columnAccessor.canMoveColumn(key) + + override fun canMoveRow(key: Any?): Boolean = true + + override fun moveColumn( + fromKey: Any?, + toKey: Any?, + ): Boolean = columnAccessor.moveColumn(fromKey, toKey) + + override fun moveRow( + fromKey: Any?, + toKey: Any?, + ): Boolean { + if (fromKey !is RowKey || toKey !is RowKey) return false + val fromIndex = idMapping.indexOf(fromKey.index) + val toIndex = idMapping.indexOf(toKey.index) + + idMapping.add(toIndex, idMapping.removeAt(fromIndex)) + return true + } + + @GenerateDataFunctions + private class RowKey( + val index: Int, + ) +} + +public fun Iterable.toTableView( + columnAccessor: ColumnAccessor, + rowConstraints: LazyTableLayoutScope.(Any?) -> Constraints? = { null }, +): InMemoryTableView = InMemoryTableView(this, columnAccessor, rowConstraints) diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/LazyTableViewContent.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/LazyTableViewContent.kt new file mode 100644 index 000000000..56692430f --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/LazyTableViewContent.kt @@ -0,0 +1,76 @@ +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) + val (columnKey, rowKey) = getKey(position) + with(style) { + state.container(position.x, position.y, columnKey, rowKey) { + with(tableView) { + scope.cell(rowKey, columnKey) + } + } + } + } +} diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/SortableTableView.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/SortableTableView.kt new file mode 100644 index 000000000..8886becdd --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/SortableTableView.kt @@ -0,0 +1,54 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableView.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableView.kt new file mode 100644 index 000000000..e848315e7 --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableView.kt @@ -0,0 +1,102 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewKeyPositionMap.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewKeyPositionMap.kt new file mode 100644 index 000000000..3ceda43eb --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewKeyPositionMap.kt @@ -0,0 +1,21 @@ +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/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewWithHeader.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewWithHeader.kt new file mode 100644 index 000000000..a460e07db --- /dev/null +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/table/view/TableViewWithHeader.kt @@ -0,0 +1,69 @@ +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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 232fc0c67..6fdf44f76 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ kotlinterGradlePlugin = "4.3.0" kotlinxSerialization = "1.6.3" kotlinxBinaryCompat = "0.14.0" poko = "0.15.3" +faker = "1.16.0" [libraries] commonmark-core = { module = "org.commonmark:commonmark", version.ref = "commonmark" } @@ -38,6 +39,7 @@ kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } kotlinter-gradlePlugin = { module = "org.jmailen.gradle:kotlinter-gradle", version.ref = "kotlinterGradlePlugin" } kotlinx-binaryCompatValidator-gradlePlugin = { module = "org.jetbrains.kotlinx:binary-compatibility-validator", version.ref = "kotlinxBinaryCompat" } poko-gradlePlugin = { module = "dev.drewhamilton.poko:poko-gradle-plugin", version.ref = "poko" } +faker = { module = "io.github.serpro69:kotlin-faker", version.ref = "faker" } [plugins] composeDesktop = { id = "org.jetbrains.compose", version.ref = "composeDesktop" } diff --git a/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridge.kt b/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridge.kt index be1ae9ab4..d82ca26fc 100644 --- a/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridge.kt +++ b/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridge.kt @@ -111,6 +111,9 @@ import org.jetbrains.jewel.ui.component.styling.TabContentAlpha import org.jetbrains.jewel.ui.component.styling.TabIcons import org.jetbrains.jewel.ui.component.styling.TabMetrics import org.jetbrains.jewel.ui.component.styling.TabStyle +import org.jetbrains.jewel.ui.component.styling.TableColors +import org.jetbrains.jewel.ui.component.styling.TableMetrics +import org.jetbrains.jewel.ui.component.styling.TableStyle import org.jetbrains.jewel.ui.component.styling.TextAreaColors import org.jetbrains.jewel.ui.component.styling.TextAreaMetrics import org.jetbrains.jewel.ui.component.styling.TextAreaStyle @@ -229,6 +232,7 @@ internal fun createBridgeComponentStyling(theme: ThemeDefinition): ComponentStyl textAreaStyle = readTextAreaStyle(textFieldStyle.metrics), textFieldStyle = textFieldStyle, tooltipStyle = readTooltipStyle(), + tableStyle = readLazyTableStyle(), undecoratedDropdownStyle = readUndecoratedDropdownStyle(menuStyle), ) } @@ -236,16 +240,24 @@ internal fun createBridgeComponentStyling(theme: ThemeDefinition): ComponentStyl private fun readDefaultButtonStyle(): ButtonStyle { val normalBackground = listOf( - JBUI.CurrentTheme.Button.defaultButtonColorStart().toComposeColor(), - JBUI.CurrentTheme.Button.defaultButtonColorEnd().toComposeColor(), + JBUI.CurrentTheme.Button + .defaultButtonColorStart() + .toComposeColor(), + JBUI.CurrentTheme.Button + .defaultButtonColorEnd() + .toComposeColor(), ).createVerticalBrush() val normalContent = retrieveColorOrUnspecified("Button.default.foreground") val normalBorder = listOf( - JBUI.CurrentTheme.Button.buttonOutlineColorStart(true).toComposeColor(), - JBUI.CurrentTheme.Button.buttonOutlineColorEnd(true).toComposeColor(), + JBUI.CurrentTheme.Button + .buttonOutlineColorStart(true) + .toComposeColor(), + JBUI.CurrentTheme.Button + .buttonOutlineColorEnd(true) + .toComposeColor(), ).createVerticalBrush() val colors = @@ -261,13 +273,21 @@ private fun readDefaultButtonStyle(): ButtonStyle { contentPressed = normalContent, contentHovered = normalContent, border = normalBorder, - borderDisabled = SolidColor(JBUI.CurrentTheme.Button.disabledOutlineColor().toComposeColor()), + borderDisabled = + SolidColor( + JBUI.CurrentTheme.Button + .disabledOutlineColor() + .toComposeColor(), + ), borderFocused = SolidColor(retrieveColorOrUnspecified("Button.default.focusedBorderColor")), borderPressed = normalBorder, borderHovered = normalBorder, ) - val minimumSize = JBUI.CurrentTheme.Button.minimumSize().toDpSize() + val minimumSize = + JBUI.CurrentTheme.Button + .minimumSize() + .toDpSize() return ButtonStyle( colors = colors, metrics = @@ -285,16 +305,24 @@ private fun readDefaultButtonStyle(): ButtonStyle { private fun readOutlinedButtonStyle(): ButtonStyle { val normalBackground = listOf( - JBUI.CurrentTheme.Button.buttonColorStart().toComposeColor(), - JBUI.CurrentTheme.Button.buttonColorEnd().toComposeColor(), + JBUI.CurrentTheme.Button + .buttonColorStart() + .toComposeColor(), + JBUI.CurrentTheme.Button + .buttonColorEnd() + .toComposeColor(), ).createVerticalBrush() val normalContent = retrieveColorOrUnspecified("Button.foreground") val normalBorder = listOf( - JBUI.CurrentTheme.Button.buttonOutlineColorStart(false).toComposeColor(), - JBUI.CurrentTheme.Button.buttonOutlineColorEnd(false).toComposeColor(), + JBUI.CurrentTheme.Button + .buttonOutlineColorStart(false) + .toComposeColor(), + JBUI.CurrentTheme.Button + .buttonOutlineColorEnd(false) + .toComposeColor(), ).createVerticalBrush() val colors = @@ -310,13 +338,26 @@ private fun readOutlinedButtonStyle(): ButtonStyle { contentPressed = normalContent, contentHovered = normalContent, border = normalBorder, - borderDisabled = SolidColor(JBUI.CurrentTheme.Button.disabledOutlineColor().toComposeColor()), - borderFocused = SolidColor(JBUI.CurrentTheme.Button.focusBorderColor(false).toComposeColor()), + borderDisabled = + SolidColor( + JBUI.CurrentTheme.Button + .disabledOutlineColor() + .toComposeColor(), + ), + borderFocused = + SolidColor( + JBUI.CurrentTheme.Button + .focusBorderColor(false) + .toComposeColor(), + ), borderPressed = normalBorder, borderHovered = normalBorder, ) - val minimumSize = JBUI.CurrentTheme.Button.minimumSize().toDpSize() + val minimumSize = + JBUI.CurrentTheme.Button + .minimumSize() + .toDpSize() return ButtonStyle( colors = colors, metrics = @@ -516,7 +557,10 @@ private fun readDefaultDropdownStyle(menuStyle: MenuStyle): DropdownStyle { iconTintHovered = Color.Unspecified, ) - val minimumSize = JBUI.CurrentTheme.ComboBox.minimumSize().toDpSize() + val minimumSize = + JBUI.CurrentTheme.ComboBox + .minimumSize() + .toDpSize() val arrowWidth = JBUI.CurrentTheme.Component.ARROW_AREA_WIDTH.dp return DropdownStyle( colors = colors, @@ -563,7 +607,10 @@ private fun readUndecoratedDropdownStyle(menuStyle: MenuStyle): DropdownStyle { ) val arrowWidth = JBUI.CurrentTheme.Component.ARROW_AREA_WIDTH.dp - val minimumSize = JBUI.CurrentTheme.Button.minimumSize().toDpSize() + val minimumSize = + JBUI.CurrentTheme.Button + .minimumSize() + .toDpSize() return DropdownStyle( colors = colors, @@ -571,7 +618,12 @@ private fun readUndecoratedDropdownStyle(menuStyle: MenuStyle): DropdownStyle { DropdownMetrics( arrowMinSize = DpSize(arrowWidth, minimumSize.height), minSize = DpSize(minimumSize.width + arrowWidth, minimumSize.height), - cornerSize = CornerSize(JBUI.CurrentTheme.MainToolbar.Dropdown.hoverArc().dp), + cornerSize = + CornerSize( + JBUI.CurrentTheme.MainToolbar.Dropdown + .hoverArc() + .dp, + ), contentPadding = PaddingValues(3.dp), // from com.intellij.ide.ui.laf.darcula.ui.DarculaComboBoxUI.getDefaultComboBoxInsets borderWidth = 0.dp, ), @@ -727,7 +779,14 @@ private fun readMenuStyle(): MenuStyle { retrieveIntAsDpOrUnspecified("PopupMenuSeparator.height") .takeOrElse { 3.dp }, iconSize = 16.dp, - minHeight = if (isNewUiTheme()) JBUI.CurrentTheme.List.rowHeight().dp else Dp.Unspecified, + minHeight = + if (isNewUiTheme()) { + JBUI.CurrentTheme.List + .rowHeight() + .dp + } else { + Dp.Unspecified + }, ), submenuMetrics = SubmenuMetrics(offset = DpOffset(0.dp, (-8).dp)), ), @@ -809,36 +868,63 @@ private object NewUiRadioButtonMetrics : BridgeRadioButtonMetrics { } private fun readSegmentedControlButtonStyle(): SegmentedControlButtonStyle { - val selectedBackground = SolidColor(JBUI.CurrentTheme.SegmentedButton.SELECTED_BUTTON_COLOR.toComposeColor()) + val selectedBackground = + SolidColor( + JBUI.CurrentTheme.SegmentedButton.SELECTED_BUTTON_COLOR + .toComposeColor(), + ) val normalBorder = listOf( - JBUI.CurrentTheme.SegmentedButton.SELECTED_START_BORDER_COLOR.toComposeColor(), - JBUI.CurrentTheme.SegmentedButton.SELECTED_END_BORDER_COLOR.toComposeColor(), + JBUI.CurrentTheme.SegmentedButton.SELECTED_START_BORDER_COLOR + .toComposeColor(), + JBUI.CurrentTheme.SegmentedButton.SELECTED_END_BORDER_COLOR + .toComposeColor(), ).createVerticalBrush() val selectedDisabledBorder = listOf( - JBUI.CurrentTheme.Button.buttonOutlineColorStart(false).toComposeColor(), - JBUI.CurrentTheme.Button.buttonOutlineColorEnd(false).toComposeColor(), + JBUI.CurrentTheme.Button + .buttonOutlineColorStart(false) + .toComposeColor(), + JBUI.CurrentTheme.Button + .buttonOutlineColorEnd(false) + .toComposeColor(), ).createVerticalBrush() val colors = SegmentedControlButtonColors( background = SolidColor(Color.Transparent), backgroundPressed = selectedBackground, - backgroundHovered = SolidColor(JBUI.CurrentTheme.ActionButton.hoverBackground().toComposeColor()), + backgroundHovered = + SolidColor( + JBUI.CurrentTheme.ActionButton + .hoverBackground() + .toComposeColor(), + ), backgroundSelected = selectedBackground, - backgroundSelectedFocused = SolidColor(JBUI.CurrentTheme.SegmentedButton.FOCUSED_SELECTED_BUTTON_COLOR.toComposeColor()), + backgroundSelectedFocused = + SolidColor( + JBUI.CurrentTheme.SegmentedButton.FOCUSED_SELECTED_BUTTON_COLOR + .toComposeColor(), + ), content = retrieveColorOrUnspecified("Button.foreground"), contentDisabled = retrieveColorOrUnspecified("Label.disabledForeground"), border = normalBorder, borderSelected = normalBorder, borderSelectedDisabled = selectedDisabledBorder, - borderSelectedFocused = SolidColor(JBUI.CurrentTheme.Button.focusBorderColor(false).toComposeColor()), + borderSelectedFocused = + SolidColor( + JBUI.CurrentTheme.Button + .focusBorderColor(false) + .toComposeColor(), + ), ) - val minimumSize = JBUI.CurrentTheme.Button.minimumSize().toDpSize() + val minimumSize = + JBUI.CurrentTheme.Button + .minimumSize() + .toDpSize() return SegmentedControlButtonStyle( colors = colors, metrics = @@ -854,17 +940,31 @@ private fun readSegmentedControlButtonStyle(): SegmentedControlButtonStyle { private fun readSegmentedControlStyle(): SegmentedControlStyle { val normalBorder = listOf( - JBUI.CurrentTheme.Button.buttonOutlineColorStart(false).toComposeColor(), - JBUI.CurrentTheme.Button.buttonOutlineColorEnd(false).toComposeColor(), + JBUI.CurrentTheme.Button + .buttonOutlineColorStart(false) + .toComposeColor(), + JBUI.CurrentTheme.Button + .buttonOutlineColorEnd(false) + .toComposeColor(), ).createVerticalBrush() val colors = SegmentedControlColors( border = normalBorder, - borderDisabled = SolidColor(JBUI.CurrentTheme.Button.disabledOutlineColor().toComposeColor()), + borderDisabled = + SolidColor( + JBUI.CurrentTheme.Button + .disabledOutlineColor() + .toComposeColor(), + ), borderPressed = normalBorder, borderHovered = normalBorder, - borderFocused = SolidColor(JBUI.CurrentTheme.Button.focusBorderColor(false).toComposeColor()), + borderFocused = + SolidColor( + JBUI.CurrentTheme.Button + .focusBorderColor(false) + .toComposeColor(), + ), ) return SegmentedControlStyle( @@ -961,7 +1061,10 @@ private fun readTextFieldStyle(): TextFieldStyle { placeholder = NamedColorUtil.getInactiveTextColor().toComposeColor(), ) - val minimumSize = JBUI.CurrentTheme.TextField.minimumSize().toDpSize() + val minimumSize = + JBUI.CurrentTheme.TextField + .minimumSize() + .toDpSize() return TextFieldStyle( colors = colors, metrics = @@ -1017,8 +1120,14 @@ private fun readLazyTreeStyle(): LazyTreeStyle { // See com.intellij.ui.tabs.impl.themes.DefaultTabTheme private fun readDefaultTabStyle(): TabStyle { - val normalBackground = JBUI.CurrentTheme.DefaultTabs.background().toComposeColor() - val selectedBackground = JBUI.CurrentTheme.DefaultTabs.underlinedTabBackground().toComposeColorOrUnspecified() + val normalBackground = + JBUI.CurrentTheme.DefaultTabs + .background() + .toComposeColor() + val selectedBackground = + JBUI.CurrentTheme.DefaultTabs + .underlinedTabBackground() + .toComposeColorOrUnspecified() val normalContent = retrieveColorOrUnspecified("TabbedPane.foreground") val selectedUnderline = retrieveColorOrUnspecified("TabbedPane.underlineColor") @@ -1027,7 +1136,10 @@ private fun readDefaultTabStyle(): TabStyle { background = normalBackground, backgroundDisabled = normalBackground, backgroundPressed = selectedBackground, - backgroundHovered = JBUI.CurrentTheme.DefaultTabs.hoverBackground().toComposeColor(), + backgroundHovered = + JBUI.CurrentTheme.DefaultTabs + .hoverBackground() + .toComposeColor(), backgroundSelected = selectedBackground, content = normalContent, contentDisabled = retrieveColorOrUnspecified("TabbedPane.disabledForeground"), @@ -1072,8 +1184,14 @@ private fun readDefaultTabStyle(): TabStyle { } private fun readEditorTabStyle(): TabStyle { - val normalBackground = JBUI.CurrentTheme.EditorTabs.background().toComposeColor() - val selectedBackground = JBUI.CurrentTheme.EditorTabs.underlinedTabBackground().toComposeColorOrUnspecified() + val normalBackground = + JBUI.CurrentTheme.EditorTabs + .background() + .toComposeColor() + val selectedBackground = + JBUI.CurrentTheme.EditorTabs + .underlinedTabBackground() + .toComposeColorOrUnspecified() val normalContent = retrieveColorOrUnspecified("TabbedPane.foreground") val selectedUnderline = retrieveColorOrUnspecified("TabbedPane.underlineColor") @@ -1082,7 +1200,10 @@ private fun readEditorTabStyle(): TabStyle { background = normalBackground, backgroundDisabled = normalBackground, backgroundPressed = selectedBackground, - backgroundHovered = JBUI.CurrentTheme.EditorTabs.hoverBackground().toComposeColor(), + backgroundHovered = + JBUI.CurrentTheme.EditorTabs + .hoverBackground() + .toComposeColor(), backgroundSelected = selectedBackground, content = normalContent, contentDisabled = retrieveColorOrUnspecified("TabbedPane.disabledForeground"), @@ -1136,11 +1257,14 @@ private fun readCircularProgressStyle(isDark: Boolean) = .takeOrElse { if (isDark) Color(0xFF6F737A) else Color(0xFFA8ADBD) }, ) -private fun readTooltipStyle(): TooltipStyle { - return TooltipStyle( +private fun readTooltipStyle(): TooltipStyle = + TooltipStyle( metrics = TooltipMetrics.defaults( - contentPadding = JBUI.CurrentTheme.HelpTooltip.smallTextBorderInsets().toPaddingValues(), + contentPadding = + JBUI.CurrentTheme.HelpTooltip + .smallTextBorderInsets() + .toPaddingValues(), showDelay = Registry.intValue("ide.tooltip.initialDelay").milliseconds, cornerSize = CornerSize(JBUI.CurrentTheme.Tooltip.CORNER_RADIUS.dp), ), @@ -1148,11 +1272,32 @@ private fun readTooltipStyle(): TooltipStyle { TooltipColors( content = retrieveColorOrUnspecified("ToolTip.foreground"), background = retrieveColorOrUnspecified("ToolTip.background"), - border = JBUI.CurrentTheme.Tooltip.borderColor().toComposeColor(), + border = + JBUI.CurrentTheme.Tooltip + .borderColor() + .toComposeColor(), shadow = retrieveColorOrUnspecified("Notification.Shadow.bottom1Color"), ), ) -} + +private 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(), + ) private fun readIconButtonStyle(): IconButtonStyle = IconButtonStyle( diff --git a/int-ui/int-ui-standalone/api/int-ui-standalone.api b/int-ui/int-ui-standalone/api/int-ui-standalone.api index 4f246279d..0b42a977b 100644 --- a/int-ui/int-ui-standalone/api/int-ui-standalone.api +++ b/int-ui/int-ui-standalone/api/int-ui-standalone.api @@ -182,6 +182,14 @@ public final class org/jetbrains/jewel/intui/standalone/styling/IntUiIconButtonS public static final fun light-8v1krLo (Lorg/jetbrains/jewel/ui/component/styling/IconButtonColors$Companion;JJJJJJJJJJJJJJJLandroidx/compose/runtime/Composer;III)Lorg/jetbrains/jewel/ui/component/styling/IconButtonColors; } +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/LazyTreeColors;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeMetrics;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeIcons;Landroidx/compose/runtime/Composer;II)Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle; public static final fun dark-v1fvUNM (Lorg/jetbrains/jewel/ui/component/styling/LazyTreeColors$Companion;JJJJJJJLandroidx/compose/runtime/Composer;II)Lorg/jetbrains/jewel/ui/component/styling/LazyTreeColors; @@ -402,10 +410,10 @@ public final class org/jetbrains/jewel/intui/standalone/theme/IntUiGlobalMetrics public final class org/jetbrains/jewel/intui/standalone/theme/IntUiThemeKt { public static final fun IntUiTheme (Lorg/jetbrains/jewel/foundation/theme/ThemeDefinition;Lorg/jetbrains/jewel/ui/ComponentStyling;ZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V public static final fun IntUiTheme (ZZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V - public static final fun dark (Lorg/jetbrains/jewel/ui/ComponentStyling;Lorg/jetbrains/jewel/ui/component/styling/CheckboxStyle;Lorg/jetbrains/jewel/ui/component/styling/ChipStyle;Lorg/jetbrains/jewel/ui/component/styling/CircularProgressStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/DividerStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/GroupHeaderStyle;Lorg/jetbrains/jewel/ui/component/styling/HorizontalProgressBarStyle;Lorg/jetbrains/jewel/ui/component/styling/IconButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle;Lorg/jetbrains/jewel/ui/component/styling/LinkStyle;Lorg/jetbrains/jewel/ui/component/styling/MenuStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/RadioButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/ScrollbarStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlStyle;Lorg/jetbrains/jewel/ui/component/styling/SliderStyle;Lorg/jetbrains/jewel/ui/component/styling/TextAreaStyle;Lorg/jetbrains/jewel/ui/component/styling/TextFieldStyle;Lorg/jetbrains/jewel/ui/component/styling/TooltipStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Landroidx/compose/runtime/Composer;IIII)Lorg/jetbrains/jewel/ui/ComponentStyling; + public static final fun dark (Lorg/jetbrains/jewel/ui/ComponentStyling;Lorg/jetbrains/jewel/ui/component/styling/CheckboxStyle;Lorg/jetbrains/jewel/ui/component/styling/ChipStyle;Lorg/jetbrains/jewel/ui/component/styling/CircularProgressStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/DividerStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/GroupHeaderStyle;Lorg/jetbrains/jewel/ui/component/styling/HorizontalProgressBarStyle;Lorg/jetbrains/jewel/ui/component/styling/IconButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle;Lorg/jetbrains/jewel/ui/component/styling/LinkStyle;Lorg/jetbrains/jewel/ui/component/styling/MenuStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/RadioButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/ScrollbarStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlStyle;Lorg/jetbrains/jewel/ui/component/styling/SliderStyle;Lorg/jetbrains/jewel/ui/component/styling/TextAreaStyle;Lorg/jetbrains/jewel/ui/component/styling/TextFieldStyle;Lorg/jetbrains/jewel/ui/component/styling/TooltipStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Landroidx/compose/runtime/Composer;IIII)Lorg/jetbrains/jewel/ui/ComponentStyling; public static final fun darkThemeDefinition-VRxQTpk (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Lorg/jetbrains/jewel/foundation/GlobalColors;Lorg/jetbrains/jewel/foundation/GlobalMetrics;Lorg/jetbrains/jewel/foundation/theme/ThemeColorPalette;Lorg/jetbrains/jewel/foundation/theme/ThemeIconData;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;JLandroidx/compose/runtime/Composer;II)Lorg/jetbrains/jewel/foundation/theme/ThemeDefinition; public static final fun default (Lorg/jetbrains/jewel/ui/ComponentStyling;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/ComponentStyling; - public static final fun light (Lorg/jetbrains/jewel/ui/ComponentStyling;Lorg/jetbrains/jewel/ui/component/styling/CheckboxStyle;Lorg/jetbrains/jewel/ui/component/styling/ChipStyle;Lorg/jetbrains/jewel/ui/component/styling/CircularProgressStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/DividerStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/GroupHeaderStyle;Lorg/jetbrains/jewel/ui/component/styling/HorizontalProgressBarStyle;Lorg/jetbrains/jewel/ui/component/styling/IconButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle;Lorg/jetbrains/jewel/ui/component/styling/LinkStyle;Lorg/jetbrains/jewel/ui/component/styling/MenuStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/RadioButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/ScrollbarStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlStyle;Lorg/jetbrains/jewel/ui/component/styling/SliderStyle;Lorg/jetbrains/jewel/ui/component/styling/TextAreaStyle;Lorg/jetbrains/jewel/ui/component/styling/TextFieldStyle;Lorg/jetbrains/jewel/ui/component/styling/TooltipStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Landroidx/compose/runtime/Composer;IIII)Lorg/jetbrains/jewel/ui/ComponentStyling; + public static final fun light (Lorg/jetbrains/jewel/ui/ComponentStyling;Lorg/jetbrains/jewel/ui/component/styling/CheckboxStyle;Lorg/jetbrains/jewel/ui/component/styling/ChipStyle;Lorg/jetbrains/jewel/ui/component/styling/CircularProgressStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/DividerStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/GroupHeaderStyle;Lorg/jetbrains/jewel/ui/component/styling/HorizontalProgressBarStyle;Lorg/jetbrains/jewel/ui/component/styling/IconButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle;Lorg/jetbrains/jewel/ui/component/styling/LinkStyle;Lorg/jetbrains/jewel/ui/component/styling/MenuStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/RadioButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/ScrollbarStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlStyle;Lorg/jetbrains/jewel/ui/component/styling/SliderStyle;Lorg/jetbrains/jewel/ui/component/styling/TextAreaStyle;Lorg/jetbrains/jewel/ui/component/styling/TextFieldStyle;Lorg/jetbrains/jewel/ui/component/styling/TooltipStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Landroidx/compose/runtime/Composer;IIII)Lorg/jetbrains/jewel/ui/ComponentStyling; public static final fun lightThemeDefinition-VRxQTpk (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Lorg/jetbrains/jewel/foundation/GlobalColors;Lorg/jetbrains/jewel/foundation/GlobalMetrics;Lorg/jetbrains/jewel/foundation/theme/ThemeColorPalette;Lorg/jetbrains/jewel/foundation/theme/ThemeIconData;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;JLandroidx/compose/runtime/Composer;II)Lorg/jetbrains/jewel/foundation/theme/ThemeDefinition; } diff --git a/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/styling/IntUiTableStyling.kt b/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/styling/IntUiTableStyling.kt new file mode 100644 index 000000000..b53ff366f --- /dev/null +++ b/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/styling/IntUiTableStyling.kt @@ -0,0 +1,84 @@ +package org.jetbrains.jewel.intui.standalone.styling + +import androidx.compose.runtime.Composable +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 + +@Composable +public fun TableStyle.Companion.light(): TableStyle = + TableStyle( + colors = TableColors.light(), + metrics = TableMetrics.defaults(), + ) + +@Composable +public fun TableStyle.Companion.dark(): TableStyle = + TableStyle( + colors = TableColors.dark(), + metrics = TableMetrics.defaults(), + ) + +@Composable +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, + ) + +@Composable +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, + ) + +@Composable +public fun TableMetrics.Companion.defaults(): TableMetrics = TableMetrics() diff --git a/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt b/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt index a963bfaa9..70e643cdd 100644 --- a/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt +++ b/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt @@ -40,6 +40,7 @@ import org.jetbrains.jewel.ui.component.styling.SegmentedControlButtonStyle import org.jetbrains.jewel.ui.component.styling.SegmentedControlStyle import org.jetbrains.jewel.ui.component.styling.SliderStyle 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 @@ -115,6 +116,7 @@ public fun ComponentStyling.dark( groupHeaderStyle: GroupHeaderStyle = GroupHeaderStyle.dark(), horizontalProgressBarStyle: HorizontalProgressBarStyle = HorizontalProgressBarStyle.dark(), iconButtonStyle: IconButtonStyle = IconButtonStyle.dark(), + tableStyle: TableStyle = TableStyle.dark(), lazyTreeStyle: LazyTreeStyle = LazyTreeStyle.dark(), linkStyle: LinkStyle = LinkStyle.dark(), menuStyle: MenuStyle = MenuStyle.dark(), @@ -142,6 +144,7 @@ public fun ComponentStyling.dark( groupHeaderStyle = groupHeaderStyle, horizontalProgressBarStyle = horizontalProgressBarStyle, iconButtonStyle = iconButtonStyle, + tableStyle = tableStyle, lazyTreeStyle = lazyTreeStyle, linkStyle = linkStyle, menuStyle = menuStyle, @@ -171,6 +174,7 @@ public fun ComponentStyling.light( groupHeaderStyle: GroupHeaderStyle = GroupHeaderStyle.light(), horizontalProgressBarStyle: HorizontalProgressBarStyle = HorizontalProgressBarStyle.light(), iconButtonStyle: IconButtonStyle = IconButtonStyle.light(), + tableStyle: TableStyle = TableStyle.light(), lazyTreeStyle: LazyTreeStyle = LazyTreeStyle.light(), linkStyle: LinkStyle = LinkStyle.light(), menuStyle: MenuStyle = MenuStyle.light(), @@ -198,6 +202,7 @@ public fun ComponentStyling.light( groupHeaderStyle = groupHeaderStyle, horizontalProgressBarStyle = horizontalProgressBarStyle, iconButtonStyle = iconButtonStyle, + tableStyle = tableStyle, lazyTreeStyle = lazyTreeStyle, linkStyle = linkStyle, menuStyle = menuStyle, diff --git a/samples/standalone/build.gradle.kts b/samples/standalone/build.gradle.kts index 37786fb6d..1c3576fee 100644 --- a/samples/standalone/build.gradle.kts +++ b/samples/standalone/build.gradle.kts @@ -10,6 +10,7 @@ plugins { dependencies { implementation(libs.kotlin.reflect) implementation(libs.filePicker) + implementation(libs.faker) implementation(projects.intUi.intUiStandalone) implementation(projects.intUi.intUiDecoratedWindow) implementation(projects.markdown.intUiStandaloneStyling) diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/StandaloneSampleIcons.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/StandaloneSampleIcons.kt index 227faef30..bb3ad0417 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/StandaloneSampleIcons.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/StandaloneSampleIcons.kt @@ -12,6 +12,7 @@ object StandaloneSampleIcons { val themeLightWithLightHeader = PathIconKey("icons/lightWithLightHeaderTheme.svg", StandaloneSampleIcons::class.java) val themeSystem = PathIconKey("icons/systemTheme.svg", StandaloneSampleIcons::class.java) val welcome = PathIconKey("icons/meetNewUi.svg", StandaloneSampleIcons::class.java) + val stub = PathIconKey("icons/stub.svg", StandaloneSampleIcons::class.java) object Components { val borders = PathIconKey("icons/components/borders.svg", StandaloneSampleIcons::class.java) @@ -22,6 +23,7 @@ object StandaloneSampleIcons { val progressBar = PathIconKey("icons/components/progressbar.svg", StandaloneSampleIcons::class.java) val radioButton = PathIconKey("icons/components/radioButton.svg", StandaloneSampleIcons::class.java) val scrollbar = PathIconKey("icons/components/scrollbar.svg", StandaloneSampleIcons::class.java) + val table = PathIconKey("icons/components/dataTables.svg", StandaloneSampleIcons::class.java) val segmentedControls = PathIconKey("icons/components/segmentedControl.svg", StandaloneSampleIcons::class.java) val slider = PathIconKey("icons/components/slider.svg", StandaloneSampleIcons::class.java) val tabs = PathIconKey("icons/components/tabs.svg", StandaloneSampleIcons::class.java) diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt index 2274a5f7d..255a9182d 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.jewel.samples.standalone.IntUiThemes import org.jetbrains.jewel.samples.standalone.StandaloneSampleIcons +import org.jetbrains.jewel.samples.standalone.view.component.FpsCounter import org.jetbrains.jewel.samples.standalone.viewmodel.MainViewModel import org.jetbrains.jewel.samples.standalone.viewmodel.forCurrentOs import org.jetbrains.jewel.ui.component.Dropdown @@ -61,6 +62,8 @@ fun DecoratedWindowScope.TitleBarView() { } } + FpsCounter(Modifier.align(Alignment.Start)) + Text(title) Row(Modifier.align(Alignment.End)) { diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Debug.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Debug.kt new file mode 100644 index 000000000..cdcbedbab --- /dev/null +++ b/samples/standalone/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 +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/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/FpsCounter.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/FpsCounter.kt new file mode 100644 index 000000000..f17f02eb5 --- /dev/null +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/FpsCounter.kt @@ -0,0 +1,142 @@ +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.jewel.ui.component.Text +import kotlin.math.roundToInt + +@Composable +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 + +enum class FPSCountMethod { + FixedInterval, + + FixedFrameCount, + + RealTime, +} diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/LongList.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/LongList.kt new file mode 100644 index 000000000..8b4ea3d5d --- /dev/null +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/LongList.kt @@ -0,0 +1,87 @@ +package org.jetbrains.jewel.samples.standalone.view.component + +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.ui.component.Text +import org.jetbrains.jewel.ui.component.VerticalScrollbar +import org.jetbrains.jewel.ui.theme.tableStyle +import org.jetbrains.jewel.ui.theme.treeStyle + +@Composable +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)) + } + } + } + + 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/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/TableView.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/TableView.kt new file mode 100644 index 000000000..085f2fd95 --- /dev/null +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/TableView.kt @@ -0,0 +1,266 @@ +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.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 kotlinx.coroutines.launch +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.rememberTableHorizontalScrollbarAdapter +import org.jetbrains.jewel.foundation.lazy.table.rememberTableVerticalScrollbarAdapter +import org.jetbrains.jewel.foundation.lazy.table.view.ColumnAccessor +import org.jetbrains.jewel.foundation.lazy.table.view.toTableView +import org.jetbrains.jewel.foundation.lazy.table.view.withHeader +import org.jetbrains.jewel.foundation.modifier.border +import org.jetbrains.jewel.foundation.theme.JewelTheme +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 +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotations + +@Composable +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(OrderColumnAccessor) { + 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.isNotEmpty()) { + view.removeLast() + } + }) { + 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, + ) { + var column by remember { mutableStateOf("") } + var row by remember { mutableStateOf("") } + + TextField(column, { + column = it + }) + + TextField(row, { + row = it + }) + + val scope = rememberCoroutineScope() + + OutlinedButton(onClick = { + scope.launch { + state.scrollToColumn(column.toIntOrNull() ?: 0) + state.scrollToRow(row.toIntOrNull() ?: 0) + } + }) { + Text("Goto") + } + + OutlinedButton(onClick = { + scope.launch { + state.animateScrollToColumn(column.toIntOrNull() ?: 0) + } + scope.launch { + state.animateScrollToRow(row.toIntOrNull() ?: 0) + } + }) { + Text("Goto with Animation") + } + } + + Box(Modifier.weight(1f).fillMaxWidth().border(1.dp, JewelTheme.globalColors.borders.normal)) { + LazyTable( + modifier = Modifier, + state = state, + verticalArrangement = Arrangement.spacedBy(1.dp), + horizontalArrangement = Arrangement.spacedBy(1.dp), + view = viewWithHeader, + style = JewelTheme.defaultTableStyle, + ) + + HorizontalScrollbar( + rememberTableHorizontalScrollbarAdapter(state), + Modifier.fillMaxWidth().align(Alignment.BottomStart), + ) + + VerticalScrollbar( + rememberTableVerticalScrollbarAdapter(state), + Modifier.fillMaxHeight().align(Alignment.TopEnd), + ) + } + } +} + +object OrderColumnAccessor : ColumnAccessor { + 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/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Tables.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Tables.kt new file mode 100644 index 000000000..62cd636a4 --- /dev/null +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Tables.kt @@ -0,0 +1,357 @@ +package org.jetbrains.jewel.samples.standalone.view.component + +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.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 io.github.serpro69.kfaker.faker +import kotlinx.coroutines.launch +import org.jetbrains.jewel.foundation.lazy.selectable.SelectionManager +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.SingleCellSelectionManager +import org.jetbrains.jewel.foundation.theme.JewelTheme +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 +import kotlin.reflect.KProperty +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotations + +@Target(AnnotationTarget.PROPERTY) +annotation class ColumnInfo( + val name: String = "", + val minWidth: Int = -1, + val maxWidth: Int = -1, + val order: Int = 0, +) + +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, + order = 3, + ) + val userName: String, + @ColumnInfo( + name = "Product ID", + minWidth = 80, + order = 4, + ) + val productId: Int, + @ColumnInfo( + name = "Product Name", + minWidth = 300, + 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 { + private val faker = faker {} + + fun fake(id: Int): Order = + Order( + id = id, + transactionId = faker.string.letterify("T?????????"), + uid = faker.string.letterify("U?????????"), + userName = faker.name.name(), + productId = faker.random.nextInt(65535), + productName = faker.book.title(), + price = faker.money.amount(10..1999), + shippingAddress = faker.address.fullAddress(), + postalCode = faker.code.asin(), + createTime = 0, + updateTime = 0, + ) + } +} + +@Composable +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, + ) { + var column by remember { mutableStateOf("") } + var row by remember { mutableStateOf("") } + + TextField(column, { + column = it + }) + + TextField(row, { + row = it + }) + + val coroutine = rememberCoroutineScope() + + OutlinedButton(onClick = { + coroutine.launch { + state.scrollToColumn(column.toIntOrNull() ?: 0) + state.scrollToRow(row.toIntOrNull() ?: 0) + } + }) { + Text("Goto") + } + + OutlinedButton(onClick = { + coroutine.launch { + state.animateScrollToColumn(column.toIntOrNull() ?: 0) + } + coroutine.launch { + state.animateScrollToRow(row.toIntOrNull() ?: 0) + } + }) { + Text("Goto with Animation") + } + } + + println("Recompose View") + + Box(Modifier.fillMaxSize()) { + println("Recompose Box") + + val selectionManager: SelectionManager = remember { SingleCellSelectionManager() } + + LazyTable( + modifier = + Modifier + .lazyTableDraggable(draggableRowState, draggableColumnState) + .selectionManager(selectionManager), + 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, + ) + } + } + } + + HorizontalScrollbar( + rememberTableHorizontalScrollbarAdapter(state), + Modifier.fillMaxWidth().align(Alignment.BottomStart), + ) + + VerticalScrollbar( + rememberTableVerticalScrollbarAdapter(state), + Modifier.fillMaxHeight().align(Alignment.TopEnd), + ) + } +} diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/viewmodel/ComponentsViewModel.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/viewmodel/ComponentsViewModel.kt index c9a13c5db..dcf37442e 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/viewmodel/ComponentsViewModel.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/viewmodel/ComponentsViewModel.kt @@ -12,11 +12,13 @@ import org.jetbrains.jewel.samples.standalone.view.component.ChipsAndTrees import org.jetbrains.jewel.samples.standalone.view.component.Dropdowns import org.jetbrains.jewel.samples.standalone.view.component.Icons import org.jetbrains.jewel.samples.standalone.view.component.Links +import org.jetbrains.jewel.samples.standalone.view.component.LongList import org.jetbrains.jewel.samples.standalone.view.component.ProgressBar import org.jetbrains.jewel.samples.standalone.view.component.RadioButtons import org.jetbrains.jewel.samples.standalone.view.component.Scrollbars import org.jetbrains.jewel.samples.standalone.view.component.SegmentedControls import org.jetbrains.jewel.samples.standalone.view.component.Sliders +import org.jetbrains.jewel.samples.standalone.view.component.TableView import org.jetbrains.jewel.samples.standalone.view.component.Tabs import org.jetbrains.jewel.samples.standalone.view.component.TextAreas import org.jetbrains.jewel.samples.standalone.view.component.TextFields @@ -110,4 +112,14 @@ private val componentsMenuItems = iconKey = StandaloneSampleIcons.Components.scrollbar, content = { Scrollbars() }, ), + ViewInfo( + title = "TableView", + iconKey = StandaloneSampleIcons.Components.table, + content = { TableView() }, + ), + ViewInfo( + title = "LongList", + iconKey = StandaloneSampleIcons.stub, + content = { LongList() }, + ), ) diff --git a/samples/standalone/src/main/resources/icons/components/dataTables.svg b/samples/standalone/src/main/resources/icons/components/dataTables.svg new file mode 100644 index 000000000..c78377d04 --- /dev/null +++ b/samples/standalone/src/main/resources/icons/components/dataTables.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/samples/standalone/src/main/resources/icons/components/dataTables_dark.svg b/samples/standalone/src/main/resources/icons/components/dataTables_dark.svg new file mode 100644 index 000000000..64e102dfa --- /dev/null +++ b/samples/standalone/src/main/resources/icons/components/dataTables_dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/samples/standalone/src/main/resources/icons/components/debug.svg b/samples/standalone/src/main/resources/icons/components/debug.svg new file mode 100644 index 000000000..415ad2daf --- /dev/null +++ b/samples/standalone/src/main/resources/icons/components/debug.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/samples/standalone/src/main/resources/icons/components/debug@20x20.svg b/samples/standalone/src/main/resources/icons/components/debug@20x20.svg new file mode 100644 index 000000000..c82ab9f0b --- /dev/null +++ b/samples/standalone/src/main/resources/icons/components/debug@20x20.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/samples/standalone/src/main/resources/icons/components/debug@20x20_dark.svg b/samples/standalone/src/main/resources/icons/components/debug@20x20_dark.svg new file mode 100644 index 000000000..5cd5d4601 --- /dev/null +++ b/samples/standalone/src/main/resources/icons/components/debug@20x20_dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/samples/standalone/src/main/resources/icons/components/debug_dark.svg b/samples/standalone/src/main/resources/icons/components/debug_dark.svg new file mode 100644 index 000000000..aa9df118b --- /dev/null +++ b/samples/standalone/src/main/resources/icons/components/debug_dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/api/ui.api b/ui/api/ui.api index 8b063f572..660e03910 100644 --- a/ui/api/ui.api +++ b/ui/api/ui.api @@ -25,7 +25,7 @@ public final class org/jetbrains/jewel/ui/ComponentStyling$DefaultImpls { public final class org/jetbrains/jewel/ui/DefaultComponentStyling : org/jetbrains/jewel/ui/ComponentStyling { public static final field $stable I - public fun (Lorg/jetbrains/jewel/ui/component/styling/CheckboxStyle;Lorg/jetbrains/jewel/ui/component/styling/ChipStyle;Lorg/jetbrains/jewel/ui/component/styling/CircularProgressStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/DividerStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/GroupHeaderStyle;Lorg/jetbrains/jewel/ui/component/styling/HorizontalProgressBarStyle;Lorg/jetbrains/jewel/ui/component/styling/IconButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle;Lorg/jetbrains/jewel/ui/component/styling/LinkStyle;Lorg/jetbrains/jewel/ui/component/styling/MenuStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/RadioButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/ScrollbarStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlStyle;Lorg/jetbrains/jewel/ui/component/styling/SliderStyle;Lorg/jetbrains/jewel/ui/component/styling/TextAreaStyle;Lorg/jetbrains/jewel/ui/component/styling/TextFieldStyle;Lorg/jetbrains/jewel/ui/component/styling/TooltipStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;)V + public fun (Lorg/jetbrains/jewel/ui/component/styling/CheckboxStyle;Lorg/jetbrains/jewel/ui/component/styling/ChipStyle;Lorg/jetbrains/jewel/ui/component/styling/CircularProgressStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/DividerStyle;Lorg/jetbrains/jewel/ui/component/styling/TabStyle;Lorg/jetbrains/jewel/ui/component/styling/GroupHeaderStyle;Lorg/jetbrains/jewel/ui/component/styling/HorizontalProgressBarStyle;Lorg/jetbrains/jewel/ui/component/styling/IconButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle;Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle;Lorg/jetbrains/jewel/ui/component/styling/LinkStyle;Lorg/jetbrains/jewel/ui/component/styling/MenuStyle;Lorg/jetbrains/jewel/ui/component/styling/ButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/RadioButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/ScrollbarStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlButtonStyle;Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlStyle;Lorg/jetbrains/jewel/ui/component/styling/SliderStyle;Lorg/jetbrains/jewel/ui/component/styling/TextAreaStyle;Lorg/jetbrains/jewel/ui/component/styling/TextFieldStyle;Lorg/jetbrains/jewel/ui/component/styling/TooltipStyle;Lorg/jetbrains/jewel/ui/component/styling/DropdownStyle;)V public fun equals (Ljava/lang/Object;)Z public final fun getCheckboxStyle ()Lorg/jetbrains/jewel/ui/component/styling/CheckboxStyle; public final fun getChipStyle ()Lorg/jetbrains/jewel/ui/component/styling/ChipStyle; @@ -38,6 +38,7 @@ public final class org/jetbrains/jewel/ui/DefaultComponentStyling : org/jetbrain public final fun getGroupHeaderStyle ()Lorg/jetbrains/jewel/ui/component/styling/GroupHeaderStyle; public final fun getHorizontalProgressBarStyle ()Lorg/jetbrains/jewel/ui/component/styling/HorizontalProgressBarStyle; public final fun getIconButtonStyle ()Lorg/jetbrains/jewel/ui/component/styling/IconButtonStyle; + public final fun getLazyTableStyle ()Lorg/jetbrains/jewel/ui/component/styling/LazyTableStyle; public final fun getLazyTreeStyle ()Lorg/jetbrains/jewel/ui/component/styling/LazyTreeStyle; public final fun getLinkStyle ()Lorg/jetbrains/jewel/ui/component/styling/LinkStyle; public final fun getMenuStyle ()Lorg/jetbrains/jewel/ui/component/styling/MenuStyle; @@ -407,6 +408,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 } @@ -1522,6 +1528,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/LazyTreeColors { public static final field $stable I public static final field Companion Lorg/jetbrains/jewel/ui/component/styling/LazyTreeColors$Companion; @@ -4257,6 +4309,7 @@ public final class org/jetbrains/jewel/ui/theme/JewelThemeKt { public static final fun getSegmentedControlButtonStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlButtonStyle; public static final fun getSegmentedControlStyle (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/ui/component/styling/SegmentedControlStyle; 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/ui/src/main/kotlin/org/jetbrains/jewel/ui/DefaultComponentStyling.kt b/ui/src/main/kotlin/org/jetbrains/jewel/ui/DefaultComponentStyling.kt index d59335cee..33bed4e9f 100644 --- a/ui/src/main/kotlin/org/jetbrains/jewel/ui/DefaultComponentStyling.kt +++ b/ui/src/main/kotlin/org/jetbrains/jewel/ui/DefaultComponentStyling.kt @@ -37,6 +37,7 @@ import org.jetbrains.jewel.ui.component.styling.LocalScrollbarStyle import org.jetbrains.jewel.ui.component.styling.LocalSegmentedControlButtonStyle import org.jetbrains.jewel.ui.component.styling.LocalSegmentedControlStyle 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 @@ -48,6 +49,7 @@ import org.jetbrains.jewel.ui.component.styling.SegmentedControlButtonStyle import org.jetbrains.jewel.ui.component.styling.SegmentedControlStyle import org.jetbrains.jewel.ui.component.styling.SliderStyle 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 @@ -66,6 +68,7 @@ public class DefaultComponentStyling( public val groupHeaderStyle: GroupHeaderStyle, public val horizontalProgressBarStyle: HorizontalProgressBarStyle, public val iconButtonStyle: IconButtonStyle, + public val tableStyle: TableStyle, public val lazyTreeStyle: LazyTreeStyle, public val linkStyle: LinkStyle, public val menuStyle: MenuStyle, @@ -95,6 +98,7 @@ public class DefaultComponentStyling( LocalGroupHeaderStyle provides groupHeaderStyle, LocalHorizontalProgressBarStyle provides horizontalProgressBarStyle, LocalIconButtonStyle provides iconButtonStyle, + LocalTableStyle provides tableStyle, LocalLazyTreeStyle provides lazyTreeStyle, LocalLinkStyle provides linkStyle, LocalMenuStyle provides menuStyle, diff --git a/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Table.kt b/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Table.kt new file mode 100644 index 000000000..9a6e8855c --- /dev/null +++ b/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Table.kt @@ -0,0 +1,119 @@ +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/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/styling/TableStyling.kt b/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/styling/TableStyling.kt new file mode 100644 index 000000000..b2d6e51ff --- /dev/null +++ b/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/styling/TableStyling.kt @@ -0,0 +1,175 @@ +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/ui/src/main/kotlin/org/jetbrains/jewel/ui/theme/JewelTheme.kt b/ui/src/main/kotlin/org/jetbrains/jewel/ui/theme/JewelTheme.kt index 348211866..0f2f7ad84 100644 --- a/ui/src/main/kotlin/org/jetbrains/jewel/ui/theme/JewelTheme.kt +++ b/ui/src/main/kotlin/org/jetbrains/jewel/ui/theme/JewelTheme.kt @@ -41,6 +41,7 @@ import org.jetbrains.jewel.ui.component.styling.LocalScrollbarStyle import org.jetbrains.jewel.ui.component.styling.LocalSegmentedControlButtonStyle import org.jetbrains.jewel.ui.component.styling.LocalSegmentedControlStyle 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 @@ -51,6 +52,7 @@ import org.jetbrains.jewel.ui.component.styling.SegmentedControlButtonStyle import org.jetbrains.jewel.ui.component.styling.SegmentedControlStyle import org.jetbrains.jewel.ui.component.styling.SliderStyle 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 @@ -164,6 +166,11 @@ public val JewelTheme.Companion.editorTabStyle: TabStyle @ReadOnlyComposable get() = LocalEditorTabStyle.current +public val JewelTheme.Companion.defaultTableStyle: TableStyle + @Composable + @ReadOnlyComposable + get() = LocalTableStyle.current + public val JewelTheme.Companion.circularProgressStyle: CircularProgressStyle @Composable @ReadOnlyComposable @@ -184,6 +191,11 @@ public val JewelTheme.Companion.sliderStyle: SliderStyle @ReadOnlyComposable get() = LocalSliderStyle.current +public val JewelTheme.Companion.tableStyle: TableStyle + @Composable + @ReadOnlyComposable + get() = LocalTableStyle.current + @Composable public fun BaseJewelTheme( theme: ThemeDefinition,