diff --git a/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBarImpl.kt b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBarImpl.kt index b6f2428c5..23dfcf9ba 100644 --- a/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBarImpl.kt +++ b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBarImpl.kt @@ -17,6 +17,7 @@ fun DecoratedDialogScope.DialogTitleBarImpl( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: LayoutDirection = LocalLayoutDirection.current, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, onPlace: (() -> Unit)? = null, backgroundContent: @Composable () -> Unit = {}, @@ -30,6 +31,7 @@ fun DecoratedDialogScope.DialogTitleBarImpl( gradientStartColor = gradientStartColor, style = style, controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, applyTitleBar = applyTitleBar, onPlace = onPlace, backgroundContent = backgroundContent, @@ -37,3 +39,28 @@ fun DecoratedDialogScope.DialogTitleBarImpl( content(dialogState) } } + +@Suppress("FunctionNaming", "LongParameterList") +@Composable +fun DecoratedDialogScope.DialogTitleBarImpl( + modifier: Modifier = Modifier, + gradientStartColor: Color = Color.Unspecified, + style: TitleBarStyle = LocalTitleBarStyle.current, + controlButtonsDirection: LayoutDirection = LocalLayoutDirection.current, + applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, + onPlace: (() -> Unit)? = null, + backgroundContent: @Composable () -> Unit = {}, + content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit, +) { + DialogTitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = TitleBarLayoutPolicy.Default, + applyTitleBar = applyTitleBar, + onPlace = onPlace, + backgroundContent = backgroundContent, + content = content, + ) +} diff --git a/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBarCore.kt b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBarCore.kt index 51d8924e6..17cef41ce 100644 --- a/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBarCore.kt +++ b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBarCore.kt @@ -24,30 +24,23 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasurePolicy -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.layout.MeasureScope -import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ParentDataModifierNode import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -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.offset import io.github.kdroidfilter.nucleus.core.runtime.Platform import io.github.kdroidfilter.nucleus.window.styling.LocalTitleBarStyle import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import java.awt.Window -import kotlin.math.max private const val GRADIENT_MIDPOINT = 0.5f @@ -70,6 +63,7 @@ fun GenericTitleBarImpl( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: LayoutDirection = LocalLayoutDirection.current, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, onPlace: (() -> Unit)? = null, backgroundContent: @Composable () -> Unit = {}, @@ -128,16 +122,24 @@ fun GenericTitleBarImpl( scope.content(state) } }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().onPlaced { onPlace?.invoke() }, measurePolicy = - rememberTitleBarMeasurePolicy(window, state, applyTitleBar, controlButtonsDirection, onPlace), + rememberTitleBarMeasurePolicy( + window = window, + state = state, + applyTitleBar = applyTitleBar, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + ), ) } } -@Suppress("FunctionNaming") +@Suppress("FunctionNaming", "LongParameterList") @Composable -fun DecoratedWindowScope.TitleBarImpl( +fun GenericTitleBarImpl( + window: Window, + state: DecoratedWindowState, modifier: Modifier = Modifier, gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, @@ -154,6 +156,7 @@ fun DecoratedWindowScope.TitleBarImpl( gradientStartColor = gradientStartColor, style = style, controlButtonsDirection = controlButtonsDirection, + layoutPolicy = TitleBarLayoutPolicy.Default, applyTitleBar = applyTitleBar, onPlace = onPlace, backgroundContent = backgroundContent, @@ -161,140 +164,58 @@ fun DecoratedWindowScope.TitleBarImpl( ) } -class TitleBarMeasurePolicy( - private val window: Window, - private val state: DecoratedWindowState, - private val applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, - private val controlButtonsDirection: LayoutDirection, - private val onPlace: (() -> Unit)? = null, -) : MeasurePolicy { - @Suppress("CyclomaticComplexMethod", "LongMethod") - override fun MeasureScope.measure( - measurables: List, - constraints: Constraints, - ): MeasureResult { - if (measurables.isEmpty()) { - return layout(width = constraints.minWidth, height = constraints.minHeight) {} - } - - var maxSpaceVertically = constraints.minHeight - val contentConstraints = constraints.copy(minWidth = 0, minHeight = 0) - - // Two-pass measurement: End items are measured independently so they - // don't reduce the available width for Start/Center items. This keeps - // behaviour consistent with JBR where native caption buttons reserve - // space via padding insets rather than Compose item measurement. - val endMeasurables = mutableListOf>() - val otherMeasurables = mutableListOf>() - - // Pass 1 – measure End-aligned items among themselves - var endOccupied = 0 - for (it in measurables) { - val alignment = (it.parentData as? TitleBarChildDataNode)?.horizontalAlignment - if (alignment != Alignment.End) continue - val placeable = it.measure(contentConstraints.offset(horizontal = -endOccupied)) - endOccupied += placeable.width - maxSpaceVertically = max(maxSpaceVertically, placeable.height) - endMeasurables += it to placeable - } - - // Pass 2 – measure non-End items with full available width - var otherOccupied = 0 - @Suppress("LoopWithTooManyJumpStatements") - for (it in measurables) { - val alignment = (it.parentData as? TitleBarChildDataNode)?.horizontalAlignment - if (alignment == Alignment.End) continue - val placeable = it.measure(contentConstraints.offset(horizontal = -otherOccupied)) - if (constraints.maxWidth < otherOccupied + placeable.width) break - otherOccupied += placeable.width - maxSpaceVertically = max(maxSpaceVertically, placeable.height) - otherMeasurables += it to placeable - } - - val measuredPlaceable = endMeasurables + otherMeasurables - val boxHeight = maxSpaceVertically - - val contentPadding = applyTitleBar(boxHeight.toDp(), state) - - // Use Ltr to get absolute left/right insets. - val leftInset = contentPadding.calculateLeftPadding(LayoutDirection.Ltr).roundToPx() - val rightInset = contentPadding.calculateRightPadding(LayoutDirection.Ltr).roundToPx() - - val occupiedSpaceHorizontally = endOccupied + otherOccupied + leftInset + rightInset - val boxWidth = maxOf(constraints.minWidth, occupiedSpaceHorizontally) - - return layout(boxWidth, boxHeight) { - onPlace?.invoke() - - val placeableGroups = - measuredPlaceable.groupBy { (measurable, _) -> - (measurable.parentData as? TitleBarChildDataNode)?.horizontalAlignment - ?: Alignment.CenterHorizontally - } - - val contentIsRtl = layoutDirection == LayoutDirection.Rtl - val controlsOnRight = controlButtonsDirection == LayoutDirection.Ltr - - // Absolute occupied-space tracking for each side - var leftUsed = leftInset - var rightUsed = rightInset - - // End items (control buttons) first — they claim the extreme edge - // before Start items, so they always stay at their designated side - // even when content and controls share the same edge. - placeableGroups[Alignment.End]?.forEach { (_, placeable) -> - val y = Alignment.CenterVertically.align(placeable.height, boxHeight) - if (controlsOnRight) { - placeable.place(boxWidth - rightUsed - placeable.width, y) - rightUsed += placeable.width - } else { - placeable.place(leftUsed, y) - leftUsed += placeable.width - } - } - - // Start items: leading edge of the content direction - placeableGroups[Alignment.Start]?.forEach { (_, placeable) -> - val y = Alignment.CenterVertically.align(placeable.height, boxHeight) - if (contentIsRtl) { - placeable.place(boxWidth - rightUsed - placeable.width, y) - rightUsed += placeable.width - } else { - placeable.place(leftUsed, y) - leftUsed += placeable.width - } - } - - // Center items: clamped between occupied edges - val centerPlaceable = placeableGroups[Alignment.CenterHorizontally].orEmpty() - val requiredCenterSpace = centerPlaceable.sumOf { it.second.width } - val minX = leftUsed - val maxX = boxWidth - rightUsed - requiredCenterSpace - var centerX = (boxWidth - requiredCenterSpace) / 2 - - if (minX <= maxX) { - centerX = centerX.coerceIn(minX, maxX) - centerPlaceable.forEach { (_, placeable) -> - val y = Alignment.CenterVertically.align(placeable.height, boxHeight) - placeable.place(centerX, y) - centerX += placeable.width - } - } - } - } +@Suppress("FunctionNaming") +@Composable +fun DecoratedWindowScope.TitleBarImpl( + modifier: Modifier = Modifier, + gradientStartColor: Color = Color.Unspecified, + style: TitleBarStyle = LocalTitleBarStyle.current, + controlButtonsDirection: LayoutDirection = LocalLayoutDirection.current, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, + applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, + onPlace: (() -> Unit)? = null, + backgroundContent: @Composable () -> Unit = {}, + content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, +) { + GenericTitleBarImpl( + window = window, + state = state, + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = applyTitleBar, + onPlace = onPlace, + backgroundContent = backgroundContent, + content = content, + ) } +@Suppress("FunctionNaming") @Composable -fun rememberTitleBarMeasurePolicy( - window: Window, - state: DecoratedWindowState, - applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, +fun DecoratedWindowScope.TitleBarImpl( + modifier: Modifier = Modifier, + gradientStartColor: Color = Color.Unspecified, + style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: LayoutDirection = LocalLayoutDirection.current, + applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, onPlace: (() -> Unit)? = null, -): MeasurePolicy = - remember(window, state, applyTitleBar, controlButtonsDirection, onPlace) { - TitleBarMeasurePolicy(window, state, applyTitleBar, controlButtonsDirection, onPlace) - } + backgroundContent: @Composable () -> Unit = {}, + content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, +) { + TitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = TitleBarLayoutPolicy.Default, + applyTitleBar = applyTitleBar, + onPlace = onPlace, + backgroundContent = backgroundContent, + content = content, + ) +} @Stable interface TitleBarScope { @@ -309,7 +230,7 @@ interface TitleBarScope { * fullscreen on non-notch screens. * * Standard [clickable][androidx.compose.foundation.clickable] requires a - * complete Press → Release (tap) gesture. On some JDK/macOS combinations, + * complete Press -> Release (tap) gesture. On some JDK/macOS combinations, * the system injects phantom pointer-exit events between Press and Release * in fullscreen, which cancels the tap gesture and prevents `onClick` from * firing. diff --git a/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBarLayoutPolicy.kt b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBarLayoutPolicy.kt new file mode 100644 index 000000000..58492dcc4 --- /dev/null +++ b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBarLayoutPolicy.kt @@ -0,0 +1,458 @@ +package io.github.kdroidfilter.nucleus.window + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.offset +import java.awt.Window +import kotlin.math.max + +@Stable +interface TitleBarLayoutPolicy { + fun MeasureScope.prepareMeasure(scope: TitleBarMeasureScope): TitleBarMeasureResult + + fun MeasureScope.measureTitleBar(scope: TitleBarPlacementScope): MeasureResult + + companion object { + val Default: TitleBarLayoutPolicy = DefaultTitleBarLayoutPolicy + val FillCenter: TitleBarLayoutPolicy = FillCenterTitleBarLayoutPolicy + } +} + +@Stable +interface TitleBarMeasureScope { + val children: List + val constraints: Constraints + val layoutDirection: LayoutDirection + val controlButtonsDirection: LayoutDirection +} + +@Stable +interface TitleBarPlacementScope { + val children: List + val constraints: Constraints + val layoutDirection: LayoutDirection + val controlButtonsDirection: LayoutDirection + val contentPadding: PaddingValues + val measureResult: TitleBarMeasureResult +} + +@Stable +interface TitleBarMeasureResult { + val heightPx: Int +} + +@Stable +interface TitleBarLayoutChild { + val measurable: Measurable + val alignment: Alignment.Horizontal +} + +@Composable +fun rememberTitleBarMeasurePolicy( + window: Window, + state: DecoratedWindowState, + applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, + controlButtonsDirection: LayoutDirection, + layoutPolicy: TitleBarLayoutPolicy, +): MeasurePolicy = + remember(window, state, applyTitleBar, controlButtonsDirection, layoutPolicy) { + TitleBarLayoutAdapterMeasurePolicy( + state = state, + applyTitleBar = applyTitleBar, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + ) + } + +@Composable +fun rememberTitleBarMeasurePolicy( + window: Window, + state: DecoratedWindowState, + applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, + controlButtonsDirection: LayoutDirection = LocalLayoutDirection.current, + onPlace: (() -> Unit)? = null, +): MeasurePolicy = + remember(window, state, applyTitleBar, controlButtonsDirection, onPlace) { + TitleBarMeasurePolicy( + window = window, + state = state, + applyTitleBar = applyTitleBar, + controlButtonsDirection = controlButtonsDirection, + onPlace = onPlace, + ) + } + +class TitleBarMeasurePolicy( + window: Window, + private val state: DecoratedWindowState, + private val applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, + private val controlButtonsDirection: LayoutDirection, + private val onPlace: (() -> Unit)? = null, +) : MeasurePolicy { + private val delegate = + TitleBarLayoutAdapterMeasurePolicy( + state = state, + applyTitleBar = applyTitleBar, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = TitleBarLayoutPolicy.Default, + ) + + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints, + ): MeasureResult { + val measureResult = with(delegate) { measure(measurables, constraints) } + return object : MeasureResult { + override val width: Int = measureResult.width + override val height: Int = measureResult.height + override val alignmentLines: Map = measureResult.alignmentLines + + override fun placeChildren() { + measureResult.placeChildren() + onPlace?.invoke() + } + } + } +} + +private class TitleBarLayoutAdapterMeasurePolicy( + private val state: DecoratedWindowState, + private val applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, + private val controlButtonsDirection: LayoutDirection, + private val layoutPolicy: TitleBarLayoutPolicy, +) : MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints, + ): MeasureResult { + if (measurables.isEmpty()) { + return layout(width = constraints.minWidth, height = constraints.minHeight) {} + } + + val children = + measurables.map { measurable -> + TitleBarLayoutChildImpl( + measurable = measurable, + alignment = + (measurable.parentData as? TitleBarChildDataNode)?.horizontalAlignment + ?: Alignment.CenterHorizontally, + ) + } + + val prepareScope = + TitleBarMeasureScopeImpl( + children = children, + constraints = constraints, + layoutDirection = layoutDirection, + controlButtonsDirection = controlButtonsDirection, + ) + val measureResult = with(layoutPolicy) { prepareMeasure(prepareScope) } + val contentPadding = applyTitleBar(measureResult.heightPx.toDp(), state) + + val placementScope = + TitleBarPlacementScopeImpl( + children = children, + constraints = constraints, + layoutDirection = layoutDirection, + controlButtonsDirection = controlButtonsDirection, + contentPadding = contentPadding, + measureResult = measureResult, + ) + return with(layoutPolicy) { measureTitleBar(placementScope) } + } +} + +private data class TitleBarLayoutChildImpl( + override val measurable: Measurable, + override val alignment: Alignment.Horizontal, +) : TitleBarLayoutChild + +private data class TitleBarMeasureScopeImpl( + override val children: List, + override val constraints: Constraints, + override val layoutDirection: LayoutDirection, + override val controlButtonsDirection: LayoutDirection, +) : TitleBarMeasureScope + +private data class TitleBarPlacementScopeImpl( + override val children: List, + override val constraints: Constraints, + override val layoutDirection: LayoutDirection, + override val controlButtonsDirection: LayoutDirection, + override val contentPadding: PaddingValues, + override val measureResult: TitleBarMeasureResult, +) : TitleBarPlacementScope + +private data class MeasuredLayoutChild( + val child: TitleBarLayoutChild, + val placeable: Placeable, +) + +private object DefaultTitleBarLayoutPolicy : TitleBarLayoutPolicy { + override fun MeasureScope.prepareMeasure(scope: TitleBarMeasureScope): TitleBarMeasureResult { + var maxSpaceVertically = scope.constraints.minHeight + val contentConstraints = scope.constraints.copy(minWidth = 0, minHeight = 0) + + // Two-pass measurement: End items are measured independently so they + // do not reduce the available width for Start/Center items. + val endMeasurables = mutableListOf() + val otherMeasurables = mutableListOf() + + var endOccupied = 0 + for (child in scope.children) { + if (child.alignment != Alignment.End) continue + val placeable = child.measurable.measure(contentConstraints.offset(horizontal = -endOccupied)) + endOccupied += placeable.width + maxSpaceVertically = max(maxSpaceVertically, placeable.height) + endMeasurables += MeasuredLayoutChild(child, placeable) + } + + var otherOccupied = 0 + @Suppress("LoopWithTooManyJumpStatements") + for (child in scope.children) { + if (child.alignment == Alignment.End) continue + val placeable = child.measurable.measure(contentConstraints.offset(horizontal = -otherOccupied)) + if (scope.constraints.maxWidth < otherOccupied + placeable.width) break + otherOccupied += placeable.width + maxSpaceVertically = max(maxSpaceVertically, placeable.height) + otherMeasurables += MeasuredLayoutChild(child, placeable) + } + + return DefaultTitleBarMeasureResult( + heightPx = maxSpaceVertically, + measuredPlaceables = endMeasurables + otherMeasurables, + endOccupied = endOccupied, + otherOccupied = otherOccupied, + ) + } + + override fun MeasureScope.measureTitleBar(scope: TitleBarPlacementScope): MeasureResult { + val result = + scope.measureResult as? DefaultTitleBarMeasureResult + ?: error("TitleBarLayoutPolicy.Default requires DefaultTitleBarMeasureResult") + + val leftInset = scope.contentPadding.calculateLeftPadding(LayoutDirection.Ltr).roundToPx() + val rightInset = scope.contentPadding.calculateRightPadding(LayoutDirection.Ltr).roundToPx() + + val occupiedSpaceHorizontally = result.endOccupied + result.otherOccupied + leftInset + rightInset + val boxWidth = maxOf(scope.constraints.minWidth, occupiedSpaceHorizontally) + val boxHeight = result.heightPx + + return layout(boxWidth, boxHeight) { + val placeableGroups = result.measuredPlaceables.groupBy { it.child.alignment } + + val contentIsRtl = scope.layoutDirection == LayoutDirection.Rtl + val controlsOnRight = scope.controlButtonsDirection == LayoutDirection.Ltr + + var leftUsed = leftInset + var rightUsed = rightInset + + placeableGroups[Alignment.End].orEmpty().forEach { measured -> + val placeable = measured.placeable + val y = Alignment.CenterVertically.align(placeable.height, boxHeight) + if (controlsOnRight) { + placeable.place(boxWidth - rightUsed - placeable.width, y) + rightUsed += placeable.width + } else { + placeable.place(leftUsed, y) + leftUsed += placeable.width + } + } + + placeableGroups[Alignment.Start].orEmpty().forEach { measured -> + val placeable = measured.placeable + val y = Alignment.CenterVertically.align(placeable.height, boxHeight) + if (contentIsRtl) { + placeable.place(boxWidth - rightUsed - placeable.width, y) + rightUsed += placeable.width + } else { + placeable.place(leftUsed, y) + leftUsed += placeable.width + } + } + + val centerPlaceables = placeableGroups[Alignment.CenterHorizontally].orEmpty() + val requiredCenterSpace = centerPlaceables.sumOf { it.placeable.width } + val minX = leftUsed + val maxX = boxWidth - rightUsed - requiredCenterSpace + var centerX = (boxWidth - requiredCenterSpace) / 2 + + if (minX <= maxX) { + centerX = centerX.coerceIn(minX, maxX) + centerPlaceables.forEach { measured -> + val placeable = measured.placeable + val y = Alignment.CenterVertically.align(placeable.height, boxHeight) + placeable.place(centerX, y) + centerX += placeable.width + } + } + } + } +} + +private data class DefaultTitleBarMeasureResult( + override val heightPx: Int, + val measuredPlaceables: List, + val endOccupied: Int, + val otherOccupied: Int, +) : TitleBarMeasureResult + +private object FillCenterTitleBarLayoutPolicy : TitleBarLayoutPolicy { + override fun MeasureScope.prepareMeasure(scope: TitleBarMeasureScope): TitleBarMeasureResult { + var maxSpaceVertically = scope.constraints.minHeight + val contentConstraints = scope.constraints.copy(minWidth = 0, minHeight = 0) + + val endMeasurables = mutableListOf() + val startMeasurables = mutableListOf() + var endOccupied = 0 + var startOccupied = 0 + var centerCount = 0 + var centerMeasurable: Measurable? = null + + for (child in scope.children) { + when (child.alignment) { + Alignment.End -> { + val placeable = child.measurable.measure(contentConstraints.offset(horizontal = -endOccupied)) + endOccupied += placeable.width + maxSpaceVertically = max(maxSpaceVertically, placeable.height) + endMeasurables += MeasuredLayoutChild(child, placeable) + } + + Alignment.Start -> { + val placeable = child.measurable.measure(contentConstraints.offset(horizontal = -startOccupied)) + startOccupied += placeable.width + maxSpaceVertically = max(maxSpaceVertically, placeable.height) + startMeasurables += MeasuredLayoutChild(child, placeable) + } + + Alignment.CenterHorizontally -> { + centerCount += 1 + centerMeasurable = child.measurable + } + } + } + + require(centerCount <= 1) { + "TitleBarLayoutPolicy.FillCenter supports at most one " + + "Alignment.CenterHorizontally child, but found $centerCount." + } + + if (centerMeasurable != null && scope.constraints.minHeight != scope.constraints.maxHeight) { + // Compose only allows a Measurable to be measured once in a layout pass. + // Use intrinsic height here and defer the actual measurement to measureTitleBar. + val centerHeightCandidate = centerMeasurable.maxIntrinsicHeight(scope.constraints.maxWidth) + maxSpaceVertically = max(maxSpaceVertically, centerHeightCandidate) + } + + return FillCenterTitleBarMeasureResult( + heightPx = maxSpaceVertically, + startMeasuredPlaceables = startMeasurables, + endMeasuredPlaceables = endMeasurables, + startOccupied = startOccupied, + endOccupied = endOccupied, + centerMeasurable = centerMeasurable, + ) + } + + override fun MeasureScope.measureTitleBar(scope: TitleBarPlacementScope): MeasureResult { + val result = + scope.measureResult as? FillCenterTitleBarMeasureResult + ?: error("TitleBarLayoutPolicy.FillCenter requires FillCenterTitleBarMeasureResult") + + val leftInset = scope.contentPadding.calculateLeftPadding(LayoutDirection.Ltr).roundToPx() + val rightInset = scope.contentPadding.calculateRightPadding(LayoutDirection.Ltr).roundToPx() + val occupiedSpaceHorizontally = result.startOccupied + result.endOccupied + leftInset + rightInset + val boxWidth = maxOf(scope.constraints.minWidth, occupiedSpaceHorizontally) + + val contentIsRtl = scope.layoutDirection == LayoutDirection.Rtl + val controlsOnRight = scope.controlButtonsDirection == LayoutDirection.Ltr + + var leftUsed = leftInset + var rightUsed = rightInset + + result.endMeasuredPlaceables.forEach { measured -> + if (controlsOnRight) { + rightUsed += measured.placeable.width + } else { + leftUsed += measured.placeable.width + } + } + + result.startMeasuredPlaceables.forEach { measured -> + if (contentIsRtl) { + rightUsed += measured.placeable.width + } else { + leftUsed += measured.placeable.width + } + } + + val centerLaneX = leftUsed + val centerLaneWidth = maxOf(0, boxWidth - leftUsed - rightUsed) + val centerPlaceable = + result.centerMeasurable?.measure( + Constraints( + minWidth = centerLaneWidth, + maxWidth = centerLaneWidth, + minHeight = 0, + maxHeight = scope.constraints.maxHeight, + ), + ) + val boxHeight = max(result.heightPx, centerPlaceable?.height ?: 0) + + return layout(boxWidth, boxHeight) { + var leftPlaced = leftInset + var rightPlaced = rightInset + + result.endMeasuredPlaceables.forEach { measured -> + val placeable = measured.placeable + val y = Alignment.CenterVertically.align(placeable.height, boxHeight) + if (controlsOnRight) { + placeable.place(boxWidth - rightPlaced - placeable.width, y) + rightPlaced += placeable.width + } else { + placeable.place(leftPlaced, y) + leftPlaced += placeable.width + } + } + + result.startMeasuredPlaceables.forEach { measured -> + val placeable = measured.placeable + val y = Alignment.CenterVertically.align(placeable.height, boxHeight) + if (contentIsRtl) { + placeable.place(boxWidth - rightPlaced - placeable.width, y) + rightPlaced += placeable.width + } else { + placeable.place(leftPlaced, y) + leftPlaced += placeable.width + } + } + + centerPlaceable?.let { placeable -> + val y = Alignment.CenterVertically.align(placeable.height, boxHeight) + placeable.place(centerLaneX, y) + } + } + } +} + +private data class FillCenterTitleBarMeasureResult( + override val heightPx: Int, + val startMeasuredPlaceables: List, + val endMeasuredPlaceables: List, + val startOccupied: Int, + val endOccupied: Int, + val centerMeasurable: Measurable?, +) : TitleBarMeasureResult diff --git a/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/WindowControlsSide.kt b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/WindowControlsSide.kt new file mode 100644 index 000000000..e87d76441 --- /dev/null +++ b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/WindowControlsSide.kt @@ -0,0 +1,11 @@ +package io.github.kdroidfilter.nucleus.window + +import androidx.compose.runtime.staticCompositionLocalOf + +enum class WindowControlsSide { + Start, + End, + Unspecified, +} + +val LocalWindowControlsSide = staticCompositionLocalOf { WindowControlsSide.Unspecified } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt index 308acd463..2674d6627 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt @@ -2,6 +2,7 @@ package io.github.kdroidfilter.nucleus.window import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -9,6 +10,7 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.jetbrains.JBR import io.github.kdroidfilter.nucleus.core.runtime.LinuxDesktopEnvironment @@ -23,33 +25,41 @@ internal fun DecoratedDialogScope.LinuxDialogTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { val linuxStyle = createLinuxTitleBarStyle(style) val dialogState = state + val controlDir = controlButtonsDirection.resolve() + val controlsSide = if (controlDir == LayoutDirection.Rtl) WindowControlsSide.Start else WindowControlsSide.End - DialogTitleBarImpl( - modifier = - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { changed -> !changed.isConsumed } - ) { - JBR.getWindowMove()?.startMovingTogetherWithMouse(window, MouseEvent.BUTTON1) - } + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + DialogTitleBarImpl( + modifier = + modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { + if ( + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { changed -> !changed.isConsumed } + ) { + JBR.getWindowMove()?.startMovingTogetherWithMouse(window, MouseEvent.BUTTON1) + } + }, + gradientStartColor = gradientStartColor, + style = linuxStyle, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> + val padding = + if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { + PaddingValues(end = 4.dp) + } else { + PaddingValues(0.dp) + } + padding }, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> - if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { - PaddingValues(end = 4.dp) - } else { - PaddingValues(0.dp) - } - }, - ) { _ -> - DialogCloseButton(window, dialogState, linuxStyle) - content(dialogState) + ) { _ -> + DialogCloseButton(window, dialogState, linuxStyle) + content(dialogState) + } } } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt index da6963fad..979ac9e26 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt @@ -2,6 +2,7 @@ package io.github.kdroidfilter.nucleus.window import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -19,6 +20,7 @@ internal fun DecoratedDialogScope.MacOSDialogTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { val titleBar = remember { JBR.getWindowDecorations().createCustomTitleBar() } @@ -27,18 +29,22 @@ internal fun DecoratedDialogScope.MacOSDialogTitleBar( val controlDir = controlButtonsDirection.resolve() val isRtl = controlDir == LayoutDirection.Rtl + val controlsSide = if (isRtl) WindowControlsSide.Start else WindowControlsSide.End - DialogTitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - applyTitleBar = { height, _ -> - titleBar.putProperty("controls.rtl", isRtl) - titleBar.height = height.value - JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) - PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) - }, - content = content, - ) + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + DialogTitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { height, _ -> + titleBar.putProperty("controls.rtl", isRtl) + titleBar.height = height.value + JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) + PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) + }, + content = content, + ) + } } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Windows.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Windows.kt index 3f1174e58..a844f8fbb 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Windows.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Windows.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -22,6 +23,7 @@ internal fun DecoratedDialogScope.WindowsDialogTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { val titleBar = remember { JBR.getWindowDecorations().createCustomTitleBar() } @@ -30,24 +32,30 @@ internal fun DecoratedDialogScope.WindowsDialogTitleBar( val controlDir = controlButtonsDirection.resolve() val isRtl = controlDir == LayoutDirection.Rtl + val controlsSide = if (isRtl) WindowControlsSide.Start else WindowControlsSide.End - DialogTitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - applyTitleBar = { height, _ -> - titleBar.putProperty("controls.rtl", isRtl) - titleBar.height = height.value - titleBar.putProperty("controls.dark", style.colors.background.isDark()) - JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) - if (isRtl) { - PaddingValues(start = titleBar.rightInset.dp, end = titleBar.leftInset.dp) - } else { - PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) - } - }, - backgroundContent = { Spacer(modifier = Modifier.fillMaxSize()) }, - content = content, - ) + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + DialogTitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { height, _ -> + titleBar.putProperty("controls.rtl", isRtl) + titleBar.height = height.value + titleBar.putProperty("controls.dark", style.colors.background.isDark()) + JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) + val padding = + if (isRtl) { + PaddingValues(start = titleBar.rightInset.dp, end = titleBar.leftInset.dp) + } else { + PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) + } + padding + }, + backgroundContent = { Spacer(modifier = Modifier.fillMaxSize()) }, + content = content, + ) + } } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.kt index 42f4854e6..da01d8790 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.kt @@ -18,6 +18,26 @@ fun DecoratedDialogScope.DialogTitleBar( style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, +) { + BasicDialogTitleBar( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = TitleBarLayoutPolicy.Default, + content = content, + ) +} + +@Suppress("FunctionNaming") +@Composable +fun DecoratedDialogScope.BasicDialogTitleBar( + modifier: Modifier = Modifier, + gradientStartColor: Color = Color.Unspecified, + style: TitleBarStyle = LocalTitleBarStyle.current, + controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, + content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { val dialogTitleBarInfo = LocalDialogTitleBarInfo.current val titleBarInfo = remember { TitleBarInfo(dialogTitleBarInfo.title, dialogTitleBarInfo.icon) } @@ -28,11 +48,32 @@ fun DecoratedDialogScope.DialogTitleBar( ) { when (Platform.Current) { Platform.Linux -> - LinuxDialogTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, content) + LinuxDialogTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + content, + ) Platform.Windows -> - WindowsDialogTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, content) + WindowsDialogTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + content, + ) Platform.MacOS -> - MacOSDialogTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, content) + MacOSDialogTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + content, + ) Platform.Unknown -> error("DialogTitleBar is not supported on this platform(${System.getProperty("os.name")})") } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Linux.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Linux.kt index 33529090c..933c93945 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Linux.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Linux.kt @@ -1,6 +1,7 @@ package io.github.kdroidfilter.nucleus.window import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -11,6 +12,7 @@ import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.platform.LocalViewConfiguration import com.jetbrains.JBR import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle +import io.github.kdroidfilter.nucleus.window.utils.linux.rememberLinuxButtonLayout import java.awt.Frame import java.awt.event.MouseEvent @@ -22,42 +24,49 @@ internal fun DecoratedWindowScope.LinuxTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { val linuxStyle = createLinuxTitleBarStyle(style) + val controlDir = controlButtonsDirection.resolve() + val controlsOnRight = rememberLinuxButtonLayout().controlsOnRight + val controlsSide = if (controlsOnRight) WindowControlsSide.End else WindowControlsSide.Start var lastPress = 0L val viewConfig = LocalViewConfiguration.current - TitleBarImpl( - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { changed -> !changed.isConsumed } - ) { - JBR.getWindowMove()?.startMovingTogetherWithMouse(window, MouseEvent.BUTTON1) + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { if ( - System.currentTimeMillis() - lastPress in - viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { changed -> !changed.isConsumed } ) { - if (state.isMaximized) { - window.extendedState = Frame.NORMAL - } else { - window.extendedState = Frame.MAXIMIZED_BOTH + JBR.getWindowMove()?.startMovingTogetherWithMouse(window, MouseEvent.BUTTON1) + if ( + System.currentTimeMillis() - lastPress in + viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis + ) { + if (state.isMaximized) { + window.extendedState = Frame.NORMAL + } else { + window.extendedState = Frame.MAXIMIZED_BOTH + } } + lastPress = System.currentTimeMillis() } - lastPress = System.currentTimeMillis() - } - }, - gradientStartColor, - linuxStyle, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> - kdePaddingForButtonLayout() - }, - backgroundContent = backgroundContent, - ) { currentState -> - WindowControlArea(window, currentState, linuxStyle) - content(currentState) + }, + gradientStartColor, + linuxStyle, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> + kdePaddingForButtonLayout() + }, + backgroundContent = backgroundContent, + ) { currentState -> + WindowControlArea(window, currentState, linuxStyle) + content(currentState) + } } } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt index 5c4741e23..93bef633a 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt @@ -2,6 +2,7 @@ package io.github.kdroidfilter.nucleus.window import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -61,6 +62,7 @@ internal fun DecoratedWindowScope.MacOSTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { @@ -91,33 +93,39 @@ internal fun DecoratedWindowScope.MacOSTitleBar( val controlDir = controlButtonsDirection.resolve() val controlIsRtl = controlDir == LayoutDirection.Rtl + val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - applyTitleBar = { height, titleBarState -> - titleBar.putProperty("controls.rtl", controlIsRtl) - titleBar.height = height.value - JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { height, titleBarState -> + titleBar.putProperty("controls.rtl", controlIsRtl) + titleBar.height = height.value + JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) - if (titleBarState.isFullscreen && newFullscreenControls) { - if (controlIsRtl) { - PaddingValues(end = 80.dp) - } else { - PaddingValues(start = 80.dp) + val padding = + if (titleBarState.isFullscreen && newFullscreenControls) { + if (controlIsRtl) { + PaddingValues(end = 80.dp) + } else { + PaddingValues(start = 80.dp) + } + } else { + PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) + } + padding + }, + onPlace = { + if (state.isFullscreen) { + MacUtil.updateFullScreenButtons(window) } - } else { - PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) - } - }, - onPlace = { - if (state.isFullscreen) { - MacUtil.updateFullScreenButtons(window) - } - }, - backgroundContent = backgroundContent, - content = content, - ) + }, + backgroundContent = backgroundContent, + content = content, + ) + } } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt index a3634ebd9..a91f560d0 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -22,6 +23,7 @@ internal fun DecoratedWindowScope.WindowsTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { @@ -31,23 +33,28 @@ internal fun DecoratedWindowScope.WindowsTitleBar( val controlDir = controlButtonsDirection.resolve() val controlIsRtl = controlDir == LayoutDirection.Rtl - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - applyTitleBar = { height, _ -> - titleBar.putProperty("controls.rtl", controlIsRtl) - titleBar.height = height.value - titleBar.putProperty("controls.dark", style.colors.background.isDark()) - JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) - PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) - }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize()) - backgroundContent() - }, - ) { state -> - content(state) + val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End + + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { height, _ -> + titleBar.putProperty("controls.rtl", controlIsRtl) + titleBar.height = height.value + titleBar.putProperty("controls.dark", style.colors.background.isDark()) + JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) + PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) + }, + backgroundContent = { + Spacer(modifier = Modifier.fillMaxSize()) + backgroundContent() + }, + ) { state -> + content(state) + } } } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.kt index 958c2f28a..6620511d4 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.kt @@ -24,14 +24,60 @@ fun DecoratedWindowScope.TitleBar( controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, +) { + BasicTitleBar( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = TitleBarLayoutPolicy.Default, + backgroundContent = backgroundContent, + content = content, + ) +} + +@Suppress("FunctionNaming") +@Composable +fun DecoratedWindowScope.BasicTitleBar( + modifier: Modifier = Modifier, + gradientStartColor: Color = Color.Unspecified, + style: TitleBarStyle = LocalTitleBarStyle.current, + controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, + backgroundContent: @Composable () -> Unit = {}, + content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { when (Platform.Current) { Platform.Linux -> - LinuxTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, backgroundContent, content) + LinuxTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + backgroundContent, + content, + ) Platform.Windows -> - WindowsTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, backgroundContent, content) + WindowsTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + backgroundContent, + content, + ) Platform.MacOS -> - MacOSTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, backgroundContent, content) + MacOSTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + backgroundContent, + content, + ) Platform.Unknown -> error("TitleBar is not supported on this platform(${System.getProperty("os.name")})") } diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt index de9c8b3e2..0bfb7bb1c 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -11,6 +12,7 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.github.kdroidfilter.nucleus.core.runtime.LinuxDesktopEnvironment import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle @@ -25,12 +27,32 @@ internal fun DecoratedDialogScope.LinuxDialogTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { + val controlDir = controlButtonsDirection.resolve() + val controlsSide = if (controlDir == LayoutDirection.Rtl) WindowControlsSide.Start else WindowControlsSide.End + if (JniLinuxWindowBridge.isLoaded) { - NativeLinuxDialogTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, content) + NativeLinuxDialogTitleBar( + modifier, + gradientStartColor, + style, + controlDir, + layoutPolicy, + controlsSide, + content, + ) } else { - FallbackLinuxDialogTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, content) + FallbackLinuxDialogTitleBar( + modifier, + gradientStartColor, + style, + controlDir, + layoutPolicy, + controlsSide, + content, + ) } } @@ -43,51 +65,58 @@ private fun DecoratedDialogScope.NativeLinuxDialogTitleBar( modifier: Modifier, gradientStartColor: Color, style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection, + controlButtonsDirection: LayoutDirection, + layoutPolicy: TitleBarLayoutPolicy, + controlsSide: WindowControlsSide, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit, ) { val linuxStyle = createLinuxTitleBarStyle(style) val dialogState = state - DialogTitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> - if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { - PaddingValues(end = 4.dp) - } else { - PaddingValues(0.dp) - } - }, - backgroundContent = { - Spacer( - modifier = - Modifier - .fillMaxSize() - .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - // Initiate native WM move - val mouseLocation = MouseInfo.getPointerInfo()?.location - if (mouseLocation != null) { - JniLinuxWindowBridge.nativeStartWindowMove( - window, - mouseLocation.x, - mouseLocation.y, - 1, - ) + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + DialogTitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = linuxStyle, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> + val padding = + if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { + PaddingValues(end = 4.dp) + } else { + PaddingValues(0.dp) + } + padding + }, + backgroundContent = { + Spacer( + modifier = + Modifier + .fillMaxSize() + .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { + if ( + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { !it.isConsumed } + ) { + // Initiate native WM move + val mouseLocation = MouseInfo.getPointerInfo()?.location + if (mouseLocation != null) { + JniLinuxWindowBridge.nativeStartWindowMove( + window, + mouseLocation.x, + mouseLocation.y, + 1, + ) + } } - } - }, - ) - }, - ) { _ -> - DialogCloseButton(window, dialogState, linuxStyle) - content(dialogState) + }, + ) + }, + ) { _ -> + DialogCloseButton(window, dialogState, linuxStyle) + content(dialogState) + } } } @@ -99,39 +128,45 @@ private fun DecoratedDialogScope.FallbackLinuxDialogTitleBar( modifier: Modifier, gradientStartColor: Color, style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection, + controlButtonsDirection: LayoutDirection, + layoutPolicy: TitleBarLayoutPolicy, + controlsSide: WindowControlsSide, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit, ) { val linuxStyle = createLinuxTitleBarStyle(style) val dialogState = state - DialogTitleBarImpl( - modifier = - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - // No double-click behavior for dialogs — just consume primary presses - // so the drag handler below can start dragging. - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - // Intentional no-op: drag is handled by the background Spacer. - } + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + DialogTitleBarImpl( + modifier = + modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { + // No double-click behavior for dialogs, drag is handled by the background Spacer. + if ( + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { !it.isConsumed } + ) { + // Intentional no-op. + } + }, + gradientStartColor = gradientStartColor, + style = linuxStyle, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> + val padding = + if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { + PaddingValues(end = 4.dp) + } else { + PaddingValues(0.dp) + } + padding + }, + backgroundContent = { + Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) }, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> - if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { - PaddingValues(end = 4.dp) - } else { - PaddingValues(0.dp) - } - }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) - }, - ) { _ -> - DialogCloseButton(window, dialogState, linuxStyle) - content(dialogState) + ) { _ -> + DialogCloseButton(window, dialogState, linuxStyle) + content(dialogState) + } } } diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt index 49ab5ac40..4d85faf93 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt @@ -4,9 +4,11 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.github.kdroidfilter.nucleus.window.styling.LocalTitleBarStyle import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle @@ -20,8 +22,13 @@ internal fun DecoratedDialogScope.MacOSDialogTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { + val controlDir = controlButtonsDirection.resolve() + val controlIsRtl = controlDir == LayoutDirection.Rtl + val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End + DisposableEffect(window) { onDispose { val ptr = JniMacWindowUtil.getWindowPtr(window) @@ -29,29 +36,33 @@ internal fun DecoratedDialogScope.MacOSDialogTitleBar( } } - DialogTitleBarImpl( - modifier = modifier.titleBarHitTestHandler(window), - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { height, _ -> - JniMacWindowUtil.applyWindowProperties(window) + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + DialogTitleBarImpl( + modifier = modifier.titleBarHitTestHandler(window), + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { height, _ -> + JniMacWindowUtil.applyWindowProperties(window) - val ptr = JniMacWindowUtil.getWindowPtr(window) - val leftInset = - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeApplyTitleBar(ptr, height.value) - } else { - @Suppress("MagicNumber") - val shrink = minOf(height.value / 28f, 1f) - @Suppress("MagicNumber") - height.value + 2f * shrink * 20f - } - PaddingValues(start = leftInset.dp) - }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize()) - }, - content = content, - ) + val ptr = JniMacWindowUtil.getWindowPtr(window) + val leftInset = + if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { + JniMacTitleBarBridge.nativeApplyTitleBar(ptr, height.value) + } else { + @Suppress("MagicNumber") + val shrink = minOf(height.value / 28f, 1f) + @Suppress("MagicNumber") + height.value + 2f * shrink * 20f + } + val padding = PaddingValues(start = leftInset.dp) + padding + }, + backgroundContent = { + Spacer(modifier = Modifier.fillMaxSize()) + }, + content = content, + ) + } } diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Windows.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Windows.kt index f2ea98630..9c6f14267 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Windows.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Windows.kt @@ -4,11 +4,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.github.kdroidfilter.nucleus.window.styling.LocalTitleBarStyle import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle @@ -22,8 +24,12 @@ internal fun DecoratedDialogScope.WindowsDialogTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { + val controlDir = controlButtonsDirection.resolve() + val controlsSide = if (controlDir == LayoutDirection.Rtl) WindowControlsSide.Start else WindowControlsSide.End + if (JniWindowsDecorationBridge.isLoaded) { DisposableEffect(window) { val hwnd = JniWindowsWindowUtil.getHwnd(window) @@ -43,17 +49,20 @@ internal fun DecoratedDialogScope.WindowsDialogTitleBar( } } - DialogTitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> PaddingValues(0.dp) }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) - }, - ) { dialogState -> - WindowsDialogCloseButton(window, dialogState, style) - content(dialogState) + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + DialogTitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> PaddingValues(0.dp) }, + backgroundContent = { + Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) + }, + ) { dialogState -> + WindowsDialogCloseButton(window, dialogState, style) + content(dialogState) + } } } diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.kt index 42f4854e6..da01d8790 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.kt @@ -18,6 +18,26 @@ fun DecoratedDialogScope.DialogTitleBar( style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, +) { + BasicDialogTitleBar( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = TitleBarLayoutPolicy.Default, + content = content, + ) +} + +@Suppress("FunctionNaming") +@Composable +fun DecoratedDialogScope.BasicDialogTitleBar( + modifier: Modifier = Modifier, + gradientStartColor: Color = Color.Unspecified, + style: TitleBarStyle = LocalTitleBarStyle.current, + controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, + content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { val dialogTitleBarInfo = LocalDialogTitleBarInfo.current val titleBarInfo = remember { TitleBarInfo(dialogTitleBarInfo.title, dialogTitleBarInfo.icon) } @@ -28,11 +48,32 @@ fun DecoratedDialogScope.DialogTitleBar( ) { when (Platform.Current) { Platform.Linux -> - LinuxDialogTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, content) + LinuxDialogTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + content, + ) Platform.Windows -> - WindowsDialogTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, content) + WindowsDialogTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + content, + ) Platform.MacOS -> - MacOSDialogTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, content) + MacOSDialogTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + content, + ) Platform.Unknown -> error("DialogTitleBar is not supported on this platform(${System.getProperty("os.name")})") } diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Linux.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Linux.kt index 8e8de03e5..7c847977a 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Linux.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Linux.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.currentCompositionLocalContext import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -13,9 +14,11 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle import io.github.kdroidfilter.nucleus.window.utils.linux.JniLinuxWindowBridge +import io.github.kdroidfilter.nucleus.window.utils.linux.rememberLinuxButtonLayout import java.awt.Frame import java.awt.MouseInfo @@ -27,13 +30,36 @@ internal fun DecoratedWindowScope.LinuxTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { + val controlDir = controlButtonsDirection.resolve() + val controlsOnRight = rememberLinuxButtonLayout().controlsOnRight + val controlsSide = if (controlsOnRight) WindowControlsSide.End else WindowControlsSide.Start + if (JniLinuxWindowBridge.isLoaded) { - NativeLinuxTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, backgroundContent, content) + NativeLinuxTitleBar( + modifier, + gradientStartColor, + style, + controlDir, + layoutPolicy, + controlsSide, + backgroundContent, + content, + ) } else { - FallbackLinuxTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, backgroundContent, content) + FallbackLinuxTitleBar( + modifier, + gradientStartColor, + style, + controlDir, + layoutPolicy, + controlsSide, + backgroundContent, + content, + ) } } @@ -47,7 +73,9 @@ private fun DecoratedWindowScope.NativeLinuxTitleBar( modifier: Modifier, gradientStartColor: Color, style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection, + controlButtonsDirection: LayoutDirection, + layoutPolicy: TitleBarLayoutPolicy, + controlsSide: WindowControlsSide, backgroundContent: @Composable () -> Unit, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, ) { @@ -66,21 +94,24 @@ private fun DecoratedWindowScope.NativeLinuxTitleBar( holder.compositionLocalContext = currentCompositionLocalContext holder.titleBarHeight = linuxStyle.metrics.height holder.content = { - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> PaddingValues(0.dp) }, - ) { currentState -> - WindowControlArea( - window = window, - state = currentState, + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, style = linuxStyle, - isFullscreen = true, - onExitFullscreen = onExitFullscreen, - ) - content(currentState) + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> PaddingValues(0.dp) }, + ) { currentState -> + WindowControlArea( + window = window, + state = currentState, + style = linuxStyle, + isFullscreen = true, + onExitFullscreen = onExitFullscreen, + ) + content(currentState) + } } } } @@ -88,60 +119,66 @@ private fun DecoratedWindowScope.NativeLinuxTitleBar( } // ── Normal title bar (or fullscreen without newFullscreenControls) ── - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> - kdePaddingForButtonLayout() - }, - backgroundContent = { - backgroundContent() - Spacer( - modifier = - Modifier - .fillMaxSize() - .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - val elapsed = now - lastPressTime - if (elapsed in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { - // Double-click: toggle maximize - if (state.isMaximized) { - window.extendedState = Frame.NORMAL + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = linuxStyle, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> + kdePaddingForButtonLayout() + }, + backgroundContent = { + backgroundContent() + Spacer( + modifier = + Modifier + .fillMaxSize() + .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { + if ( + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { !it.isConsumed } + ) { + val now = System.currentTimeMillis() + val elapsed = now - lastPressTime + if ( + elapsed in + viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis + ) { + // Double-click: toggle maximize + if (state.isMaximized) { + window.extendedState = Frame.NORMAL + } else { + window.extendedState = Frame.MAXIMIZED_BOTH + } } else { - window.extendedState = Frame.MAXIMIZED_BOTH - } - } else { - // Single press: initiate native WM move - val mouseLocation = MouseInfo.getPointerInfo()?.location - if (mouseLocation != null) { - JniLinuxWindowBridge.nativeStartWindowMove( - window, - mouseLocation.x, - mouseLocation.y, - 1, - ) + // Single press: initiate native WM move + val mouseLocation = MouseInfo.getPointerInfo()?.location + if (mouseLocation != null) { + JniLinuxWindowBridge.nativeStartWindowMove( + window, + mouseLocation.x, + mouseLocation.y, + 1, + ) + } } + lastPressTime = now } - lastPressTime = now - } - }, + }, + ) + }, + ) { currentState -> + WindowControlArea( + window = window, + state = currentState, + style = linuxStyle, + isFullscreen = isNativeFullscreen, + onExitFullscreen = onExitFullscreen, ) - }, - ) { currentState -> - WindowControlArea( - window = window, - state = currentState, - style = linuxStyle, - isFullscreen = isNativeFullscreen, - onExitFullscreen = onExitFullscreen, - ) - content(currentState) + content(currentState) + } } } @@ -153,7 +190,9 @@ private fun DecoratedWindowScope.FallbackLinuxTitleBar( modifier: Modifier, gradientStartColor: Color, style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection, + controlButtonsDirection: LayoutDirection, + layoutPolicy: TitleBarLayoutPolicy, + controlsSide: WindowControlsSide, backgroundContent: @Composable () -> Unit, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, ) { @@ -162,37 +201,40 @@ private fun DecoratedWindowScope.FallbackLinuxTitleBar( var lastPress = 0L - TitleBarImpl( - // Detect double-click to maximize/restore on the title bar area - modifier = - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - if (now - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { - if (state.isMaximized) { - window.extendedState = Frame.NORMAL - } else { - window.extendedState = Frame.MAXIMIZED_BOTH + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + // Detect double-click to maximize/restore on the title bar area + modifier = + modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { + if ( + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { !it.isConsumed } + ) { + val now = System.currentTimeMillis() + if (now - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { + if (state.isMaximized) { + window.extendedState = Frame.NORMAL + } else { + window.extendedState = Frame.MAXIMIZED_BOTH + } } + lastPress = now } - lastPress = now - } + }, + gradientStartColor = gradientStartColor, + style = linuxStyle, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> + kdePaddingForButtonLayout() }, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> - kdePaddingForButtonLayout() - }, - backgroundContent = { - backgroundContent() - Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) - }, - ) { currentState -> - WindowControlArea(window, currentState, linuxStyle) - content(currentState) + backgroundContent = { + backgroundContent() + Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) + }, + ) { currentState -> + WindowControlArea(window, currentState, linuxStyle) + content(currentState) + } } } diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt index d3d089d94..e9c89d1f8 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -41,6 +42,7 @@ internal fun DecoratedWindowScope.MacOSTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { @@ -95,6 +97,7 @@ internal fun DecoratedWindowScope.MacOSTitleBar( // correct side. Uses the control buttons direction (decoupled from content). val controlDir = controlButtonsDirection.resolve() val controlIsRtl = controlDir == LayoutDirection.Rtl + val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End LaunchedEffect(window, controlIsRtl) { val ptr = JniMacWindowUtil.getWindowPtr(window) if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { @@ -150,77 +153,84 @@ internal fun DecoratedWindowScope.MacOSTitleBar( val viewConfig = LocalViewConfiguration.current var lastPress = 0L - TitleBarImpl( - modifier = - Modifier - .offset(y = menuBarOffset) - .zIndex(if (menuBarOffset > 0.dp) 1f else 0f) - .then(modifier) - .titleBarHitTestHandler(window) - .onPointerEvent(PointerEventType.Press, PointerEventPass.Final) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - if (now - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { - val p = JniMacWindowUtil.getWindowPtr(window) - if (p != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativePerformTitleBarDoubleClickAction(p) + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier = + Modifier + .offset(y = menuBarOffset) + .zIndex(if (menuBarOffset > 0.dp) 1f else 0f) + .then(modifier) + .titleBarHitTestHandler(window) + .onPointerEvent(PointerEventType.Press, PointerEventPass.Final) { + if ( + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { !it.isConsumed } + ) { + val now = System.currentTimeMillis() + if ( + now - lastPress in + viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis + ) { + val p = JniMacWindowUtil.getWindowPtr(window) + if (p != 0L && JniMacTitleBarBridge.isLoaded) { + JniMacTitleBarBridge.nativePerformTitleBarDoubleClickAction(p) + } } + lastPress = now } - lastPress = now - } - }, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - applyTitleBar = { height, titleBarState -> - JniMacWindowUtil.applyWindowProperties(window) + }, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlDir, + layoutPolicy = layoutPolicy, + applyTitleBar = { height, titleBarState -> + JniMacWindowUtil.applyWindowProperties(window) - val p = JniMacWindowUtil.getWindowPtr(window) - - if (titleBarState.isFullscreen) { - if (controlIsRtl) { - PaddingValues(end = 80.dp) - } else { - PaddingValues(start = 80.dp) - } - } else { - val buttonInset = - if (p != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeApplyTitleBar(p, height.value) + val p = JniMacWindowUtil.getWindowPtr(window) + val padding = + if (titleBarState.isFullscreen) { + if (controlIsRtl) { + PaddingValues(end = 80.dp) + } else { + PaddingValues(start = 80.dp) + } } else { - @Suppress("MagicNumber") - val shrink = minOf(height.value / 28f, 1f) + val buttonInset = + if (p != 0L && JniMacTitleBarBridge.isLoaded) { + JniMacTitleBarBridge.nativeApplyTitleBar(p, height.value) + } else { + @Suppress("MagicNumber") + val shrink = minOf(height.value / 28f, 1f) - @Suppress("MagicNumber") - val leftMargin = minOf(height.value / 2f, 20f) + @Suppress("MagicNumber") + val leftMargin = minOf(height.value / 2f, 20f) - @Suppress("MagicNumber") - 2f * leftMargin + 2f * shrink * 20f + @Suppress("MagicNumber") + 2f * leftMargin + 2f * shrink * 20f + } + if (controlIsRtl) { + PaddingValues(end = buttonInset.dp) + } else { + PaddingValues(start = buttonInset.dp) + } + } + padding + }, + onPlace = { + if (state.isFullscreen) { + val p = JniMacWindowUtil.getWindowPtr(window) + if (p != 0L && JniMacTitleBarBridge.isLoaded) { + JniMacTitleBarBridge.nativeUpdateFullScreenButtons(p) } - if (controlIsRtl) { - PaddingValues(end = buttonInset.dp) - } else { - PaddingValues(start = buttonInset.dp) - } - } - }, - onPlace = { - if (state.isFullscreen) { - val p = JniMacWindowUtil.getWindowPtr(window) - if (p != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeUpdateFullScreenButtons(p) } - } - }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize()) - backgroundContent() - }, - content = content, - ) + }, + backgroundContent = { + Spacer(modifier = Modifier.fillMaxSize()) + backgroundContent() + }, + content = content, + ) + } } /** diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt index 7525718b0..e2cc07675 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.currentCompositionLocalContext @@ -17,6 +18,7 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.github.kdroidfilter.nucleus.window.styling.LocalTitleBarStyle import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle @@ -32,15 +34,22 @@ internal fun DecoratedWindowScope.WindowsTitleBar( gradientStartColor: Color = Color.Unspecified, style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { + val controlDir = controlButtonsDirection.resolve() + val controlIsRtl = controlDir == LayoutDirection.Rtl + val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End + if (JniWindowsDecorationBridge.isLoaded) { NativeWindowsTitleBar( modifier, gradientStartColor, style, - controlButtonsDirection, + controlDir, + layoutPolicy, + controlsSide, backgroundContent, content, ) @@ -49,7 +58,9 @@ internal fun DecoratedWindowScope.WindowsTitleBar( modifier, gradientStartColor, style, - controlButtonsDirection, + controlDir, + layoutPolicy, + controlsSide, backgroundContent, content, ) @@ -63,7 +74,9 @@ private fun DecoratedWindowScope.NativeWindowsTitleBar( modifier: Modifier, gradientStartColor: Color, style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection, + controlButtonsDirection: LayoutDirection, + layoutPolicy: TitleBarLayoutPolicy, + controlsSide: WindowControlsSide, backgroundContent: @Composable () -> Unit, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, ) { @@ -118,21 +131,24 @@ private fun DecoratedWindowScope.NativeWindowsTitleBar( holder.compositionLocalContext = currentCompositionLocalContext holder.titleBarHeight = style.metrics.height holder.content = { - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> PaddingValues(0.dp) }, - ) { currentState -> - WindowsWindowControlArea( - window = window, - state = currentState, + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, style = style, - isFullscreen = true, - onExitFullscreen = onExitFullscreen, - ) - content(currentState) + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> PaddingValues(0.dp) }, + ) { currentState -> + WindowsWindowControlArea( + window = window, + state = currentState, + style = style, + isFullscreen = true, + onExitFullscreen = onExitFullscreen, + ) + content(currentState) + } } } } @@ -140,58 +156,64 @@ private fun DecoratedWindowScope.NativeWindowsTitleBar( } // ── Normal title bar (or fullscreen without newFullscreenControls) ── - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { height, _ -> - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) { - val heightPx = with(density) { height.roundToPx() } - JniWindowsDecorationBridge.nativeSetTitleBarHeight(hwnd, heightPx) - } - PaddingValues(0.dp) - }, - backgroundContent = { - backgroundContent() - Spacer( - modifier = - Modifier - .fillMaxSize() - .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - val elapsed = now - lastPressTime - if (elapsed in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { - if (state.isMaximized) { - window.extendedState = Frame.NORMAL + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = { height, currentState -> + val hwnd = JniWindowsWindowUtil.getHwnd(window) + if (hwnd != 0L) { + val heightPx = with(density) { height.roundToPx() } + JniWindowsDecorationBridge.nativeSetTitleBarHeight(hwnd, heightPx) + } + PaddingValues(0.dp) + }, + backgroundContent = { + backgroundContent() + Spacer( + modifier = + Modifier + .fillMaxSize() + .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { + if ( + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { !it.isConsumed } + ) { + val now = System.currentTimeMillis() + val elapsed = now - lastPressTime + if ( + elapsed in + viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis + ) { + if (state.isMaximized) { + window.extendedState = Frame.NORMAL + } else { + window.extendedState = Frame.MAXIMIZED_BOTH + } } else { - window.extendedState = Frame.MAXIMIZED_BOTH - } - } else { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) { - JniWindowsDecorationBridge.nativeStartDrag(hwnd) + val hwnd = JniWindowsWindowUtil.getHwnd(window) + if (hwnd != 0L) { + JniWindowsDecorationBridge.nativeStartDrag(hwnd) + } } + lastPressTime = now } - lastPressTime = now - } - }, + }, + ) + }, + ) { currentState -> + WindowsWindowControlArea( + window = window, + state = currentState, + style = style, + isFullscreen = isNativeFullscreen, + onExitFullscreen = onExitFullscreen, ) - }, - ) { currentState -> - WindowsWindowControlArea( - window = window, - state = currentState, - style = style, - isFullscreen = isNativeFullscreen, - onExitFullscreen = onExitFullscreen, - ) - content(currentState) + content(currentState) + } } } @@ -203,42 +225,47 @@ private fun DecoratedWindowScope.FallbackWindowsTitleBar( modifier: Modifier, gradientStartColor: Color, style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection, + controlButtonsDirection: LayoutDirection, + layoutPolicy: TitleBarLayoutPolicy, + controlsSide: WindowControlsSide, backgroundContent: @Composable () -> Unit, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, ) { val viewConfig = LocalViewConfiguration.current var lastPress = 0L - TitleBarImpl( - modifier = - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - if (now - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { - if (state.isMaximized) { - window.extendedState = Frame.NORMAL - } else { - window.extendedState = Frame.MAXIMIZED_BOTH + CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { + TitleBarImpl( + modifier = + modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { + if ( + this.currentEvent.button == PointerButton.Primary && + this.currentEvent.changes.any { !it.isConsumed } + ) { + val now = System.currentTimeMillis() + if (now - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { + if (state.isMaximized) { + window.extendedState = Frame.NORMAL + } else { + window.extendedState = Frame.MAXIMIZED_BOTH + } } + lastPress = now } - lastPress = now - } + }, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = layoutPolicy, + applyTitleBar = { _, _ -> PaddingValues(0.dp) }, + backgroundContent = { + backgroundContent() + Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) }, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection.resolve(), - applyTitleBar = { _, _ -> PaddingValues(0.dp) }, - backgroundContent = { - backgroundContent() - Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) - }, - ) { currentState -> - WindowsWindowControlArea(window, currentState, style) - content(currentState) + ) { currentState -> + WindowsWindowControlArea(window, currentState, style) + content(currentState) + } } } diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.kt index b887b820b..54329bb3b 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.kt @@ -27,14 +27,60 @@ fun DecoratedWindowScope.TitleBar( controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, +) { + BasicTitleBar( + modifier = modifier, + gradientStartColor = gradientStartColor, + style = style, + controlButtonsDirection = controlButtonsDirection, + layoutPolicy = TitleBarLayoutPolicy.Default, + backgroundContent = backgroundContent, + content = content, + ) +} + +@Suppress("FunctionNaming") +@Composable +fun DecoratedWindowScope.BasicTitleBar( + modifier: Modifier = Modifier, + gradientStartColor: Color = Color.Unspecified, + style: TitleBarStyle = LocalTitleBarStyle.current, + controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, + layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, + backgroundContent: @Composable () -> Unit = {}, + content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { when (Platform.Current) { Platform.Linux -> - LinuxTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, backgroundContent, content) + LinuxTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + backgroundContent, + content, + ) Platform.Windows -> - WindowsTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, backgroundContent, content) + WindowsTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + backgroundContent, + content, + ) Platform.MacOS -> - MacOSTitleBar(modifier, gradientStartColor, style, controlButtonsDirection, backgroundContent, content) + MacOSTitleBar( + modifier, + gradientStartColor, + style, + controlButtonsDirection, + layoutPolicy, + backgroundContent, + content, + ) Platform.Unknown -> error("TitleBar is not supported on this platform(${System.getProperty("os.name")})") } diff --git a/docs/changelog.md b/docs/changelog.md index acbb38176..c82c129f8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,6 +11,7 @@ ### New Features - **Reactive GNOME titlebar button layout** — Decorated windows on Linux now read `org.gnome.desktop.wm.preferences` → `button-layout` via GSettings (`libgio` dlopen) to determine which buttons to show and on which side. The layout updates reactively when the user changes it in GNOME Tweaks or via `gsettings set`. Falls back to the default layout on KDE and other desktop environments. New `rememberLinuxButtonLayout()` composable for direct access. +- **Title bar controls-side plumbing (core/JBR/JNI)** — Add non-breaking `WindowControlsSide` + `LocalWindowControlsSide` infrastructure to share platform-resolved window controls side with core layout. This stage is infrastructure only and does not change current title bar layout behavior. - **GraalVM reachability metadata for FileKit and dbus-java** — Apps using FileKit on Linux no longer need manual reachability entries for xdg-desktop-portal file dialogs. Includes new dbus-java conditional library metadata and Linux JDK internals (`UnixSystem`, `NativePRNG$NonBlocking`, `CollationData`). - **Windows notification shortcut policies** — New `ShortcutPolicy` enum on `WindowsNotificationCenter` for finer control over Start Menu shortcut creation behavior. - **`NucleusApp.appName` and `NucleusApp.aumid` properties** — Expose application name and AUMID for better configuration handling. diff --git a/docs/runtime/decorated-window.md b/docs/runtime/decorated-window.md index da2a92825..246ac43ed 100644 --- a/docs/runtime/decorated-window.md +++ b/docs/runtime/decorated-window.md @@ -301,6 +301,11 @@ Platform-dispatched title bar composable. Provides a `TitleBarScope` with: - `icon: Painter?` — the window icon - `Modifier.align(alignment: Alignment.Horizontal)` — positions content within the title bar +!!! note "Advanced: internal controls-side channel" + `TitleBar` now exposes an internal cross-module channel through `LocalWindowControlsSide`. + It carries the platform-resolved side (`Start` / `End` / `Unspecified`) where window controls are located. + Most applications should keep using `TitleBar` normally and do not need to read or provide this local. + ```kotlin TitleBar { state -> // Left-aligned icon diff --git a/example/src/main/kotlin/com/example/demo/FillCenterDemoWindow.kt b/example/src/main/kotlin/com/example/demo/FillCenterDemoWindow.kt new file mode 100644 index 000000000..ab45eb2e1 --- /dev/null +++ b/example/src/main/kotlin/com/example/demo/FillCenterDemoWindow.kt @@ -0,0 +1,246 @@ +package com.example.demo + +import androidx.compose.foundation.background +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import com.example.demo.gallery.GalleryScreen +import io.github.kdroidfilter.nucleus.core.runtime.Platform +import io.github.kdroidfilter.nucleus.window.BasicTitleBar +import io.github.kdroidfilter.nucleus.window.TitleBarLayoutPolicy +import io.github.kdroidfilter.nucleus.window.macOSLargeCornerRadius +import io.github.kdroidfilter.nucleus.window.material.MaterialDecoratedWindow +import io.github.kdroidfilter.nucleus.window.newFullscreenControls + +@Composable +fun FillCenterDemoWindow( + visible: Boolean, + onCloseRequest: () -> Unit, + seedColor: Color, +) { + if (!visible) return + + val fillCenterWindowState = + rememberWindowState( + position = WindowPosition.Aligned(Alignment.Center), + placement = WindowPlacement.Floating, + size = DpSize(1340.dp, 480.dp), + ) + + MaterialDecoratedWindow( + state = fillCenterWindowState, + onCloseRequest = onCloseRequest, + title = "Fill Title", + ) { + val demoTabs = remember { buildDemoWindowTabs() } + var demoSelectedTab by remember { mutableStateOf("Nucleus") } + + BasicTitleBar( + modifier = Modifier.newFullscreenControls().macOSLargeCornerRadius(), + layoutPolicy = TitleBarLayoutPolicy.FillCenter, + ) { _ -> + Box( + modifier = + Modifier + .align(Alignment.Start) + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = "Start", + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Box( + modifier = + Modifier + .align(Alignment.End) + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = "End", + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + + Box( + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(horizontal = 4.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + FillTitleTabs( + tabs = demoTabs, + selectedTab = demoSelectedTab, + onSelect = { demoSelectedTab = it }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + DemoWindowTabContent( + selectedTab = demoSelectedTab, + seedColor = seedColor, + window = window, + ) + } +} + +private fun buildDemoWindowTabs(): List = + buildList { + addAll(listOf("Nucleus", "Gallery", "Taskbar")) + add("Notifications (Common)") + if (Platform.Current == Platform.MacOS || + Platform.Current == Platform.Linux || + Platform.Current == Platform.Windows + ) { + add("Notifications") + } + if (Platform.Current == Platform.Windows || + Platform.Current == Platform.Linux || + Platform.Current == Platform.MacOS + ) { + add("Launcher") + } + add("Media Control") + add("Auto-Launch") + add("Hotkeys") + if (Platform.Current == Platform.MacOS) { + add("Menu") + } + } + +@Composable +private fun io.github.kdroidfilter.nucleus.window.TitleBarScope.FillTitleTabs( + tabs: List, + selectedTab: String?, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.height(28.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { tabTitle -> + val isSelected = tabTitle == selectedTab + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + val backgroundColor = + when { + isSelected -> MaterialTheme.colorScheme.surfaceContainerHigh + isHovered -> MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f) + else -> Color.Transparent + } + + val textColor = + if (isSelected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Box( + modifier = + Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(backgroundColor) + .hoverable(hoverInteraction) + .titleBarClickable { onSelect(tabTitle) } + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = tabTitle, + style = MaterialTheme.typography.labelMedium, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun DemoWindowTabContent( + selectedTab: String, + seedColor: Color, + window: java.awt.Window, +) { + when (selectedTab) { + "Nucleus" -> NucleusContent() + "Notifications (Common)" -> CommonNotificationsScreen() + "Gallery" -> { + val currentDensity = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides + Density( + density = currentDensity.density * 0.75f, + fontScale = currentDensity.fontScale, + ), + ) { + GalleryScreen(seedColor = seedColor) + } + } + "Taskbar" -> TaskbarProgressScreen(window) + "Notifications" -> { + when (Platform.Current) { + Platform.MacOS -> NotificationsScreen() + Platform.Linux -> LinuxNotificationsScreen() + Platform.Windows -> WindowsNotificationsScreen() + else -> {} + } + } + "Launcher" -> { + when (Platform.Current) { + Platform.Windows -> WindowsLauncherScreen(window) + Platform.MacOS -> MacOsLauncherScreen() + Platform.Linux -> LauncherScreen() + else -> {} + } + } + "Media Control" -> MediaControlScreen() + "Auto-Launch" -> AutoLaunchScreen() + "Hotkeys" -> GlobalHotKeyScreen() + "Menu" -> MacOsMenuScreen() + } +} diff --git a/example/src/main/kotlin/com/example/demo/Main.kt b/example/src/main/kotlin/com/example/demo/Main.kt index 35ac7eb1f..b33c964c9 100644 --- a/example/src/main/kotlin/com/example/demo/Main.kt +++ b/example/src/main/kotlin/com/example/demo/Main.kt @@ -136,6 +136,7 @@ fun main(args: Array) { application { var isWindowVisible by remember { mutableStateOf(true) } + var isFillCenterWindowVisible by remember { mutableStateOf(false) } var restoreRequestCount by remember { mutableStateOf(0) } var themeMode by remember { mutableStateOf(ThemeMode.System) } var showInfoDialog by remember { mutableStateOf(false) } @@ -191,7 +192,7 @@ fun main(args: Array) { ) { val tabs = buildList { - addAll(listOf("Nucleus", "Gallery", "Taskbar")) + addAll(listOf("Nucleus", "Fill Title", "Gallery", "Taskbar")) add("Notifications (Common)") if (Platform.Current == Platform.MacOS || Platform.Current == Platform.Linux || @@ -207,7 +208,6 @@ fun main(args: Array) { } add("Media Control") add("Auto-Launch") - add("Hotkeys") if (Platform.Current == Platform.MacOS) { add("Menu") @@ -324,6 +324,10 @@ fun main(args: Array) { when (selectedTab) { "Nucleus" -> NucleusContent() + "Fill Title" -> + FillCenterDemoEntryTab( + onOpenDemo = { isFillCenterWindowVisible = true }, + ) "Notifications (Common)" -> CommonNotificationsScreen() "Gallery" -> { val currentDensity = LocalDensity.current @@ -395,13 +399,19 @@ fun main(args: Array) { } } } + + FillCenterDemoWindow( + visible = isFillCenterWindowVisible, + onCloseRequest = { isFillCenterWindowVisible = false }, + seedColor = seedColor, + ) } } } } @Composable -fun NucleusContent() { +internal fun NucleusContent() { val currentDeepLink by deepLinkUri // macOS: the kAEOpenApplication AppleEvent is delivered after NSApp.run() // starts processing — which is exactly when this composable first runs. @@ -498,11 +508,11 @@ fun NucleusContent() { ) } - if (updater.isUpdateSupported()) { - Column( - modifier = Modifier.align(Alignment.BottomCenter), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + Column( + modifier = Modifier.align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (updater.isUpdateSupported()) { Text( text = "Auto-Update", style = MaterialTheme.typography.titleMedium, @@ -531,6 +541,17 @@ fun NucleusContent() { } } +@Composable +private fun FillCenterDemoEntryTab(onOpenDemo: () -> Unit) { + Surface(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Button(onClick = onOpenDemo) { + Text("Open Fill Title Window") + } + } + } +} + @Composable private fun UpdateBanner( event: UpdateEvent,