Skip to content

Commit 76a99e3

Browse files
faogustavorosejr
andcommitted
[JEWEL-61] LazyTable Component
- Add LazyTable component for large datasets - Create OverflowBox utility for smart cell content overflow on hover - Provide themed TableViewCell and TableViewHeader components with IntelliJ theme integration - Added theme classes based on IJ table Co-authored-by: James Rose <[email protected]>
1 parent 7d7f201 commit 76a99e3

File tree

56 files changed

+6946
-5
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+6946
-5
lines changed

platform/jewel/docs/lazy-table.md

Lines changed: 477 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package org.jetbrains.jewel.foundation
3+
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.BoxScope
6+
import androidx.compose.runtime.Composable
7+
import androidx.compose.runtime.LaunchedEffect
8+
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.mutableStateOf
10+
import androidx.compose.runtime.remember
11+
import androidx.compose.runtime.rememberUpdatedState
12+
import androidx.compose.runtime.setValue
13+
import androidx.compose.ui.Alignment
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.awt.awtEventOrNull
16+
import androidx.compose.ui.input.pointer.PointerEventType
17+
import androidx.compose.ui.input.pointer.pointerInput
18+
import androidx.compose.ui.layout.layout
19+
import androidx.compose.ui.unit.Constraints
20+
import kotlin.time.Duration.Companion.milliseconds
21+
import kotlinx.coroutines.delay
22+
import org.jetbrains.annotations.ApiStatus
23+
24+
/**
25+
* A container that allows its child to temporarily overflow its constraints (width and/or height) when hovered.
26+
*
27+
* When the mouse cursor enters the box, the child is remeasured with an effectively unbounded maximum width and/or
28+
* height (depending on which side would overflow) and placed above surrounding content using [overflowZIndex], provided
29+
* its intrinsic size exceeds the incoming constraints. When the cursor leaves, or when [overflowEnabled] is `false`,
30+
* the child is measured with the original constraints and no overflow is shown.
31+
*
32+
* The [content] lambda receives an [OverflowBoxScope] that exposes [OverflowBoxScope.isOverflowing], which you can use
33+
* to adapt visuals while the overflow is active (for example, show a shadow or fade).
34+
*
35+
* Behavior notes:
36+
* - Overflow can affect width, height, or both, depending on the child's intrinsic size relative to the current
37+
* constraints.
38+
* - A small delay (~700 ms) is applied before the overflow becomes visible after the cursor enters.
39+
* - Overflow occurs only if [overflowEnabled] is `true` and the child's max intrinsic size exceeds the container's
40+
* current max constraints.
41+
*
42+
* This API is experimental and may change without notice.
43+
*
44+
* @param modifier the [Modifier] to be applied to the container.
45+
* @param overflowEnabled whether the overflow behavior is active. If `false`, the child never overflows.
46+
* @param contentAlignment alignment of the child within the box.
47+
* @param overflowZIndex the z-index used while overflowing so the content renders above neighboring nodes.
48+
* @param content the content of this box. The receiver provides [OverflowBoxScope] utilities.
49+
*/
50+
@ApiStatus.Experimental
51+
@ExperimentalJewelApi
52+
@Composable
53+
public fun OverflowBox(
54+
modifier: Modifier = Modifier,
55+
overflowEnabled: Boolean = true,
56+
contentAlignment: Alignment = Alignment.TopStart,
57+
overflowZIndex: Float = 1f,
58+
content: @Composable OverflowBoxScope.() -> Unit,
59+
) {
60+
val currentOverflowEnabled by rememberUpdatedState(overflowEnabled)
61+
62+
var lastMousePosition by remember { mutableStateOf<java.awt.Point?>(null) }
63+
var isOverflowVisible by remember { mutableStateOf(false) }
64+
65+
LaunchedEffect(lastMousePosition) {
66+
if (!currentOverflowEnabled) {
67+
isOverflowVisible = false
68+
return@LaunchedEffect
69+
}
70+
71+
val shouldShowOverflow = lastMousePosition != null
72+
if (!isOverflowVisible && shouldShowOverflow) delay(700.milliseconds)
73+
isOverflowVisible = shouldShowOverflow
74+
}
75+
76+
Box(
77+
Modifier.layout { measurable, constraints ->
78+
// Predict intrinsic sizes to determine potential overflow on each axis.
79+
val predictWidth = measurable.maxIntrinsicWidth(constraints.maxHeight)
80+
val predictHeight = measurable.maxIntrinsicHeight(constraints.maxWidth)
81+
82+
val constraintWith = constraints.maxWidth
83+
val constraintHeight = constraints.maxHeight
84+
85+
val overflowingX =
86+
isOverflowVisible && constraintWith != Constraints.Infinity && predictWidth > constraintWith
87+
88+
val overflowingY =
89+
isOverflowVisible && constraintHeight != Constraints.Infinity && predictHeight > constraintHeight
90+
91+
val targetConstraints =
92+
constraints.copy(
93+
maxWidth = if (overflowingX) Constraints.Infinity else constraints.maxWidth,
94+
maxHeight = if (overflowingY) Constraints.Infinity else constraints.maxHeight,
95+
)
96+
97+
val zIndex = if (overflowingX || overflowingY) overflowZIndex else 0f
98+
99+
// Trick for overflow layout: compensate layout alignment by offsetting half of the extra space.
100+
val xOffset = if (overflowingX) (predictWidth - constraintWith) / 2 else 0
101+
val yOffset = if (overflowingY) (predictHeight - constraintHeight) / 2 else 0
102+
103+
val placements = measurable.measure(targetConstraints)
104+
layout(placements.width, placements.height) { placements.placeRelative(xOffset, yOffset, zIndex) }
105+
}
106+
.pointerInput(Unit) {
107+
awaitPointerEventScope {
108+
while (true) {
109+
val event = awaitPointerEvent()
110+
lastMousePosition =
111+
when (event.type) {
112+
PointerEventType.Enter,
113+
PointerEventType.Move -> event.awtEventOrNull?.point?.takeIf { overflowEnabled }
114+
else -> null
115+
}
116+
}
117+
}
118+
}
119+
.then(modifier),
120+
contentAlignment = contentAlignment,
121+
) {
122+
rememberOverflowBoxScope(isOverflowVisible, this).content()
123+
}
124+
}
125+
126+
public interface OverflowBoxScope : BoxScope {
127+
/**
128+
* Whether the child is currently laid out in overflow mode (i.e., measured with relaxed constraints on at least one
129+
* axis — width and/or height — and visually extending beyond the container's normal max constraints).
130+
*/
131+
public val isOverflowing: Boolean
132+
}
133+
134+
@Composable
135+
private fun rememberOverflowBoxScope(isOverflowing: Boolean, scope: BoxScope) =
136+
remember(isOverflowing, scope) {
137+
object : OverflowBoxScope, BoxScope by scope {
138+
override val isOverflowing: Boolean = isOverflowing
139+
}
140+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package org.jetbrains.jewel.foundation.lazy.draggable
3+
4+
import androidx.compose.foundation.gestures.Orientation
5+
import androidx.compose.foundation.gestures.detectDragGestures
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.setValue
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.composed
12+
import androidx.compose.ui.geometry.Offset
13+
import androidx.compose.ui.graphics.graphicsLayer
14+
import androidx.compose.ui.input.pointer.pointerInput
15+
import androidx.compose.ui.layout.onGloballyPositioned
16+
import androidx.compose.ui.layout.positionInRoot
17+
import androidx.compose.ui.modifier.ModifierLocal
18+
import androidx.compose.ui.modifier.modifierLocalConsumer
19+
import androidx.compose.ui.modifier.modifierLocalOf
20+
import androidx.compose.ui.modifier.modifierLocalProvider
21+
import androidx.compose.ui.zIndex
22+
23+
internal val ModifierLocalDraggableLayoutOffset = modifierLocalOf { Offset.Zero }
24+
25+
@Suppress("ModifierComposed") // To fix in JEWEL-921
26+
internal fun Modifier.draggableLayout(): Modifier = composed {
27+
var offset by remember { mutableStateOf(Offset.Zero) }
28+
29+
this.onGloballyPositioned { offset = it.positionInRoot() }
30+
.modifierLocalProvider(ModifierLocalDraggableLayoutOffset) { offset }
31+
}
32+
33+
@Suppress("ModifierComposed") // To fix in JEWEL-921
34+
internal fun Modifier.draggingGestures(stateLocal: ModifierLocal<LazyLayoutDraggingState<*>>, key: Any?): Modifier =
35+
composed {
36+
var state by remember { mutableStateOf<LazyLayoutDraggingState<*>?>(null) }
37+
var itemOffset by remember { mutableStateOf(Offset.Zero) }
38+
var layoutOffset by remember { mutableStateOf(Offset.Zero) }
39+
40+
modifierLocalConsumer {
41+
state = stateLocal.current
42+
layoutOffset = ModifierLocalDraggableLayoutOffset.current
43+
}
44+
.then(
45+
if (state != null) {
46+
Modifier.onGloballyPositioned { itemOffset = it.positionInRoot() }
47+
.pointerInput(Unit) {
48+
detectDragGestures(
49+
onDrag = { change, offset ->
50+
change.consume()
51+
state?.onDrag(offset)
52+
},
53+
onDragStart = { state?.onDragStart(key, it + itemOffset - layoutOffset) },
54+
onDragEnd = { state?.onDragInterrupted() },
55+
onDragCancel = { state?.onDragInterrupted() },
56+
)
57+
}
58+
} else {
59+
Modifier
60+
}
61+
)
62+
}
63+
64+
@Suppress("ModifierComposed") // To fix in JEWEL-921
65+
internal fun Modifier.draggingOffset(
66+
stateLocal: ModifierLocal<LazyLayoutDraggingState<*>>,
67+
key: Any?,
68+
orientation: Orientation? = null,
69+
): Modifier = composed {
70+
var state by remember { mutableStateOf<LazyLayoutDraggingState<*>?>(null) }
71+
val dragging = state?.draggingItemKey == key
72+
73+
this.modifierLocalConsumer { state = stateLocal.current }
74+
.then(
75+
if (state != null && dragging) {
76+
Modifier.zIndex(2f)
77+
.graphicsLayer(
78+
translationX =
79+
if (orientation == Orientation.Vertical) {
80+
0f
81+
} else {
82+
state?.draggingItemOffsetTransformX ?: 0f
83+
},
84+
translationY =
85+
if (orientation == Orientation.Horizontal) {
86+
0f
87+
} else {
88+
state?.draggingItemOffsetTransformY ?: 0f
89+
},
90+
)
91+
} else {
92+
Modifier
93+
}
94+
)
95+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package org.jetbrains.jewel.foundation.lazy.draggable
3+
4+
import androidx.compose.foundation.interaction.MutableInteractionSource
5+
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.mutableFloatStateOf
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.setValue
9+
import androidx.compose.ui.geometry.Offset
10+
import androidx.compose.ui.geometry.Size
11+
12+
public abstract class LazyLayoutDraggingState<T> {
13+
public var draggingItemOffsetTransformX: Float by mutableFloatStateOf(0f)
14+
15+
public var draggingItemOffsetTransformY: Float by mutableFloatStateOf(0f)
16+
17+
public var draggingItemKey: Any? by mutableStateOf(null)
18+
19+
public var initialOffset: Offset = Offset.Zero
20+
21+
public var draggingOffset: Offset = Offset.Zero
22+
23+
internal val interactionSource: MutableInteractionSource = MutableInteractionSource()
24+
25+
public fun onDragStart(key: Any?, offset: Offset) {
26+
draggingItemKey = key
27+
initialOffset = offset
28+
draggingOffset = Offset.Zero
29+
draggingItemOffsetTransformX = 0f
30+
draggingItemOffsetTransformY = 0f
31+
}
32+
33+
public fun onDrag(offset: Offset) {
34+
draggingItemOffsetTransformX += offset.x
35+
draggingItemOffsetTransformY += offset.y
36+
draggingOffset += offset
37+
38+
val draggingItem = getItemWithKey(draggingItemKey ?: return) ?: return
39+
val hoverItem = getReplacingItem(draggingItem)
40+
41+
if (hoverItem != null && draggingItem.key != hoverItem.key) {
42+
val targetOffset =
43+
if (draggingItem.index < hoverItem.index) {
44+
val maxOffset = hoverItem.offset + Offset(hoverItem.size.width, hoverItem.size.height)
45+
maxOffset - Offset(draggingItem.size.width, draggingItem.size.height)
46+
} else {
47+
hoverItem.offset
48+
}
49+
50+
val changedOffset = draggingItem.offset - targetOffset
51+
52+
if (moveItem(draggingItem.key, hoverItem.key)) {
53+
draggingItemOffsetTransformX += changedOffset.x
54+
draggingItemOffsetTransformY += changedOffset.y
55+
}
56+
}
57+
}
58+
59+
public fun onDragInterrupted() {
60+
draggingItemKey = null
61+
initialOffset = Offset.Zero
62+
draggingOffset = Offset.Zero
63+
draggingItemOffsetTransformX = 0f
64+
draggingItemOffsetTransformY = 0f
65+
}
66+
67+
public abstract fun canMove(key: Any?): Boolean
68+
69+
public abstract fun moveItem(from: Any?, to: Any?): Boolean
70+
71+
public abstract fun getReplacingItem(draggingItem: T): T?
72+
73+
public abstract fun getItemWithKey(key: Any): T?
74+
75+
public abstract val T.offset: Offset
76+
77+
public abstract val T.size: Size
78+
79+
public abstract val T.index: Int
80+
81+
public abstract val T.key: Any?
82+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package org.jetbrains.jewel.foundation.lazy.selectable
3+
4+
public interface SelectionEvent
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package org.jetbrains.jewel.foundation.lazy.selectable
3+
4+
import androidx.compose.foundation.focusGroup
5+
import androidx.compose.foundation.focusable
6+
import androidx.compose.foundation.interaction.MutableInteractionSource
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.mutableStateOf
10+
import androidx.compose.runtime.remember
11+
import androidx.compose.runtime.setValue
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.composed
14+
import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
15+
import androidx.compose.ui.input.pointer.isCtrlPressed
16+
import androidx.compose.ui.input.pointer.isMetaPressed
17+
import androidx.compose.ui.input.pointer.isShiftPressed
18+
import androidx.compose.ui.modifier.modifierLocalConsumer
19+
import androidx.compose.ui.modifier.modifierLocalOf
20+
import androidx.compose.ui.modifier.modifierLocalProvider
21+
22+
public interface SelectionManager {
23+
public val interactionSource: MutableInteractionSource
24+
25+
public val selectedItems: Set<Any>
26+
27+
public fun isSelectable(itemKey: Any?): Boolean
28+
29+
public fun isSelected(itemKey: Any?): Boolean
30+
31+
public fun handleEvent(event: SelectionEvent)
32+
33+
public fun clearSelection()
34+
}
35+
36+
internal val ModifierLocalSelectionManager = modifierLocalOf<SelectionManager?> { null }
37+
38+
public fun Modifier.selectionManager(manager: SelectionManager): Modifier =
39+
focusable(interactionSource = manager.interactionSource).focusGroup().modifierLocalProvider(
40+
ModifierLocalSelectionManager
41+
) {
42+
manager
43+
}
44+
45+
@Suppress("ModifierComposed") // To fix in JEWEL-921
46+
public fun Modifier.selectionManagerConsumer(factory: @Composable (SelectionManager) -> Modifier): Modifier = composed {
47+
var manager by remember { mutableStateOf<SelectionManager?>(null) }
48+
49+
this.modifierLocalConsumer { manager = ModifierLocalSelectionManager.current }
50+
.then(manager?.let { factory(it) } ?: Modifier)
51+
}
52+
53+
internal fun PointerKeyboardModifiers.selectionType(): SelectionType =
54+
when {
55+
this.isCtrlPressed || this.isMetaPressed -> SelectionType.Multi
56+
this.isShiftPressed -> SelectionType.Contiguous
57+
else -> SelectionType.Normal
58+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package org.jetbrains.jewel.foundation.lazy.selectable
3+
4+
/** Specifies the selection mode for a selectable lazy list. */
5+
public enum class SelectionMode {
6+
/** No selection is allowed. */
7+
None,
8+
9+
/** Only a single cell can be selected. */
10+
Single,
11+
12+
/** Multiple cells can be selected. */
13+
Multiple,
14+
}

0 commit comments

Comments
 (0)