diff --git a/compose/ui/ui-unit/api/ui-unit.klib.api b/compose/ui/ui-unit/api/ui-unit.klib.api index 5a9b1316e2736..802644d66e1ea 100644 --- a/compose/ui/ui-unit/api/ui-unit.klib.api +++ b/compose/ui/ui-unit/api/ui-unit.klib.api @@ -392,6 +392,7 @@ final const val androidx.compose.ui.unit/MaxDimensionsAndFocusMask // androidx.c final fun (): kotlin/Long // androidx.compose.ui.unit/MaxDimensionsAndFocusMask.|(){}[0] final val androidx.compose.ui.unit/androidx_compose_ui_unit_ComposeUiUnitFlags$stableprop // androidx.compose.ui.unit/androidx_compose_ui_unit_ComposeUiUnitFlags$stableprop|#static{}androidx_compose_ui_unit_ComposeUiUnitFlags$stableprop[0] +final val androidx.compose.ui.unit/androidx_compose_ui_unit_DpInsets$stableprop // androidx.compose.ui.unit/androidx_compose_ui_unit_DpInsets$stableprop|#static{}androidx_compose_ui_unit_DpInsets$stableprop[0] final val androidx.compose.ui.unit/androidx_compose_ui_unit_DpRect$stableprop // androidx.compose.ui.unit/androidx_compose_ui_unit_DpRect$stableprop|#static{}androidx_compose_ui_unit_DpRect$stableprop[0] final val androidx.compose.ui.unit/androidx_compose_ui_unit_IntRect$stableprop // androidx.compose.ui.unit/androidx_compose_ui_unit_IntRect$stableprop|#static{}androidx_compose_ui_unit_IntRect$stableprop[0] final val androidx.compose.ui.unit/center // androidx.compose.ui.unit/center|@androidx.compose.ui.unit.DpSize{}center[0] @@ -467,6 +468,7 @@ final fun androidx.compose.ui.unit/IntRect(androidx.compose.ui.unit/IntOffset, k final fun androidx.compose.ui.unit/TextUnit(kotlin/Float, androidx.compose.ui.unit/TextUnitType): androidx.compose.ui.unit/TextUnit // androidx.compose.ui.unit/TextUnit|TextUnit(kotlin.Float;androidx.compose.ui.unit.TextUnitType){}[0] final fun androidx.compose.ui.unit/Velocity(kotlin/Float, kotlin/Float): androidx.compose.ui.unit/Velocity // androidx.compose.ui.unit/Velocity|Velocity(kotlin.Float;kotlin.Float){}[0] final fun androidx.compose.ui.unit/androidx_compose_ui_unit_ComposeUiUnitFlags$stableprop_getter(): kotlin/Int // androidx.compose.ui.unit/androidx_compose_ui_unit_ComposeUiUnitFlags$stableprop_getter|androidx_compose_ui_unit_ComposeUiUnitFlags$stableprop_getter(){}[0] +final fun androidx.compose.ui.unit/androidx_compose_ui_unit_DpInsets$stableprop_getter(): kotlin/Int // androidx.compose.ui.unit/androidx_compose_ui_unit_DpInsets$stableprop_getter|androidx_compose_ui_unit_DpInsets$stableprop_getter(){}[0] final fun androidx.compose.ui.unit/androidx_compose_ui_unit_DpRect$stableprop_getter(): kotlin/Int // androidx.compose.ui.unit/androidx_compose_ui_unit_DpRect$stableprop_getter|androidx_compose_ui_unit_DpRect$stableprop_getter(){}[0] final fun androidx.compose.ui.unit/androidx_compose_ui_unit_IntRect$stableprop_getter(): kotlin/Int // androidx.compose.ui.unit/androidx_compose_ui_unit_IntRect$stableprop_getter|androidx_compose_ui_unit_IntRect$stableprop_getter(){}[0] final fun androidx.compose.ui.unit/checkArithmetic(androidx.compose.ui.unit/TextUnit) // androidx.compose.ui.unit/checkArithmetic|checkArithmetic(androidx.compose.ui.unit.TextUnit){}[0] diff --git a/compose/ui/ui-unit/src/nonAndroidMain/kotlin/androidx/compose/ui/unit/DpInsets.nonAndroid.kt b/compose/ui/ui-unit/src/nonAndroidMain/kotlin/androidx/compose/ui/unit/DpInsets.nonAndroid.kt new file mode 100644 index 0000000000000..525567cb0be74 --- /dev/null +++ b/compose/ui/ui-unit/src/nonAndroidMain/kotlin/androidx/compose/ui/unit/DpInsets.nonAndroid.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.ExperimentalComposeUiApi + + +/** + * Represents a set of insets in [Dp] units. + */ +@ExperimentalComposeUiApi +@Immutable +class DpInsets( + val top: Dp, + val left: Dp, + val bottom: Dp, + val right: Dp +) { + + /** + * Returns the sum of the insets. + */ + operator fun plus(other: DpInsets) = DpInsets( + top = top + other.top, + left = left + other.left, + bottom = bottom + other.bottom, + right = right + other.right + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DpInsets) return false + + if (top != other.top) return false + if (left != other.left) return false + if (bottom != other.bottom) return false + if (right != other.right) return false + + return true + } + + override fun hashCode(): Int { + var result = top.hashCode() + result = 31 * result + left.hashCode() + result = 31 * result + bottom.hashCode() + result = 31 * result + right.hashCode() + return result + } +} + +/** + * Returns the rectangle remaining after applying the given insets. + */ +@ExperimentalComposeUiApi +operator fun DpRect.minus(insets: DpInsets): DpRect = + DpRect( + top = top + insets.top, + left = left + insets.left, + bottom = bottom - insets.bottom, + right = right - insets.right + ) + +/** + * Returns the size after adding the given insets. + */ +@ExperimentalComposeUiApi +operator fun DpSize.plus(insets: DpInsets): DpSize = + DpSize( + width = width + insets.left + insets.right, + height = height + insets.top + insets.bottom + ) diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeDialog.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeDialog.desktop.kt index 51f57099e1ab7..73c2a82edba63 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeDialog.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeDialog.desktop.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.layout.MeasurableRootContent import androidx.compose.ui.layout.layoutId import androidx.compose.ui.semantics.dialog import androidx.compose.ui.semantics.semantics @@ -313,6 +314,14 @@ class ComposeDialog : JDialog { return composePanel.saveState() } + /** + * Returns an object through which the composable content of the window can be queried for its + * size preferences, such as its intrinsic size. + */ + @ExperimentalComposeUiApi + val measurableContent: MeasurableRootContent + get() = composePanel.measurableContent + override fun dispose() { super.dispose() composePanel.dispose() diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindow.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindow.desktop.kt index c304cd86c0ace..dd4bc72c12609 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindow.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindow.desktop.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.layout.MeasurableRootContent import androidx.compose.ui.layout.layoutId import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.unit.Dp @@ -35,7 +36,7 @@ import java.awt.GraphicsConfiguration import java.awt.event.MouseListener import java.awt.event.MouseMotionListener import java.awt.event.MouseWheelListener -import java.util.Locale +import java.util.* import javax.swing.JFrame import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -196,6 +197,13 @@ class ComposeWindow @ExperimentalComposeUiApi constructor( return composePanel.saveState() } + /** + * Returns an object through which the composable content of the window can be queried for its + * size preferences, such as its intrinsic size. + */ + @ExperimentalComposeUiApi + val measurableContent: MeasurableRootContent by composePanel::measurableContent + override fun dispose() { super.dispose() composePanel.dispose() diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindowPanel.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindowPanel.desktop.kt index 8e761ffe59e4c..a08d6a4fe93bc 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindowPanel.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindowPanel.desktop.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.ComposeFeatureFlags import androidx.compose.ui.LayerType import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.layout.MeasurableRootContent import androidx.compose.ui.scene.ComposeContainer import androidx.savedstate.SavedState import java.awt.Component @@ -116,6 +117,9 @@ internal class ComposeWindowPanel( isFocusCycleRoot = true } + val measurableContent: MeasurableRootContent + get() = composeContainer.measurableContent + override fun setBounds(x: Int, y: Int, width: Int, height: Int) { super.setBounds(x, y, width, height) composeContainer.setBounds(0, 0, width, height) diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingDialog.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingDialog.desktop.kt index 454bf989dbe38..9af4147bc9a91 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingDialog.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingDialog.desktop.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent @@ -146,7 +147,12 @@ fun SwingDialog( // - Make the dialog displayable // - Size the dialog and the ComposeLayer correctly, so that we can draw it here if (!wasDisplayable && it.isDisplayable) { - it.renderImmediately() + Snapshot.withoutReadObservation { + if (!it.isValid) { + it.validate() + } + it.renderImmediately() + } } }, ) diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingWindow.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingWindow.desktop.kt index 65d79e904d518..ebfb163b987ff 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingWindow.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingWindow.desktop.kt @@ -135,11 +135,14 @@ fun SwingWindow( // If displaying for the first time, make sure we draw the first frame before making // the window visible to avoid showing the window background - // It's the responsibility of setSizeSafely to + // It's the responsibility of update(it) to: // - Make the window displayable // - Size the window and the ComposeLayer correctly, so that we can draw it here if (!wasDisplayable && it.isDisplayable) { Snapshot.withoutReadObservation { + if (!it.isValid) { + it.validate() + } it.renderImmediately() } } diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/Utils.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/Utils.desktop.kt index 83666b3ca767c..b482c18310a9f 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/Utils.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/Utils.desktop.kt @@ -19,7 +19,9 @@ package androidx.compose.ui.awt import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.size import androidx.compose.ui.util.fastRoundToInt import java.awt.Component import java.awt.EventQueue @@ -60,7 +62,7 @@ internal fun toAwtRectangle( return Rectangle(rleft, rtop, rwidth, rheight) } -internal fun IntRect.toAwtRectangle(density: Density = Density(1f)) = toAwtRectangle( +internal fun IntRect.toAwtRectangle(density: Density) = toAwtRectangle( left = left.toFloat(), top = top.toFloat(), right = right.toFloat(), @@ -68,6 +70,28 @@ internal fun IntRect.toAwtRectangle(density: Density = Density(1f)) = toAwtRecta density = density.density ) +internal fun DpRect.toAwtRectangleRounded(): Rectangle { + val left = this.left.value.fastRoundToInt() + val top = this.top.value.fastRoundToInt() + val right = this.right.value.fastRoundToInt() + val bottom = this.bottom.value.fastRoundToInt() + return Rectangle(left, top, right - left, bottom - top) +} + +/** + * Returns a [java.awt.Rectangle] corresponding to this [DpRect], in the given density. + * + * The size of the rectangle is rounded up to the nearest integer. + */ +internal fun DpRect.toAwtRectangleSizeRoundedUp(): Rectangle { + val left = this.left.value.fastRoundToInt() + val top = this.top.value.fastRoundToInt() + val size = this.size + val width = ceil(size.width.value).toInt() + val height = ceil(size.height.value).toInt() + return Rectangle(left, top, width, height) +} + /** * Returns a [java.awt.Rectangle] corresponding to this [Rect], in the given density. * @@ -162,4 +186,4 @@ internal class DebouncingEdtExecutor { } } } -} +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/v2/SwingDialog.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/v2/SwingDialog.desktop.kt new file mode 100644 index 0000000000000..e76763a6f0a9e --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/v2/SwingDialog.desktop.kt @@ -0,0 +1,284 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.awt.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.awt.LocalAwtWindow +import androidx.compose.ui.awt.SwingDialog +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.util.ComponentUpdater +import androidx.compose.ui.util.componentListenerRef +import androidx.compose.ui.util.setIcon +import androidx.compose.ui.util.setUndecoratedSafely +import androidx.compose.ui.util.windowListenerRef +import androidx.compose.ui.window.DialogWindowScope +import androidx.compose.ui.window.UndecoratedWindowDecoration +import androidx.compose.ui.window.WindowDecoration +import androidx.compose.ui.window.asDpRect +import androidx.compose.ui.window.resizerThickness +import androidx.compose.ui.window.roundToDimensionOrNull +import androidx.compose.ui.window.v2.DialogState +import androidx.compose.ui.window.v2.WindowBoundsProvider +import androidx.compose.ui.window.v2.WindowScreenProvider +import androidx.compose.ui.window.v2.rememberDialogState +import java.awt.Dialog.ModalityType +import java.awt.GraphicsEnvironment +import java.awt.Window +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import javax.swing.JDialog +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +// TODO(demin): fix mouse hover after opening a dialog. +// When we open a modal dialog, ComposeLayer/mouseExited will +// never be called for the parent window. See ./gradlew run3 + +/** + * Similar to the corresponding [androidx.compose.ui.window.v2.DialogWindow] function, but + * additionally allows configuring the underlying AWT dialog before it has been made displayable, + * by providing an [init] block. + * + * This is useful to: + * - Set dialog properties which cannot be changed after it has been made displayable, such as + * [java.awt.Window.setType]. + * - Adding listeners for events that can occur when the dialog becomes displayable/visible. + * + * IMPORTANT: this function should not be used to set properties which can be changed after the + * window has been made displayable. Doing so can cause your code to stop working in the future if + * a parameter that controls this property is added to this function. + * For example, if you set the window's minimum size in [init] and later a `minimumSize` parameter + * is added to this function, it will override your setting of the minimum size in [init]. + * + * To set these kinds of properties, use this pattern instead: + * ``` + * WindowDialog( ... ) { + * // Dialog content here + * LaunchedEffect(window) { + * // Configure dialog here + * } + * } + * ``` + * + * Note: this function may be moved to `androidx.compose.ui.awt` before stabilization. + * + * @see androidx.compose.ui.window.v2.DialogWindow + */ +@ExperimentalComposeUiApi +@Composable +@ComposableOpenTarget(-1) +fun SwingDialog( + onCloseRequest: () -> Unit, + parentWindow: Window? = LocalAwtWindow.current, + state: DialogState = rememberDialogState(), + visible: Boolean = true, + title: String = "Untitled", + icon: Painter? = null, + decoration: WindowDecoration = WindowDecoration.SystemDefault, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false }, + onKeyEvent: ((KeyEvent) -> Boolean) = { false }, + modalityType: ModalityType = + if (parentWindow == null) ModalityType.APPLICATION_MODAL else ModalityType.DOCUMENT_MODAL, + init: (ComposeDialog) -> Unit, + content: @Composable DialogWindowScope.() -> Unit +) { + if ((parentWindow == null) && (modalityType == ModalityType.DOCUMENT_MODAL)) { + throw IllegalArgumentException("SwingDialog with no parent window cannot be DOCUMENT_MODAL") + } + + val currentState by rememberUpdatedState(state) + val currentTitle by rememberUpdatedState(title) + val currentIcon by rememberUpdatedState(icon) + val currentDecoration by rememberUpdatedState(decoration) + val currentTransparent by rememberUpdatedState(transparent) + val currentResizable by rememberUpdatedState(resizable) + val currentEnabled by rememberUpdatedState(enabled) + val currentFocusable by rememberUpdatedState(focusable) + val currentAlwaysOnTop by rememberUpdatedState(alwaysOnTop) + val currentMinSize by rememberUpdatedState(minSize) + val currentMaxSize by rememberUpdatedState(maxSize) + val currentModalityType by rememberUpdatedState(modalityType) + val currentOnCloseRequest by rememberUpdatedState(onCloseRequest) + + val updater = remember(::ComponentUpdater) + + val listeners = remember { + object { + var windowListenerRef = windowListenerRef() + var componentListenerRef = componentListenerRef() + + fun removeFromAndClear(window: ComposeDialog) { + windowListenerRef.unregisterFromAndClear(window) + componentListenerRef.unregisterFromAndClear(window) + } + } + } + + val coroutineContext = rememberCoroutineScope().coroutineContext + + var dialog: ComposeDialog? by remember { mutableStateOf(null) } + SwingDialog( + visible = visible, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + create = { + val graphicsDevices = GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices + val currentDevice = currentState._screenId?.let { screenId -> + graphicsDevices.firstOrNull { it.iDstring == screenId } + } + val parentDevice = parentWindow?.graphicsConfiguration?.device + val initialDevice = currentDevice + ?: state.screenRequests.tryReceive().getOrNull()?.getInitialScreenDevice(parentDevice) + ?: WindowScreenProvider.Default.getInitialScreenDevice(parentDevice) + val graphicsConfiguration = initialDevice.defaultConfiguration + + val dlg = if (parentWindow != null) { + ComposeDialog( + owner = parentWindow, + modalityType = currentModalityType, + graphicsConfiguration = graphicsConfiguration, + coroutineContext = coroutineContext + ) + } else { + ComposeDialog( + graphicsConfiguration = graphicsConfiguration, + coroutineContext = coroutineContext + ) + } + + // close state is controlled by DialogState.isOpen + dlg.defaultCloseOperation = JDialog.DO_NOTHING_ON_CLOSE + listeners.windowListenerRef.registerWithAndSet( + dlg, + object : WindowAdapter() { + override fun windowClosing(e: WindowEvent?) { + currentOnCloseRequest() + } + } + ) + + listeners.componentListenerRef.registerWithAndSet( + dlg, + object : ComponentAdapter() { + fun applyBoundsChanges() { + currentState._bounds = dlg.bounds.asDpRect() + if (currentState._screenId != dlg.graphicsConfiguration.device.iDstring) { + currentState._screenId = dlg.graphicsConfiguration.device.iDstring + } + } + + override fun componentShown(e: ComponentEvent) { + // Initialize all state properties + applyBoundsChanges() + currentState.isInitialized = true + } + + override fun componentResized(e: ComponentEvent) { + applyBoundsChanges() + } + + override fun componentMoved(e: ComponentEvent) { + applyBoundsChanges() + } + } + ) + + init(dlg) + dialog = dlg + + dlg + }, + dispose = { + // We need to remove them because AWT can still call them after dispose() + listeners.removeFromAndClear(it) + it.dispose() + }, + update = { dialog -> + updater.update { + set(currentTitle, dialog::setTitle) + set(currentIcon, dialog::setIcon) + set(currentDecoration is UndecoratedWindowDecoration, dialog::setUndecoratedSafely) + set(currentTransparent, dialog::isTransparent::set) + set(currentResizable, dialog::setResizable) + set(currentEnabled, dialog::setEnabled) + set(currentFocusable, dialog::setFocusableWindowState) + set(currentAlwaysOnTop, dialog::setAlwaysOnTop) + set(currentMinSize) { dialog.minimumSize = it.roundToDimensionOrNull() } + set(currentMaxSize) { dialog.maximumSize = it.roundToDimensionOrNull() } + set(currentModalityType, dialog::setModalityType) + set(currentDecoration.resizerThickness, dialog::undecoratedResizerThickness::set) + } + + if (!dialog.isDisplayable) { + dialog.initializeBounds(currentState) + + // Need to make the dialog displayable, to make awt.SwingDialog render the first + // frame before the dialog is visible. + // Check isDisplayable again because initializeBounds could have already + // called pack(), and we don't need to do it twice + if (!dialog.isDisplayable) { + dialog.preferredSize = dialog.size + dialog.pack() // Sizes to preferred size + } + } + }, + content = content + ) + + LaunchedEffect(dialog, state) { + val dialog = dialog ?: return@LaunchedEffect + launch { + while (isActive) { + dialog.setScreenFrom(state.screenRequests.receive()) + } + } + launch { + while (isActive) { + dialog.setBoundsFrom(state.boundsRequests.receive()) + } + } + } +} + +private fun ComposeDialog.initializeBounds(state: DialogState) { + initializeBounds(state.boundsRequests, state._bounds, owner, ::measurableContent) +} + +private fun ComposeDialog.setBoundsFrom(boundsProvider: WindowBoundsProvider) { + setBoundsFrom(boundsProvider, owner, ::measurableContent) +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/v2/SwingWindow.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/v2/SwingWindow.desktop.kt new file mode 100644 index 0000000000000..d43974974fc52 --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/v2/SwingWindow.desktop.kt @@ -0,0 +1,395 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.awt.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.awt.SwingWindow +import androidx.compose.ui.awt.toAwtRectangleSizeRoundedUp +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.layout.MeasurableRootContent +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.requireReal +import androidx.compose.ui.util.ComponentUpdater +import androidx.compose.ui.util.componentListenerRef +import androidx.compose.ui.util.setIcon +import androidx.compose.ui.util.setUndecoratedSafely +import androidx.compose.ui.util.windowListenerRef +import androidx.compose.ui.util.windowStateListenerRef +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.UndecoratedWindowDecoration +import androidx.compose.ui.window.WindowDecoration +import androidx.compose.ui.window.WindowLocationTracker +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.asDpRect +import androidx.compose.ui.window.resizerThickness +import androidx.compose.ui.window.roundToDimensionOrNull +import androidx.compose.ui.window.v2.WindowBoundsProvider +import androidx.compose.ui.window.v2.WindowGeometryProviderScope +import androidx.compose.ui.window.v2.WindowScreenProvider +import androidx.compose.ui.window.v2.WindowScreenProviderScope +import androidx.compose.ui.window.v2.WindowState +import androidx.compose.ui.window.v2.rememberWindowState +import java.awt.GraphicsDevice +import java.awt.GraphicsEnvironment +import java.awt.Toolkit +import java.awt.Window +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import javax.swing.JFrame +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + + +/** + * Similar to the corresponding [androidx.compose.ui.window.v2.Window] function, but additionally + * allows configuring the underlying AWT window before it has been made displayable by providing an + * [init] block. + * + * This is useful to: + * - Set window properties which cannot be changed after it has been made displayable, such as + * [java.awt.Window.setType]. + * - Adding listeners for events that can occur when the window becomes displayable/visible. + * + * IMPORTANT: this function should not be used to set properties which can be changed after the + * window has been made displayable. Doing so can cause your code to stop working in the future if + * a parameter that controls this property is added to this function. + * For example, if you set the window's minimum size in [init] and later a `minimumSize` parameter + * is added to this function, it will override your setting of the minimum size in [init]. + * + * To set these kinds of properties, use this pattern instead: + * ``` + * Window( ... ) { + * // Window content here + * LaunchedEffect(window) { + * // Configure window here + * } + * } + * ``` + * + * Note: this function may be moved to `androidx.compose.ui.awt` before stabilization. + * + * @see androidx.compose.ui.window.v2.Window + */ +@ExperimentalComposeUiApi +@Composable +@ComposableOpenTarget(-1) +fun SwingWindow( + onCloseRequest: () -> Unit, + state: WindowState = rememberWindowState(), + visible: Boolean = true, + title: String = "Untitled", + icon: Painter? = null, + decoration: WindowDecoration = WindowDecoration.SystemDefault, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + init: (ComposeWindow) -> Unit, + content: @Composable FrameWindowScope.() -> Unit +) { + val currentState by rememberUpdatedState(state) + val currentTitle by rememberUpdatedState(title) + val currentIcon by rememberUpdatedState(icon) + val currentDecoration by rememberUpdatedState(decoration) + val currentTransparent by rememberUpdatedState(transparent) + val currentResizable by rememberUpdatedState(resizable) + val currentEnabled by rememberUpdatedState(enabled) + val currentFocusable by rememberUpdatedState(focusable) + val currentAlwaysOnTop by rememberUpdatedState(alwaysOnTop) + val currentMinSize by rememberUpdatedState(minSize) + val currentMaxSize by rememberUpdatedState(maxSize) + val currentOnCloseRequest by rememberUpdatedState(onCloseRequest) + + val updater = remember(::ComponentUpdater) + + val listeners = remember { + object { + var windowListenerRef = windowListenerRef() + var windowStateListenerRef = windowStateListenerRef() + var componentListenerRef = componentListenerRef() + + fun removeFromAndClear(window: ComposeWindow) { + windowListenerRef.unregisterFromAndClear(window) + windowStateListenerRef.unregisterFromAndClear(window) + componentListenerRef.unregisterFromAndClear(window) + } + } + } + + val coroutineContext = rememberCoroutineScope().coroutineContext + + var window: ComposeWindow? by remember { mutableStateOf(null) } + SwingWindow( + visible = visible, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + create = { + val graphicsDevices = GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices + val currentDevice = currentState._screenId?.let { screenId -> + graphicsDevices.firstOrNull { it.iDstring == screenId } + } + val initialDevice = currentDevice + ?: state.screenRequests.tryReceive().getOrNull()?.getInitialScreenDevice() + ?: WindowScreenProvider.Default.getInitialScreenDevice() + + val wnd = ComposeWindow( + graphicsConfiguration = initialDevice.defaultConfiguration, + coroutineContext = coroutineContext + ) + + // close state is controlled by WindowState.isOpen + wnd.defaultCloseOperation = JFrame.DO_NOTHING_ON_CLOSE + listeners.windowListenerRef.registerWithAndSet( + wnd, + object : WindowAdapter() { + override fun windowClosing(e: WindowEvent) { + currentOnCloseRequest() + } + } + ) + listeners.windowStateListenerRef.registerWithAndSet(wnd) { + currentState._placement = wnd.placement + currentState._isMinimized = wnd.isMinimized + } + listeners.componentListenerRef.registerWithAndSet( + wnd, + object : ComponentAdapter() { + fun applyBoundsChanges() { + currentState._bounds = wnd.bounds.asDpRect() + if (currentState._screenId != wnd.graphicsConfiguration.device.iDstring) { + currentState._screenId = wnd.graphicsConfiguration.device.iDstring + } + } + + override fun componentShown(e: ComponentEvent) { + // Initialize all state properties + currentState._placement = wnd.placement + currentState._isMinimized = wnd.isMinimized + applyBoundsChanges() + currentState.isInitialized = true + } + + override fun componentResized(e: ComponentEvent) { + // we check placement here and in windowStateChanged, + // because fullscreen changing doesn't + // fire windowStateChanged, only componentResized + currentState._placement = wnd.placement + applyBoundsChanges() + } + + override fun componentMoved(e: ComponentEvent) { + applyBoundsChanges() + } + } + ) + WindowLocationTracker.onWindowCreated(wnd) + + init(wnd) + window = wnd + + wnd + }, + dispose = { + WindowLocationTracker.onWindowDisposed(it) + // We need to remove them because AWT can still call them after dispose() + listeners.removeFromAndClear(it) + it.dispose() + }, + update = { window -> + updater.update { + set(currentTitle, window::setTitle) + set(currentIcon, window::setIcon) + set(currentDecoration is UndecoratedWindowDecoration, window::setUndecoratedSafely) + set(currentTransparent, window::isTransparent::set) + set(currentResizable, window::setResizable) + set(currentEnabled, window::setEnabled) + set(currentFocusable, window::setFocusableWindowState) + set(currentAlwaysOnTop, window::setAlwaysOnTop) + set(currentMinSize) { window.minimumSize = it.roundToDimensionOrNull() } + set(currentMaxSize) { window.maximumSize = it.roundToDimensionOrNull() } + set(currentDecoration.resizerThickness, window::undecoratedResizerThickness::set) + } + + if (!window.isDisplayable) { + window.initializePlacement(currentState) + window.initializeBounds(currentState) + + // Need to make the window displayable, to make awt.SwingWindow render the first + // frame before the window is visible. + // Check isDisplayable again because initializeBounds could have already + // called pack(), and we don't need to do it twice + if (!window.isDisplayable) { + window.preferredSize = window.size + window.pack() // Sizes to preferred size + } + } + }, + content = content + ) + + LaunchedEffect(window, state) { + val window = window ?: return@LaunchedEffect + launch { + while (isActive) { + window.setScreenFrom(state.screenRequests.receive()) + } + } + launch { + while (isActive) { + window.placement = state.placementRequests.receive() + } + } + launch { + while (isActive) { + window.isMinimized = state.isMinimizedRequests.receive() + } + } + launch { + while (isActive) { + window.setBoundsFrom(state.boundsRequests.receive()) + } + } + } +} + +internal fun WindowScreenProvider.getInitialScreenDevice( + defaultDevice: GraphicsDevice? = null +): GraphicsDevice { + val lastActiveConfig = WindowLocationTracker.lastActiveGraphicsConfiguration + val env = GraphicsEnvironment.getLocalGraphicsEnvironment() + val devices = env.screenDevices + val actualDefaultDevice = defaultDevice + ?: devices.firstOrNull { it.iDstring == lastActiveConfig?.device?.iDstring } + ?: env.defaultScreenDevice + val scope = WindowScreenProviderScope(devices.toList(), actualDefaultDevice) + return with(scope) { + getScreen().device + } +} + +private fun ComposeWindow.initializePlacement(state: WindowState) { + val placementRequest = state.placementRequests.tryReceive().getOrNull() + val currentPlacement = state._placement + + placement = placementRequest ?: currentPlacement ?: WindowPlacement.Floating +} + +internal fun Window.initializeBounds( + boundsRequests: ReceiveChannel, + currentBounds: DpRect?, + parentWindow: Window?, + measurableContentProvider: () -> MeasurableRootContent +) { + var boundsRequest = boundsRequests.tryReceive().getOrNull() + + // Prioritize requests, then currentBounds, then default + if (boundsRequest != null) { + // Apply all pending requests + while (boundsRequest != null) { + setBoundsFrom(boundsRequest, parentWindow, measurableContentProvider) + boundsRequest = boundsRequests.tryReceive().getOrNull() + } + } else if (currentBounds != null) { + bounds = currentBounds.toAwtRectangleSizeRoundedUp() + } else { + setBoundsFrom(WindowBoundsProvider.Default, parentWindow, measurableContentProvider) + } +} + +internal fun Window.setBoundsFrom( + boundsProvider: WindowBoundsProvider, + parentWindow: Window?, + measurableContentProvider: () -> MeasurableRootContent +) { + if (!isDisplayable) { + // Give it a preferred size to avoid measuring via ComposeSceneMediator.preferredSize + // when pack() is called + preferredSize = java.awt.Dimension(0, 0) + pack() + } + + val scope = WindowGeometryProviderScope( + parentWindow = parentWindow, + window = this, + measurableContentProvider = measurableContentProvider + ) + with(scope) { + bounds = boundsProvider.getBounds().requireReal().toAwtRectangleSizeRoundedUp() + } +} + +private fun ComposeWindow.initializeBounds(state: WindowState) { + initializeBounds(state.boundsRequests, state._bounds, null, ::measurableContent) +} + +private fun ComposeWindow.setBoundsFrom(boundsProvider: WindowBoundsProvider) { + setBoundsFrom(boundsProvider, null, ::measurableContent) +} + +internal fun Window.setScreenFrom(screenProvider: WindowScreenProvider) { + val devices = GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices + val defaultDevice = graphicsConfiguration.device + + val scope = WindowScreenProviderScope( + devices = devices.toList(), + defaultDevice = defaultDevice + ) + val device = with(scope) { screenProvider.getScreen().device } + setScreenFrom(device) +} + +/** Moves the window to the given screen, preserving relative position within the screen. */ +private fun Window.setScreenFrom(device: GraphicsDevice) { + if (device == graphicsConfiguration.device) return + + val toolkit = Toolkit.getDefaultToolkit() + + val configuration = device.defaultConfiguration + val screenBounds = configuration.bounds + val screenInsets = toolkit.getScreenInsets(configuration) + + val currentConfiguration = graphicsConfiguration + val currentScreenBounds = currentConfiguration.bounds + val currentScreenInsets = toolkit.getScreenInsets(currentConfiguration) + val currentRelativeX = x - currentScreenBounds.x - currentScreenInsets.left + val currentRelativeY = y - currentScreenBounds.y - currentScreenInsets.top + + setLocation( + screenBounds.x + screenInsets.left + currentRelativeX, + screenBounds.y + screenInsets.top + currentRelativeY, + ) +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/PlatformWindowContext.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/PlatformWindowContext.desktop.kt index 3430b76d7295b..78b7c32ff47ad 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/PlatformWindowContext.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/PlatformWindowContext.desktop.kt @@ -20,12 +20,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.PointerKeyboardModifiers -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.roundToIntSize -import androidx.compose.ui.unit.toIntSize import androidx.compose.ui.unit.toOffset import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.asDpOffset @@ -37,7 +33,6 @@ import java.awt.Container import java.awt.Frame import java.awt.Point import javax.swing.SwingUtilities -import kotlin.math.roundToInt /** * Tracking a state of window. diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeContainer.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeContainer.desktop.kt index 7c7bfbcd0130e..7d25a1797bacd 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeContainer.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeContainer.desktop.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.awt.AwtEventListener import androidx.compose.ui.awt.AwtEventListeners import androidx.compose.ui.awt.RenderSettings import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.layout.MeasurableRootContent import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformWindowContext @@ -217,6 +218,9 @@ internal class ComposeContainer( layers.fastForEach(DesktopComposeSceneLayer::close) } + val measurableContent: MeasurableRootContent + get() = mediator.measurableSceneContent + override fun windowGainedFocus(event: WindowEvent) = onWindowFocusChanged() override fun windowLostFocus(event: WindowEvent) = onWindowFocusChanged() diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt index ead44605eeaca..86a1f6f98c652 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.isClearFocusOnMouseDownEnabled +import androidx.compose.ui.layout.MeasurableRootContent import androidx.compose.ui.navigationevent.BackNavigationEventInput import androidx.compose.ui.platform.AwtDragAndDropManager import androidx.compose.ui.platform.DefaultInputModeManager @@ -365,6 +366,9 @@ internal class ComposeSceneMediator( ) } + val measurableSceneContent: MeasurableRootContent + get() = scene.measurableContent + /** * Keyboard modifiers state might be changed when window is not focused, so window doesn't * receive any key events. diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/ComponentUpdater.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/ComponentUpdater.kt index 309e8a9fdb036..bfa4ea06ffcf2 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/ComponentUpdater.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/ComponentUpdater.kt @@ -17,7 +17,7 @@ package androidx.compose.ui.util /** - * Stores the previous applied state, and provide ability to update component if the new state is + * Stores the previous applied state, and provide an ability to update component if the new state is * changed. */ internal class ComponentUpdater { @@ -34,7 +34,7 @@ internal class ComponentUpdater { * Compare [value] with the old one and if it is changed - store a new value and call * [update] */ - fun set(value: T, update: (T) -> Unit) { + fun set(value: T, update: (T) -> Unit) { if (index < updatedValues.size) { if (updatedValues[index] != value) { update(value) diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/Windows.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/Windows.desktop.kt index f6e5eea38655c..e43e1318d7f97 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/Windows.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/Windows.desktop.kt @@ -57,7 +57,7 @@ internal fun Window.setSizeSafely(size: DpSize, placement: WindowPlacement) { /** * Sets the position of the window, given its placement. - * If the window is already visible, then change the position only if it's floating, in order to + * If the window is already visible, then change the position only if it's floating to * avoid resetting the maximized / fullscreen state. * If the window is not visible yet, we _do_ set its size so that it will have an "un-maximized" * position to go to when the user un-maximizes the window. @@ -126,24 +126,29 @@ internal fun Window.setPositionImpl( platformDefaultPosition: () -> Point ) = when (position) { WindowPosition.PlatformDefault -> location = platformDefaultPosition() - is WindowPosition.Aligned -> align(position.alignment) + is WindowPosition.Aligned -> alignToScreen(position.alignment) is WindowPosition.Absolute -> setLocation( position.x.value.roundToInt(), position.y.value.roundToInt() ) } -internal fun Window.align(alignment: Alignment) { +internal fun Window.alignToScreen(alignment: Alignment) { + location = locationAlignedToScreen( + windowSize = IntSize(width, height), + alignment = alignment + ) +} + +internal fun Window.locationAlignedToScreen(windowSize: IntSize, alignment: Alignment): Point { val screenInsets = Toolkit.getDefaultToolkit().getScreenInsets(graphicsConfiguration) val screenBounds = graphicsConfiguration.bounds - val size = IntSize(size.width, size.height) val screenSize = IntSize( screenBounds.width - screenInsets.left - screenInsets.right, screenBounds.height - screenInsets.top - screenInsets.bottom ) - val location = alignment.align(size, screenSize, LayoutDirection.Ltr) - - setLocation( + val location = alignment.align(windowSize, screenSize, LayoutDirection.Ltr) + return Point( screenBounds.x + screenInsets.left + location.x, screenBounds.y + screenInsets.top + location.y ) diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Geometry.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Geometry.desktop.kt index 250a76b849895..d67319e6c2780 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Geometry.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Geometry.desktop.kt @@ -16,13 +16,17 @@ package androidx.compose.ui.window +import androidx.compose.ui.unit.DpInsets import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import java.awt.Dimension +import java.awt.Insets import java.awt.Point import java.awt.Rectangle +import kotlin.math.roundToInt internal val Dimension.rightBottom get() = Point(width, height) internal operator fun Point.plus(other: Point) = Point(x + other.x, y + other.y) @@ -36,6 +40,23 @@ internal fun Point.asDpOffset() = DpOffset(x.dp, y.dp) internal fun Rectangle.asDpRect() = DpRect( left = x.dp, top = y.dp, - right = x.dp + width.dp, - bottom = y.dp + height.dp + right = (x + width).dp, + bottom = (y + height).dp ) + +internal fun DpSize.roundToDimension() = Dimension( + width.value.roundToInt(), + height.value.roundToInt() +) +internal fun DpSize.roundToDimensionOrNull() = + if (isSpecified) roundToDimension() else null + +/** + * Converts AWT [Insets] to [DpInsets]. + */ +internal fun Insets.toDpInsets() = DpInsets( + top = top.dp, + left = left.dp, + bottom = bottom.dp, + right = right.dp +) \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/LayoutConfiguration.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/LayoutConfiguration.desktop.kt index 28a3250a70fbc..b447b0889d46e 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/LayoutConfiguration.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/LayoutConfiguration.desktop.kt @@ -78,6 +78,6 @@ internal fun layoutDirectionFor(component: Component): LayoutDirection { orientation.layoutDirection } else { // To preserve backwards compatibility we fall back to the locale - return component.locale.layoutDirection + component.locale.layoutDirection } } diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Window.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Window.desktop.kt index e73bb79bd4f55..6055d6f5bf582 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Window.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Window.desktop.kt @@ -32,6 +32,7 @@ import java.awt.Window import javax.swing.JMenuBar // TODO(demin): support focus management +// https://youtrack.jetbrains.com/issue/CMP-10092/Window-API.-Support-focus-management /** * Composes platform window in the current composition. When [Window] enters the composition, * a new platform window will be created and receive focus. When [Window] leaves the composition, @@ -594,7 +595,7 @@ interface FrameWindowScope : WindowScope { interface SingleWindowApplicationScope: ApplicationScope, FrameWindowScope @Composable -private fun SingleWindowApplicationScope( +internal fun SingleWindowApplicationScope( applicationScope: ApplicationScope, windowScope: FrameWindowScope ): SingleWindowApplicationScope { diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowLocationTracker.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowLocationTracker.desktop.kt index 38d2947bc1269..d1d95496c3afe 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowLocationTracker.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowLocationTracker.desktop.kt @@ -16,8 +16,9 @@ package androidx.compose.ui.window +import java.awt.Dimension import java.awt.GraphicsConfiguration -import java.awt.GraphicsEnvironment +import java.awt.GraphicsDevice import java.awt.Point import java.awt.Toolkit import java.awt.Window @@ -25,23 +26,24 @@ import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener /** - * Track position of all opened windows and provide an appropriate location for new created windows. + * Track the position of all opened windows and provide an appropriate location for newly created + * windows. * * Needed to place windows in cascade, and on the same screen. * * Singleton because we have only the single platform. - * We basically override the standard behaviour of the window manager. + * We basically override the standard behavior of the window manager. */ internal object WindowLocationTracker { private val cascadeOffset = Point(48, 48) - private var lastFocusedWindows = mutableSetOf() + private var windowsOrderedByLastFocused = mutableSetOf() private val focusListener = object : WindowFocusListener { override fun windowGainedFocus(e: WindowEvent) { // put window on the top of the set - lastFocusedWindows.remove(e.window) - lastFocusedWindows.add(e.window) + windowsOrderedByLastFocused.remove(e.window) + windowsOrderedByLastFocused.add(e.window) } override fun windowLostFocus(e: WindowEvent) = Unit @@ -53,32 +55,39 @@ internal object WindowLocationTracker { fun onWindowDisposed(window: Window) { window.removeWindowFocusListener(focusListener) - lastFocusedWindows.remove(window) + windowsOrderedByLastFocused.remove(window) } val lastActiveGraphicsConfiguration: GraphicsConfiguration? get() = - lastFocusedWindows.lastOrNull()?.graphicsConfiguration + windowsOrderedByLastFocused.lastOrNull()?.graphicsConfiguration fun getCascadeLocationFor(window: Window): Point { - val lastWindow = lastFocusedWindows.lastOrNull() - val graphicsConfiguration = lastWindow?.graphicsConfiguration ?: - GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice?.defaultConfiguration + return getCascadeLocationFor( + graphicsDevice = window.graphicsConfiguration.device, + windowSize = window.size + ) + } + + fun getCascadeLocationFor( + graphicsDevice: GraphicsDevice, + windowSize: Dimension + ): Point { + val lastFocusedWindow = windowsOrderedByLastFocused.lastOrNull { + it.graphicsConfiguration.device == graphicsDevice + } - return if (graphicsConfiguration != null) { - val screenBounds = graphicsConfiguration.bounds - val screenInsets = Toolkit.getDefaultToolkit().getScreenInsets(graphicsConfiguration) - val screenLeftTop = screenBounds.leftTop + Point(screenInsets.left, screenInsets.top) - val screenRightBottom = screenBounds.rightBottom - Point(screenInsets.right, screenInsets.bottom) + val graphicsConfiguration = graphicsDevice.defaultConfiguration + val screenBounds = graphicsConfiguration.bounds + val screenInsets = Toolkit.getDefaultToolkit().getScreenInsets(graphicsConfiguration) + val screenLeftTop = screenBounds.leftTop + Point(screenInsets.left, screenInsets.top) + val screenRightBottom = screenBounds.rightBottom - Point(screenInsets.right, screenInsets.bottom) - val lastLocation = lastWindow?.location ?: screenLeftTop - var location = lastLocation + cascadeOffset - val rightBottom = location + window.size.rightBottom - if (rightBottom.x > screenRightBottom.x || rightBottom.y > screenRightBottom.y) { - location = screenLeftTop + cascadeOffset - } - location - } else { - cascadeOffset + val lastLocation = lastFocusedWindow?.location ?: screenLeftTop + var location = lastLocation + cascadeOffset + val rightBottom = location + windowSize.rightBottom + if (rightBottom.x > screenRightBottom.x || rightBottom.y > screenRightBottom.y) { + location = screenLeftTop + cascadeOffset } + return location } } diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowPlacement.desktop.kt.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowPlacement.desktop.kt.kt index f825474905a86..63b69e7fb08d7 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowPlacement.desktop.kt.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowPlacement.desktop.kt.kt @@ -21,21 +21,21 @@ package androidx.compose.ui.window */ enum class WindowPlacement { /** - * Window don't occupy the all available space and can be moved and resized by the user. + * Window doesn't occupy the all available space and can be moved and resized by the user. */ Floating, /** * The window is maximized and occupies all available space on the screen excluding * the space that is occupied by the screen insets (taskbar/dock and top-level application menu - * on macOs). + * on macOS). */ Maximized, /** * The window is in fullscreen mode and occupies all available space of the screen, * including the space that is occupied by the screen insets (taskbar/dock and top-level - * application menu on macOs). + * application menu on macOS). */ Fullscreen } \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowPosition.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowPosition.desktop.kt index 7d8cf5af86a57..a4e96ca575d10 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowPosition.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowPosition.desktop.kt @@ -29,6 +29,7 @@ fun WindowPosition(x: Dp, y: Dp) = WindowPosition.Absolute(x, y) /** * Constructs an [WindowPosition.Aligned] from [alignment] value. */ +@Suppress("DEPRECATION") fun WindowPosition(alignment: Alignment) = WindowPosition.Aligned(alignment) /** diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowState.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowState.desktop.kt index dba8095a4cca9..41d646762b0df 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowState.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/WindowState.desktop.kt @@ -19,7 +19,6 @@ package androidx.compose.ui.window import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/DialogState.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/DialogState.desktop.kt new file mode 100644 index 0000000000000..edffcf09400e0 --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/DialogState.desktop.kt @@ -0,0 +1,424 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.toAwtRectangleRounded +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.isFinite +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.requireReal +import androidx.compose.ui.unit.size +import androidx.compose.ui.unit.topLeft +import java.awt.Rectangle +import kotlinx.coroutines.channels.Channel + + +/** + * Creates a [DialogState] that is remembered across compositions. + * + * Changes to the provided initial values will **not** result in the state being recreated or + * changed in any way if it has already been created. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param initialPosition The initial position of the dialog; default if `null`. All the + * coordinates must be [Dp.isSpecified] and [Dp.isFinite], and the [DpOffset] object itself must be + * [DpOffset.isSpecified]. + * @param initialSize The initial size of the dialog; default if `null`. All the + * coordinates must be [Dp.isSpecified] and [Dp.isFinite], and the [DpSize] object itself must be + * [DpOffset.isSpecified]. + */ +@ExperimentalComposeUiApi +@Composable +fun rememberDialogStateWithBounds( + initialPosition: DpOffset? = null, + initialSize: DpSize? = null, +): DialogState = rememberSaveable(saver = DialogState.Saver) { + DialogStateWithBounds( + initialPosition = initialPosition, + initialSize = initialSize, + ) +} + +/** + * Creates a [DialogState] that is remembered across compositions. + * + * Changes to the provided initial values will **not** result in the state being recreated or + * changed in any way if it has already been created. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param initialScreenProvider Provides the initial screen on which the dialog will be placed. + * @param initialBoundsProvider Provides the initial bounds of the dialog. + */ +@ExperimentalComposeUiApi +@Composable +fun rememberDialogState( + initialScreenProvider: WindowScreenProvider = WindowScreenProvider.Default, + initialBoundsProvider: WindowBoundsProvider = WindowBoundsProvider.Default, +): DialogState = rememberSaveable(saver = DialogState.Saver) { + DialogState( + initialScreenProvider = initialScreenProvider, + initialBoundsProvider = initialBoundsProvider, + ) +} + + +/** + * Creates a [DialogState] with the specified initial values. + * + * Changes to the provided initial values will **not** result in the state being recreated or + * changed in any way if it has already been created. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param initialSize The initial size of the dialog; default if `null`. All the + * coordinates must be [Dp.isSpecified] and [Dp.isFinite], and the [DpSize] object itself must be + * [DpOffset.isSpecified]. + * @param initialPosition The initial position of the dialog; default if `null`. All the + * coordinates must be [Dp.isSpecified] and [Dp.isFinite], and the [DpOffset] object itself must be + * [DpOffset.isSpecified]. + */ +@ExperimentalComposeUiApi +fun DialogStateWithBounds( + initialSize: DpSize? = null, + initialPosition: DpOffset? = null, +): DialogState { + val sizeProvider = + initialSize?.let { WindowSizeProvider.Fixed(it) } ?: WindowSizeProvider.Default + val positionProvider = + initialPosition?.let { WindowPositionProvider.Absolute(it) } ?: WindowPositionProvider.Default + return DialogState( + initialBoundsProvider = WindowBoundsProvider(sizeProvider, positionProvider), + ) +} + +/** + * Creates a [DialogState] with the specified initial bounds provider. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param initialScreenProvider Provides the initial screen on which the dialog will be placed. + * @param initialBoundsProvider Provides the initial bounds of the dialog. + */ +@ExperimentalComposeUiApi +fun DialogState( + initialScreenProvider: WindowScreenProvider = WindowScreenProvider.Default, + initialBoundsProvider: WindowBoundsProvider = WindowBoundsProvider.Default, +): DialogState = DialogState.createUninitialized().apply { + requestScreen(initialScreenProvider) + requestBounds(initialBoundsProvider) +} + +/** + * A state object that can be hoisted to control and observe dialog attributes (size, position). + * + * Note: this class may be moved to `androidx.compose.ui.window` before stabilization. + */ +@Stable +@ExperimentalComposeUiApi +class DialogState private constructor( + isInitialized: Boolean, + screenId: String?, + bounds: DpRect?, +) { + /** + * Creates a new [DialogState] that is initialized with the specified values. + */ + internal constructor( + screenId: String, + bounds: DpRect, + ): this( + isInitialized = true, + screenId = screenId, + bounds = bounds, + ) + + init { + bounds?.requireReal() + } + + /** + * Whether the dialog associated with this state has become visible at least once. + */ + var isInitialized: Boolean by mutableStateOf(isInitialized) + internal set + + /** + * The id of the screen with which the dialog is currently associated; `null` if the dialog is + * not yet [isInitialized]. + */ + @Suppress("PropertyName") + internal var _screenId: String? by mutableStateOf(screenId) + + /** + * The id of the screen with which the dialog is currently associated; throws + * [IllegalStateException] if the dialog is not yet [isInitialized]. + */ + val screenId: String + get() = _screenId ?: dialogNotInitializedError("screenId") + + internal val screenRequests = Channel(Channel.CONFLATED) + + /** + * Requests to position the dialog on the specified screen. + * + * Note that the actual positioning is done asynchronously. + */ + fun requestScreen(screenProvider: WindowScreenProvider) { + screenRequests.trySend(screenProvider) + } + + /** + * The current bounds of the dialog; `null` if the dialog is not yet [isInitialized]. + */ + @Suppress("PropertyName") + internal var _bounds: DpRect? by mutableStateOf(bounds) + + /** + * The current bounds of the dialog; throws [IllegalStateException] if the dialog is not yet + * [isInitialized]. + */ + val bounds: DpRect + get() = _bounds ?: dialogNotInitializedError("bounds") + + internal val boundsRequests = Channel(Channel.UNLIMITED) + + /** + * Requests to set the bounds of the dialog via a [WindowBoundsProvider]. + * + * Note that the actual bounds are set asynchronously and may be different from the requested + * ones (e.g., if the window manager can't position as requested). + * + * @param boundsProvider Provides the bounds to apply to the window. + */ + fun requestBounds(boundsProvider: WindowBoundsProvider) { + boundsRequests.trySend(boundsProvider) + } + + /** + * Requests to set the bounds of the dialog via a function that returns a [DpRect]. + * + * Note that the actual bounds are set asynchronously and may be different from the requested + * ones (e.g., if the window manager can't position as requested). + * + * @param boundsProvider Returns the bounds to apply to the window. + */ + fun requestBounds(boundsProvider: WindowGeometryProviderScope.() -> DpRect) { + boundsRequests.trySend(WindowBoundsProvider(boundsProvider)) + } + + /** + * Requests to set the bounds of the dialog. + * + * This is the same as using [WindowBoundsProvider.Absolute]. + * + * + * Note that the actual bounds are set asynchronously and may be different from the requested + * ones (e.g., if the window manager can't position as requested). + * + * @param bounds The bounds to apply to the window. All the coordinates must be [Dp.isSpecified] + * and [Dp.isFinite]. + */ + fun requestBounds(bounds: DpRect) { + boundsRequests.trySend( + WindowBoundsProvider.Absolute(bounds) + ) + } + + /** + * The current position of the dialog; throws [IllegalStateException] if the dialog is not yet + * [isInitialized]. + */ + val position: DpOffset + get() = _bounds?.topLeft ?: dialogNotInitializedError("position") + + /** + * Requests to set the position of the dialog via a [WindowPositionProvider]. + * + * Note that the actual position is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't position as requested). + * + * @param positionProvider Provides the position to apply to the dialog. + */ + fun requestPosition(positionProvider: WindowPositionProvider) { + boundsRequests.trySend( + WindowBoundsProvider( + positionProvider = positionProvider, + ) + ) + } + + /** + * Requests to set the position of the dialog. + * + * Note that the actual position is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't position as requested). + * + * @param position The position to apply to the dialog. The value must be [DpOffset.isSpecified] + * and all the coordinates must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun requestPosition(position: DpOffset) { + boundsRequests.trySend( + WindowBoundsProvider( + positionProvider = WindowPositionProvider.Absolute(position), + ) + ) + } + + /** + * Requests to set the position of the dialog. + * + * Note that the actual position is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't position as requested). + * + * @param x The x coordinate. The value must be [Dp.isSpecified] and [Dp.isFinite]. + * @param y The y coordinate. The value must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun requestPosition(x: Dp, y: Dp) { + boundsRequests.trySend( + WindowBoundsProvider( + positionProvider = WindowPositionProvider.Absolute(x, y), + ) + ) + } + + /** + * The current size of the dialog; throws [IllegalStateException] if the dialog is not yet + * [isInitialized]. + */ + val size: DpSize + get() = _bounds?.size ?: dialogNotInitializedError("size") + + /** + * Requests to set the size of the dialog via a [WindowSizeProvider]. + * + * Note that the actual size is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't size as requested). + * + * @param sizeProvider Provides the size to apply to the dialog. + */ + fun requestSize(sizeProvider: WindowSizeProvider) { + boundsRequests.trySend( + WindowBoundsProvider( + sizeProvider = sizeProvider, + ) + ) + } + + /** + * Requests to set the size of the dialog. + * + * Note that the actual size is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't size as requested). + * + * @param size The position to apply to the dialog. The value must be [DpSize.isSpecified] + * and all the coordinates must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun requestSize(size: DpSize) { + boundsRequests.trySend( + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(size), + ) + ) + } + + /** + * Requests to set the size of the dialog. + * + * Note that the actual size is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't size as requested). + * + * @param width The width. The value must be [Dp.isSpecified] and [Dp.isFinite]. + * @param height The height. The value must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun requestSize(width: Dp, height: Dp) { + boundsRequests.trySend( + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(width, height), + ) + ) + } + + @ExperimentalComposeUiApi + companion object { + /** + * Creates a new [DialogState] that is not yet initialized. + */ + internal fun createUninitialized() = + DialogState( + isInitialized = false, + screenId = null, + bounds = null + ) + + /** + * A [Saver] implementation for [DialogState]. + */ + val Saver: Saver = listSaver( + save = { + if (!it.isInitialized) return@listSaver emptyList() + val bounds = it.bounds + arrayListOf( + it.screenId, + bounds.top.value, + bounds.left.value, + bounds.right.value, + bounds.bottom.value, + ) + }, + restore = { state -> + if (state.isEmpty()) return@listSaver null + DialogState( + screenId = state[0] as String, + bounds = DpRect( + top = Dp(state[1] as Float), + left = Dp(state[2] as Float), + right = Dp(state[3] as Float), + bottom = Dp(state[4] as Float) + ) + ) + } + ) + } +} + +/** + * Returns the bounds of the dialog, as an AWT [Rectangle]; throws [IllegalStateException] if the + * window is not yet [isInitialized]. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +val DialogState.awtBounds: Rectangle + get() = bounds.toAwtRectangleRounded() + +private fun dialogNotInitializedError(propertyName: String): Nothing = + throw IllegalStateException("Can't read $propertyName before the dialog has been made visible;" + + " use isInitialized to check.") \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/DialogWindow.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/DialogWindow.desktop.kt new file mode 100644 index 0000000000000..5bd9a13a0857d --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/DialogWindow.desktop.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.LocalAwtWindow +import androidx.compose.ui.awt.toAwtModalityType +import androidx.compose.ui.awt.v2.SwingDialog +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.DialogModalityType +import androidx.compose.ui.window.DialogWindowScope +import androidx.compose.ui.window.WindowDecoration +import java.awt.Window + +/** + * Composes platform dialog in the current composition. When [DialogWindow] enters the composition, + * a new platform dialog will be created and receive focus. When [DialogWindow] leaves the + * composition, the dialog will be disposed and closed. + * + * Dialog is a modal window. It means it blocks the parent [Window] / [DialogWindow] in whose + * composition context it was created. + * + * Usage: + * ``` + * @Composable + * fun main() = application { + * var isDialogOpen by remember { mutableStateOf(true) } + * if (isDialogOpen) { + * DialogWindow(onCloseRequest = { isDialogOpen = false }) {} + * } + * } + * ``` + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param onCloseRequest Callback that will be called when the user closes the dialog. + * Usually in this callback we need to manually tell Compose what to do: + * - Change `isOpen` state of the dialog (which is manually defined) + * - Close the whole application (`onCloseRequest = ::exitApplication` in [ApplicationScope]) + * - Don't close the dialog on close request (`onCloseRequest = {}`) + * @param state The state object to control and observe the dialog's state. + * @param visible Whether the dialog is visible to the user. + * When `false`: + * - The internal state of the [DialogWindow] is preserved and will be restored the next time the + * dialog will be made visible; + * - Native resources will not be released. They will be released only when [DialogWindow] leaves + * the composition. + * @param title The title of the dialog. + * @param icon The icon of the window (for platforms that support this). + * On macOS individual windows can't have a separate icon. To change the icon in the Dock, + * set it via `iconFile` in build.gradle or via an `-Xdock:icon=...` parameter to the process + * (https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html#platform-specific-options) + * @param decoration Specifies the decoration for this dialog. + * @param transparent Controls dialog transparency. Only an undecorated dialog may be transparent. + * Attempting to make a decorated dialog transparent will throw an exception. + * @param resizable Whether the user can resize the dialog (application can resize the dialog by + * changing [state] regardless of this parameter). + * @param enabled Whether the dialog reacts to input events. + * @param focusable Whether the dialog can receive focus. + * @param alwaysOnTop whether the dialog will always be on top of other windows and dialogs in the + * application. + * @param minSize The minimum dialog size. This will prevent the user from resizing the dialog + * to smaller than the specified value. A value of [DpSize.Unspecified] means no minimum. + * Note that some window managers may not respect this. + * @param maxSize The maximum dialog size. This will prevent the user from resizing the dialog + * to larger than the specified value. A value of [DpSize.Unspecified] means no maximum. + * Note that some window managers may not respect this. + * @param modalityType Modality type for the dialog. A top-level dialog cannot be + * [DialogModalityType.DocumentModal] + * @param onPreviewKeyEvent Invoked when the dialog receives a key event, before it is sent to the + * [content]. The return value controls whether the key event will be sent to the [content] + * afterward. Return `true` to consume it, preventing further processing. + * @param onKeyEvent Invoked when the dialog receives a key event, after it has been sent to + * [content], only if nothing there had consumed it. The return value controls whether the key + * event will be processed further (e.g., by the system). Return `true` to consume it, preventing + * further processing. + * @param content Composable content of the dialog. + */ +@ExperimentalComposeUiApi +@Composable +@ComposableOpenTarget(-1) +fun DialogWindow( + onCloseRequest: () -> Unit, + state: DialogState = rememberDialogState(), + visible: Boolean = true, + title: String = "Untitled", + icon: Painter? = null, + decoration: WindowDecoration = WindowDecoration.SystemDefault, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + modalityType: DialogModalityType = defaultDialogModality(), + onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false }, + onKeyEvent: ((KeyEvent) -> Boolean) = { false }, + content: @Composable DialogWindowScope.() -> Unit +) { + SwingDialog( + onCloseRequest = onCloseRequest, + parentWindow = LocalAwtWindow.current, + state = state, + visible = visible, + title = title, + icon = icon, + decoration = decoration, + transparent = transparent, + resizable = resizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + minSize = minSize, + maxSize = maxSize, + modalityType = modalityType.toAwtModalityType(), + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + init = { }, + content = content, + ) +} + +@Composable +private fun defaultDialogModality() = + if (LocalAwtWindow.current == null) { + DialogModalityType.ApplicationModal + } else { + DialogModalityType.DocumentModal + } diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/Screen.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/Screen.desktop.kt new file mode 100644 index 0000000000000..f4fa63cdacb82 --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/Screen.desktop.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.DpInsets +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.minus +import androidx.compose.ui.window.toDpInsets +import androidx.compose.ui.window.asDpRect +import java.awt.GraphicsDevice +import java.awt.Toolkit + + +/** + * Represents a screen (a graphical device on which windows can be rendered). + * + * Note that a [Screen] holds a reference to an underlying native object representing it. + * Additionally, screens can come and go (the user may disconnect one, for example). + * Therefore, it is highly discouraged to keep long-term references to [Screen] objects, beyond + * their use in [WindowScreenProviderScope] or [WindowGeometryProviderScope]. + * + * Note: this class may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +class Screen internal constructor( + internal val device: GraphicsDevice +) { + + /** + * The identifier of the screen. + */ + val id: String = device.iDstring + + private val configuration + get() = device.defaultConfiguration + + /** + * The bounds of the screen in the coordinate system of all screens. + * + * Note that the coordinates may be negative, as the screen may be positioned + * to the left or above the primary screen. + */ + val bounds: DpRect + get() = configuration.bounds.asDpRect() + + /** + * The insets of the screen. + */ + val insets: DpInsets + get() = Toolkit.getDefaultToolkit().getScreenInsets(configuration).toDpInsets() + + /** + * The bounds of the screen excluding the insets. + */ + val availableBounds: DpRect + get() = bounds - insets + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Screen) return false + + return this.id == other.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun toString(): String = "Screen $id" +} diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/Window.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/Window.desktop.kt new file mode 100644 index 0000000000000..6a179020ae104 --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/Window.desktop.kt @@ -0,0 +1,239 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.v2.SwingWindow +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.SingleWindowApplicationScope +import androidx.compose.ui.window.WindowDecoration +import androidx.compose.ui.window.application + +// TODO(demin): support focus management +// https://youtrack.jetbrains.com/issue/CMP-10092/Window-API.-Support-focus-management +/** + * Composes a platform window in the current composition. When [Window] enters the composition, + * a new platform window will be created and receive focus. When [Window] leaves the composition, + * the window will be disposed and closed. + * + * The placement and positioning of the window is controlled via [WindowState]. + * + * [onCloseRequest] is called when the user asks to close the window. To close all windows and shut + * down the application, use ([ApplicationScope.exitApplication]: + * ``` + * fun main() = application { + * Window(onCloseRequest = ::exitApplication) { ... } + * } + * ``` + * + * To merely close the window, use: + * ``` + * fun main() = application { + * var isOpen by remember { mutableStateOf(true) } + * if (isOpen) { + * Window(onCloseRequest = { isOpen = false }) { ... } + * } + * } + * ``` + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param onCloseRequest Callback that will be called when the user tries to close the window. + * @param state The state object to control and observe the window's state. + * @param visible Whether the window is visible to the user. + * When `false`: + * - The internal state of the [Window] is preserved and will be restored the next time the window + * will be made visible; + * - Native resources will not be released. They will be released only when [Window] leaves the + * composition. + * @param title The title of the window. + * @param icon The icon of the window (for platforms that support this). + * On macOS individual windows can't have a separate icon. To change the icon in the Dock, + * set it via `iconFile` in build.gradle or via an `-Xdock:icon=...` parameter to the process + * (https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html#platform-specific-options) + * @param decoration Specifies the decoration for this window. + * @param transparent Controls window transparency. Only an undecorated window may be transparent. + * Attempting to make a decorated window transparent will throw an exception. + * @param resizable Whether the user can resize the window (application can resize the window by + * changing [state] regardless of this parameter). + * @param enabled Whether the window reacts to input events. + * @param focusable Whether the window can receive focus. + * @param alwaysOnTop whether the window will always be on top of other windows and dialogs in the + * application. + * @param minSize The minimum window size. This will prevent the user from resizing the window + * to smaller than the specified value. A value of [DpSize.Unspecified] means no minimum. + * Note that some window managers may not respect this. + * @param maxSize The maximum window size. This will prevent the user from resizing the window + * to larger than the specified value. A value of [DpSize.Unspecified] means no maximum. + * Note that some window managers may not respect this. + * @param onPreviewKeyEvent Invoked when the window receives a key event, before it is sent to the + * [content]. The return value controls whether the key event will be sent to the [content] + * afterward. Return `true` to consume it, preventing further processing. + * @param onKeyEvent Invoked when the window receives a key event, after it has been sent to + * [content], only if nothing there had consumed it. The return value controls whether the key + * event will be processed further (e.g., by the system). Return `true` to consume it, preventing + * further processing. + * @param content Composable content of the window. + */ +@ExperimentalComposeUiApi +@Composable +@ComposableOpenTarget(-1) +fun Window( + onCloseRequest: () -> Unit, + state: WindowState = rememberWindowState(), + visible: Boolean = true, + title: String = "Untitled", + icon: Painter? = null, + decoration: WindowDecoration = WindowDecoration.SystemDefault, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + content: @Composable FrameWindowScope.() -> Unit +) { + SwingWindow( + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + decoration = decoration, + transparent = transparent, + resizable = resizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + init = { }, + content = content, + ) +} + +/** + * An entry point for Compose applications with a single top-level window. + * + * To show more than one top-level window, or to implement custom closing logic, use + * Composable [androidx.compose.ui.window.v2.Window] in [application] entry point instead: + * ``` + * application { + * Window(...) { } + * Window(onCloseRequest = { ... } ) { } + * } + * ``` + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * Set [exitProcessOnExit] to `false` to execute code after the [singleWindowApplication] block, + * otherwise it won't be executed as [singleWindowApplication] will exit the process. + * + * @param state The state object to be used to control or observe the window's state + * @param visible Whether the window is visible to the user. + * When `false`: + * - The internal state of the [Window] is preserved and will be restored the next time the window + * will be made visible; + * - Native resources will not be released. They will be released only when [Window] leaves the + * composition. + * @param title The title of the window. + * @param icon The icon of the window (for platforms that support this). + * On macOS individual windows can't have a separate icon. To change the icon in the Dock, + * set it via `iconFile` in build.gradle or via an `-Xdock:icon=...` parameter to the process + * (https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html#platform-specific-options) + * @param decoration Specifies the decoration for this window. + * @param transparent Controls window transparency. Only an undecorated window may be transparent. + * Attempting to make a decorated window transparent will throw an exception. + * @param resizable Whether the user can resize the window (application can resize the window by + * changing [state] regardless of this parameter). + * @param enabled Whether the window reacts to input events. + * @param focusable Whether the window can receive focus. + * @param alwaysOnTop whether the window will always be on top of other windows and dialogs in the + * application. + * @param minSize The minimum window size. This will prevent the user from resizing the window + * to smaller than the specified value. A value of [DpSize.Unspecified] means no minimum. + * Note that some window managers may not respect this. + * @param maxSize The maximum window size. This will prevent the user from resizing the window + * to larger than the specified value. A value of [DpSize.Unspecified] means no maximum. + * Note that some window managers may not respect this. + * @param onPreviewKeyEvent Invoked when the window receives a key event, before it is sent to the + * [content]. The return value controls whether the key event will be sent to the [content] + * afterward. Return `true` to consume it, preventing further processing. + * @param onKeyEvent Invoked when the window receives a key event, after it has been sent to + * [content], only if nothing there had consumed it. The return value controls whether the key + * event will be processed further (e.g., by the system). Return `true` to consume it, preventing + * further processing. + * @param exitProcessOnExit Whether `exitProcess(0)` will be called after the window is closed. + * `exitProcess` speeds up process exit (instant instead of 1-4sec). + * If `false`, the execution of the function will be unblocked after application is exited + * (when the last window is closed, and all [LaunchedEffect]s are complete). + * @param content Composable content of the window. + */ +@ExperimentalComposeUiApi +fun singleWindowApplication( + state: WindowState = WindowState(), + visible: Boolean = true, + title: String = "Untitled", + icon: Painter? = null, + decoration: WindowDecoration = WindowDecoration.SystemDefault, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + exitProcessOnExit: Boolean = true, + content: @Composable SingleWindowApplicationScope.() -> Unit +) = application(exitProcessOnExit = exitProcessOnExit) { + Window( + onCloseRequest = ::exitApplication, + state = state, + visible = visible, + title = title, + icon = icon, + decoration = decoration, + transparent = transparent, + resizable = resizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + content = { + with(SingleWindowApplicationScope(this@application, this@Window)) { + content() + } + } + ) +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/WindowGeometryProviders.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/WindowGeometryProviders.desktop.kt new file mode 100644 index 0000000000000..39d8a0b232878 --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/WindowGeometryProviders.desktop.kt @@ -0,0 +1,703 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.MeasurableRootContent +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpInsets +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.coerceAtMost +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.isFinite +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.minus +import androidx.compose.ui.unit.plus +import androidx.compose.ui.unit.requireReal +import androidx.compose.ui.unit.roundToIntSize +import androidx.compose.ui.unit.size +import androidx.compose.ui.unit.topLeft +import androidx.compose.ui.unit.width +import androidx.compose.ui.window.WindowLocationTracker +import androidx.compose.ui.window.density +import androidx.compose.ui.window.roundToDimension +import androidx.compose.ui.window.toDpInsets +import androidx.compose.ui.window.asDpOffset +import androidx.compose.ui.window.asDpRect +import java.awt.GraphicsDevice +import kotlin.math.roundToInt + + +/** + * The scope in which [WindowScreenProvider] is evaluated. + * + * Note: this class may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +class WindowScreenProviderScope internal constructor( + devices: List, + defaultDevice: GraphicsDevice, +) { + /** + * The list of screens on which the window can be placed. + */ + val screens: List = devices.map { Screen(it) } + + /** + * The default screen, on which the window should typically be placed. + */ + val defaultScreen: Screen = Screen(defaultDevice) + + /** + * Evaluates the given [WindowScreenProvider] in this scope. + */ + internal fun WindowScreenProvider.getScreen(): Screen = with(this) { + this@WindowScreenProviderScope.getScreen() + } +} + +/** + * Provides the screen on which the window will be placed. + * + * Note: this interface may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +fun interface WindowScreenProvider { + /** + * Returns the screen on which the window will be placed. + * + * When implementing this function, use the given [WindowGeometryProviderScope] to examine the + * available screens and determine the appropriate one for the window. + */ + fun WindowScreenProviderScope.getScreen(): Screen + + @ExperimentalComposeUiApi + companion object { + /** + * Returns the default screen for a new window. + */ + val Default = WindowScreenProvider { defaultScreen } + } +} + +/** + * The various properties of a window that are useful in a [WindowGeometryProviderScope]. + * + * Note: this class may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +class WindowMetrics internal constructor( + private val window: java.awt.Window +) { + /** + * The screen on which the window is placed. + */ + val screen: Screen by lazy { Screen(window.graphicsConfiguration.device) } + + /** + * The bounds of the entire window (including insets) on the screen. + */ + val bounds: DpRect + get() = window.bounds.asDpRect() + + /** + * The window's insets (the sizes of the areas where the content isn't placed, such as the title + * bar). + */ + val insets: DpInsets + get() = window.insets.toDpInsets() +} + +/** + * The scope in which window geometry providers (e.g. [WindowBoundsProvider]) are evaluated. + * + * Note: this class may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +class WindowGeometryProviderScope internal constructor( + parentWindow: java.awt.Window?, + private val window: java.awt.Window, + private val measurableContentProvider: () -> MeasurableRootContent, +): Density { + init { + require(window.isDisplayable) { + "Window must be displayable before it can be used in WindowGeometryProviderScope" + } + } + + /** + * The density of the window. + */ + private val windowDensity: Density + get() = window.density + + override val density: Float + get() = windowDensity.density + + override val fontScale: Float + get() = windowDensity.fontScale + + /** + * The metrics of the parent window, if any. + */ + val parentWindowMetrics: WindowMetrics? = parentWindow?.let { WindowMetrics(it) } + + /** + * The window's metrics. + */ + val windowMetrics: WindowMetrics = WindowMetrics(window) + + /** + * Returns the size a window should have, given the size of its content. + * + * The content size is expanded by the window's insets and then constrained to + * [Screen.availableBounds]. + */ + fun contentToWindowSize(contentSize: DpSize): DpSize = + with(windowMetrics) { + (contentSize + insets).coerceAtMost(screen.availableBounds.size) + } + + /** + * Represents the composable content of the window, which can be queried for its preferred size + * properties. + */ + val windowContent: MeasurableRootContent + get() = measurableContentProvider() + + /** + * Evaluates the given [WindowSizeProvider] in this scope. + */ + internal fun WindowSizeProvider.getSize(): DpSize = with(this) { + this@WindowGeometryProviderScope.getSize() + } + + /** + * Evaluates the given [WindowPositionProvider] in this scope. + */ + internal fun WindowPositionProvider.getPosition(size: DpSize): DpOffset = with(this) { + this@WindowGeometryProviderScope.getPosition(size) + } + + /** + * Evaluates the given [WindowBoundsProvider] in this scope. + */ + internal fun WindowBoundsProvider.getBounds(): DpRect = with(this) { + this@WindowGeometryProviderScope.getBounds() + } +} + +/** + * Provides the bounds of the window. + * + * Note: this interface may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +interface WindowBoundsProvider { + /** + * Returns the bounds of the window. + * + * When implementing this function, use the given [WindowGeometryProviderScope] to examine the + * geometry of the screen and determine the appropriate bounds for the window. + * + * All coordinates in the returned [DpRect] must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun WindowGeometryProviderScope.getBounds(): DpRect + + @ExperimentalComposeUiApi + companion object { + /** + * Returns the default position and size for a new window. + */ + val Default = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Default, + positionProvider = WindowPositionProvider.Default + ) + + /** + * Positions the window at the given [bounds]. + * + * @param bounds The bounds of the window. + * + */ + fun Absolute(bounds: DpRect): WindowBoundsProvider { + bounds.requireReal() + return WindowBoundsProvider { bounds } + } + } +} + +/** + * Creates a [WindowBoundsProvider] from the given [bounds] function. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +fun WindowBoundsProvider( + bounds: WindowGeometryProviderScope.() -> DpRect, +) = object : WindowBoundsProvider { + override fun WindowGeometryProviderScope.getBounds() = bounds() +} + +/** + * Combines a [WindowSizeProvider] and [WindowPositionProvider] into a [WindowBoundsProvider]. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +fun WindowBoundsProvider( + sizeProvider: WindowSizeProvider = WindowSizeProvider.Current, + positionProvider: WindowPositionProvider = WindowPositionProvider.Current, +): WindowBoundsProvider = WindowBoundsProvider { + val size = sizeProvider.getSize().requireReal() + val position = positionProvider.getPosition(size) + DpRect(position, size) +} + +/** + * Provides the position of the window. + * + * Use this in conjunction with a [WindowSizeProvider] to construct a [WindowBoundsProvider]. + * + * Note: this interface may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +fun interface WindowPositionProvider { + /** + * Returns the position of the window. + * + * When implementing this function, use the given [WindowGeometryProviderScope] to examine the + * geometry of the screen and determine the appropriate position for the window. + * + * All coordinates in the returned [DpOffset] must be [Dp.isSpecified] and [Dp.isFinite]. + * The [DpOffset] itself must also be [DpOffset.isSpecified]. + */ + fun WindowGeometryProviderScope.getPosition(size: DpSize): DpOffset + + @ExperimentalComposeUiApi + companion object { + /** + * Returns the default position for a new window. + */ + val Default = WindowPositionProvider { size -> + WindowLocationTracker.getCascadeLocationFor( + graphicsDevice = windowMetrics.screen.device, + windowSize = size.roundToDimension() + ).asDpOffset() + } + + /** + * Returns the current position of the window. + */ + val Current = WindowPositionProvider { windowMetrics.bounds.topLeft } + + /** + * Positions the window at the given [position]. + * + * @param position The position of the window. + */ + fun Absolute(position: DpOffset): WindowPositionProvider { + position.requireReal() + return WindowPositionProvider { position } + } + + /** + * Positions the window at the given coordinates. + * + * @param x The x position of the window. + * @param y The y position of the window. + */ + fun Absolute(x: Dp, y: Dp): WindowPositionProvider = Absolute(DpOffset(x, y)) + + /** + * Aligns the window within the screen according to [alignment] and [offset]. + * + * @param alignment The alignment of the window relative to the screen. + * @param offset An additional absolute offset added after aligning. + */ + fun AlignedToScreen( + alignment: Alignment, + offset: DpOffset = DpOffset.Zero, + ): WindowPositionProvider = WindowPositionProvider { size -> + val availableBounds = windowMetrics.screen.availableBounds + + val position = alignment.align( + size = size.roundToIntSize(), + space = availableBounds.size.roundToIntSize(), + layoutDirection = LayoutDirection.Ltr + ) + DpOffset( + x = availableBounds.left + position.x.dp + offset.x, + y = availableBounds.top + position.y.dp + offset.y + ) + } + + /** + * Aligns the window relative to its parent window, according to [anchor], [alignment] and + * [offset]. + * + * [anchor] specifies the point in the parent bounds relative to which [alignment] is + * applied. For example, [Alignment.BottomEnd] specifies the bottom-right corner. + * [alignment] specifies the alignment inside an area centered at [anchor] and is twice the + * width and height of the window. For example, [Alignment.TopStart] will position it such + * that its bottom-right corner is at [anchor]. + * + * @param anchor The anchor relative to which [alignment] is applied. + * @param alignment The alignment of the window relative to the [anchor]. + * @param offset An additional absolute offset added after aligning. + * @param excludeParentInsets Whether to position relative to the content of the parent + * window, excluding the insets. + */ + fun AlignedToParentWindow( + anchor: Alignment, + alignment: Alignment = Alignment.Center, + offset: DpOffset = DpOffset.Zero, + excludeParentInsets: Boolean = false, + ): WindowPositionProvider = WindowPositionProvider { size -> + val parentMetrics = parentWindowMetrics ?: error("No parent window metrics specified") + val parentBounds = if (excludeParentInsets) { + parentMetrics.bounds - parentMetrics.insets + } else { + parentMetrics.bounds + } + + val anchorPointInParent = anchor.align( + size = IntSize.Zero, + space = parentBounds.size.roundToIntSize(), + layoutDirection = LayoutDirection.Ltr + ) + val anchorPoint = IntOffset( + anchorPointInParent.x + parentBounds.left.value.roundToInt(), + anchorPointInParent.y + parentBounds.top.value.roundToInt(), + ) + + val intSize = IntSize( + width = size.width.value.roundToInt(), + height = size.height.value.roundToInt() + ) + val targetArea = IntRect( + left = anchorPoint.x - intSize.width, + top = anchorPoint.y - intSize.height, + right = anchorPoint.x + intSize.width, + bottom = anchorPoint.y + intSize.height + ) + val positionInTargetArea = + alignment.align(intSize, targetArea.size, LayoutDirection.Ltr) + + DpOffset( + x = (targetArea.left + positionInTargetArea.x).dp, + y = (targetArea.top + positionInTargetArea.y).dp + ) + offset + } + } +} + +/** + * Provides the size of the window. + * + * Use this in conjunction with a [WindowPositionProvider] to construct a [WindowBoundsProvider]. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +fun interface WindowSizeProvider { + /** + * Returns the size of the window. + * + * When implementing this function, use the given [WindowGeometryProviderScope] to examine the + * geometry of the screen and the size properties of the window's content to determine the + * appropriate size for the window. + * + * All coordinates in the returned [DpSize] must be [Dp.isSpecified] and [Dp.isFinite]. + * The [DpSize] itself must also be [DpSize.isSpecified]. + */ + fun WindowGeometryProviderScope.getSize(): DpSize + + @ExperimentalComposeUiApi + companion object { + /** + * Sets the size of the window to the default one. + */ + val Default = Fixed(DpSize(800.dp, 600.dp)) + + /** + * Returns the current size of the window. + */ + val Current = WindowSizeProvider { windowMetrics.bounds.size } + + /** + * Sets the size of the window to the given [size]. + * + * @param size The size of the window. + */ + fun Fixed(size: DpSize): WindowSizeProvider { + size.requireReal() + return WindowSizeProvider { size } + } + + /** + * Sets the size of the window to the given [width] and [height]. + * + * @param width The width of the window. + * @param height The height of the window. + */ + fun Fixed(width: Dp, height: Dp): WindowSizeProvider = Fixed(DpSize(width, height)) + + /** + * Sets the size of the window to its preferred size, constrained only by the size of the + * screen. + * + * The preferred size is computed by measuring the content with infinite + * [Constraints], and adding the window's insets to that. + */ + val Unconstrained = WindowSizeProvider { + windowContent.measuringIn(Constraints()) { + contentToWindowSize( + DpSize( + width = it.measuredWidth.toDp(), + height = it.measuredHeight.toDp() + ) + ) + } + } + + /** + * Sets one dimension of the window to its intrinsic size at the given [otherDimensionSize] + * on the other dimension. + */ + private fun IntrinsicDimension( + isWidth: Boolean, + intrinsicSize: WindowIntrinsicSize, + otherDimensionSize: Dp, + otherDimensionName: String, + ): WindowSizeProvider { + otherDimensionSize.requireReal(otherDimensionName) + return WindowSizeProvider { + val otherDimensionPx = otherDimensionSize.roundToPx() + val width: Dp + val height: Dp + if (isWidth) { + width = intrinsicSize.widthOf(windowContent, otherDimensionPx).toDp() + height = otherDimensionSize + } else { + width = otherDimensionSize + height = intrinsicSize.heightOf(windowContent, otherDimensionPx).toDp() + } + contentToWindowSize( + DpSize( + width = width, + height = height + ) + ) + } + } + + /** + * Sets the width of the window to its minimum intrinsic width at the given [height]. + * + * The height of the window is set to [height]. + * + * @param height The height of the window. + * + * @see [IntrinsicMeasurable.minIntrinsicWidth] + */ + fun MinIntrinsicWidth(height: Dp) = IntrinsicDimension( + isWidth = true, + intrinsicSize = WindowIntrinsicSize.Min, + otherDimensionSize = height, + otherDimensionName = "height" + ) + + /** + * Sets the width of the window to its maximum intrinsic width at the given [height]. + * + * The height of the window is set to [height]. + * + * @param height The height of the window. + * + * @see [IntrinsicMeasurable.maxIntrinsicWidth] + */ + fun MaxIntrinsicWidth(height: Dp) = IntrinsicDimension( + isWidth = true, + intrinsicSize = WindowIntrinsicSize.Max, + otherDimensionSize = height, + otherDimensionName = "height" + ) + + /** + * Sets the height of the window to its minimum intrinsic height at the given [width]. + * + * The width of the window is set to [width]. + * + * @param width The width of the window. + * + * @see [IntrinsicMeasurable.minIntrinsicHeight] + */ + fun MinIntrinsicHeight(width: Dp) = IntrinsicDimension( + isWidth = false, + intrinsicSize = WindowIntrinsicSize.Min, + otherDimensionSize = width, + otherDimensionName = "width" + ) + + /** + * Sets the height of the window to its maximum intrinsic height at the given [width]. + * + * The width of the window is set to [width]. + * + * @param width The width of the window. + * + * @see [IntrinsicMeasurable.maxIntrinsicHeight] + */ + fun MaxIntrinsicHeight(width: Dp) = IntrinsicDimension( + isWidth = false, + intrinsicSize = WindowIntrinsicSize.Max, + otherDimensionSize = width, + otherDimensionName = "width" + ) + + /** + * Sets the primary dimension of the window to its intrinsic size, unconstrained at the + * secondary dimension, and the secondary dimension to its intrinsic size at the size of + * the primary dimension. + * + * This is useful for cases where the window is fixed on one dimension, but the one is + * flexible. + * + * @param isWidth Whether the primary dimension is width. + * @param intrinsicPrimary The intrinsic width to measure. + * @param intrinsicSecondary The intrinsic height to measure. + */ + private fun IntrinsicDimensionWithMatchingOtherDimension( + isWidth: Boolean, + intrinsicPrimary: WindowIntrinsicSize, + intrinsicSecondary: WindowIntrinsicSize, + ) = WindowSizeProvider { + val availableScreenBounds = windowMetrics.screen.availableBounds + val width: Int + val height: Int + if (isWidth) { + width = intrinsicPrimary.widthOf(windowContent, availableScreenBounds.height.roundToPx()) + height = intrinsicSecondary.heightOf(windowContent, width) + } else { + height = intrinsicPrimary.heightOf(windowContent, availableScreenBounds.width.roundToPx()) + width = intrinsicSecondary.widthOf(windowContent, height) + } + contentToWindowSize( + DpSize( + width = width.toDp(), + height = height.toDp() + ) + ) + } + + /** + * Sets the width of the window to its intrinsic width at unconstrained height, and + * the height of the window to its intrinsic height at that width. + * + * This is useful for cases where the window has a fixed width, but the height is flexible. + * + * @param intrinsicWidth The intrinsic width to measure. + * @param intrinsicHeight The intrinsic height to measure. + */ + fun IntrinsicWidthWithMatchingIntrinsicHeight( + intrinsicWidth: WindowIntrinsicSize, + intrinsicHeight: WindowIntrinsicSize, + ): WindowSizeProvider = IntrinsicDimensionWithMatchingOtherDimension( + isWidth = true, + intrinsicPrimary = intrinsicWidth, + intrinsicSecondary = intrinsicHeight, + ) + + /** + * Sets the height of the window to its intrinsic height at unconstrained width, and + * the width of the window to its intrinsic width at that height. + * + * This is useful for cases where the window has a fixed height, but the width is flexible. + * + * @param intrinsicWidth The intrinsic width to measure. + * @param intrinsicHeight The intrinsic height to measure. + */ + fun IntrinsicHeightWithMatchingIntrinsicWidth( + intrinsicHeight: WindowIntrinsicSize, + intrinsicWidth: WindowIntrinsicSize, + ) = IntrinsicDimensionWithMatchingOtherDimension( + isWidth = false, + intrinsicPrimary = intrinsicHeight, + intrinsicSecondary = intrinsicWidth, + ) + } +} + + +/** + * The kinds of intrinsic sizes that can be used with [WindowSizeProvider]. + * + * Note: this class may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +abstract class WindowIntrinsicSize internal constructor() { + + /** + * Returns the intrinsic width (min or max) of the given [measurable] at the given [height]. + */ + abstract fun widthOf(measurable: IntrinsicMeasurable, height: Int): Int + + /** + * Returns the intrinsic height (min or max) of the given [measurable] at the given [width]. + */ + abstract fun heightOf(measurable: IntrinsicMeasurable, width: Int): Int + + /** + * Measures minimum intrinsic size. + */ + @ExperimentalComposeUiApi + data object Min: WindowIntrinsicSize() { + override fun widthOf(measurable: IntrinsicMeasurable, height: Int): Int { + return measurable.minIntrinsicWidth(height) + } + + override fun heightOf(measurable: IntrinsicMeasurable, width: Int): Int { + return measurable.minIntrinsicHeight(width) + } + } + + /** + * Measures maximum intrinsic size. + */ + @ExperimentalComposeUiApi + data object Max: WindowIntrinsicSize() { + override fun widthOf(measurable: IntrinsicMeasurable, height: Int): Int { + return measurable.maxIntrinsicWidth(height) + } + + override fun heightOf(measurable: IntrinsicMeasurable, width: Int): Int { + return measurable.maxIntrinsicHeight(width) + } + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/WindowState.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/WindowState.desktop.kt new file mode 100644 index 0000000000000..4cf50484caa1b --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/v2/WindowState.desktop.kt @@ -0,0 +1,531 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.toAwtRectangleRounded +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.isFinite +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.requireReal +import androidx.compose.ui.unit.size +import androidx.compose.ui.unit.topLeft +import androidx.compose.ui.window.WindowPlacement +import java.awt.Rectangle +import kotlinx.coroutines.channels.Channel + + +/** + * Creates a [WindowState] that is remembered across compositions. + * + * Changes to the provided initial values will **not** result in the state being recreated or + * changed in any way if it has already been created. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param initialPosition The initial position of the window; default if `null`. All the + * coordinates must be [Dp.isSpecified] and [Dp.isFinite], and the [DpOffset] object itself must be + * [DpOffset.isSpecified]. + * @param initialSize The initial size of the window; default if `null`. All the + * coordinates must be [Dp.isSpecified] and [Dp.isFinite], and the [DpSize] object itself must be + * [DpOffset.isSpecified]. + * @param initiallyMinimized Whether the window is initially minimized. + */ +@ExperimentalComposeUiApi +@Composable +fun rememberWindowStateWithBounds( + initialPosition: DpOffset? = null, + initialSize: DpSize? = null, + initiallyMinimized: Boolean = false, +): WindowState = rememberSaveable(saver = WindowState.Saver) { + WindowStateWithBounds( + initialPosition = initialPosition, + initialSize = initialSize, + initiallyMinimized = initiallyMinimized + ) +} + +/** + * Creates a [WindowState] that is remembered across compositions. + * + * Changes to the provided initial values will **not** result in the state being recreated or + * changed in any way if it has already been created. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param initialScreenProvider Provides the initial screen on which the window will be placed. + * @param initialPlacement The initial placement of the window. + * @param initialBoundsProvider Provides the initial bounds of the window. + * @param initiallyMinimized Whether the window is initially minimized. + */ +@ExperimentalComposeUiApi +@Composable +fun rememberWindowState( + initialScreenProvider: WindowScreenProvider = WindowScreenProvider.Default, + initialPlacement: WindowPlacement = WindowPlacement.Floating, + initialBoundsProvider: WindowBoundsProvider = WindowBoundsProvider.Default, + initiallyMinimized: Boolean = false, +): WindowState = rememberSaveable(saver = WindowState.Saver) { + WindowState( + initialScreenProvider = initialScreenProvider, + initialPlacement = initialPlacement, + initialBoundsProvider = initialBoundsProvider, + initiallyMinimized = initiallyMinimized + ) +} + + +/** + * Creates a [WindowState] with the specified initial values. + * + * Changes to the provided initial values will **not** result in the state being recreated or + * changed in any way if it has already been created. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param initialPosition The initial position of the window; default if `null`. All the + * coordinates must be [Dp.isSpecified] and [Dp.isFinite], and the [DpOffset] object itself must be + * [DpOffset.isSpecified]. + * @param initialSize The initial size of the window; default if `null`. All the + * coordinates must be [Dp.isSpecified] and [Dp.isFinite], and the [DpSize] object itself must be + * [DpOffset.isSpecified]. + * @param initiallyMinimized Whether the window is initially minimized. + */ +@ExperimentalComposeUiApi +fun WindowStateWithBounds( + initialPosition: DpOffset? = null, + initialSize: DpSize? = null, + initiallyMinimized: Boolean = false, +): WindowState { + val sizeProvider = + initialSize?.let { WindowSizeProvider.Fixed(it) } ?: WindowSizeProvider.Default + val positionProvider = + initialPosition?.let { WindowPositionProvider.Absolute(it) } ?: WindowPositionProvider.Default + return WindowState( + initialBoundsProvider = WindowBoundsProvider(sizeProvider, positionProvider), + initiallyMinimized = initiallyMinimized + ) +} + +/** + * Creates a [WindowState] with the specified initial values. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + * + * @param initialScreenProvider Provides the initial screen on which the window will be placed. + * @param initialPlacement The initial placement of the window. + * @param initialBoundsProvider Provides the initial bounds of the window. + * @param initiallyMinimized Whether the window is initially minimized. + */ +@ExperimentalComposeUiApi +fun WindowState( + initialScreenProvider: WindowScreenProvider = WindowScreenProvider.Default, + initialPlacement: WindowPlacement = WindowPlacement.Floating, + initialBoundsProvider: WindowBoundsProvider = WindowBoundsProvider.Default, + initiallyMinimized: Boolean = false, +): WindowState = WindowState.createUninitialized().apply { + requestScreen(initialScreenProvider) + requestPlacement(initialPlacement) + requestBounds(initialBoundsProvider) + requestMinimized(initiallyMinimized) +} + +/** + * A state object that can be hoisted to control and observe window attributes + * (size, position, etc.). + * + * Note: this class may be moved to `androidx.compose.ui.window` before stabilization. + */ +@Stable +@ExperimentalComposeUiApi +class WindowState private constructor( + isInitialized: Boolean, + screenId: String?, + placement: WindowPlacement?, + isMinimized: Boolean?, + bounds: DpRect?, +) { + /** + * Creates a new [WindowState] that is initialized with the specified values. + */ + internal constructor( + screenId: String, + placement: WindowPlacement, + isMinimized: Boolean, + bounds: DpRect, + ): this( + isInitialized = true, + screenId = screenId, + placement = placement, + isMinimized = isMinimized, + bounds = bounds, + ) + + init { + bounds?.requireReal() + } + + /** + * Whether the window associated with this state has become visible at least once. + */ + var isInitialized: Boolean by mutableStateOf(isInitialized) + internal set + + /** + * The id of the screen with which the window is currently associated; `null` if the window is + * not yet [isInitialized]. + */ + @Suppress("PropertyName") + internal var _screenId: String? by mutableStateOf(screenId) + + /** + * The id of the screen with which the window is currently associated; throws + * [IllegalStateException] if the window is not yet [isInitialized]. + */ + val screenId: String + get() = _screenId ?: windowNotInitializedError("screenId") + + internal val screenRequests = Channel(Channel.CONFLATED) + + /** + * Requests to position the window on the specified screen. + * + * Note that the actual positioning is done asynchronously. + */ + fun requestScreen(screenProvider: WindowScreenProvider) { + screenRequests.trySend(screenProvider) + } + + /** + * The placement of the window on the screen; `null` if the window is not yet [isInitialized]. + */ + @Suppress("PropertyName") + internal var _placement: WindowPlacement? by mutableStateOf(placement) + + /** + * The placement of the window on the screen; throws [IllegalStateException] if the window is + * not yet [isInitialized]. + */ + val placement: WindowPlacement + get() = _placement ?: windowNotInitializedError("placement") + + internal val placementRequests = Channel(Channel.CONFLATED) + + /** + * Requests to set the placement of the window. + * + * Note that the actual placement is set asynchronously. + */ + fun requestPlacement(placement: WindowPlacement) { + placementRequests.trySend(placement) + } + + /** + * Whether the window is minimized; `null` if the window is not [isInitialized] yet. + */ + @Suppress("PropertyName") + internal var _isMinimized: Boolean? by mutableStateOf(isMinimized) + + /** + * Whether the window is minimized; throws [IllegalStateException] if the window is not yet + * [isInitialized]. + */ + val isMinimized: Boolean + get() = _isMinimized ?: windowNotInitializedError("isMinimized") + + internal val isMinimizedRequests = Channel(Channel.CONFLATED) + + /** + * Requests to set the minimized state of the window. + * + * Note that the actual minimized state is set asynchronously. + */ + fun requestMinimized(value: Boolean) { + isMinimizedRequests.trySend(value) + } + + /** + * The current bounds of the window; `null` if the window is not yet [isInitialized]. + */ + @Suppress("PropertyName") + internal var _bounds: DpRect? by mutableStateOf(bounds) + + /** + * The current bounds of the window; throws [IllegalStateException] if the window is not yet + * [isInitialized]. + */ + val bounds: DpRect + get() = _bounds ?: windowNotInitializedError("bounds") + + internal val boundsRequests = Channel(Channel.UNLIMITED) + + /** + * Requests to set the bounds of the window via a [WindowBoundsProvider]. + * + * Note that the actual bounds are set asynchronously and may be different from the requested + * ones (e.g., if the window manager can't position as requested). + * + * Setting the bounds when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param boundsProvider Provides the bounds to apply to the window. + */ + fun requestBounds(boundsProvider: WindowBoundsProvider) { + boundsRequests.trySend(boundsProvider) + } + + /** + * Requests to set the bounds of the window via a function that returns a [DpRect]. + * + * Note that the actual bounds are set asynchronously and may be different from the requested + * ones (e.g., if the window manager can't position as requested). + * + * Setting the bounds when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param boundsProvider Returns the bounds to apply to the window. + */ + fun requestBounds(boundsProvider: WindowGeometryProviderScope.() -> DpRect) { + boundsRequests.trySend(WindowBoundsProvider(boundsProvider)) + } + + /** + * Requests to set the bounds of the window. + * + * This is the same as using [WindowBoundsProvider.Absolute]. + * + * Note that the actual bounds are set asynchronously and may be different from the requested + * ones (e.g., if the window manager can't position as requested). + * + * Setting the bounds when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param bounds The bounds to apply to the window. All the coordinates must be [Dp.isSpecified] + * and [Dp.isFinite]. + */ + fun requestBounds(bounds: DpRect) { + boundsRequests.trySend( + WindowBoundsProvider.Absolute(bounds) + ) + } + + /** + * The current position of the window; throws [IllegalStateException] if the window is not yet + * [isInitialized]. + */ + val position: DpOffset + get() = _bounds?.topLeft ?: windowNotInitializedError("position") + + /** + * Requests to set the position of the window via a [WindowPositionProvider]. + * + * Note that the actual position is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't position as requested). + * + * Setting the position when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param positionProvider Provides the position to apply to the window. + */ + fun requestPosition(positionProvider: WindowPositionProvider) { + boundsRequests.trySend( + WindowBoundsProvider( + positionProvider = positionProvider, + ) + ) + } + + /** + * Requests to set the position of the window. + * + * Note that the actual position is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't position as requested). + * + * Setting the position when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param position The position to apply to the window. The value must be [DpOffset.isSpecified] + * and all the coordinates must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun requestPosition(position: DpOffset) { + boundsRequests.trySend( + WindowBoundsProvider( + positionProvider = WindowPositionProvider.Absolute(position), + ) + ) + } + + /** + * Requests to set the position of the window. + * + * Note that the actual position is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't position as requested). + * + * Setting the position when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param x The x coordinate. The value must be [Dp.isSpecified] and [Dp.isFinite]. + * @param y The y coordinate. The value must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun requestPosition(x: Dp, y: Dp) { + boundsRequests.trySend( + WindowBoundsProvider( + positionProvider = WindowPositionProvider.Absolute(x, y), + ) + ) + } + + + /** + * The current size of the window; throws [IllegalStateException] if the window is not yet + * [isInitialized]. + */ + val size: DpSize + get() = _bounds?.size ?: windowNotInitializedError("size") + + /** + * Requests to set the size of the window via a [WindowSizeProvider]. + * + * Note that the actual size is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't size as requested). + * + * Setting the size when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param sizeProvider Provides the size to apply to the window. + */ + fun requestSize(sizeProvider: WindowSizeProvider) { + boundsRequests.trySend( + WindowBoundsProvider( + sizeProvider = sizeProvider, + ) + ) + } + + /** + * Requests to set the size of the window. + * + * Note that the actual size is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't size as requested). + * + * Setting the size when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param size The position to apply to the window. The value must be [DpSize.isSpecified] + * and all the coordinates must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun requestSize(size: DpSize) { + boundsRequests.trySend( + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(size), + ) + ) + } + + /** + * Requests to set the size of the window. + * + * Note that the actual size is set asynchronously and may be different from the requested + * one (e.g., if the window manager can't size as requested). + * + * Setting the size when the window placement is not [WindowPlacement.Floating] will change + * the placement to floating. + * + * @param width The width. The value must be [Dp.isSpecified] and [Dp.isFinite]. + * @param height The height. The value must be [Dp.isSpecified] and [Dp.isFinite]. + */ + fun requestSize(width: Dp, height: Dp) { + boundsRequests.trySend( + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(width, height), + ) + ) + } + + @ExperimentalComposeUiApi + companion object { + /** + * Creates a new [WindowState] that is not yet initialized. + */ + internal fun createUninitialized() = + WindowState( + isInitialized = false, + screenId = null, + placement = null, + isMinimized = null, + bounds = null + ) + + /** + * A [Saver] implementation for [WindowState]. + */ + val Saver: Saver = listSaver( + save = { + if (!it.isInitialized) return@listSaver emptyList() + val bounds = it.bounds + arrayListOf( + it.screenId, + it.placement.ordinal, + it.isMinimized, + bounds.top.value, + bounds.left.value, + bounds.right.value, + bounds.bottom.value, + ) + }, + restore = { state -> + if (state.isEmpty()) return@listSaver null + WindowState( + screenId = state[0] as String, + placement = WindowPlacement.entries[(state[1] as Int)], + isMinimized = state[2] as Boolean, + bounds = DpRect( + top = Dp(state[3] as Float), + left = Dp(state[4] as Float), + right = Dp(state[5] as Float), + bottom = Dp(state[6] as Float) + ) + ) + } + ) + } +} + +/** + * Returns the bounds of the window, as an AWT [Rectangle]; throws [IllegalStateException] if the + * window is not yet [isInitialized]. + * + * Note: this function may be moved to `androidx.compose.ui.window` before stabilization. + */ +@ExperimentalComposeUiApi +val WindowState.awtBounds: Rectangle + get() = bounds.toAwtRectangleRounded() + +private fun windowNotInitializedError(propertyName: String): Nothing = + throw IllegalStateException("Can't read $propertyName before the window has been made visible;" + + " use isInitialized to check.") \ No newline at end of file diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt index b76d0fee696ad..a6d66f6578bd5 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -278,6 +279,8 @@ suspend fun Robot.awaitEDT() { fun Dimension.toDpSize() = DpSize(width.dp, height.dp) +fun Point.toDpOffset() = DpOffset(x.dp, y.dp) + fun Point.toWindowPosition() = WindowPosition(x.dp, y.dp) fun Size.toInt() = IntSize(width.toInt(), height.toInt()) diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/BaseWindowTextFieldTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/BaseWindowTextFieldTest.kt similarity index 96% rename from compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/BaseWindowTextFieldTest.kt rename to compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/BaseWindowTextFieldTest.kt index 091e9520573b1..d2fc278921828 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/BaseWindowTextFieldTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/BaseWindowTextFieldTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.ui.window.window +package androidx.compose.ui.window import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -49,12 +49,6 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.WindowState -import androidx.compose.ui.window.WindowTestScope -import androidx.compose.ui.window.density -import androidx.compose.ui.window.runApplicationTest -import androidx.compose.ui.window.waitForFocusGain import com.google.common.truth.Truth.assertThat import kotlin.math.roundToInt import org.junit.experimental.theories.DataPoint diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/ComposeDialogTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/ComposeDialogTest.kt new file mode 100644 index 0000000000000..3d6a4e50584d3 --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/ComposeDialogTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.sendMousePress +import androidx.compose.ui.sendMouseRelease +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import java.awt.Dimension +import javax.swing.JFrame +import kotlin.test.Test + +class ComposeDialogTest { + @Test + fun testComposeDialogClearFocusOnMouseDownEnabled() = + testComposeDialogClearFocusOnMouseDownEnabledFlag(true) + + @Test + fun testComposeDialogClearFocusOnMouseDownDisabled() = + testComposeDialogClearFocusOnMouseDownEnabledFlag(false) + + fun testComposeDialogClearFocusOnMouseDownEnabledFlag(enabled: Boolean) = runApplicationTest { + val focusRequester = FocusRequester() + var textFieldIsFocused = false + + val window = JFrame() + val dialog = ComposeDialog(window) + try { + window.size = Dimension(800, 600) + dialog.isClearFocusOnMouseDownEnabled = enabled + dialog.setContent { + Column(Modifier.size(300.dp, 400.dp)) { + BasicTextField( + state = rememberTextFieldState(), + modifier = Modifier + .testTag("textField") + .fillMaxWidth() + .height(100.dp) + .focusRequester(focusRequester) + .onFocusChanged { + textFieldIsFocused = it.isFocused + } + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Box(Modifier.testTag("box").fillMaxWidth().weight(1f)) + } + } + + dialog.size = Dimension(300, 400) + dialog.isVisible = true + + awaitIdle() + + assertThat(textFieldIsFocused).isTrue() + dialog.sendMousePress(x = 100, y = 300) + dialog.sendMouseRelease(x = 100, y = 300) + awaitIdle() + + assertThat(textFieldIsFocused).isEqualTo(!enabled) + } finally { + dialog.dispose() + window.dispose() + } + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/ComposeWindowTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/ComposeWindowTest.kt new file mode 100644 index 0000000000000..907c3a998ff57 --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/ComposeWindowTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.sendMousePress +import androidx.compose.ui.sendMouseRelease +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import java.awt.Dimension +import kotlin.test.Test + +class ComposeWindowTest { + @Test + fun testComposeWindowClearFocusOnMouseDownEnabled() = + testComposeWindowClearFocusOnMouseDownEnabledFlag(true) + + @Test + fun testComposeWindowClearFocusOnMouseDownDisabled() = + testComposeWindowClearFocusOnMouseDownEnabledFlag(false) + + fun testComposeWindowClearFocusOnMouseDownEnabledFlag(enabled: Boolean) = runApplicationTest { + val focusRequester = FocusRequester() + var textFieldIsFocused = false + + val window = ComposeWindow() + try { + window.isClearFocusOnMouseDownEnabled = enabled + window.setContent { + Column(Modifier.size(300.dp, 400.dp)) { + BasicTextField( + state = rememberTextFieldState(), + modifier = Modifier + .testTag("textField") + .fillMaxWidth() + .height(100.dp) + .focusRequester(focusRequester) + .onFocusChanged { + textFieldIsFocused = it.isFocused + } + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Box(Modifier.testTag("box").fillMaxWidth().weight(1f)) + } + } + window.size = Dimension(300, 400) + window.isVisible = true + + awaitIdle() + + assertThat(textFieldIsFocused).isTrue() + window.sendMousePress(x = 100, y = 300) + window.sendMouseRelease(x = 100, y = 300) + awaitIdle() + + assertThat(textFieldIsFocused).isEqualTo(!enabled) + } finally { + window.dispose() + } + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/DialogWindowTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/DialogWindowTest.kt index b4d108e997d2a..80e831658ae8d 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/DialogWindowTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/DialogWindowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,40 +17,54 @@ package androidx.compose.ui.window import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.runtime.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.* +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposeDialog import androidx.compose.ui.awt.LocalAwtWindow import androidx.compose.ui.awt.SwingDialog +import androidx.compose.ui.background import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusTarget -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.sendKeyEvent import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.* -import androidx.compose.ui.window.window.animationsRunAtNonInfiniteRateIn -import androidx.compose.ui.window.window.coroutineContextIsPropagatedTo -import androidx.compose.ui.window.window.toSize +import androidx.compose.ui.toInt +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntSize import com.google.common.truth.Truth.assertThat -import java.awt.* +import java.awt.Dialog +import java.awt.Dimension +import java.awt.Point +import java.awt.Robot +import java.awt.Window import java.awt.event.KeyEvent import java.awt.event.WindowAdapter import java.awt.event.WindowEvent -import javax.swing.JFrame +import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.thread import kotlin.test.Test import kotlin.test.assertEquals @@ -707,7 +721,7 @@ class DialogWindowTest { } @Test - fun `window does not flash background when closed`() = runApplicationTest { + fun `dialog does not flash background when closed`() = runApplicationTest { lateinit var window: Window lateinit var dialog: Dialog var showDialog by mutableStateOf(false) @@ -740,7 +754,7 @@ class DialogWindowTest { val testLocation = dialog.bounds.let { Point(it.x + it.width / 2, it.y + it.height / 2) } - val stopThread = java.util.concurrent.atomic.AtomicBoolean(false) + val stopThread = AtomicBoolean(false) val t = thread { val robot = Robot() while (!stopThread.get()) { @@ -762,60 +776,6 @@ class DialogWindowTest { assertThat(nonBlackPixelDetected).isNull() } - @Test - fun testComposeDialogClearFocusOnMouseDownEnabled() = - testComposeDialogClearFocusOnMouseDownEnabledFlag(true) - - @Test - fun testComposeDialogClearFocusOnMouseDownDisabled() = - testComposeDialogClearFocusOnMouseDownEnabledFlag(false) - - fun testComposeDialogClearFocusOnMouseDownEnabledFlag(enabled: Boolean) = runApplicationTest { - val focusRequester = FocusRequester() - var textFieldIsFocused = false - - val window = JFrame() - val dialog = ComposeDialog(window) - try { - window.size = Dimension(800, 600) - dialog.isClearFocusOnMouseDownEnabled = enabled - dialog.setContent { - Column(Modifier.size(300.dp, 400.dp)) { - BasicTextField( - state = rememberTextFieldState(), - modifier = Modifier - .testTag("textField") - .fillMaxWidth() - .height(100.dp) - .focusRequester(focusRequester) - .onFocusChanged { - textFieldIsFocused = it.isFocused - } - ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - Box(Modifier.testTag("box").fillMaxWidth().weight(1f)) - } - } - - dialog.size = Dimension(300, 400) - dialog.isVisible = true - - awaitIdle() - - assertThat(textFieldIsFocused).isTrue() - dialog.sendMousePress(x = 100, y = 300) - dialog.sendMouseRelease(x = 100, y = 300) - awaitIdle() - - assertThat(textFieldIsFocused).isEqualTo(!enabled) - } finally { - dialog.dispose() - window.dispose() - } - } - @Test fun coroutineContextIsPropagatedToDialog() = coroutineContextIsPropagatedTo { content -> DialogWindow(onCloseRequest = ::exitApplication) { diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/TestUtils.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/TestUtils.kt index 406c58f31b673..6d603a1756206 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/TestUtils.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/TestUtils.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.withTimeout import org.jetbrains.skiko.MainUIDispatcher import org.junit.Assume.assumeFalse import androidx.compose.ui.window.launchApplication as realLaunchApplication +import androidx.compose.ui.window.v2.Window import java.awt.Robot import java.awt.Window import java.awt.event.WindowAdapter @@ -153,6 +154,21 @@ internal class WindowTestScope( } } + fun launchTestWindowV2Application( + state: androidx.compose.ui.window.v2.WindowState = androidx.compose.ui.window.v2.WindowState(), + decoration: WindowDecoration = WindowDecoration.SystemDefault, + content: @Composable FrameWindowScope.() -> Unit + ) = launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + state = state, + decoration = decoration + ) { + this@WindowTestScope.window = window + content() + } + } + // Overload `launchApplication` to prohibit calling it from tests @Deprecated( "Do not use `launchApplication` from tests; use `launchTestApplication` instead", diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowInputEventTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowInputEventTest.kt similarity index 97% rename from compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowInputEventTest.kt rename to compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowInputEventTest.kt index 1149072975055..32d8283449b73 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowInputEventTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowInputEventTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,14 +14,19 @@ * limitations under the License. */ -package androidx.compose.ui.window.window +package androidx.compose.ui.window -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.material.Slider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -41,11 +46,12 @@ import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.isShiftPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.sendKeyEvent +import androidx.compose.ui.sendMouseEvent +import androidx.compose.ui.sendMousePress +import androidx.compose.ui.sendMouseRelease +import androidx.compose.ui.sendMouseWheelEvent import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.density -import androidx.compose.ui.window.rememberWindowState -import androidx.compose.ui.window.runApplicationTest import com.google.common.truth.Truth.assertThat import java.awt.Dimension import java.awt.event.KeyEvent diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowParameterTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowParameterTest.kt similarity index 96% rename from compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowParameterTest.kt rename to compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowParameterTest.kt index 1e3d22819cc96..7c06aab330744 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowParameterTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowParameterTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.ui.window.window +package androidx.compose.ui.window import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -29,8 +29,6 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.readFirstPixel import androidx.compose.ui.testImage import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.runApplicationTest import com.google.common.truth.Truth.assertThat import java.awt.event.WindowEvent import org.junit.Test diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowStateTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowStateTest.kt similarity index 98% rename from compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowStateTest.kt rename to compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowStateTest.kt index c248847434b81..8fb02a446cfeb 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowStateTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowStateTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -7,13 +7,14 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ -package androidx.compose.ui.window.window +package androidx.compose.ui.window import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height @@ -36,12 +37,6 @@ import androidx.compose.ui.toWindowPosition import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.WindowState -import androidx.compose.ui.window.rememberWindowState -import androidx.compose.ui.window.runApplicationTest import com.google.common.truth.Truth.assertThat import java.awt.Dimension import java.awt.Point diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTest.kt similarity index 92% rename from compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTest.kt rename to compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTest.kt index 3ca3a5a5aa53e..390cbc4bb6a6f 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,31 +14,52 @@ * limitations under the License. */ -package androidx.compose.ui.window.window +package androidx.compose.ui.window import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.material.Button import androidx.compose.material.Slider -import androidx.compose.runtime.* -import androidx.compose.ui.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.LeakDetector +import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.awt.LocalAwtWindow import androidx.compose.ui.awt.SwingWindow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.background import androidx.compose.ui.graphics.Color +import androidx.compose.ui.isLinux import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.* -import androidx.compose.ui.window.* +import androidx.compose.ui.toInt +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntSize import com.google.common.truth.Truth.assertThat -import java.awt.* +import java.awt.Dimension +import java.awt.GraphicsEnvironment +import java.awt.Point +import java.awt.Robot +import java.awt.Toolkit +import java.awt.Window import java.awt.event.WindowAdapter import java.awt.event.WindowEvent import kotlin.concurrent.thread @@ -50,7 +71,11 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.* +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking import org.jetbrains.skiko.MainUIDispatcher import org.junit.Assume.assumeFalse import org.junit.Ignore @@ -819,56 +844,6 @@ class WindowTest { assertThat(nonBlackPixelDetected).isNull() } - @Test - fun testComposeWindowClearFocusOnMouseDownEnabled() = - testComposeWindowClearFocusOnMouseDownEnabledFlag(true) - - @Test - fun testComposeWindowClearFocusOnMouseDownDisabled() = - testComposeWindowClearFocusOnMouseDownEnabledFlag(false) - - fun testComposeWindowClearFocusOnMouseDownEnabledFlag(enabled: Boolean) = runApplicationTest { - val focusRequester = FocusRequester() - var textFieldIsFocused = false - - val window = ComposeWindow() - try { - window.isClearFocusOnMouseDownEnabled = enabled - window.setContent { - Column(Modifier.size(300.dp, 400.dp)) { - BasicTextField( - state = rememberTextFieldState(), - modifier = Modifier - .testTag("textField") - .fillMaxWidth() - .height(100.dp) - .focusRequester(focusRequester) - .onFocusChanged { - textFieldIsFocused = it.isFocused - } - ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - Box(Modifier.testTag("box").fillMaxWidth().weight(1f)) - } - } - window.size = Dimension(300, 400) - window.isVisible = true - - awaitIdle() - - assertThat(textFieldIsFocused).isTrue() - window.sendMousePress(x = 100, y = 300) - window.sendMouseRelease(x = 100, y = 300) - awaitIdle() - - assertThat(textFieldIsFocused).isEqualTo(!enabled) - } finally { - window.dispose() - } - } - @Test fun coroutineContextIsPropagatedToWindow() = coroutineContextIsPropagatedTo { content -> Window(onCloseRequest = ::exitApplication) { diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTestUtils.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTestUtils.kt similarity index 93% rename from compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTestUtils.kt rename to compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTestUtils.kt index 542770560d1e1..3ab6a11067579 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTestUtils.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTestUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.ui.window.window +package androidx.compose.ui.window import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTypeTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTypeTest.kt similarity index 99% rename from compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTypeTest.kt rename to compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTypeTest.kt index 54601ada8a1c4..706defe31df66 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTypeTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTypeTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.ui.window.window +package androidx.compose.ui.window import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text.input.InputTransformation diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTypingLocationTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTypingLocationTest.kt similarity index 97% rename from compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTypingLocationTest.kt rename to compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTypingLocationTest.kt index 789cbbcbca472..25b447a14975b 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/window/WindowTypingLocationTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/WindowTypingLocationTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Android Open Source Project + * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.ui.window.window +package androidx.compose.ui.window import androidx.compose.ui.focusedInputMethodRequests import androidx.compose.ui.sendCharTypedEvents diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/DialogWindowV2StateTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/DialogWindowV2StateTest.kt new file mode 100644 index 0000000000000..00894e72d486d --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/DialogWindowV2StateTest.kt @@ -0,0 +1,956 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.isLinux +import androidx.compose.ui.isMacOs +import androidx.compose.ui.toDpOffset +import androidx.compose.ui.toDpSize +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.plus +import androidx.compose.ui.unit.size +import androidx.compose.ui.unit.topLeft +import androidx.compose.ui.unit.width +import androidx.compose.ui.window.WindowDecoration +import androidx.compose.ui.window.runApplicationTest +import androidx.compose.ui.window.toDpInsets +import androidx.compose.ui.window.asDpOffset +import com.google.common.truth.Truth.assertThat +import java.awt.Dimension +import java.awt.Point +import java.awt.Rectangle +import java.awt.Window +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.WindowEvent +import kotlin.math.abs +import kotlin.math.max +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.delay +import org.junit.Assume.assumeTrue + +class DialogWindowV2StateTest { + @Test + fun `manually close dialog`() = runApplicationTest { + lateinit var dialog: ComposeDialog + var isOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + DialogWindow( + onCloseRequest = { isOpen = false }, + title = "manually close dialog" + ) { + dialog = this.window + } + } + } + + awaitIdle() + assertThat(dialog.isShowing).isTrue() + + dialog.dispatchEvent(WindowEvent(dialog, WindowEvent.WINDOW_CLOSING)) + awaitIdle() + assertThat(dialog.isShowing).isFalse() + } + + @Test + fun `programmatically close dialog`() = runApplicationTest { + lateinit var dialog: ComposeDialog + var isOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + DialogWindow( + onCloseRequest = { isOpen = false }, + title = "programmatically close dialog" + ) { + dialog = this.window + } + } + } + + awaitIdle() + assertThat(dialog.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(dialog.isShowing).isFalse() + } + + @Test + fun `programmatically open and close owned dialog`() = runApplicationTest(useDelay = true) { + var parentWindow: ComposeWindow? = null + var childDialog: ComposeDialog? = null + var isParentOpen by mutableStateOf(true) + var isChildOpen by mutableStateOf(false) + + launchTestApplication { + if (isParentOpen) { + Window( + onCloseRequest = {}, + title = "(parent) programmatically open and close owned dialog" + ) { + parentWindow = this.window + + if (isChildOpen) { + DialogWindow( + onCloseRequest = {}, + title = "(child) programmatically open and close owned dialog" + ) { + childDialog = this.window + } + } + } + } + } + + awaitIdle() + assertThat(parentWindow?.isShowing).isTrue() + + isChildOpen = true + awaitIdle() + assertThat(parentWindow?.isShowing).isTrue() + assertThat(childDialog?.isShowing).isTrue() + + isChildOpen = false + awaitIdle() + assertThat(parentWindow?.isShowing).isTrue() + assertThat(childDialog?.isShowing).isFalse() + + isParentOpen = false + awaitIdle() + assertThat(parentWindow?.isShowing).isFalse() + } + + @Test + fun `set size and position before show`() = runApplicationTest(useDelay = isLinux) { + val size = Dimension(200, 200) + val position = Point(242, 242) + val state = DialogStateWithBounds( + initialSize = size.toDpSize(), + initialPosition = position.asDpOffset() + ) + + lateinit var dialog: ComposeDialog + + launchTestApplication { + DialogWindow( + onCloseRequest = {}, + state = state, + title = "set size and position before show" + ) { + dialog = this.window + } + } + + awaitIdle() + assertSizesApproximatelyEqual(size, dialog.size) + assertCoordinatesApproximatelyEqual(position, dialog.location) + } + + @Test + fun `change position after show`() = runApplicationTest(useDelay = isLinux) { + val size = Dimension(200, 200) + val position = Point(200, 200) + + val state = DialogStateWithBounds( + initialSize = size.toDpSize(), + initialPosition = position.asDpOffset() + ) + lateinit var dialog: ComposeDialog + + launchTestApplication { + DialogWindow( + onCloseRequest = {}, + state = state, + title = "change position after show" + ) { + dialog = this.window + } + } + + awaitIdle() + + val newPosition = Point(242, 242) + state.requestPosition(newPosition.asDpOffset()) + awaitIdle() + assertCoordinatesApproximatelyEqual(newPosition, dialog.location) + } + + @Test + fun `change size after show`() = runApplicationTest(useDelay = isLinux) { + val size = Dimension(200, 200) + val position = Point(200, 200) + + val state = DialogStateWithBounds( + initialSize = size.toDpSize(), + initialPosition = position.asDpOffset() + ) + lateinit var dialog: ComposeDialog + + launchTestApplication { + DialogWindow( + onCloseRequest = {}, + state = state, + title = "change size after show" + ) { + dialog = this.window + } + } + + awaitIdle() + + val newSize = Dimension(250, 200) + state.requestSize(newSize.toDpSize()) + awaitIdle() + assertSizesApproximatelyEqual(newSize, dialog.size) + } + + fun Rectangle.center() = Point(x + width / 2, y + height / 2) + fun Window.center() = bounds.center() + fun Window.screenCenter() = graphicsConfiguration.bounds.center() + infix fun Point.maxDistance(other: Point) = max(abs(x - other.x), abs(y - other.y)) + + @Test + fun `center dialog on screen`() = runApplicationTest { + val state = DialogState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(200.dp, 200.dp), + positionProvider = WindowPositionProvider.AlignedToScreen(Alignment.Center) + ) + ) + lateinit var dialog: ComposeDialog + + launchTestApplication { + DialogWindow( + onCloseRequest = {}, + state = state, + title = "center dialog on screen" + ) { + dialog = this.window + } + } + + awaitIdle() + assertThat(dialog.center() maxDistance dialog.screenCenter() < 250) + } + + @Test + fun `center dialog in parent`() = runApplicationTest { + val windowState = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(400.dp, 400.dp), + positionProvider = WindowPositionProvider.AlignedToScreen(Alignment.Center) + ) + ) + lateinit var window: ComposeWindow + + val dialogState = DialogState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(200.dp, 200.dp), + positionProvider = WindowPositionProvider.AlignedToParentWindow(Alignment.Center) + ) + ) + lateinit var dialog: ComposeDialog + + var showDialog by mutableStateOf(false) + + launchTestApplication { + Window(onCloseRequest = {}, windowState, title = "(parent) center dialog in parent") { + window = this.window + if (showDialog) { + DialogWindow( + onCloseRequest = { }, + state = dialogState, + title = "(child) center dialog in parent" + ) { + dialog = this.window + } + } + } + } + + awaitIdle() + showDialog = true + awaitIdle() + + assertThat(dialog.center() maxDistance window.center() <= 5) + } + + @Test + fun `remember position after reattach`() = runApplicationTest(useDelay = isLinux) { + val state = DialogStateWithBounds(initialSize = DpSize(200.dp, 200.dp)) + var dialog1: ComposeDialog? = null + var dialog2: ComposeDialog? = null + var isDialog1 by mutableStateOf(true) + + launchTestApplication { + if (isDialog1) { + DialogWindow( + onCloseRequest = {}, + state = state, + title = "remember position after reattach 1" + ) { + dialog1 = this.window + } + } else { + DialogWindow( + onCloseRequest = {}, + state = state, + title = "remember position after reattach 2" + ) { + dialog2 = this.window + } + } + } + + awaitIdle() + + val position = Point(242, 242) + state.requestPosition(position.asDpOffset()) + awaitIdle() + assertThat(dialog1?.location).isEqualTo(position) + + isDialog1 = false + awaitIdle() + assertThat(dialog2?.location).isEqualTo(position) + } + + @Test + fun `state bounds should be initialized after show`() = runApplicationTest( + useDelay = isLinux + ) { + val state = DialogState() + launchTestApplication { + DialogWindow( + onCloseRequest = {}, + state = state, + title = "state bounds should be initialized after show" + ) { } + } + + assertThat(state.isInitialized).isFalse() + + awaitIdle() + assertThat(state.isInitialized).isTrue() + state.bounds // Just make sure it doesn't crash + } + + @Test + fun `set dialog min intrinsic height`() = runApplicationTest(useDelay = isLinux) { + assumeTrue(!isLinux) // Flaky on our CI + + lateinit var dialog: ComposeDialog + val state = DialogState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.MinIntrinsicHeight(width = 300.dp) + ) + ) + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + state = state, + title = "set dialog min intrinsic height" + ) { + dialog = this.window + + Box( + Modifier + .width(400.dp) + .height(200.dp) + ) + } + } + + awaitIdle() + assertThat(dialog.contentSize.width).isEqualTo(300) + assertThat(dialog.contentSize.height).isEqualTo(200) + assertThat(state.size).isEqualTo(DpSize(dialog.size.width.dp, dialog.size.height.dp)) + } + + @Test + fun `set dialog min intrinsic width`() = runApplicationTest { + assumeTrue(!isLinux) // Flaky on our CI + + lateinit var dialog: ComposeDialog + val state = DialogState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.MinIntrinsicWidth(height = 300.dp) + ) + ) + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + state = state, + title = "set dialog min intrinsic width" + ) { + dialog = this.window + + Box( + Modifier + .width(400.dp) + .height(200.dp) + ) + } + } + + awaitIdle() + assertThat(dialog.contentSize.height).isEqualTo(300) + assertThat(dialog.contentSize.width).isEqualTo(400) + assertThat(state.size).isEqualTo(DpSize(dialog.size.width.dp, dialog.size.height.dp)) + } + + @Test + fun `set unconstrained dialog size by its content`() = runApplicationTest { + assumeTrue(!isLinux) // Flaky on our CI + + lateinit var dialog: ComposeDialog + val state = DialogState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Unconstrained + ) + ) + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + state = state, + title = "set unconstrained dialog size by its content" + ) { + dialog = this.window + + Box( + Modifier + .width(400.dp) + .height(200.dp) + ) + } + } + + awaitIdle() + assertThat(dialog.contentSize).isEqualTo(Dimension(400, 200)) + assertThat(state.size).isEqualTo(DpSize(dialog.size.width.dp, dialog.size.height.dp)) + } + + @Test + fun `set dialog size by its content when dialog is visible`() = runApplicationTest( + useDelay = isLinux || isMacOs + ) { + lateinit var dialog: ComposeDialog + val state = DialogStateWithBounds(initialSize = DpSize(100.dp, 100.dp)) + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + state = state, + title = "set dialog size by its content when dialog is visible" + ) { + dialog = this.window + + Box( + Modifier + .width(400.dp) + .height(200.dp) + ) + } + } + + awaitIdle() + + state.requestSize(WindowSizeProvider.Unconstrained) + awaitIdle() + assertThat(dialog.contentSize).isEqualTo(Dimension(400, 200)) + assertThat(state.size).isEqualTo(DpSize(dialog.size.width.dp, dialog.size.height.dp)) + } + + @Test + fun `change visibility`() = runApplicationTest { + lateinit var dialog: ComposeDialog + + var visible by mutableStateOf(false) + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + visible = visible, + title = "change visible" + ) { + dialog = this.window + } + } + + awaitIdle() + assertThat(dialog.isVisible).isEqualTo(false) + + visible = true + awaitIdle() + assertThat(dialog.isVisible).isEqualTo(true) + } + + @Test + fun `invisible dialog should be active`() = runApplicationTest { + val receivedNumbers = mutableListOf() + + val sendChannel = Channel(Channel.UNLIMITED) + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + visible = false, + title = "invisible dialog should be active" + ) { + LaunchedEffect(Unit) { + sendChannel.consumeEach { + receivedNumbers.add(it) + } + } + } + } + + sendChannel.send(1) + awaitIdle() + assertThat(receivedNumbers).isEqualTo(listOf(1)) + + sendChannel.send(2) + awaitIdle() + assertThat(receivedNumbers).isEqualTo(listOf(1, 2)) + } + + @Test + fun `show invisible undecorated dialog`() = runApplicationTest { + val receivedNumbers = mutableListOf() + + val sendChannel = Channel(Channel.UNLIMITED) + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + visible = false, + decoration = WindowDecoration.Undecorated(), + title = "show invisible undecorated dialog" + ) { + LaunchedEffect(Unit) { + sendChannel.consumeEach { + receivedNumbers.add(it) + } + } + } + } + + sendChannel.send(1) + awaitIdle() + assertThat(receivedNumbers).isEqualTo(listOf(1)) + + sendChannel.send(2) + awaitIdle() + assertThat(receivedNumbers).isEqualTo(listOf(1, 2)) + } + + @Test + fun dialogStateIsPreservedWhenRemovingAndAddingComposable() = runApplicationTest { + var showDialog by mutableStateOf(true) + lateinit var dialogState: DialogState + var dialogVisible = false + launchTestApplication { + val state = rememberDialogStateWithBounds() + dialogState = state + if (showDialog) { + DialogWindow( + state = state, + onCloseRequest = { }, + title = "dialogStateIsPreservedWhenRemovingAndAddingComposable" + ) { + Box(Modifier.size(32.dp)) + DisposableEffect(Unit) { + dialogVisible = true + onDispose { + dialogVisible = false + } + } + } + } + + // Prevent app from dying when nothing is shown + LaunchedEffect(Unit) { + delay(Duration.INFINITE) + } + } + awaitIdle() + + dialogState.requestBounds { + val screenBounds = windowMetrics.screen.availableBounds + val size = DpSize(400.dp, 400.dp) + DpRect( + origin = DpOffset( + (screenBounds.width - size.width) / 2, + (screenBounds.height - size.height) / 2 + ), + size = size + ) + } + awaitIdle() + val windowBounds = dialogState.bounds + + showDialog = false + awaitIdle() + assertFalse(dialogVisible) + + showDialog = true + awaitIdle() + assertTrue(dialogState.isInitialized) + assertEquals(windowBounds, dialogState.bounds) + } + + @Test + fun dialogStateIsPreservedWhenSavingAndRestoring() = runApplicationTest { + var showDialog by mutableStateOf(true) + var dialogState: DialogState? = null + launchTestApplication { + val stateHolder = rememberSaveableStateHolder() + stateHolder.SaveableStateProvider(showDialog) { + if (showDialog) { + val state = rememberDialogStateWithBounds() + DisposableEffect(state) { + dialogState = state + onDispose { + dialogState = null + } + } + DialogWindow( + state = state, + onCloseRequest = { }, + title = "dialogStateIsPreservedWhenSavingAndRestoring" + ) { + Box(Modifier.size(32.dp)) + } + } + } + + // Prevent app from dying when nothing is shown + LaunchedEffect(Unit) { + delay(Duration.INFINITE) + } + } + awaitIdle() + + dialogState!!.requestBounds { + val screenBounds = windowMetrics.screen.availableBounds + val size = DpSize(400.dp, 400.dp) + DpRect( + origin = DpOffset( + (screenBounds.width - size.width) / 2, + (screenBounds.height - size.height) / 2 + ), + size = size + ) + } + awaitIdle() + val windowBounds = dialogState!!.bounds + + showDialog = false + awaitIdle() + assertNull(dialogState) + + showDialog = true + awaitIdle() + assertTrue(dialogState!!.isInitialized) + assertEquals(windowBounds, dialogState!!.bounds) + } + + @Test + fun dialogIsShownCorrectlyIfStateSavedBeforeWindowIsShown() = runApplicationTest { + var createDialogState by mutableStateOf(true) + var showDialog by mutableStateOf(false) + var dialogState: DialogState? = null + launchTestApplication { + val stateHolder = rememberSaveableStateHolder() + stateHolder.SaveableStateProvider(createDialogState) { + if (createDialogState) { + val state = rememberDialogStateWithBounds( + initialSize = DpSize(300.dp, 300.dp) + ) + DisposableEffect(state) { + dialogState = state + onDispose { + dialogState = null + } + } + if (showDialog) { + DialogWindow( + state = state, + onCloseRequest = { }, + title = "dialogIsShownCorrectlyIfStateSavedBeforeWindowIsShown" + ) { + Box(Modifier.size(32.dp)) + } + } + } + } + + // Prevent app from dying when nothing is shown + LaunchedEffect(Unit) { + delay(Duration.INFINITE) + } + } + + awaitIdle() + assertNotNull(dialogState) + assertFalse(dialogState!!.isInitialized) + dialogState!!.requestBounds { + val screenBounds = windowMetrics.screen.availableBounds + val size = DpSize(400.dp, 400.dp) + DpRect( + origin = DpOffset( + (screenBounds.width - size.width) / 2, + (screenBounds.height - size.height) / 2 + ), + size = size + ) + } + + createDialogState = false + awaitIdle() + assertNull(dialogState) + + createDialogState = true + showDialog = true + awaitIdle() + + awaitIdle() + assertNotNull(dialogState) + assertTrue(dialogState!!.isInitialized) + // Size should be as the one requested in rememberDialogStateWithBounds, not the one in + // dialogState!!.requestBounds above. + assertEquals(DpSize(300.dp, 300.dp), dialogState!!.bounds.size) + } + + private fun runDialogSizeTest( + testName: String, + sizeProvider: WindowSizeProvider, + content: @Composable () -> Unit, + expectedWindowSizeSansInsets: DpSize, + ) = runApplicationTest { + val dialogState = DialogState( + initialBoundsProvider = WindowBoundsProvider(sizeProvider) + ) + lateinit var dialog: ComposeDialog + launchTestApplication { + DialogWindow( + state = dialogState, + onCloseRequest = {}, + title = testName + ) { + dialog = this.window + content() + } + } + awaitIdle() + assertEquals( + expectedWindowSizeSansInsets + dialog.insets.toDpInsets(), + dialogState.bounds.size + ) + } + + @Test + fun dialogMinIntrinsicWidth() = runDialogSizeTest( + testName = "windowMinIntrinsicWidth", + sizeProvider = WindowSizeProvider.MinIntrinsicWidth(height = 500.dp), + content = { + BoxWithIntrinsicSize( + minWidth = { 400.dp.roundToPx() } + ) + }, + expectedWindowSizeSansInsets = DpSize(400.dp, 500.dp) + ) + + @Test + fun windowMaxIntrinsicWidth() = runDialogSizeTest( + testName = "windowMaxIntrinsicWidth", + sizeProvider = WindowSizeProvider.MaxIntrinsicWidth(height = 500.dp), + content = { + BoxWithIntrinsicSize( + maxWidth = { 400.dp.roundToPx() } + ) + }, + expectedWindowSizeSansInsets = DpSize(400.dp, 500.dp) + ) + + @Test + fun windowMinIntrinsicHeight() = runDialogSizeTest( + testName = "windowMinIntrinsicHeight", + sizeProvider = WindowSizeProvider.MinIntrinsicHeight(width = 500.dp), + content = { + BoxWithIntrinsicSize( + minHeight = { 400.dp.roundToPx() } + ) + }, + expectedWindowSizeSansInsets = DpSize(500.dp, 400.dp) + ) + + @Test + fun windowMaxIntrinsicHeight() = runDialogSizeTest( + testName = "windowMaxIntrinsicHeight", + sizeProvider = WindowSizeProvider.MaxIntrinsicHeight(width = 500.dp), + content = { + BoxWithIntrinsicSize( + maxHeight = { 400.dp.roundToPx() } + ) + }, + expectedWindowSizeSansInsets = DpSize(500.dp, 400.dp) + ) + + @Test + fun windowMinWidthWithMatchingMinHeight() = runDialogSizeTest( + testName = "windowMinWidthWithMatchingMinHeight", + sizeProvider = WindowSizeProvider.IntrinsicWidthWithMatchingIntrinsicHeight( + intrinsicWidth = WindowIntrinsicSize.Min, + intrinsicHeight = WindowIntrinsicSize.Min, + ), + content = { + BoxWithIntrinsicSize( + minWidth = { 400.dp.roundToPx() }, + minHeight = { it } // Return width to make it a square + ) + }, + expectedWindowSizeSansInsets = DpSize(400.dp, 400.dp) + ) + + @Test + fun windowMaxHeightWithMatchingMaxWidth() = runDialogSizeTest( + testName = "windowMaxHeightWithMatchingMaxWidth", + sizeProvider = WindowSizeProvider.IntrinsicHeightWithMatchingIntrinsicWidth( + intrinsicWidth = WindowIntrinsicSize.Max, + intrinsicHeight = WindowIntrinsicSize.Max, + ), + content = { + BoxWithIntrinsicSize( + maxHeight = { 400.dp.roundToPx() }, + maxWidth = { it } // Return height to make it a square + ) + }, + expectedWindowSizeSansInsets = DpSize(400.dp, 400.dp) + ) + + @Test + fun `requested size is rounded up`() = runDialogSizeTest( + testName = "requested size is rounded up", + sizeProvider = WindowSizeProvider.IntrinsicWidthWithMatchingIntrinsicHeight( + intrinsicWidth = WindowIntrinsicSize.Min, + intrinsicHeight = WindowIntrinsicSize.Min, + ), + content = { + BoxWithIntrinsicSize( + minWidth = { (density * 100 + 1).toInt() }, + minHeight = { it } + ) + }, + expectedWindowSizeSansInsets = DpSize(101.dp, 101.dp) + ) + + private fun runBoundsOverwriteTest( + name: String, + dialogState: DialogState, + expectedPosition: DpOffset, + expectedSize: DpSize + ) = runApplicationTest { + launchTestApplication { + DialogWindow( + state = dialogState, + onCloseRequest = {}, + title = name + ) { + LaunchedEffect(Unit) { + window.addComponentListener(object: ComponentAdapter() { + // Verify that the bounds are set correctly immediately, not just at some + // point after the window is shown. + override fun componentShown(e: ComponentEvent) { + assertEquals(expectedSize, window.size.toDpSize()) + assertEquals(expectedPosition, window.location.toDpOffset()) + } + }) + } + } + } + awaitIdle() + + assertEquals(expectedSize, dialogState.bounds.size) + assertEquals(expectedPosition, dialogState.bounds.topLeft) + } + + @Test + fun `requesting size before initialization does not overwrite position`() { + val position = DpOffset(300.dp, 300.dp) + val size = DpSize(400.dp, 400.dp) + val dialogState = DialogStateWithBounds( + initialPosition = position, + ) + dialogState.requestSize(size) + + runBoundsOverwriteTest( + name = "requesting size before initialization does not overwrite position", + dialogState = dialogState, + expectedSize = size, + expectedPosition = position, + ) + } + + @Test + fun `requesting position before initialization does not overwrite size`() { + val position = DpOffset(300.dp, 300.dp) + val size = DpSize(400.dp, 400.dp) + val dialogState = DialogStateWithBounds( + initialSize = size, + ) + dialogState.requestPosition(position) + + runBoundsOverwriteTest( + name = "requesting position before initialization does not overwrite size", + dialogState = dialogState, + expectedSize = size, + expectedPosition = position, + ) + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/DialogWindowV2Test.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/DialogWindowV2Test.kt new file mode 100644 index 0000000000000..9de436e1565ab --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/DialogWindowV2Test.kt @@ -0,0 +1,691 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.awt.v2.SwingDialog +import androidx.compose.ui.background +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.sendKeyEvent +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.toInt +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntSize +import androidx.compose.ui.window.DialogWindowScope +import androidx.compose.ui.window.runApplicationTest +import androidx.compose.ui.window.toSize +import com.google.common.truth.Truth.assertThat +import java.awt.Dialog +import java.awt.Point +import java.awt.Robot +import java.awt.Window +import java.awt.event.KeyEvent +import java.awt.event.WindowEvent +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.delay + +class DialogWindowV2Test { + @Test + fun `open and close dialog`() = runApplicationTest { + var window: ComposeDialog? = null + + launchTestApplication { + DialogWindow(onCloseRequest = ::exitApplication) { + window = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + } + + awaitIdle() + assertThat(window?.isShowing).isTrue() + + window?.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + } + + @Test + fun `disable closing dialog`() = runApplicationTest { + var isOpen by mutableStateOf(true) + var isCloseCalled by mutableStateOf(false) + var window: ComposeDialog? = null + + launchTestApplication { + if (isOpen) { + DialogWindow( + onCloseRequest = { + isCloseCalled = true + } + ) { + window = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + } + } + + awaitIdle() + + window?.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + awaitIdle() + assertThat(isCloseCalled).isTrue() + assertThat(window?.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window?.isShowing).isFalse() + } + + @Test + fun `show splash screen`() = runApplicationTest { + var window1: ComposeDialog? = null + var window2: ComposeDialog? = null + + var isOpen by mutableStateOf(true) + var isLoading by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + if (isLoading) { + DialogWindow(onCloseRequest = {}) { + window1 = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + } else { + DialogWindow(onCloseRequest = {}) { + window2 = this.window + Box(Modifier.size(32.dp).background(Color.Blue)) + } + } + } + } + + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2).isNull() + + isLoading = false + awaitIdle() + assertThat(window1?.isShowing).isFalse() + assertThat(window2?.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window1?.isShowing).isFalse() + assertThat(window2?.isShowing).isFalse() + } + + @Test + fun `open two dialogs`() = runApplicationTest { + var window1: ComposeDialog? = null + var window2: ComposeDialog? = null + + var isOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + DialogWindow(onCloseRequest = {}) { + window1 = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + + DialogWindow(onCloseRequest = {}) { + window2 = this.window + Box(Modifier.size(32.dp).background(Color.Blue)) + } + } + } + + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window1?.isShowing).isFalse() + assertThat(window2?.isShowing).isFalse() + } + + @Test + fun `open nested dialog`() = runApplicationTest(useDelay = true) { + var window1: ComposeDialog? = null + var window2: ComposeDialog? = null + + var isOpen by mutableStateOf(true) + var isNestedOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + DialogWindow( + onCloseRequest = {}, + state = rememberDialogStateWithBounds( + initialSize = DpSize(600.dp, 600.dp), + ) + ) { + window1 = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + + if (isNestedOpen) { + DialogWindow( + onCloseRequest = {}, + state = rememberDialogStateWithBounds( + initialSize = DpSize(300.dp, 300.dp), + ) + ) { + window2 = this.window + Box(Modifier.size(32.dp).background(Color.Blue)) + } + } + } + } + } + + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isTrue() + + isNestedOpen = false + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isFalse() + + isNestedOpen = true + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window1?.isShowing).isFalse() + assertThat(window2?.isShowing).isFalse() + } + + @Test + fun `pass composition local to dialogs`() = runApplicationTest { + var actualValue1: Int? = null + var actualValue2: Int? = null + + var isOpen by mutableStateOf(true) + var testValue by mutableStateOf(0) + val localTestValue = compositionLocalOf { testValue } + + launchTestApplication { + if (isOpen) { + CompositionLocalProvider(localTestValue provides testValue) { + DialogWindow( + onCloseRequest = {}, + state = rememberDialogStateWithBounds( + initialSize = DpSize(600.dp, 600.dp), + ) + ) { + actualValue1 = localTestValue.current + Box(Modifier.size(32.dp).background(Color.Red)) + + DialogWindow( + onCloseRequest = {}, + state = rememberDialogStateWithBounds( + initialSize = DpSize(300.dp, 300.dp), + ) + ) { + actualValue2 = localTestValue.current + Box(Modifier.size(32.dp).background(Color.Blue)) + } + } + } + } + } + + awaitIdle() + assertThat(actualValue1).isEqualTo(0) + assertThat(actualValue2).isEqualTo(0) + + testValue = 42 + awaitIdle() + assertThat(actualValue1).isEqualTo(42) + assertThat(actualValue2).isEqualTo(42) + + isOpen = false + } + + @Test + fun `DisposableEffect call order`() = runApplicationTest { + var initCount = 0 + var disposeCount = 0 + + var isOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + DialogWindow(onCloseRequest = {}) { + DisposableEffect(Unit) { + initCount++ + onDispose { + disposeCount++ + } + } + } + } + } + + awaitIdle() + assertThat(initCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + isOpen = false + awaitIdle() + assertThat(initCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(1) + } + + @Test + fun `catch key handlers`() = runApplicationTest { + var window: ComposeDialog? = null + val onKeyEventKeys = mutableSetOf() + val onPreviewKeyEventKeys = mutableSetOf() + + fun clear() { + onKeyEventKeys.clear() + onPreviewKeyEventKeys.clear() + } + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + onPreviewKeyEvent = { + onPreviewKeyEventKeys.add(it.key) + it.key == Key.Q + }, + onKeyEvent = { + onKeyEventKeys.add(it.key) + it.key == Key.W + } + ) { + window = this.window + } + } + + awaitIdle() + + window?.sendKeyEvent(KeyEvent.VK_Q) + awaitIdle() + assertThat(onPreviewKeyEventKeys).isEqualTo(setOf(Key.Q)) + assertThat(onKeyEventKeys).isEqualTo(emptySet()) + + clear() + window?.sendKeyEvent(KeyEvent.VK_W) + awaitIdle() + assertThat(onPreviewKeyEventKeys).isEqualTo(setOf(Key.W)) + assertThat(onKeyEventKeys).isEqualTo(setOf(Key.W)) + + clear() + window?.sendKeyEvent(KeyEvent.VK_E) + awaitIdle() + assertThat(onPreviewKeyEventKeys).isEqualTo(setOf(Key.E)) + assertThat(onKeyEventKeys).isEqualTo(setOf(Key.E)) + } + + @Test + fun `catch key handlers with focused node`() = runApplicationTest { + var window: ComposeDialog? = null + val onWindowKeyEventKeys = mutableSetOf() + val onWindowPreviewKeyEventKeys = mutableSetOf() + val onNodeKeyEventKeys = mutableSetOf() + val onNodePreviewKeyEventKeys = mutableSetOf() + + fun clear() { + onWindowKeyEventKeys.clear() + onWindowPreviewKeyEventKeys.clear() + onNodeKeyEventKeys.clear() + onNodePreviewKeyEventKeys.clear() + } + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + onPreviewKeyEvent = { + onWindowPreviewKeyEventKeys.add(it.key) + it.key == Key.Q + }, + onKeyEvent = { + onWindowKeyEventKeys.add(it.key) + it.key == Key.W + }, + ) { + window = this.window + + val focusRequester = remember(::FocusRequester) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Box( + Modifier + .focusRequester(focusRequester) + .focusTarget() + .onPreviewKeyEvent { + onNodePreviewKeyEventKeys.add(it.key) + it.key == Key.E + } + .onKeyEvent { + onNodeKeyEventKeys.add(it.key) + it.key == Key.R + } + ) + } + } + + awaitIdle() + + window?.sendKeyEvent(KeyEvent.VK_Q) + awaitIdle() + assertThat(onWindowPreviewKeyEventKeys).isEqualTo(setOf(Key.Q)) + assertThat(onNodePreviewKeyEventKeys).isEqualTo(emptySet()) + assertThat(onNodeKeyEventKeys).isEqualTo(emptySet()) + assertThat(onWindowKeyEventKeys).isEqualTo(emptySet()) + + clear() + window?.sendKeyEvent(KeyEvent.VK_W) + awaitIdle() + assertThat(onWindowPreviewKeyEventKeys).isEqualTo(setOf(Key.W)) + assertThat(onNodePreviewKeyEventKeys).isEqualTo(setOf(Key.W)) + assertThat(onNodeKeyEventKeys).isEqualTo(setOf(Key.W)) + assertThat(onWindowKeyEventKeys).isEqualTo(setOf(Key.W)) + + clear() + window?.sendKeyEvent(KeyEvent.VK_E) + awaitIdle() + assertThat(onWindowPreviewKeyEventKeys).isEqualTo(setOf(Key.E)) + assertThat(onNodePreviewKeyEventKeys).isEqualTo(setOf(Key.E)) + assertThat(onNodeKeyEventKeys).isEqualTo(emptySet()) + assertThat(onWindowKeyEventKeys).isEqualTo(emptySet()) + + clear() + window?.sendKeyEvent(KeyEvent.VK_R) + awaitIdle() + assertThat(onWindowPreviewKeyEventKeys).isEqualTo(setOf(Key.R)) + assertThat(onNodePreviewKeyEventKeys).isEqualTo(setOf(Key.R)) + assertThat(onNodeKeyEventKeys).isEqualTo(setOf(Key.R)) + assertThat(onWindowKeyEventKeys).isEqualTo(emptySet()) + + clear() + window?.sendKeyEvent(KeyEvent.VK_T) + awaitIdle() + assertThat(onWindowPreviewKeyEventKeys).isEqualTo(setOf(Key.T)) + assertThat(onNodePreviewKeyEventKeys).isEqualTo(setOf(Key.T)) + assertThat(onNodeKeyEventKeys).isEqualTo(setOf(Key.T)) + assertThat(onWindowKeyEventKeys).isEqualTo(setOf(Key.T)) + } + + private fun testDrawingBeforeDialogIsVisible( + dialogState: DialogState, + canvasSizeModifier: Modifier, + expectedCanvasSize: DialogWindowScope.() -> DpSize + ) = runApplicationTest { + var isComposed = false + var isDrawn = false + var isVisibleOnFirstComposition = false + var isVisibleOnFirstDraw = false + var actualCanvasSize: IntSize? = null + var expectedCanvasSizePx: IntSize? = null + + launchTestApplication { + DialogWindow( + onCloseRequest = ::exitApplication, + state = dialogState + ) { + if (!isComposed) { + isVisibleOnFirstComposition = window.isVisible + isComposed = true + } + + Canvas(canvasSizeModifier) { + if (!isDrawn) { + isVisibleOnFirstDraw = window.isVisible + isDrawn = true + + // toInt() because this is how ComposeWindow rounds decimal sizes + // (see ComposeBridge.updateSceneSize) + actualCanvasSize = size.toInt() + expectedCanvasSizePx = expectedCanvasSize().toSize().roundToIntSize() + } + } + } + } + + awaitIdle() + + assertThat(isComposed).isTrue() + assertThat(isDrawn).isTrue() + assertThat(isVisibleOnFirstComposition).isFalse() + assertThat(isVisibleOnFirstDraw).isFalse() + assertEquals(expectedCanvasSizePx, actualCanvasSize) + } + + @Test(timeout = 30000) + fun `should draw before dialog is visible`() { + val windowSize = DpSize(400.dp, 300.dp) + testDrawingBeforeDialogIsVisible( + dialogState = DialogStateWithBounds(initialSize = windowSize), + canvasSizeModifier = Modifier.fillMaxSize(), + expectedCanvasSize = { windowSize - window.insets.toSize() } + ) + } + + @Test(timeout = 30000) + fun `should draw before dialog with unconstrained size is visible`() { + val canvasSize = DpSize(400.dp, 300.dp) + testDrawingBeforeDialogIsVisible( + dialogState = DialogState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Unconstrained + ) + ), + canvasSizeModifier = Modifier.size(canvasSize), + expectedCanvasSize = { canvasSize } + ) + } + + @Test + fun `pass LayoutDirection to DialogWindow`() = runApplicationTest { + lateinit var localLayoutDirection: LayoutDirection + + var layoutDirection by mutableStateOf(LayoutDirection.Rtl) + launchTestApplication { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + DialogWindow(onCloseRequest = {}) { + localLayoutDirection = LocalLayoutDirection.current + } + } + } + awaitIdle() + + assertThat(localLayoutDirection).isEqualTo(LayoutDirection.Rtl) + + // Test that changing the local propagates it into the dialog + layoutDirection = LayoutDirection.Ltr + awaitIdle() + assertThat(localLayoutDirection).isEqualTo(LayoutDirection.Ltr) + } + + @Test + fun `modal DialogWindow does not block parent window rendering`() { + runApplicationTest(useDelay = true) { + var text by mutableStateOf("1") + var renderedText: String? = null + lateinit var dialog: ComposeDialog + + launchTestApplication { + Window(onCloseRequest = {}) { + val textMeasurer = rememberTextMeasurer() + Canvas(Modifier.size(200.dp)) { + renderedText = text + drawText( + textMeasurer = textMeasurer, + text = text + ) + } + + DialogWindow(onCloseRequest = {}) { + dialog = window + } + } + } + + awaitIdle() + assertThat(dialog.isModal).isTrue() + assertThat(renderedText).isEqualTo("1") + + text = "2" + awaitIdle() + assertThat(renderedText).isEqualTo("2") + } + } + + @Test + fun `change alwaysOnTop`() = runApplicationTest { + var dialog: ComposeDialog? = null + + var alwaysOnTop by mutableStateOf(false) + + launchTestApplication { + DialogWindow(onCloseRequest = ::exitApplication, alwaysOnTop = alwaysOnTop) { + dialog = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + } + + awaitIdle() + assertThat(dialog?.isAlwaysOnTop).isFalse() + + alwaysOnTop = true + awaitIdle() + assertThat(dialog?.isAlwaysOnTop).isTrue() + + dialog?.dispatchEvent(WindowEvent(dialog, WindowEvent.WINDOW_CLOSING)) + } + + @Test + fun `swing dialog init called before it is displayable`() = runApplicationTest { + var isDisplayableInInit: Boolean? = null + launchTestApplication { + SwingDialog( + onCloseRequest = ::exitApplication, + init = { + isDisplayableInInit = it.isDisplayable + } + ) { } + } + + awaitIdle() + assertThat(isDisplayableInInit).isFalse() + } + + @Test + fun `dialog does not flash background when closed`() = runApplicationTest { + lateinit var window: Window + lateinit var dialog: Dialog + var showDialog by mutableStateOf(false) + val windowSize = DpSize(800.dp, 800.dp) + launchTestWindowV2Application( + state = WindowStateWithBounds(initialSize = windowSize), + ) { + window = this.window + Box(Modifier.fillMaxSize().background(Color.Black)) + if (showDialog) { + DialogWindow( + onCloseRequest = {}, + state = rememberDialogStateWithBounds(initialSize = windowSize) + ) { + dialog = this.window + Box(Modifier.fillMaxSize().background(Color.Black)) + LaunchedEffect(Unit) { + dialog.location = window.location + } + } + } + } + awaitIdle() + + showDialog = true + awaitIdle() + delay(1000) + + var nonBlackPixelDetected: java.awt.Color? = null + val testLocation = dialog.bounds.let { + Point(it.x + it.width / 2, it.y + it.height / 2) + } + val stopThread = AtomicBoolean(false) + val t = thread { + val robot = Robot() + while (!stopThread.get()) { + val pixel = robot.getPixelColor(testLocation.x, testLocation.y) + if (pixel != java.awt.Color.BLACK) { + nonBlackPixelDetected = pixel + return@thread + } + } + } + + dialog.dispose() + awaitIdle() + delay(1000) + + stopThread.getAndSet(true) + t.join() + + assertThat(nonBlackPixelDetected).isNull() + } + + @Test + fun coroutineContextIsPropagatedToDialog() = coroutineContextIsPropagatedTo { content -> + DialogWindow(onCloseRequest = ::exitApplication) { + content() + } + } + + @Test + fun animationsRunAtNonInfiniteRateInDialog() = animationsRunAtNonInfiniteRateIn { content -> + DialogWindow(onCloseRequest = ::exitApplication) { + content() + } + } +} diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowTestUtils.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowTestUtils.kt new file mode 100644 index 0000000000000..e9b0b8c7fd909 --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowTestUtils.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +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.unit.Constraints +import java.awt.Dimension +import java.awt.Window + + +abstract class EmptyMeasurePolicy : MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints + ): MeasureResult { + return layout(1, 1) {} + } +} + + +@Composable +fun BoxWithIntrinsicSize( + minWidth: (IntrinsicMeasureScope.(Int) -> Int)? = null, + maxWidth: (IntrinsicMeasureScope.(Int) -> Int)? = null, + minHeight: (IntrinsicMeasureScope.(Int) -> Int)? = null, + maxHeight: (IntrinsicMeasureScope.(Int) -> Int)? = null, +) { + Box { + Layout( + measurePolicy = object : EmptyMeasurePolicy() { + override fun IntrinsicMeasureScope.minIntrinsicWidth( + measurables: List, + height: Int + ): Int { + return minWidth?.invoke(this, height) ?: 0 + } + + override fun IntrinsicMeasureScope.maxIntrinsicWidth( + measurables: List, + height: Int + ): Int { + return maxWidth?.invoke(this, height) ?: 0 + } + + override fun IntrinsicMeasureScope.minIntrinsicHeight( + measurables: List, + width: Int + ): Int { + return minHeight?.invoke(this, width) ?: 0 + } + + override fun IntrinsicMeasureScope.maxIntrinsicHeight( + measurables: List, + width: Int + ): Int { + return maxHeight?.invoke(this, width) ?: 0 + } + }, + content = {} + ) + } +} + +val Window.contentSize + get() = Dimension( + size.width - insets.left - insets.right, + size.height - insets.top - insets.bottom, + ) diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowV2StateTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowV2StateTest.kt new file mode 100644 index 0000000000000..0f06b8208dbfe --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowV2StateTest.kt @@ -0,0 +1,1254 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.isLinux +import androidx.compose.ui.isMacOs +import androidx.compose.ui.toDpOffset +import androidx.compose.ui.toDpSize +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.plus +import androidx.compose.ui.unit.size +import androidx.compose.ui.unit.topLeft +import androidx.compose.ui.unit.width +import androidx.compose.ui.window.WindowDecoration +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.asDpOffset +import androidx.compose.ui.window.runApplicationTest +import androidx.compose.ui.window.toDpInsets +import com.google.common.truth.Truth.assertThat +import java.awt.Dimension +import java.awt.Point +import java.awt.Rectangle +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.WindowEvent +import javax.swing.JFrame +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.max +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.delay +import org.junit.Assume.assumeTrue + +// Note that on Linux some tests are flaky. Swing event listener's on Linux has non-deterministic +// nature. To avoid flakiness we use delays +// (see description of `delay` parameter in TestUtils.runApplicationTest). +// It is not a good solution, but it works. + +// TODO(demin): figure out how can we fix flaky tests on Linux + +class WindowV2StateTest { + @Test + fun `manually close window`() = runApplicationTest { + lateinit var window: ComposeWindow + var isOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + Window(onCloseRequest = { isOpen = false }, title = "manually close window") { + window = this.window + } + } + } + + awaitIdle() + assertThat(window.isShowing).isTrue() + + window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + awaitIdle() + assertThat(window.isShowing).isFalse() + } + + @Test + fun `programmatically close window`() = runApplicationTest { + lateinit var window: ComposeWindow + var isOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + Window( + onCloseRequest = { isOpen = false }, + title = "programmatically close window" + ) { + window = this.window + } + } + } + + awaitIdle() + assertThat(window.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window.isShowing).isFalse() + } + + @Test + fun `programmatically open and close nested window`() = runApplicationTest(useDelay = true) { + var parentWindow: ComposeWindow? = null + var childWindow: ComposeWindow? = null + var isParentOpen by mutableStateOf(true) + var isChildOpen by mutableStateOf(false) + + launchTestApplication { + if (isParentOpen) { + Window( + onCloseRequest = {}, + title = "(parent) programmatically open and close nested window" + ) { + parentWindow = this.window + + if (isChildOpen) { + Window( + onCloseRequest = {}, + title = "(child) programmatically open and close nested window" + ) { + childWindow = this.window + } + } + } + } + } + + awaitIdle() + assertThat(parentWindow?.isShowing).isTrue() + + isChildOpen = true + awaitIdle() + assertThat(parentWindow?.isShowing).isTrue() + assertThat(childWindow?.isShowing).isTrue() + + isChildOpen = false + awaitIdle() + assertThat(parentWindow?.isShowing).isTrue() + assertThat(childWindow?.isShowing).isFalse() + + isParentOpen = false + awaitIdle() + assertThat(parentWindow?.isShowing).isFalse() + } + + @Test + fun `set size and position before show`() = runApplicationTest(useDelay = isLinux) { + val size = Dimension(200, 200) + val position = Point(242, 242) + val state = WindowStateWithBounds( + initialSize = size.toDpSize(), + initialPosition = position.asDpOffset() + ) + + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "set size and position before show") { + window = this.window + } + } + + awaitIdle() + assertSizesApproximatelyEqual(size, window.size) + assertCoordinatesApproximatelyEqual(position, window.location) + } + + @Test + fun `change position after show`() = runApplicationTest(useDelay = isLinux) { + val size = Dimension(200, 200) + val position = Point(200, 200) + + val state = WindowStateWithBounds( + initialSize = size.toDpSize(), + initialPosition = position.asDpOffset() + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "change position after show") { + window = this.window + } + } + + awaitIdle() + + val newPosition = Point(242, 242) + state.requestPosition(newPosition.asDpOffset()) + awaitIdle() + assertCoordinatesApproximatelyEqual(newPosition, window.location) + } + + @Test + fun `change size after show`() = runApplicationTest(useDelay = isLinux) { + val size = Dimension(200, 200) + val position = Point(200, 200) + + val state = WindowStateWithBounds( + initialSize = size.toDpSize(), + initialPosition = position.asDpOffset() + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "change size after show") { + window = this.window + } + } + + awaitIdle() + + val newSize = Dimension(250, 200) + state.requestSize(newSize.toDpSize()) + awaitIdle() + assertSizesApproximatelyEqual(newSize, window.size) + } + + @Test + fun `center window`() = runApplicationTest { + fun Rectangle.center() = Point(x + width / 2, y + height / 2) + fun JFrame.center() = bounds.center() + fun JFrame.screenCenter() = graphicsConfiguration.bounds.center() + infix fun Point.maxDistance(other: Point) = max(abs(x - other.x), abs(y - other.y)) + + val state = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(200.dp, 200.dp), + positionProvider = WindowPositionProvider.AlignedToScreen(Alignment.Center) + ) + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "center window") { + window = this.window + } + } + + awaitIdle() + assertThat(window.center() maxDistance window.screenCenter() < 250) + } + + @Test + fun `remember position after reattach`() = runApplicationTest(useDelay = isLinux) { + val state = WindowStateWithBounds(initialSize = DpSize(200.dp, 200.dp)) + var window1: ComposeWindow? = null + var window2: ComposeWindow? = null + var isWindow1 by mutableStateOf(true) + + launchTestApplication { + if (isWindow1) { + Window(onCloseRequest = {}, state, title = "remember position after reattach 1") { + window1 = this.window + } + } else { + Window(onCloseRequest = {}, state, title = "remember position after reattach 2") { + window2 = this.window + } + } + } + + awaitIdle() + + val position = Point(242, 242) + state.requestPosition(position.asDpOffset()) + awaitIdle() + assertThat(window1?.location).isEqualTo(position) + + isWindow1 = false + awaitIdle() + assertThat(window2?.location).isEqualTo(position) + } + + @Test + fun `state bounds should be initialized after show`() = runApplicationTest( + useDelay = isLinux + ) { + val state = WindowState() + launchTestApplication { + Window( + onCloseRequest = {}, + state = state, + title = "state bounds should be initialized after show" + ) { } + } + + assertThat(state.isInitialized).isFalse() + + awaitIdle() + assertThat(state.isInitialized).isTrue() + state.bounds // Just make sure it doesn't crash + } + + @Test + fun `enter fullscreen`() = runApplicationTest( + useDelay = isLinux || isMacOs, + delayMillis = 1000 + ) { + val state = WindowStateWithBounds(initialSize = DpSize(200.dp, 200.dp)) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "enter fullscreen") { + window = this.window + } + } + + awaitIdle() + + state.requestPlacement(WindowPlacement.Fullscreen) + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Fullscreen) + + state.requestPlacement(WindowPlacement.Floating) + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Floating) + } + + // https://github.com/JetBrains/compose-multiplatform/issues/3003 + @Test + fun `WindowState placement after showing fullscreen window`() = runApplicationTest( + useDelay = isLinux || isMacOs, + delayMillis = 1000 + ) { + val state = WindowState(initialPlacement = WindowPlacement.Fullscreen) + launchTestApplication { + Window(onCloseRequest = {}, state, title = "WindowState placement after showing fullscreen window") { } + } + + awaitIdle() + + assertThat(state.placement).isEqualTo(WindowPlacement.Fullscreen) + } + + // TODO(https://github.com/JetBrains/compose-multiplatform/issues/3557): check this test on Linux CI + @Test + fun maximize() = runApplicationTest(useDelay = isMacOs) { + assumeTrue(!isLinux) + val state = WindowStateWithBounds(initialSize = DpSize(200.dp, 200.dp)) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "maximize") { + window = this.window + } + } + + awaitIdle() + + state.requestPlacement(WindowPlacement.Maximized) + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Maximized) + + state.requestPlacement(WindowPlacement.Floating) + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Floating) + } + + @Test + fun minimize() = runApplicationTest(useDelay = isMacOs, delayMillis = 1000) { + val state = WindowStateWithBounds(initialSize = DpSize(200.dp, 200.dp)) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "minimize") { + window = this.window + } + } + + awaitIdle() + + state.requestMinimized(true) + awaitIdle() + assertThat(window.isMinimized).isTrue() + + state.requestMinimized(false) + awaitIdle() + assertThat(window.isMinimized).isFalse() + } + + @Test + fun `maximize and minimize`() = runApplicationTest { + // macOS can't be maximized and minimized at the same time + // Seems like it can't be on Linux too + assumeTrue(!isMacOs && !isLinux) + + val state = WindowStateWithBounds(initialSize = DpSize(200.dp, 200.dp)) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "maximize and minimize") { + window = this.window + } + } + + awaitIdle() + + state.requestMinimized(true) + state.requestPlacement(WindowPlacement.Maximized) + awaitIdle() + assertThat(window.isMinimized).isTrue() + assertThat(window.placement).isEqualTo(WindowPlacement.Maximized) + } + + // TODO(https://github.com/JetBrains/compose-multiplatform/issues/3557): check this test on Linux CI + @Test + fun `restore size and position after maximize`() = runApplicationTest( + useDelay = isMacOs, + delayMillis = 1000 + ) { + assumeTrue(!isLinux) + val size = Dimension(201, 203) + val position = Point(196, 257) + + val state = WindowStateWithBounds( + initialSize = size.toDpSize(), + initialPosition = position.asDpOffset() + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "1 restore size and position after maximize") { + window = this.window + } + } + + awaitIdle() + assertSizesApproximatelyEqual(size, window.size) + assertCoordinatesApproximatelyEqual(position, window.location) + + state.requestPlacement(WindowPlacement.Maximized) + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Maximized) + assertSizesNotApproximatelyEqual(size, window.size) + assertCoordinatesNotApproximatelyEqual(position, window.location) + + state.requestPlacement(WindowPlacement.Floating) + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Floating) + assertSizesApproximatelyEqual(size, window.size) + assertCoordinatesApproximatelyEqual(position, window.location) + } + + @Test + fun `restore size and position after fullscreen`() = runApplicationTest( + useDelay = isMacOs || isLinux, + delayMillis = 1000, + ) { + val size = Dimension(201, 203) + val position = Point(196, 257) + + val state = WindowStateWithBounds( + initialSize = size.toDpSize(), + initialPosition = position.asDpOffset() + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "2 restore size and position after fullscreen") { + window = this.window + } + } + + awaitIdle() + assertSizesApproximatelyEqual(size, window.size) + assertCoordinatesApproximatelyEqual(position, window.location) + + state.requestPlacement(WindowPlacement.Fullscreen) + awaitIdle() + assertSizesNotApproximatelyEqual(size, window.size) + assertCoordinatesNotApproximatelyEqual(position, window.location) + assertThat(window.size).isNotEqualTo(size) + + state.requestPlacement(WindowPlacement.Floating) + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Floating) + assertSizesApproximatelyEqual(size, window.size) + assertCoordinatesApproximatelyEqual(position, window.location) + } + + @Test + fun `window state size and position determine unmaximized state`() = runApplicationTest( + useDelay = true, + delayMillis = 1000 + ) { + // This fails on our CI it fails because the initial placement fails to be Maximized. + // The `maximize window before show` test fails the same way. + // Haven't actually tested on Windows; if you run it, and it doesn't pass, replace with + // assumeTrue(isMacOs), or investigate/fix. + assumeTrue(!isLinux) + + val size = Dimension(201, 203) + val position = Point(196, 257) + + val state = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(size.toDpSize()), + positionProvider = WindowPositionProvider.Absolute(position.asDpOffset()) + ), + initialPlacement = WindowPlacement.Maximized + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "window state size and position determine unmaximized state") { + window = this.window + } + } + + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Maximized) + + state.requestPlacement(WindowPlacement.Floating) + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Floating) + assertSizesApproximatelyEqual(size, window.size) + assertCoordinatesApproximatelyEqual(position, window.location) + } + + @Test + fun `maximize window before show`() = runApplicationTest(useDelay = isLinux) { + // This fails on our Linux CI; the window reports WindowPlacement.Floating. + // But testing in an actual Ubuntu 22 system, it succeeds. + assumeTrue(!isLinux) + + val state = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(200.dp, 200.dp), + positionProvider = WindowPositionProvider.AlignedToScreen(Alignment.Center), + ), + initialPlacement = WindowPlacement.Maximized, + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "maximize window before show") { + window = this.window + } + } + + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Maximized) + } + + @Test + fun `minimize window before show`() = runApplicationTest( + useDelay = isMacOs, + delayMillis = 1000 + ) { + val state = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(200.dp, 200.dp), + positionProvider = WindowPositionProvider.AlignedToScreen(Alignment.Center), + ), + initiallyMinimized = true + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "minimize window before show") { + window = this.window + } + } + + awaitIdle() + assertThat(window.isMinimized).isTrue() + } + + @Test + fun `enter fullscreen before show`() = runApplicationTest( + useDelay = isMacOs, + delayMillis = 1000, + ) { + val state = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(200.dp, 200.dp), + positionProvider = WindowPositionProvider.AlignedToScreen(Alignment.Center), + ), + initialPlacement = WindowPlacement.Fullscreen, + ) + lateinit var window: ComposeWindow + + launchTestApplication { + Window(onCloseRequest = {}, state, title = "enter fullscreen before show") { + window = this.window + } + } + + awaitIdle() + assertThat(window.placement).isEqualTo(WindowPlacement.Fullscreen) + } + + @Test + fun `set window min intrinsic height`() = runApplicationTest(useDelay = isLinux) { + assumeTrue(!isLinux) // Flaky on our CI + + lateinit var window: ComposeWindow + val state = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.MinIntrinsicHeight(width = 300.dp) + ) + ) + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + state = state, + title = "set window min intrinsic height" + ) { + window = this.window + + Box( + Modifier + .width(400.dp) + .height(200.dp) + ) + } + } + + awaitIdle() + assertThat(window.contentSize.width).isEqualTo(300) + assertThat(window.contentSize.height).isEqualTo(200) + assertThat(state.size).isEqualTo(DpSize(window.size.width.dp, window.size.height.dp)) + } + + @Test + fun `set window min intrinsic width`() = runApplicationTest { + assumeTrue(!isLinux) // Flaky on our CI + + lateinit var window: ComposeWindow + val state = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.MinIntrinsicWidth(height = 300.dp) + ) + ) + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + state = state, + title = "set window min intrinsic width" + ) { + window = this.window + + Box( + Modifier + .width(400.dp) + .height(200.dp) + ) + } + } + + awaitIdle() + assertThat(window.contentSize.height).isEqualTo(300) + assertThat(window.contentSize.width).isEqualTo(400) + assertThat(state.size).isEqualTo(DpSize(window.size.width.dp, window.size.height.dp)) + } + + @Test + fun `set unconstrained window size by its content`() = runApplicationTest { + assumeTrue(!isLinux) // Flaky on our CI + + lateinit var window: ComposeWindow + val state = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Unconstrained + ) + ) + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + state = state, + title = "set unconstrained window size by its content" + ) { + window = this.window + + Box( + Modifier + .width(400.dp) + .height(200.dp) + ) + } + } + + awaitIdle() + assertThat(window.contentSize).isEqualTo(Dimension(400, 200)) + assertThat(state.size).isEqualTo(DpSize(window.size.width.dp, window.size.height.dp)) + } + + @Test + fun `set window size by its content when window is visible`() = runApplicationTest( + useDelay = isLinux || isMacOs + ) { + lateinit var window: ComposeWindow + val state = WindowStateWithBounds(initialSize = DpSize(100.dp, 100.dp)) + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + state = state, + title = "set window size by its content when window is visible" + ) { + window = this.window + + Box( + Modifier + .width(400.dp) + .height(200.dp) + ) + } + } + + awaitIdle() + + state.requestSize(WindowSizeProvider.Unconstrained) + awaitIdle() + assertThat(window.contentSize).isEqualTo(Dimension(400, 200)) + assertThat(state.size).isEqualTo(DpSize(window.size.width.dp, window.size.height.dp)) + } + + @Test + fun `change visibility`() = runApplicationTest { + lateinit var window: ComposeWindow + + var visible by mutableStateOf(false) + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + visible = visible, + title = "change visibility" + ) { + window = this.window + } + } + + awaitIdle() + assertThat(window.isVisible).isEqualTo(false) + + visible = true + awaitIdle() + assertThat(window.isVisible).isEqualTo(true) + } + + @Test + fun `invisible window should be active`() = runApplicationTest { + val receivedNumbers = mutableListOf() + + val sendChannel = Channel(Channel.UNLIMITED) + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + visible = false, + title = "invisible window should be active" + ) { + LaunchedEffect(Unit) { + sendChannel.consumeEach { + receivedNumbers.add(it) + } + } + } + } + + sendChannel.send(1) + awaitIdle() + assertThat(receivedNumbers).isEqualTo(listOf(1)) + + sendChannel.send(2) + awaitIdle() + assertThat(receivedNumbers).isEqualTo(listOf(1, 2)) + } + + @Test + fun `show invisible undecorated window`() = runApplicationTest { + val receivedNumbers = mutableListOf() + + val sendChannel = Channel(Channel.UNLIMITED) + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + visible = false, + decoration = WindowDecoration.Undecorated(), + title = "show invisible undecorated window" + ) { + LaunchedEffect(Unit) { + sendChannel.consumeEach { + receivedNumbers.add(it) + } + } + } + } + + sendChannel.send(1) + awaitIdle() + assertThat(receivedNumbers).isEqualTo(listOf(1)) + + sendChannel.send(2) + awaitIdle() + assertThat(receivedNumbers).isEqualTo(listOf(1, 2)) + } + + @Test + fun windowStateIsPreservedWhenRemovingAndAddingComposable() = runApplicationTest { + var showWindow by mutableStateOf(true) + lateinit var windowState: WindowState + var windowVisible = false + launchTestApplication { + val state = rememberWindowStateWithBounds() + windowState = state + if (showWindow) { + Window( + state = state, + onCloseRequest = { }, + title = "windowStateIsPreservedWhenRemovingAndAddingComposable" + ) { + Box(Modifier.size(32.dp)) + DisposableEffect(Unit) { + windowVisible = true + onDispose { + windowVisible = false + } + } + } + } + + // Prevent app from dying when nothing is shown + LaunchedEffect(Unit) { + delay(Duration.INFINITE) + } + } + awaitIdle() + + windowState.requestBounds { + val screenBounds = windowMetrics.screen.availableBounds + val size = DpSize(400.dp, 400.dp) + DpRect( + origin = DpOffset( + (screenBounds.width - size.width) / 2, + (screenBounds.height - size.height) / 2 + ), + size = size + ) + } + awaitIdle() + val windowBounds = windowState.bounds + + showWindow = false + awaitIdle() + assertFalse(windowVisible) + + showWindow = true + awaitIdle() + assertTrue(windowState.isInitialized) + assertEquals(windowBounds, windowState.bounds) + } + + @Test + fun windowStateIsPreservedWhenSavingAndRestoring() = runApplicationTest { + var showWindow by mutableStateOf(true) + var windowState: WindowState? = null + launchTestApplication { + val stateHolder = rememberSaveableStateHolder() + stateHolder.SaveableStateProvider(showWindow) { + if (showWindow) { + val state = rememberWindowStateWithBounds() + DisposableEffect(state) { + windowState = state + onDispose { + windowState = null + } + } + Window( + state = state, + onCloseRequest = { }, + title = "windowStateIsPreservedWhenSavingAndRestoring" + ) { + Box(Modifier.size(32.dp)) + } + } + } + + // Prevent app from dying when nothing is shown + LaunchedEffect(Unit) { + delay(Duration.INFINITE) + } + } + awaitIdle() + + windowState!!.requestBounds { + val screenBounds = windowMetrics.screen.availableBounds + val size = DpSize(400.dp, 400.dp) + DpRect( + origin = DpOffset( + (screenBounds.width - size.width) / 2, + (screenBounds.height - size.height) / 2 + ), + size = size + ) + } + awaitIdle() + val windowBounds = windowState!!.bounds + + showWindow = false + awaitIdle() + assertNull(windowState) + + showWindow = true + awaitIdle() + assertTrue(windowState!!.isInitialized) + assertEquals(windowBounds, windowState!!.bounds) + } + + @Test + fun windowIsShownCorrectlyIfStateSavedBeforeWindowIsShown() = runApplicationTest { + var createWindowState by mutableStateOf(true) + var showWindow by mutableStateOf(false) + var windowState: WindowState? = null + launchTestApplication { + val stateHolder = rememberSaveableStateHolder() + stateHolder.SaveableStateProvider(createWindowState) { + if (createWindowState) { + val state = rememberWindowStateWithBounds( + initialSize = DpSize(300.dp, 300.dp) + ) + DisposableEffect(state) { + windowState = state + onDispose { + windowState = null + } + } + if (showWindow) { + Window( + state = state, + onCloseRequest = { }, + title = "windowIsShownCorrectlyIfStateSavedBeforeWindowIsShown" + ) { + Box(Modifier.size(32.dp)) + } + } + } + } + + // Prevent app from dying when nothing is shown + LaunchedEffect(Unit) { + delay(Duration.INFINITE) + } + } + + awaitIdle() + assertNotNull(windowState) + assertFalse(windowState!!.isInitialized) + windowState!!.requestBounds { + val screenBounds = windowMetrics.screen.availableBounds + val size = DpSize(400.dp, 400.dp) + DpRect( + origin = DpOffset( + (screenBounds.width - size.width) / 2, + (screenBounds.height - size.height) / 2 + ), + size = size + ) + } + + createWindowState = false + awaitIdle() + assertNull(windowState) + + createWindowState = true + showWindow = true + awaitIdle() + + awaitIdle() + assertNotNull(windowState) + assertTrue(windowState!!.isInitialized) + // Size should be as the one requested in rememberWindowStateWithBounds, not the one in + // windowState!!.requestBounds above. + assertEquals(DpSize(300.dp, 300.dp), windowState!!.bounds.size) + } + + private fun runWindowSizeTest( + testName: String, + sizeProvider: WindowSizeProvider, + content: @Composable () -> Unit, + expectedWindowSizeSansInsets: DpSize, + ) = runApplicationTest { + val windowState = WindowState( + initialBoundsProvider = WindowBoundsProvider(sizeProvider) + ) + lateinit var window: ComposeWindow + launchTestApplication { + Window( + state = windowState, + onCloseRequest = {}, + title = testName + ) { + window = this.window + content() + } + } + awaitIdle() + assertEquals( + expectedWindowSizeSansInsets + window.insets.toDpInsets(), + windowState.bounds.size + ) + } + + @Test + fun windowMinIntrinsicWidth() = runWindowSizeTest( + testName = "windowMinIntrinsicWidth", + sizeProvider = WindowSizeProvider.MinIntrinsicWidth(height = 500.dp), + content = { + BoxWithIntrinsicSize( + minWidth = { 400.dp.roundToPx() } + ) + }, + expectedWindowSizeSansInsets = DpSize(400.dp, 500.dp) + ) + + @Test + fun windowMaxIntrinsicWidth() = runWindowSizeTest( + testName = "windowMaxIntrinsicWidth", + sizeProvider = WindowSizeProvider.MaxIntrinsicWidth(height = 500.dp), + content = { + BoxWithIntrinsicSize( + maxWidth = { 400.dp.roundToPx() } + ) + }, + expectedWindowSizeSansInsets = DpSize(400.dp, 500.dp) + ) + + @Test + fun windowMinIntrinsicHeight() = runWindowSizeTest( + testName = "windowMinIntrinsicHeight", + sizeProvider = WindowSizeProvider.MinIntrinsicHeight(width = 500.dp), + content = { + BoxWithIntrinsicSize( + minHeight = { 400.dp.roundToPx() } + ) + }, + expectedWindowSizeSansInsets = DpSize(500.dp, 400.dp) + ) + + @Test + fun windowMaxIntrinsicHeight() = runWindowSizeTest( + testName = "windowMaxIntrinsicHeight", + sizeProvider = WindowSizeProvider.MaxIntrinsicHeight(width = 500.dp), + content = { + BoxWithIntrinsicSize( + maxHeight = { 400.dp.roundToPx() } + ) + }, + expectedWindowSizeSansInsets = DpSize(500.dp, 400.dp) + ) + + @Test + fun windowMinWidthWithMatchingMinHeight() = runWindowSizeTest( + testName = "windowMinWidthWithMatchingMinHeight", + sizeProvider = WindowSizeProvider.IntrinsicWidthWithMatchingIntrinsicHeight( + intrinsicWidth = WindowIntrinsicSize.Min, + intrinsicHeight = WindowIntrinsicSize.Min, + ), + content = { + BoxWithIntrinsicSize( + minWidth = { 400.dp.roundToPx() }, + minHeight = { it } // Return width to make it a square + ) + }, + expectedWindowSizeSansInsets = DpSize(400.dp, 400.dp) + ) + + @Test + fun windowMaxHeightWithMatchingMaxWidth() = runWindowSizeTest( + testName = "windowMaxHeightWithMatchingMaxWidth", + sizeProvider = WindowSizeProvider.IntrinsicHeightWithMatchingIntrinsicWidth( + intrinsicWidth = WindowIntrinsicSize.Max, + intrinsicHeight = WindowIntrinsicSize.Max, + ), + content = { + BoxWithIntrinsicSize( + maxHeight = { 400.dp.roundToPx() }, + maxWidth = { it } // Return height to make it a square + ) + }, + expectedWindowSizeSansInsets = DpSize(400.dp, 400.dp) + ) + + @Test + fun `requested size is rounded up`() = runWindowSizeTest( + testName = "requested size is rounded up", + sizeProvider = WindowSizeProvider.IntrinsicWidthWithMatchingIntrinsicHeight( + intrinsicWidth = WindowIntrinsicSize.Min, + intrinsicHeight = WindowIntrinsicSize.Min, + ), + content = { + BoxWithIntrinsicSize( + minWidth = { (density * 100 + 1).toInt() }, + minHeight = { it } + ) + }, + expectedWindowSizeSansInsets = DpSize(101.dp, 101.dp) + ) + + private fun runBoundsOverwriteTest( + name: String, + windowState: WindowState, + expectedPosition: DpOffset, + expectedSize: DpSize + ) = runApplicationTest { + launchTestApplication { + Window( + state = windowState, + onCloseRequest = {}, + title = name + ) { + LaunchedEffect(Unit) { + window.addComponentListener(object: ComponentAdapter() { + // Verify that the bounds are set correctly immediately, not just at some + // point after the window is shown. + override fun componentShown(e: ComponentEvent) { + assertEquals(expectedSize, window.size.toDpSize()) + assertEquals(expectedPosition, window.location.toDpOffset()) + } + }) + } + } + } + awaitIdle() + + assertEquals(expectedSize, windowState.bounds.size) + assertEquals(expectedPosition, windowState.bounds.topLeft) + } + + @Test + fun `requesting size before initialization does not overwrite position`() { + val position = DpOffset(300.dp, 300.dp) + val size = DpSize(400.dp, 400.dp) + val windowState = WindowStateWithBounds( + initialPosition = position, + ) + windowState.requestSize(size) + + runBoundsOverwriteTest( + name = "requesting size before initialization does not overwrite position", + windowState = windowState, + expectedSize = size, + expectedPosition = position, + ) + } + + @Test + fun `requesting position before initialization does not overwrite size`() { + val position = DpOffset(300.dp, 300.dp) + val size = DpSize(400.dp, 400.dp) + val windowState = WindowStateWithBounds( + initialSize = size, + ) + windowState.requestPosition(position) + + runBoundsOverwriteTest( + name = "requesting position before initialization does not overwrite size", + windowState = windowState, + expectedSize = size, + expectedPosition = position, + ) + } +} + +private const val LinuxCoordinateTolerance = 10 + +private val CoordinateTolerance = if (isLinux) LinuxCoordinateTolerance else 0 + +internal fun assertCoordinatesApproximatelyEqual( + expected: Point, + actual: Point, +) { + if (((expected.x - actual.x).absoluteValue > CoordinateTolerance) || + ((expected.y - actual.y).absoluteValue > CoordinateTolerance) + ) { + throw AssertionError( + "Expected <$expected> with absolute tolerance" + + " <$CoordinateTolerance>, actual <$actual>." + ) + } +} + +internal fun assertSizesApproximatelyEqual( + expected: Dimension, + actual: Dimension, +) { + if (((expected.width - actual.width).absoluteValue > CoordinateTolerance) || + ((expected.height - actual.height).absoluteValue > CoordinateTolerance) + ) { + throw AssertionError( + "Expected <$expected> with absolute tolerance" + + " <$CoordinateTolerance>, actual <$actual>." + ) + } +} + +internal fun assertCoordinatesNotApproximatelyEqual( + expected: Point, + actual: Point, +) { + if (((expected.x - actual.x).absoluteValue <= CoordinateTolerance) && + ((expected.y - actual.y).absoluteValue <= CoordinateTolerance) + ) { + throw AssertionError( + "Expected <$expected> to not equal actual <$actual> with absolute" + + " tolerance <$CoordinateTolerance>" + ) + } +} + +internal fun assertSizesNotApproximatelyEqual( + expected: Dimension, + actual: Dimension, +) { + if (((expected.width - actual.width).absoluteValue <= CoordinateTolerance) && + ((expected.height - actual.height).absoluteValue <= CoordinateTolerance) + ) { + throw AssertionError( + "Expected <$expected> to not equal actual <$actual> with absolute" + + " tolerance <$CoordinateTolerance>" + ) + } +} diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowV2Test.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowV2Test.kt new file mode 100644 index 0000000000000..73f78ff53b2a8 --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/v2/WindowV2Test.kt @@ -0,0 +1,807 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window.v2 + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.awt.SwingWindow +import androidx.compose.ui.background +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.isLinux +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.toInt +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntSize +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.WindowDecoration +import androidx.compose.ui.window.density +import androidx.compose.ui.window.runApplicationTest +import androidx.compose.ui.window.toSize +import com.google.common.truth.Truth.assertThat +import java.awt.Point +import java.awt.Robot +import java.awt.Window +import java.awt.event.WindowEvent +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread +import kotlin.coroutines.CoroutineContext +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.plus +import org.junit.Assume.assumeFalse +import org.junit.Ignore + +class WindowV2Test { + + @Test + fun `open and close window`() = runApplicationTest { + var window: ComposeWindow? = null + + launchTestApplication { + Window(onCloseRequest = ::exitApplication) { + window = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + } + + awaitIdle() + assertThat(window?.isShowing).isTrue() + + window?.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + } + + @Test + fun `disable closing window`() = runApplicationTest { + var isOpen by mutableStateOf(true) + var isCloseCalled by mutableStateOf(false) + var window: ComposeWindow? = null + + launchTestApplication { + if (isOpen) { + Window( + onCloseRequest = { + isCloseCalled = true + } + ) { + window = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + } + } + + awaitIdle() + + window?.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + awaitIdle() + assertThat(isCloseCalled).isTrue() + assertThat(window?.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window?.isShowing).isFalse() + } + + @Test + fun `show splash screen`() = runApplicationTest { + var window1: ComposeWindow? = null + var window2: ComposeWindow? = null + + var isOpen by mutableStateOf(true) + var isLoading by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + if (isLoading) { + Window(onCloseRequest = {}) { + window1 = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + } else { + Window(onCloseRequest = {}) { + window2 = this.window + Box(Modifier.size(32.dp).background(Color.Blue)) + } + } + } + } + + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2).isNull() + + isLoading = false + awaitIdle() + assertThat(window1?.isShowing).isFalse() + assertThat(window2?.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window1?.isShowing).isFalse() + assertThat(window2?.isShowing).isFalse() + } + + @Test + fun `open two windows`() = runApplicationTest { + var window1: ComposeWindow? = null + var window2: ComposeWindow? = null + + var isOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + Window(onCloseRequest = {}) { + window1 = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + } + + Window(onCloseRequest = {}) { + window2 = this.window + Box(Modifier.size(32.dp).background(Color.Blue)) + } + } + } + + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window1?.isShowing).isFalse() + assertThat(window2?.isShowing).isFalse() + } + + @Test + fun `open nested window`() = runApplicationTest(useDelay = true) { + var window1: ComposeWindow? = null + var window2: ComposeWindow? = null + + var isOpen by mutableStateOf(true) + var isNestedOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + Window( + onCloseRequest = {}, + state = rememberWindowStateWithBounds( + initialSize = DpSize(600.dp, 600.dp), + ) + ) { + window1 = this.window + Box(Modifier.size(32.dp).background(Color.Red)) + + if (isNestedOpen) { + Window( + onCloseRequest = {}, + state = rememberWindowStateWithBounds( + initialSize = DpSize(300.dp, 300.dp), + ) + ) { + window2 = this.window + Box(Modifier.size(32.dp).background(Color.Blue)) + } + } + } + } + } + + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isTrue() + + isNestedOpen = false + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isFalse() + + isNestedOpen = true + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isTrue() + + isOpen = false + awaitIdle() + assertThat(window1?.isShowing).isFalse() + assertThat(window2?.isShowing).isFalse() + } + + @Test + fun `pass composition local to windows`() = runApplicationTest { + var actualValue1: Int? = null + var actualValue2: Int? = null + var actualValue3: Int? = null + + var isOpen by mutableStateOf(true) + val local1TestValue = compositionLocalOf { 0 } + val local2TestValue = compositionLocalOf { 0 } + var locals by mutableStateOf(arrayOf(local1TestValue provides 1)) + + launchTestApplication { + if (isOpen) { + CompositionLocalProvider(*locals) { + Window( + onCloseRequest = {}, + state = rememberWindowStateWithBounds( + initialSize = DpSize(600.dp, 600.dp), + ) + ) { + actualValue1 = local1TestValue.current + actualValue2 = local2TestValue.current + Box(Modifier.size(32.dp).background(Color.Red)) + + Window( + onCloseRequest = {}, + state = rememberWindowStateWithBounds( + initialSize = DpSize(300.dp, 300.dp), + ) + ) { + actualValue3 = local1TestValue.current + Box(Modifier.size(32.dp).background(Color.Blue)) + } + } + } + } + } + + awaitIdle() + assertThat(actualValue1).isEqualTo(1) + assertThat(actualValue2).isEqualTo(0) + assertThat(actualValue3).isEqualTo(1) + + locals = arrayOf(local1TestValue provides 42) + awaitIdle() + assertThat(actualValue1).isEqualTo(42) + assertThat(actualValue2).isEqualTo(0) + assertThat(actualValue3).isEqualTo(42) + + locals = arrayOf(local1TestValue provides 43) + awaitIdle() + assertThat(actualValue1).isEqualTo(43) + assertThat(actualValue2).isEqualTo(0) + assertThat(actualValue3).isEqualTo(43) + + locals = arrayOf(local1TestValue provides 43, local2TestValue provides 12) + awaitIdle() + assertThat(actualValue1).isEqualTo(43) + assertThat(actualValue2).isEqualTo(12) + assertThat(actualValue3).isEqualTo(43) + + locals = emptyArray() + awaitIdle() + assertThat(actualValue1).isEqualTo(0) + assertThat(actualValue2).isEqualTo(0) + assertThat(actualValue3).isEqualTo(0) + + isOpen = false + } + + @Test + fun `DisposableEffect call order`() = runApplicationTest { + var initCount = 0 + var disposeCount = 0 + + var isOpen by mutableStateOf(true) + + launchTestApplication { + if (isOpen) { + Window(onCloseRequest = {}) { + DisposableEffect(Unit) { + initCount++ + onDispose { + disposeCount++ + } + } + } + } + } + + awaitIdle() + assertThat(initCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + isOpen = false + awaitIdle() + assertThat(initCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(1) + } + + private fun testDrawingBeforeWindowIsVisible( + windowState: WindowState, + canvasSizeModifier: Modifier, + expectedCanvasSize: FrameWindowScope.() -> DpSize + ) = runApplicationTest { + var isComposed = false + var isDrawn = false + var isVisibleOnFirstComposition = false + var isVisibleOnFirstDraw = false + var actualCanvasSize: IntSize? = null + var expectedCanvasSizePx: IntSize? = null + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + state = windowState + ) { + if (!isComposed) { + isVisibleOnFirstComposition = window.isVisible + isComposed = true + } + + Canvas(canvasSizeModifier) { + if (!isDrawn) { + isVisibleOnFirstDraw = window.isVisible + isDrawn = true + + // toInt() because this is how the ComposeWindow rounds decimal sizes + // (see ComposeBridge.updateSceneSize) + actualCanvasSize = size.toInt() + expectedCanvasSizePx = expectedCanvasSize().toSize().roundToIntSize() + } + } + } + } + + awaitIdle() + + assertThat(isComposed).isTrue() + assertThat(isDrawn).isTrue() + assertThat(isVisibleOnFirstComposition).isFalse() + assertThat(isVisibleOnFirstDraw).isFalse() + assertEquals(expectedCanvasSizePx, actualCanvasSize) + } + + @Test(timeout = 30000) + fun `should draw before window is visible`() { + val windowSize = DpSize(400.dp, 300.dp) + testDrawingBeforeWindowIsVisible( + windowState = WindowStateWithBounds(initialSize = windowSize), + canvasSizeModifier = Modifier.fillMaxSize(), + expectedCanvasSize = { windowSize - window.insets.toSize() } + ) + } + + @Test(timeout = 30000) + fun `should draw before window with unconstrained size is visible`() { + val canvasSize = DpSize(400.dp, 300.dp) + testDrawingBeforeWindowIsVisible( + windowState = WindowState( + initialBoundsProvider = WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Unconstrained, + ) + ), + canvasSizeModifier = Modifier.size(canvasSize), + expectedCanvasSize = { canvasSize } + ) + } + + @Test(timeout = 30000) + fun `Window should override density provided by application`() = runApplicationTest { + val customDensity = Density(3.14f) + var actualDensity: Density? = null + + launchTestApplication { + if (isOpen) { + CompositionLocalProvider(LocalDensity provides customDensity) { + Window(onCloseRequest = ::exitApplication) { + actualDensity = LocalDensity.current + } + } + } + } + + awaitIdle() + assertThat(actualDensity).isNotNull() + assertThat(actualDensity).isNotEqualTo(customDensity) + } + + @Test + fun `LaunchedEffect should end before application exit`() = runApplicationTest { + var isApplicationEffectEnded = false + var isWindowEffectEnded = false + + val job = launchTestApplication { + if (isOpen) { + Window(onCloseRequest = ::exitApplication) { + LaunchedEffect(Unit) { + try { + delay(1000000) + } finally { + isWindowEffectEnded = true + } + } + } + } + + LaunchedEffect(Unit) { + try { + delay(1000000) + } finally { + isApplicationEffectEnded = true + } + } + } + + awaitIdle() + exitTestApplication() + job.cancelAndJoin() + + assertThat(isApplicationEffectEnded).isTrue() + assertThat(isWindowEffectEnded).isTrue() + } + + @Ignore("Flaky https://youtrack.jetbrains.com/issue/CMP-9422") + @Test + fun `undecorated resizable window with unconstrained size`() = runApplicationTest { + lateinit var window: ComposeWindow + + launchTestApplication { + Window( + onCloseRequest = { }, + state = rememberWindowState( + initialBoundsProvider = WindowBoundsProvider(WindowSizeProvider.Unconstrained) + ), + decoration = WindowDecoration.Undecorated(), + resizable = true, + ) { + window = this.window + Box(Modifier.size(32.dp)) + } + } + + awaitIdle() + window.renderImmediately() + assertEquals(32, window.width) + assertEquals(32, window.height) + } + + @Test + fun `showing a window should measure content specified size`() = runApplicationTest { + // TODO fix on Linux https://github.com/JetBrains/compose-multiplatform/issues/1297 + assumeFalse(isLinux) + val constraintsList = mutableListOf() + val windowSize = DpSize(400.dp, 300.dp) + lateinit var window: ComposeWindow + + launchTestApplication { + Window( + onCloseRequest = { }, + state = rememberWindowState( + initialBoundsProvider = + WindowBoundsProvider( + WindowSizeProvider.Fixed(windowSize) + ) + ), + ) { + window = this.window + Layout( + measurePolicy = { _, constraints -> + constraintsList.add(constraints) + layout(0, 0) { } + } + ) + } + } + + awaitIdle() + + with(window.density) { + val expectedSize = (windowSize - window.insets.toSize()).toSize() + assertEquals(1, constraintsList.size) + assertEquals( + Constraints( + maxWidth = expectedSize.width.roundToInt(), + maxHeight = expectedSize.height.roundToInt() + ), + constraintsList.first() + ) + } + } + + @Test + fun `pass LayoutDirection to Window`() = runApplicationTest { + lateinit var localLayoutDirection: LayoutDirection + + var layoutDirection by mutableStateOf(LayoutDirection.Rtl) + launchTestApplication { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + Window(onCloseRequest = {}) { + localLayoutDirection = LocalLayoutDirection.current + } + } + } + awaitIdle() + + assertThat(localLayoutDirection).isEqualTo(LayoutDirection.Rtl) + + // Test that changing the local propagates it into the window + layoutDirection = LayoutDirection.Ltr + awaitIdle() + assertThat(localLayoutDirection).isEqualTo(LayoutDirection.Ltr) + } + + @Test + fun `pass LayoutDirection from Window to Popup`() = runApplicationTest { + lateinit var windowLayoutDirectionResult: LayoutDirection + lateinit var popupLayoutDirectionResult: LayoutDirection + + var windowLayoutDirection by mutableStateOf(LayoutDirection.Rtl) + var popupLayoutDirection by mutableStateOf(LayoutDirection.Ltr) + launchTestApplication { + CompositionLocalProvider(LocalLayoutDirection provides windowLayoutDirection) { + Window(onCloseRequest = {}) { + windowLayoutDirectionResult = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides popupLayoutDirection) { + Popup { + popupLayoutDirectionResult = LocalLayoutDirection.current + } + } + } + } + } + awaitIdle() + + assertThat(windowLayoutDirectionResult).isEqualTo(LayoutDirection.Rtl) + assertThat(popupLayoutDirectionResult).isEqualTo(LayoutDirection.Ltr) + + // Test that changing the local propagates it into the window + windowLayoutDirection = LayoutDirection.Ltr + popupLayoutDirection = LayoutDirection.Rtl + awaitIdle() + assertThat(windowLayoutDirectionResult).isEqualTo(LayoutDirection.Ltr) + assertThat(popupLayoutDirectionResult).isEqualTo(LayoutDirection.Rtl) + } + + @Test + fun `window does not move to front on recomposition`() = runApplicationTest { + var window1: ComposeWindow? = null + var window2: ComposeWindow? = null + + var window1Title by mutableStateOf("Window 1") + + launchTestApplication { + Window( + onCloseRequest = ::exitApplication, + title = window1Title, + ) { + window1 = this.window + Box(Modifier.size(32.dp)) + } + + Window( + onCloseRequest = ::exitApplication, + title = "Window 2" + ) { + window2 = this.window + Box(Modifier.size(32.dp)) + LaunchedEffect(Unit) { + window.toFront() + } + } + } + + awaitIdle() + assertThat(window1?.isShowing).isTrue() + assertThat(window2?.isShowing).isTrue() + assertThat(window1?.isActive).isFalse() + assertThat(window2?.isActive).isTrue() + + window1Title = "Retitled Window" + awaitIdle() + assertThat(window1?.isActive).isFalse() + assertThat(window2?.isActive).isTrue() + } + + @Test + fun `compose empty window once`() = runApplicationTest { + var compositions = 0 + launchTestApplication { + Window(onCloseRequest = ::exitApplication) { + compositions++ + } + } + awaitIdle() + assertEquals(1, compositions) + } + + @Test + fun `swing frame init called before it is displayable`() = runApplicationTest { + var isDisplayableInInit: Boolean? = null + launchTestApplication { + SwingWindow( + onCloseRequest = ::exitApplication, + init = { + isDisplayableInInit = it.isDisplayable + } + ) { } + } + + awaitIdle() + assertThat(isDisplayableInInit).isFalse() + } + + @Test + fun `window does not flash background when closed`() = runApplicationTest { + lateinit var outerWindow: Window + lateinit var innerWindow: Window + var showInnerWindow by mutableStateOf(false) + val windowSize = DpSize(800.dp, 800.dp) + val outerWindowState = WindowState( + initialBoundsProvider = WindowBoundsProvider(WindowSizeProvider.Fixed(windowSize)) + ) + launchTestWindowV2Application(outerWindowState) { + outerWindow = this.window + Box(Modifier.fillMaxSize().background(Color.Black)) + + if (showInnerWindow) { + Window( + onCloseRequest = {}, + state = rememberWindowState( + initialBoundsProvider = WindowBoundsProvider.Absolute( + outerWindowState.bounds + ) + ) + ) { + innerWindow = this.window + Box(Modifier.fillMaxSize().background(Color.Black)) + LaunchedEffect(Unit) { + innerWindow.location = outerWindow.location + } + } + } + } + awaitIdle() + + showInnerWindow = true + awaitIdle() + + var nonBlackPixelDetected: java.awt.Color? = null + val testLocation = innerWindow.bounds.let { + Point(it.x + it.width / 2, it.y + it.height / 2) + } + val stopThread = AtomicBoolean(false) + val t = thread { + val robot = Robot() + while (!stopThread.get()) { + val pixel = robot.getPixelColor(testLocation.x, testLocation.y) + if (pixel != java.awt.Color.BLACK) { + nonBlackPixelDetected = pixel + return@thread + } + } + } + + delay(500.milliseconds) + showInnerWindow = false + delay(500.milliseconds) + assertFalse(innerWindow.isVisible) + + stopThread.getAndSet(true) + t.join() + + assertThat(nonBlackPixelDetected).isNull() + } + + @Test + fun coroutineContextIsPropagatedToWindow() = coroutineContextIsPropagatedTo { content -> + Window(onCloseRequest = ::exitApplication) { + content() + } + } + + @Test + fun animationsRunAtNonInfiniteRateInWindow() = animationsRunAtNonInfiniteRateIn { content -> + Window(onCloseRequest = ::exitApplication) { + content() + } + } + +} + +private object CtxElement : CoroutineContext.Element, CoroutineContext.Key { + override val key: CoroutineContext.Key<*> = this +} + +internal fun coroutineContextIsPropagatedTo( + window: @Composable ApplicationScope.(@Composable () -> Unit) -> Unit +) = runApplicationTest { + var applicationContextElement: CtxElement? = null + var windowContextElement: CtxElement? = null + var innerWindowContextElement: CtxElement? = null + val scope = this + CtxElement + scope.launchTestApplication { + LaunchedEffect(Unit) { + applicationContextElement = currentCoroutineContext()[CtxElement] + } + window { + LaunchedEffect(Unit) { + windowContextElement = currentCoroutineContext()[CtxElement] + } + + window { + LaunchedEffect(Unit) { + innerWindowContextElement = currentCoroutineContext()[CtxElement] + } + } + } + } + + awaitIdle() + + assertThat(applicationContextElement).isNotNull() + assertThat(windowContextElement).isNotNull() + assertThat(innerWindowContextElement).isNotNull() +} + +internal fun animationsRunAtNonInfiniteRateIn( + window: @Composable ApplicationScope.(@Composable () -> Unit) -> Unit +) = runApplicationTest { + suspend fun countFramesForOneSecond(onFrame: () -> Unit) { + val startTime = System.nanoTime() + while (System.nanoTime() - startTime < 1.seconds.inWholeNanoseconds) { + withFrameNanos { + onFrame() + } + } + } + + var appFrameCount = 0 + var windowFrameCount = 0 + launchTestApplication { + LaunchedEffect(Unit) { + countFramesForOneSecond { appFrameCount++ } + } + window { + LaunchedEffect(Unit) { + countFramesForOneSecond { windowFrameCount++ } + } + } + } + + awaitIdle() + + // Actually, just check that the application "frame rate" is significantly smaller than the window frame rate + assertThat(windowFrameCount * 10).isLessThan(appFrameCount) +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/MeasurableRootContent.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/MeasurableRootContent.skiko.kt similarity index 94% rename from compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/MeasurableRootContent.kt rename to compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/MeasurableRootContent.skiko.kt index fba6df795fc58..384512eb3d9ed 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/MeasurableRootContent.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/MeasurableRootContent.skiko.kt @@ -16,14 +16,14 @@ package androidx.compose.ui.layout -import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.unit.Constraints /** * The interface through which composable content can be queried for its size preferences, such as * its intrinsic size. */ -@InternalComposeUiApi +@ExperimentalComposeUiApi interface MeasurableRootContent : IntrinsicMeasurable { /** * Measures the content with the given constraints and calls [block] on the resulting diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt index bd4004a393327..655420942d7c0 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt @@ -321,4 +321,4 @@ fun ComposeScene.unconstrainedSize(): IntSize { return measurableContent.measuringIn(Constraints()) { IntSize(it.measuredWidth, it.measuredHeight) } -} +} \ No newline at end of file diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt index 611ae04a7c836..95d66e12078b8 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isSpecified +import kotlin.math.roundToInt /** * Convert a [Offset] to a [DpOffset]. @@ -80,6 +81,16 @@ internal inline fun DpSize.toSize(density: Density): Size = with(density) { toSize() } +/** + * Coerces this [DpSize] to at most the specified [size], on each axis. + */ +@Stable +internal inline fun DpSize.coerceAtMost(size: DpSize): DpSize = + DpSize( + width = width.coerceAtMost(size.width), + height = height.coerceAtMost(size.height) + ) + /** * Converts a [IntSize] to a [Rect]. */ @@ -87,3 +98,48 @@ internal inline fun DpSize.toSize(density: Density): Size = with(density) { internal inline fun IntSize.toRect(): Rect = Rect(0f, 0f, width.toFloat(), height.toFloat()) +@Stable +internal fun DpSize.roundToIntSize() = IntSize( + width = width.value.roundToInt(), + height = height.value.roundToInt() +) + +@Stable +internal val DpRect.topLeft: DpOffset get() = DpOffset(left, top) + +@Stable +internal operator fun DpRect.plus(offset: DpOffset): DpRect = + DpRect(left + offset.x, top + offset.y, right + offset.x, bottom + offset.y) + +@Stable +internal val Dp.isReal + get() = isSpecified && isFinite + +@Stable +internal fun Dp.requireReal(propertyName: String) = + require(isReal) { "$propertyName must be specified and finite"} + +@Stable +internal fun DpRect.requireReal(): DpRect { + left.requireReal("left") + top.requireReal("top") + right.requireReal("right") + bottom.requireReal("bottom") + return this +} + +@Stable +internal fun DpSize.requireReal(): DpSize { + require(isSpecified) { "size must be specified" } + width.requireReal("width") + height.requireReal("height") + return this +} + +@Stable +internal fun DpOffset.requireReal(): DpOffset { + require(isSpecified) { "offset must be specified" } + x.requireReal("x") + y.requireReal("y") + return this +}