Skip to content

Commit 97df4e2

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 8967696 commit 97df4e2

File tree

70 files changed

+9040
-8
lines changed

Some content is hidden

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

70 files changed

+9040
-8
lines changed

platform/jewel/docs/lazy-table.md

Lines changed: 509 additions & 0 deletions
Large diffs are not rendered by default.

platform/jewel/foundation/api-dump-experimental.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ f:org.jetbrains.jewel.foundation.JewelFlags
55
- *f:setUseCustomPopupRenderer(Z):V
66
f:org.jetbrains.jewel.foundation.LocalComponentKt
77
- *sf:getLocalComponent():androidx.compose.runtime.ProvidableCompositionLocal
8+
f:org.jetbrains.jewel.foundation.OverflowBoxKt
9+
- *sf:OverflowBox(androidx.compose.ui.Modifier,Z,androidx.compose.ui.Alignment,F,kotlin.jvm.functions.Function3,androidx.compose.runtime.Composer,I,I):V
810
*:org.jetbrains.jewel.foundation.code.highlighting.CodeHighlighter
911
- a:highlight-zTGadEY(java.lang.String,java.lang.String):kotlinx.coroutines.flow.Flow
1012
f:org.jetbrains.jewel.foundation.code.highlighting.CodeHighlighterKt

platform/jewel/foundation/api-dump.txt

Lines changed: 350 additions & 0 deletions
Large diffs are not rendered by default.

platform/jewel/foundation/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ private val composeVersion
1414

1515
dependencies {
1616
api("org.jetbrains.compose.foundation:foundation-desktop:$composeVersion")
17+
implementation(libs.androidx.collection)
1718

1819
testImplementation(compose.desktop.uiTestJUnit4)
1920
testImplementation(compose.desktop.currentOs) { exclude(group = "org.jetbrains.compose.material") }
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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.Measurable
19+
import androidx.compose.ui.layout.MeasureResult
20+
import androidx.compose.ui.layout.MeasureScope
21+
import androidx.compose.ui.layout.layout
22+
import androidx.compose.ui.unit.Constraints
23+
import kotlin.time.Duration.Companion.milliseconds
24+
import kotlinx.coroutines.delay
25+
import org.jetbrains.annotations.ApiStatus
26+
27+
/**
28+
* A container that allows its child to temporarily overflow its constraints (width and/or height) when hovered.
29+
*
30+
* When the mouse cursor enters the box, the child is remeasured with an effectively unbounded maximum width and/or
31+
* height (depending on which side would overflow) and placed above surrounding content using [overflowZIndex], provided
32+
* its intrinsic size exceeds the incoming constraints. When the cursor leaves, or when [overflowEnabled] is `false`,
33+
* the child is measured with the original constraints and no overflow is shown.
34+
*
35+
* The [content] lambda receives an [OverflowBoxScope] that exposes [OverflowBoxScope.isOverflowing], which you can use
36+
* to adapt visuals while the overflow is active (for example, show a shadow or fade).
37+
*
38+
* Behavior notes:
39+
* - Overflow can affect width, height, or both, depending on the child's intrinsic size relative to the current
40+
* constraints.
41+
* - A small delay (~700 ms) is applied before the overflow becomes visible after the cursor enters.
42+
* - Overflow occurs only if [overflowEnabled] is `true` and the child's max intrinsic size exceeds the container's
43+
* current max constraints.
44+
*
45+
* This API is experimental and may change without notice.
46+
*
47+
* @param modifier the [Modifier] to be applied to the container.
48+
* @param overflowEnabled whether the overflow behavior is active. If `false`, the child never overflows.
49+
* @param contentAlignment alignment of the child within the box.
50+
* @param overflowZIndex the z-index used while overflowing so the content renders above neighboring nodes.
51+
* @param content the content of this box. The receiver provides [OverflowBoxScope] utilities.
52+
*/
53+
@ApiStatus.Experimental
54+
@ExperimentalJewelApi
55+
@Composable
56+
public fun OverflowBox(
57+
modifier: Modifier = Modifier,
58+
overflowEnabled: Boolean = true,
59+
contentAlignment: Alignment = Alignment.TopStart,
60+
overflowZIndex: Float = 1f,
61+
content: @Composable OverflowBoxScope.() -> Unit,
62+
) {
63+
val currentOverflowEnabled by rememberUpdatedState(overflowEnabled)
64+
val currentOverflowZIndex by rememberUpdatedState(overflowZIndex)
65+
66+
var lastMousePosition by remember { mutableStateOf<java.awt.Point?>(null) }
67+
var isOverflowVisible by remember { mutableStateOf(false) }
68+
69+
val onMeasure: MeasureScope.(Measurable, Constraints) -> MeasureResult =
70+
remember(isOverflowVisible) {
71+
{ measurable, constraints ->
72+
// Predict intrinsic sizes to determine potential overflow on each axis.
73+
val predictWidth = measurable.maxIntrinsicWidth(constraints.maxHeight)
74+
val predictHeight = measurable.maxIntrinsicHeight(constraints.maxWidth)
75+
76+
val constraintWith = constraints.maxWidth
77+
val constraintHeight = constraints.maxHeight
78+
79+
val overflowingX =
80+
isOverflowVisible && constraintWith != Constraints.Infinity && predictWidth > constraintWith
81+
82+
val overflowingY =
83+
isOverflowVisible && constraintHeight != Constraints.Infinity && predictHeight > constraintHeight
84+
85+
val targetConstraints =
86+
constraints.copy(
87+
maxWidth = if (overflowingX) Constraints.Infinity else constraints.maxWidth,
88+
maxHeight = if (overflowingY) Constraints.Infinity else constraints.maxHeight,
89+
)
90+
91+
val zIndex = if (overflowingX || overflowingY) currentOverflowZIndex else 0f
92+
93+
// Trick for overflow layout: compensate layout alignment by offsetting half of the extra space.
94+
val xOffset = if (overflowingX) (predictWidth - constraintWith) / 2 else 0
95+
val yOffset = if (overflowingY) (predictHeight - constraintHeight) / 2 else 0
96+
97+
val placements = measurable.measure(targetConstraints)
98+
layout(placements.width, placements.height) { placements.placeRelative(xOffset, yOffset, zIndex) }
99+
}
100+
}
101+
102+
LaunchedEffect(lastMousePosition) {
103+
if (!currentOverflowEnabled) {
104+
isOverflowVisible = false
105+
return@LaunchedEffect
106+
}
107+
108+
val shouldShowOverflow = lastMousePosition != null
109+
if (!isOverflowVisible && shouldShowOverflow) delay(700.milliseconds)
110+
isOverflowVisible = shouldShowOverflow
111+
}
112+
113+
Box(
114+
Modifier.layout(onMeasure)
115+
.pointerInput(Unit) {
116+
awaitPointerEventScope {
117+
while (true) {
118+
val event = awaitPointerEvent()
119+
lastMousePosition =
120+
when (event.type) {
121+
PointerEventType.Enter,
122+
PointerEventType.Move -> event.awtEventOrNull?.point?.takeIf { currentOverflowEnabled }
123+
124+
else -> null
125+
}
126+
}
127+
}
128+
}
129+
.then(modifier),
130+
contentAlignment = contentAlignment,
131+
) {
132+
rememberOverflowBoxScope(isOverflowVisible, this).content()
133+
}
134+
}
135+
136+
public interface OverflowBoxScope : BoxScope {
137+
/**
138+
* Whether the child is currently laid out in overflow mode (i.e., measured with relaxed constraints on at least one
139+
* axis — width and/or height — and visually extending beyond the container's normal max constraints).
140+
*/
141+
public val isOverflowing: Boolean
142+
}
143+
144+
@Composable
145+
private fun rememberOverflowBoxScope(isOverflowing: Boolean, scope: BoxScope) =
146+
remember(isOverflowing, scope) {
147+
object : OverflowBoxScope, BoxScope by scope {
148+
override val isOverflowing: Boolean = isOverflowing
149+
}
150+
}
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

0 commit comments

Comments
 (0)