From 5efee37e72d5cac6f34250c39498840620886463 Mon Sep 17 00:00:00 2001 From: Ivan Morgillo Date: Fri, 6 Sep 2024 14:33:04 +0200 Subject: [PATCH] New SplitLayout (#570) * replace SplitLayout Signed-off-by: Ivan Morgillo * add splitlayout icon Signed-off-by: Ivan Morgillo * add SplitLayout icon to the collection Signed-off-by: Ivan Morgillo * create the SplitLayouts showcase in the Standalone Signed-off-by: Ivan Morgillo * make it uglier but now the linter is happy Signed-off-by: Ivan Morgillo * hoist the SplitLayout divider state Signed-off-by: Ivan Morgillo * remove nested layout from SplitLayouts showcase Signed-off-by: Ivan Morgillo * add responsive minimum width to SplitLayout Signed-off-by: Ivan Morgillo * make the linter happy Signed-off-by: Ivan Morgillo * iterate on SplitLayout icon Signed-off-by: Ivan Morgillo * fix NaN operation in calculateAdjustedSizes() Signed-off-by: Ivan Morgillo * iterate on SplitLayout draggable Signed-off-by: Ivan Morgillo * replace a whens with ifs Signed-off-by: Ivan Morgillo * iterate on draggable divider Signed-off-by: Ivan Morgillo * tune the pointer icon while dragging Signed-off-by: Ivan Morgillo * make the linter happy Signed-off-by: Ivan Morgillo * add min width to SplitLayout in IDE sample Signed-off-by: Ivan Morgillo * fix wrong params in SplitLayouts reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744048060 Signed-off-by: Ivan Morgillo * name params consistently in SplitLayout reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744051557 Signed-off-by: Ivan Morgillo * add missing KDOC reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744053784 Signed-off-by: Ivan Morgillo * add missing KDOC to public API reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744054562 Signed-off-by: Ivan Morgillo * remove redundant value reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744056026 Signed-off-by: Ivan Morgillo * inlined variable reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744109722 Signed-off-by: Ivan Morgillo * extract draggableState variable reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744059589 Signed-off-by: Ivan Morgillo * rename maxPositionPx reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744064354 Signed-off-by: Ivan Morgillo * simplify pointer icon management reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744069248 Signed-off-by: Ivan Morgillo * rename fillModifier reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744070355 Signed-off-by: Ivan Morgillo * remove redundant .then() reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744071185 Signed-off-by: Ivan Morgillo * add -Px suffix to a couple of values reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744075815 Signed-off-by: Ivan Morgillo * extract a couple of methods reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744116763 Signed-off-by: Ivan Morgillo * moved values into doLayout() reference: https://github.com/JetBrains/jewel/pull/570#discussion_r1744117767 Signed-off-by: Ivan Morgillo * iterate on windows resizing Signed-off-by: Ivan Morgillo * iterate once more on divider positioning while dragging Signed-off-by: Ivan Morgillo * Fix formatting --------- Signed-off-by: Ivan Morgillo Co-authored-by: Sebastiano Poggi --- .../releasessample/ReleasesSampleCompose.kt | 13 +- .../standalone/StandaloneSampleIcons.kt | 1 + .../standalone/view/component/SplitLayouts.kt | 94 +++ .../viewmodel/ComponentsViewModel.kt | 113 ++-- .../icons/components/splitLayout.svg | 7 + .../icons/components/splitLayout_dark.svg | 7 + ui/api/ui.api | 14 +- .../jewel/ui/component/SplitLayout.kt | 542 +++++++++++++----- 8 files changed, 606 insertions(+), 185 deletions(-) create mode 100644 samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/SplitLayouts.kt create mode 100644 samples/standalone/src/main/resources/icons/components/splitLayout.svg create mode 100644 samples/standalone/src/main/resources/icons/components/splitLayout_dark.svg diff --git a/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/releasessample/ReleasesSampleCompose.kt b/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/releasessample/ReleasesSampleCompose.kt index d557197c9..eb63152ee 100644 --- a/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/releasessample/ReleasesSampleCompose.kt +++ b/samples/ide-plugin/src/main/kotlin/org/jetbrains/jewel/samples/ideplugin/releasessample/ReleasesSampleCompose.kt @@ -94,18 +94,17 @@ import org.jetbrains.jewel.ui.util.thenIf fun ReleasesSampleCompose(project: Project) { var selectedItem: ContentItem? by remember { mutableStateOf(null) } HorizontalSplitLayout( - first = { modifier -> + first = { LeftColumn( project = project, - modifier = modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), onSelectedItemChange = { selectedItem = it }, ) }, - second = { modifier -> RightColumn(selectedItem = selectedItem, modifier = modifier.fillMaxSize()) }, - Modifier.fillMaxSize(), - initialDividerPosition = 400.dp, - minRatio = .15f, - maxRatio = .7f, + second = { RightColumn(selectedItem = selectedItem, modifier = Modifier.fillMaxSize()) }, + modifier = Modifier.fillMaxSize(), + firstPaneMinWidth = 300.dp, + secondPaneMinWidth = 300.dp, ) } 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 442d8d7d7..4381ec699 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 @@ -25,6 +25,7 @@ object StandaloneSampleIcons { val scrollbar = PathIconKey("icons/components/scrollbar.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 splitlayout = PathIconKey("icons/components/splitLayout.svg", StandaloneSampleIcons::class.java) val tabs = PathIconKey("icons/components/tabs.svg", StandaloneSampleIcons::class.java) val textArea = PathIconKey("icons/components/textArea.svg", StandaloneSampleIcons::class.java) val textField = PathIconKey("icons/components/textField.svg", StandaloneSampleIcons::class.java) diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/SplitLayouts.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/SplitLayouts.kt new file mode 100644 index 000000000..b70f9d26d --- /dev/null +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/SplitLayouts.kt @@ -0,0 +1,94 @@ +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.HorizontalSplitLayout +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.SplitLayoutState +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.component.VerticalSplitLayout + +@Composable +fun SplitLayouts( + outerSplitState: SplitLayoutState, + verticalSplitState: SplitLayoutState, + innerSplitState: SplitLayoutState, + onResetState: () -> Unit, +) { + Column(Modifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Reset split state:") + Spacer(Modifier.width(8.dp)) + OutlinedButton(onClick = onResetState) { Text("Reset") } + } + + Spacer(Modifier.height(16.dp)) + + HorizontalSplitLayout( + state = outerSplitState, + first = { FirstPane() }, + second = { SecondPane(innerSplitState = innerSplitState, verticalSplitState = verticalSplitState) }, + modifier = Modifier.fillMaxWidth().weight(1f).border(1.dp, color = JewelTheme.globalColors.borders.normal), + firstPaneMinWidth = 300.dp, + secondPaneMinWidth = 200.dp, + ) + } +} + +@Composable +private fun FirstPane() { + Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + val state by remember { mutableStateOf(TextFieldState()) } + TextField(state, placeholder = { Text("Placeholder") }) + } +} + +@Composable +private fun SecondPane(innerSplitState: SplitLayoutState, verticalSplitState: SplitLayoutState) { + VerticalSplitLayout( + state = verticalSplitState, + modifier = Modifier.fillMaxSize(), + first = { + val state by remember { mutableStateOf(TextFieldState()) } + Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + TextField(state, placeholder = { Text("Right Panel Content") }) + } + }, + second = { + HorizontalSplitLayout( + first = { + Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Text("Second Pane left") + } + }, + second = { + Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Text("Second Pane right") + } + }, + modifier = Modifier.fillMaxSize(), + state = innerSplitState, + firstPaneMinWidth = 100.dp, + secondPaneMinWidth = 100.dp, + ) + }, + firstPaneMinWidth = 300.dp, + secondPaneMinWidth = 100.dp, + ) +} 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 7af0a80e9..4891b35c5 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 @@ -1,9 +1,11 @@ package org.jetbrains.jewel.samples.standalone.viewmodel +import SplitLayouts import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import org.jetbrains.jewel.samples.standalone.StandaloneSampleIcons import org.jetbrains.jewel.samples.standalone.view.component.Borders import org.jetbrains.jewel.samples.standalone.view.component.Buttons @@ -21,49 +23,78 @@ 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 import org.jetbrains.jewel.samples.standalone.view.component.Tooltips +import org.jetbrains.jewel.ui.component.SplitLayoutState object ComponentsViewModel { - val views = componentsMenuItems + private var outerSplitState by mutableStateOf(SplitLayoutState(0.5f)) + private var verticalSplitState by mutableStateOf(SplitLayoutState(0.5f)) + private var innerSplitState by mutableStateOf(SplitLayoutState(0.5f)) + val views: SnapshotStateList = + mutableStateListOf( + ViewInfo(title = "Buttons", iconKey = StandaloneSampleIcons.Components.button, content = { Buttons() }), + ViewInfo( + title = "Radio Buttons", + iconKey = StandaloneSampleIcons.Components.radioButton, + content = { RadioButtons() }, + ), + ViewInfo( + title = "Checkboxes", + iconKey = StandaloneSampleIcons.Components.checkbox, + content = { Checkboxes() }, + ), + ViewInfo( + title = "Dropdowns", + iconKey = StandaloneSampleIcons.Components.comboBox, + content = { Dropdowns() }, + ), + ViewInfo( + title = "Chips and trees", + iconKey = StandaloneSampleIcons.Components.tree, + content = { ChipsAndTrees() }, + ), + ViewInfo( + title = "Progressbar", + iconKey = StandaloneSampleIcons.Components.progressBar, + content = { ProgressBar() }, + ), + ViewInfo(title = "Icons", iconKey = StandaloneSampleIcons.Components.toolbar, content = { Icons() }), + ViewInfo(title = "Links", iconKey = StandaloneSampleIcons.Components.links, content = { Links() }), + ViewInfo(title = "Borders", iconKey = StandaloneSampleIcons.Components.borders, content = { Borders() }), + ViewInfo( + title = "Segmented Controls", + iconKey = StandaloneSampleIcons.Components.segmentedControls, + content = { SegmentedControls() }, + ), + ViewInfo(title = "Sliders", iconKey = StandaloneSampleIcons.Components.slider, content = { Sliders() }), + ViewInfo(title = "Tabs", iconKey = StandaloneSampleIcons.Components.tabs, content = { Tabs() }), + ViewInfo(title = "Tooltips", iconKey = StandaloneSampleIcons.Components.tooltip, content = { Tooltips() }), + ViewInfo( + title = "TextAreas", + iconKey = StandaloneSampleIcons.Components.textArea, + content = { TextAreas() }, + ), + ViewInfo( + title = "TextFields", + iconKey = StandaloneSampleIcons.Components.textField, + content = { TextFields() }, + ), + ViewInfo( + title = "Scrollbars", + iconKey = StandaloneSampleIcons.Components.scrollbar, + content = { Scrollbars() }, + ), + ViewInfo( + title = "SplitLayout", + iconKey = StandaloneSampleIcons.Components.splitlayout, + content = { + SplitLayouts(outerSplitState, verticalSplitState, innerSplitState) { + outerSplitState = SplitLayoutState(0.5f) + verticalSplitState = SplitLayoutState(0.5f) + innerSplitState = SplitLayoutState(0.5f) + } + }, + ), + ) var currentView by mutableStateOf(views.first()) } - -private val componentsMenuItems = - mutableStateListOf( - ViewInfo(title = "Buttons", iconKey = StandaloneSampleIcons.Components.button, content = { Buttons() }), - ViewInfo( - title = "Radio Buttons", - iconKey = StandaloneSampleIcons.Components.radioButton, - content = { RadioButtons() }, - ), - ViewInfo(title = "Checkboxes", iconKey = StandaloneSampleIcons.Components.checkbox, content = { Checkboxes() }), - ViewInfo(title = "Dropdowns", iconKey = StandaloneSampleIcons.Components.comboBox, content = { Dropdowns() }), - ViewInfo( - title = "Chips and trees", - iconKey = StandaloneSampleIcons.Components.tree, - content = { ChipsAndTrees() }, - ), - ViewInfo( - title = "Progressbar", - iconKey = StandaloneSampleIcons.Components.progressBar, - content = { ProgressBar() }, - ), - ViewInfo(title = "Icons", iconKey = StandaloneSampleIcons.Components.toolbar, content = { Icons() }), - ViewInfo(title = "Links", iconKey = StandaloneSampleIcons.Components.links, content = { Links() }), - ViewInfo(title = "Borders", iconKey = StandaloneSampleIcons.Components.borders, content = { Borders() }), - ViewInfo( - title = "Segmented Controls", - iconKey = StandaloneSampleIcons.Components.segmentedControls, - content = { SegmentedControls() }, - ), - ViewInfo(title = "Sliders", iconKey = StandaloneSampleIcons.Components.slider, content = { Sliders() }), - ViewInfo(title = "Tabs", iconKey = StandaloneSampleIcons.Components.tabs, content = { Tabs() }), - ViewInfo(title = "Tooltips", iconKey = StandaloneSampleIcons.Components.tooltip, content = { Tooltips() }), - ViewInfo(title = "TextAreas", iconKey = StandaloneSampleIcons.Components.textArea, content = { TextAreas() }), - ViewInfo( - title = "TextFields", - iconKey = StandaloneSampleIcons.Components.textField, - content = { TextFields() }, - ), - ViewInfo(title = "Scrollbars", iconKey = StandaloneSampleIcons.Components.scrollbar, content = { Scrollbars() }), - ) diff --git a/samples/standalone/src/main/resources/icons/components/splitLayout.svg b/samples/standalone/src/main/resources/icons/components/splitLayout.svg new file mode 100644 index 000000000..c661e72d4 --- /dev/null +++ b/samples/standalone/src/main/resources/icons/components/splitLayout.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/samples/standalone/src/main/resources/icons/components/splitLayout_dark.svg b/samples/standalone/src/main/resources/icons/components/splitLayout_dark.svg new file mode 100644 index 000000000..ae2d9d335 --- /dev/null +++ b/samples/standalone/src/main/resources/icons/components/splitLayout_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/api/ui.api b/ui/api/ui.api index 8712f3052..d08782a86 100644 --- a/ui/api/ui.api +++ b/ui/api/ui.api @@ -755,8 +755,18 @@ public final class org/jetbrains/jewel/ui/component/SliderState$Companion { } public final class org/jetbrains/jewel/ui/component/SplitLayoutKt { - public static final fun HorizontalSplitLayout-BssWTFQ (Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Modifier;JFFFFFFLandroidx/compose/runtime/Composer;II)V - public static final fun VerticalSplitLayout-BssWTFQ (Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Modifier;JFFFFFFLandroidx/compose/runtime/Composer;II)V + public static final fun HorizontalSplitLayout-Zv8xjqY (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lorg/jetbrains/jewel/ui/component/styling/DividerStyle;FFFLorg/jetbrains/jewel/ui/component/SplitLayoutState;Landroidx/compose/runtime/Composer;II)V + public static final fun VerticalSplitLayout-Zv8xjqY (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lorg/jetbrains/jewel/ui/component/styling/DividerStyle;FFFLorg/jetbrains/jewel/ui/component/SplitLayoutState;Landroidx/compose/runtime/Composer;II)V + public static final fun rememberSplitLayoutState (FLandroidx/compose/runtime/Composer;II)Lorg/jetbrains/jewel/ui/component/SplitLayoutState; +} + +public final class org/jetbrains/jewel/ui/component/SplitLayoutState { + public static final field $stable I + public fun (F)V + public final fun getDividerPosition ()F + public final fun getLayoutCoordinates ()Landroidx/compose/ui/layout/LayoutCoordinates; + public final fun setDividerPosition (F)V + public final fun setLayoutCoordinates (Landroidx/compose/ui/layout/LayoutCoordinates;)V } public abstract interface class org/jetbrains/jewel/ui/component/TabContentScope { diff --git a/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/SplitLayout.kt b/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/SplitLayout.kt index 6d3d43416..1ba463a39 100644 --- a/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/SplitLayout.kt +++ b/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/SplitLayout.kt @@ -1,6 +1,9 @@ +@file:Suppress("DuplicatedCode") // It's similar, but not exactly the same + package org.jetbrains.jewel.ui.component -import androidx.compose.foundation.gestures.Orientation as ComposeOrientation +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource @@ -15,184 +18,453 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import java.awt.Cursor import kotlin.math.roundToInt import org.jetbrains.jewel.foundation.theme.JewelTheme -import org.jetbrains.jewel.ui.Orientation +import org.jetbrains.jewel.ui.Orientation.Horizontal +import org.jetbrains.jewel.ui.Orientation.Vertical +import org.jetbrains.jewel.ui.component.styling.DividerStyle +import org.jetbrains.jewel.ui.theme.dividerStyle +import org.jetbrains.skiko.Cursor +/** + * A customizable horizontal split layout Composable function that allows you to divide the available space between two + * components using a draggable divider. The divider can be dragged to resize the panes, but cannot be focused. + * + * @param first The Composable function representing the first component, that will be placed on one side of the + * divider, typically on the left or above. + * @param second The Composable function representing the second component, that will be placed on the other side of the + * divider, typically on the right or below. + * @param modifier The modifier to be applied to the layout. + * @param draggableWidth The width of the draggable area around the divider. This is a invisible, wider area around the + * divider that can be dragged by the user to resize the panes. + * @param firstPaneMinWidth The minimum size of the first component. + * @param secondPaneMinWidth The minimum size of the second component. + * @param dividerStyle The divider style to be applied to the layout. + * @param state The [SplitLayoutState] object that will be used to store the split state. + */ @Composable public fun HorizontalSplitLayout( - first: @Composable (Modifier) -> Unit, - second: @Composable (Modifier) -> Unit, + first: @Composable () -> Unit, + second: @Composable () -> Unit, modifier: Modifier = Modifier, - dividerColor: Color = JewelTheme.globalColors.borders.normal, - dividerThickness: Dp = 1.dp, - dividerIndent: Dp = 0.dp, + dividerStyle: DividerStyle = JewelTheme.dividerStyle, draggableWidth: Dp = 8.dp, - minRatio: Float = 0f, - maxRatio: Float = 1f, - initialDividerPosition: Dp = 300.dp, + firstPaneMinWidth: Dp = Dp.Unspecified, + secondPaneMinWidth: Dp = Dp.Unspecified, + state: SplitLayoutState = rememberSplitLayoutState(), +) { + SplitLayoutImpl( + first = first, + second = second, + modifier = modifier, + dividerStyle = dividerStyle, + draggableWidth = draggableWidth, + firstPaneMinWidth = firstPaneMinWidth, + secondPaneMinWidth = secondPaneMinWidth, + strategy = horizontalTwoPaneStrategy(), + state = state, + ) +} + +/** + * A customizable vertical split layout Composable function that allows you to divide the available space between two + * components using a draggable divider. The divider can be dragged to resize the panes, but cannot be focused. + * + * @param first The Composable function representing the first component, that will be placed on one side of the + * divider, typically on the left or above. + * @param second The Composable function representing the second component, that will be placed on the other side of the + * divider, typically on the right or below. + * @param modifier The modifier to be applied to the layout. + * @param draggableWidth The width of the draggable area around the divider. This is a invisible, wider area around the + * divider that can be dragged by the user to resize the panes. + * @param firstPaneMinWidth The minimum size of the first component. + * @param secondPaneMinWidth The minimum size of the second component. + * @param dividerStyle The divider style to be applied to the layout. + * @param state The [SplitLayoutState] object that will be used to store the split state. + */ +@Composable +public fun VerticalSplitLayout( + first: @Composable () -> Unit, + second: @Composable () -> Unit, + modifier: Modifier = Modifier, + dividerStyle: DividerStyle = JewelTheme.dividerStyle, + draggableWidth: Dp = 8.dp, + firstPaneMinWidth: Dp = Dp.Unspecified, + secondPaneMinWidth: Dp = Dp.Unspecified, + state: SplitLayoutState = rememberSplitLayoutState(), +) { + SplitLayoutImpl( + first = first, + second = second, + modifier = modifier, + dividerStyle = dividerStyle, + draggableWidth = draggableWidth, + firstPaneMinWidth = firstPaneMinWidth, + secondPaneMinWidth = secondPaneMinWidth, + strategy = verticalTwoPaneStrategy(), + state = state, + ) +} + +/** + * Represents the state for a split layout, which is used to control the position of the divider and layout coordinates. + * + * @param initialSplitFraction The initial fraction value that determines the position of the divider. + * @constructor Creates a [SplitLayoutState] with the given initial split fraction. + */ +public class SplitLayoutState(initialSplitFraction: Float) { + /** + * A mutable floating-point value representing the position of the divider within the split layout. The position is + * expressed as a fraction of the total layout size, ranging from 0.0 (divider at the start) to 1.0 (divider at the + * end). This allows dynamic adjustment of the layout's two panes, reflecting the current state of the divider's + * placement. + */ + public var dividerPosition: Float by mutableStateOf(initialSplitFraction.coerceIn(0f, 1f)) + + /** + * Holds the layout coordinates for the split layout. These coordinates are used to track the position and size of + * the layout parts, facilitating the adjustment of the divider and the layout's panes during interactions like + * dragging. + */ + public var layoutCoordinates: LayoutCoordinates? by mutableStateOf(null) +} + +/** + * Remembers a [SplitLayoutState] instance with the provided initial split fraction. + * + * @param initialSplitFraction The initial fraction value that determines the position of the divider. + * @return A remembered [SplitLayoutState] instance. + */ +@Composable +public fun rememberSplitLayoutState(initialSplitFraction: Float = 0.5f): SplitLayoutState = remember { + SplitLayoutState(initialSplitFraction) +} + +private val HorizontalResizePointerIcon = PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)) +private val VerticalResizePointerIcon = PointerIcon(Cursor(Cursor.N_RESIZE_CURSOR)) + +@Composable +private fun SplitLayoutImpl( + first: @Composable () -> Unit, + second: @Composable () -> Unit, + strategy: SplitLayoutStrategy, + modifier: Modifier, + draggableWidth: Dp, + firstPaneMinWidth: Dp, + secondPaneMinWidth: Dp, + dividerStyle: DividerStyle, + state: SplitLayoutState, ) { val density = LocalDensity.current - var dividerX by remember { mutableStateOf(with(density) { initialDividerPosition.roundToPx() }) } + var isDragging by remember { mutableStateOf(false) } + val resizePointerIcon = if (strategy.isHorizontal()) HorizontalResizePointerIcon else VerticalResizePointerIcon + + var dragOffset by remember { mutableStateOf(0f) } + + val draggableState = rememberDraggableState { delta -> + state.layoutCoordinates?.let { coordinates -> + val size = if (strategy.isHorizontal()) coordinates.size.width else coordinates.size.height + val minFirstPositionPx = with(density) { firstPaneMinWidth.toPx() } + val minSecondPositionPx = with(density) { secondPaneMinWidth.toPx() } + + dragOffset += delta + val position = size * state.dividerPosition + dragOffset + val newPosition = position.coerceIn(minFirstPositionPx, size - minSecondPositionPx) + state.dividerPosition = newPosition / size + } + } Layout( - modifier = modifier, + modifier = + modifier + .onGloballyPositioned { coordinates -> + state.layoutCoordinates = coordinates + // Reset drag offset when layout changes + dragOffset = 0f + } + .pointerHoverIcon(if (isDragging) resizePointerIcon else PointerIcon.Default), content = { + Box(Modifier.layoutId("first")) { first() } + Box(Modifier.layoutId("second")) { second() } + val dividerInteractionSource = remember { MutableInteractionSource() } - first(Modifier.layoutId("first")) + val dividerOrientation = if (strategy.isHorizontal()) Vertical else Horizontal + val fillModifier = if (strategy.isHorizontal()) Modifier.fillMaxHeight() else Modifier.fillMaxWidth() + val orientation = if (strategy.isHorizontal()) Orientation.Horizontal else Orientation.Vertical Divider( - orientation = Orientation.Vertical, - modifier = Modifier.fillMaxHeight().layoutId("divider"), - color = dividerColor, - thickness = dividerThickness, - startIndent = dividerIndent, + orientation = dividerOrientation, + modifier = fillModifier.layoutId("divider").focusable(false), + color = dividerStyle.color, + thickness = dividerStyle.metrics.thickness, ) - second(Modifier.layoutId("second")) - Box( - Modifier.fillMaxHeight() - .width(draggableWidth) + Modifier.let { modifier -> + if (strategy.isHorizontal()) { + modifier.fillMaxHeight().width(draggableWidth) + } else { + modifier.fillMaxWidth().height(draggableWidth) + } + } .draggable( + orientation = orientation, + state = draggableState, + onDragStarted = { + isDragging = true + dragOffset = 0f + }, + onDragStopped = { + isDragging = false + dragOffset = 0f + }, interactionSource = dividerInteractionSource, - orientation = ComposeOrientation.Horizontal, - state = rememberDraggableState { delta -> dividerX += delta.toInt() }, ) - .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) + .pointerHoverIcon(resizePointerIcon) .layoutId("divider-handle") + .focusable(false) ) }, - ) { measurables, incomingConstraints -> - val availableWidth = incomingConstraints.maxWidth - val actualDividerX = - dividerX - .coerceIn(0, availableWidth) - .coerceIn((availableWidth * minRatio).roundToInt(), (availableWidth * maxRatio).roundToInt()) - - val dividerMeasurable = measurables.single { it.layoutId == "divider" } - val dividerPlaceable = - dividerMeasurable.measure(Constraints.fixed(dividerThickness.roundToPx(), incomingConstraints.maxHeight)) - - val firstComponentConstraints = - Constraints.fixed((actualDividerX).coerceAtLeast(0), incomingConstraints.maxHeight) - val firstPlaceable = - measurables.find { it.layoutId == "first" }?.measure(firstComponentConstraints) - ?: error("No first component found. Have you applied the provided Modifier to it?") - - val secondComponentConstraints = - Constraints.fixed( - width = availableWidth - actualDividerX + dividerPlaceable.width, - height = incomingConstraints.maxHeight, + ) { measurables, constraints -> + if (state.layoutCoordinates == null) { + notReadyLayout(constraints) + } else { + doLayout( + strategy, + density, + state, + dividerStyle, + draggableWidth, + firstPaneMinWidth, + secondPaneMinWidth, + constraints, + measurables, ) - val secondPlaceable = - measurables.find { it.layoutId == "second" }?.measure(secondComponentConstraints) - ?: error("No second component found. Have you applied the provided Modifier to it?") - - val dividerHandlePlaceable = - measurables - .single { it.layoutId == "divider-handle" } - .measure(Constraints.fixedHeight(incomingConstraints.maxHeight)) - - layout(availableWidth, incomingConstraints.maxHeight) { - firstPlaceable.placeRelative(0, 0) - dividerPlaceable.placeRelative(actualDividerX - dividerPlaceable.width / 2, 0) - secondPlaceable.placeRelative(actualDividerX + dividerPlaceable.width, 0) - dividerHandlePlaceable.placeRelative(actualDividerX - dividerHandlePlaceable.measuredWidth / 2, 0) } } } -@Composable -public fun VerticalSplitLayout( - first: @Composable (Modifier) -> Unit, - second: @Composable (Modifier) -> Unit, - modifier: Modifier = Modifier, - dividerColor: Color = JewelTheme.globalColors.borders.normal, - dividerThickness: Dp = 1.dp, - dividerIndent: Dp = 0.dp, - draggableWidth: Dp = 8.dp, - minRatio: Float = 0f, - maxRatio: Float = 1f, - initialDividerPosition: Dp = 300.dp, -) { - val density = LocalDensity.current - var dividerY by remember { mutableStateOf(with(density) { initialDividerPosition.roundToPx() }) } +private fun MeasureScope.notReadyLayout(constraints: Constraints) = + layout(constraints.minWidth, constraints.minHeight) {} - Layout( - modifier = modifier, - content = { - val dividerInteractionSource = remember { MutableInteractionSource() } - first(Modifier.layoutId("first")) +private fun MeasureScope.doLayout( + strategy: SplitLayoutStrategy, + density: Density, + state: SplitLayoutState, + dividerStyle: DividerStyle, + draggableWidth: Dp, + firstPaneMinWidth: Dp, + secondPaneMinWidth: Dp, + constraints: Constraints, + measurables: List, +): MeasureResult { + val firstMeasurable = measurables.find { it.layoutId == "first" } ?: error("No first component found.") + val secondMeasurable = measurables.find { it.layoutId == "second" } ?: error("No second component found.") + val dividerMeasurable = measurables.find { it.layoutId == "divider" } ?: error("No divider component found.") + val dividerHandleMeasurable = + measurables.find { it.layoutId == "divider-handle" } ?: error("No divider-handle component found.") - Divider( - orientation = Orientation.Horizontal, - modifier = Modifier.fillMaxHeight().layoutId("divider"), - color = dividerColor, - thickness = dividerThickness, - startIndent = dividerIndent, - ) + val splitResult = strategy.calculateSplitResult(density = density, layoutDirection = layoutDirection, state = state) - second(Modifier.layoutId("second")) + val gapOrientation = splitResult.gapOrientation + val gapBounds = splitResult.gapBounds - Box( - Modifier.fillMaxWidth() - .height(draggableWidth) - .draggable( - interactionSource = dividerInteractionSource, - orientation = ComposeOrientation.Vertical, - state = rememberDraggableState { delta -> dividerY += delta.toInt() }, + val dividerWidth = with(density) { dividerStyle.metrics.thickness.roundToPx() } + val handleWidth = with(density) { draggableWidth.roundToPx() } + val minFirstPaneSizePx = with(density) { firstPaneMinWidth.roundToPx() } + val minSecondPaneSizePx = with(density) { secondPaneMinWidth.roundToPx() } + + // The visual divider itself. It's a thin line that separates the two panes + val dividerPlaceable = + dividerMeasurable.measure( + when (gapOrientation) { + Orientation.Vertical -> { + constraints.copy( + minWidth = dividerWidth, + maxWidth = dividerWidth, + minHeight = constraints.minHeight, + maxHeight = constraints.maxHeight, ) - .pointerHoverIcon(PointerIcon(Cursor(Cursor.N_RESIZE_CURSOR))) - .layoutId("divider-handle") + } + + Orientation.Horizontal -> { + constraints.copy( + minWidth = constraints.minWidth, + maxWidth = constraints.maxWidth, + minHeight = dividerWidth, + maxHeight = dividerWidth, + ) + } + } + ) + + // This is an invisible, wider area around the divider that can be dragged by the user + // to resize the panes + val dividerHandlePlaceable = + dividerHandleMeasurable.measure( + when (gapOrientation) { + Orientation.Vertical -> { + constraints.copy( + minWidth = handleWidth, + maxWidth = handleWidth, + minHeight = constraints.minHeight, + maxHeight = constraints.maxHeight, + ) + } + + Orientation.Horizontal -> { + constraints.copy( + minWidth = constraints.minWidth, + maxWidth = constraints.maxWidth, + minHeight = handleWidth, + maxHeight = handleWidth, + ) + } + } + ) + + val availableSpace = + if (gapOrientation == Orientation.Vertical) { + (constraints.maxWidth - dividerWidth).coerceAtLeast(1) + } else { + (constraints.maxHeight - dividerWidth).coerceAtLeast(1) + } + + val (adjustedFirstSize, adjustedSecondSize) = + calculateAdjustedSizes(availableSpace, minFirstPaneSizePx, minSecondPaneSizePx) + + val firstGap = + when (gapOrientation) { + Orientation.Vertical -> gapBounds.left + Orientation.Horizontal -> gapBounds.top + } + + val firstSize: Int = firstGap.roundToInt().coerceIn(adjustedFirstSize, availableSpace - adjustedSecondSize) + + val secondSize = availableSpace - firstSize + + val firstConstraints = + when (gapOrientation) { + Orientation.Vertical -> constraints.copy(minWidth = adjustedFirstSize, maxWidth = firstSize) + Orientation.Horizontal -> constraints.copy(minHeight = adjustedFirstSize, maxHeight = firstSize) + } + + val secondConstraints = + when (gapOrientation) { + Orientation.Vertical -> constraints.copy(minWidth = adjustedSecondSize, maxWidth = secondSize) + Orientation.Horizontal -> constraints.copy(minHeight = adjustedSecondSize, maxHeight = secondSize) + } + + val firstPlaceable = firstMeasurable.measure(firstConstraints) + val secondPlaceable = secondMeasurable.measure(secondConstraints) + + return layout(constraints.maxWidth, constraints.maxHeight) { + firstPlaceable.placeRelative(0, 0) + when (gapOrientation) { + Orientation.Vertical -> { + dividerPlaceable.placeRelative(firstSize, 0) + dividerHandlePlaceable.placeRelative(firstSize - handleWidth / 2, 0) + secondPlaceable.placeRelative(firstSize + dividerWidth, 0) + } + + Orientation.Horizontal -> { + dividerPlaceable.placeRelative(0, firstSize) + dividerHandlePlaceable.placeRelative(0, firstSize - handleWidth / 2) + secondPlaceable.placeRelative(0, firstSize + dividerWidth) + } + } + } +} + +private class SplitResult(val gapOrientation: Orientation, val gapBounds: Rect) + +private interface SplitLayoutStrategy { + fun calculateSplitResult(density: Density, layoutDirection: LayoutDirection, state: SplitLayoutState): SplitResult + + fun isHorizontal(): Boolean +} + +private fun horizontalTwoPaneStrategy(gapWidth: Dp = 0.dp): SplitLayoutStrategy = + object : SplitLayoutStrategy { + override fun calculateSplitResult( + density: Density, + layoutDirection: LayoutDirection, + state: SplitLayoutState, + ): SplitResult { + val layoutCoordinates = state.layoutCoordinates ?: return SplitResult(Orientation.Vertical, Rect.Zero) + val availableWidth = layoutCoordinates.size.width.toFloat().coerceAtLeast(1f) + val splitWidthPixel = with(density) { gapWidth.toPx() } + + val dividerPosition = state.dividerPosition.coerceIn(0f, 1f) + val splitX = (availableWidth * dividerPosition).coerceIn(0f, availableWidth) + + return SplitResult( + gapOrientation = Orientation.Vertical, + gapBounds = + Rect( + left = splitX - splitWidthPixel / 2f, + top = 0f, + right = (splitX + splitWidthPixel / 2f).coerceAtMost(availableWidth), + bottom = layoutCoordinates.size.height.toFloat().coerceAtLeast(1f), + ), ) - }, - ) { measurables, incomingConstraints -> - val availableHeight = incomingConstraints.maxHeight - val actualDividerY = - dividerY - .coerceIn(0, availableHeight) - .coerceIn((availableHeight * minRatio).roundToInt(), (availableHeight * maxRatio).roundToInt()) - - val dividerMeasurable = measurables.single { it.layoutId == "divider" } - val dividerPlaceable = - dividerMeasurable.measure(Constraints.fixed(incomingConstraints.maxWidth, dividerThickness.roundToPx())) - - val firstComponentConstraints = - Constraints.fixed(incomingConstraints.maxWidth, (actualDividerY - 1).coerceAtLeast(0)) - val firstPlaceable = - measurables.find { it.layoutId == "first" }?.measure(firstComponentConstraints) - ?: error("No first component found. Have you applied the provided Modifier to it?") - - val secondComponentConstraints = - Constraints.fixed( - width = incomingConstraints.maxWidth, - height = availableHeight - actualDividerY + dividerPlaceable.height, + } + + override fun isHorizontal(): Boolean = true + } + +private fun verticalTwoPaneStrategy(gapHeight: Dp = 0.dp): SplitLayoutStrategy = + object : SplitLayoutStrategy { + override fun calculateSplitResult( + density: Density, + layoutDirection: LayoutDirection, + state: SplitLayoutState, + ): SplitResult { + val layoutCoordinates = state.layoutCoordinates ?: return SplitResult(Orientation.Horizontal, Rect.Zero) + val availableHeight = layoutCoordinates.size.height.toFloat().coerceAtLeast(1f) + val splitHeightPixel = with(density) { gapHeight.toPx() } + + val dividerPosition = state.dividerPosition.coerceIn(0f, 1f) + val splitY = (availableHeight * dividerPosition).coerceIn(0f, availableHeight) + + return SplitResult( + gapOrientation = Orientation.Horizontal, + gapBounds = + Rect( + left = 0f, + top = splitY - splitHeightPixel / 2f, + right = layoutCoordinates.size.width.toFloat().coerceAtLeast(1f), + bottom = (splitY + splitHeightPixel / 2f).coerceAtMost(availableHeight), + ), ) - val secondPlaceable = - measurables.find { it.layoutId == "second" }?.measure(secondComponentConstraints) - ?: error("No second component found. Have you applied the provided Modifier to it?") - - val dividerHandlePlaceable = - measurables - .single { it.layoutId == "divider-handle" } - .measure(Constraints.fixedWidth(incomingConstraints.maxWidth)) - - layout(incomingConstraints.maxWidth, availableHeight) { - firstPlaceable.placeRelative(0, 0) - dividerPlaceable.placeRelative(0, actualDividerY - dividerPlaceable.height / 2) - secondPlaceable.placeRelative(0, actualDividerY + dividerPlaceable.height) - dividerHandlePlaceable.placeRelative(0, actualDividerY - dividerHandlePlaceable.measuredHeight / 2) } + + override fun isHorizontal(): Boolean = false } + +private fun calculateAdjustedSizes(availableSpace: Int, minFirstPaneSize: Int, minSecondPaneSize: Int): Pair { + val totalMinSize = minFirstPaneSize + minSecondPaneSize + if (availableSpace >= totalMinSize) { + return minFirstPaneSize to minSecondPaneSize + } + + val ratio = minFirstPaneSize.toFloat() / totalMinSize + val adjustedFirstSize = (availableSpace * ratio).roundToInt() + return adjustedFirstSize to availableSpace - adjustedFirstSize }