From ceb35921b14eb0e8b26e100bab4ee9d813125d22 Mon Sep 17 00:00:00 2001 From: Oleg-Melnik Date: Wed, 5 Aug 2026 13:44:33 +0300 Subject: [PATCH 1/3] Keep a dialog with no buttons measurable. `Dialog.buttonsSection()` placed the buttons into a row spaced with `Look.buttonsSpacing`, and such a row reports an intrinsic width of minus that spacing when it has no children. A dialog that opted into neither the Submit nor the Cancel button therefore failed the measure pass whenever its `width` was left unspecified. The section is now skipped altogether in that case, leaving the spacing of the dialogs that do display their buttons intact. Co-Authored-By: Claude Opus 5 --- .../io/spine/chords/core/layout/Dialog.kt | 11 +- .../chords/core/layout/DialogSizingSpec.kt | 126 +++++++++++++++++- 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/Dialog.kt b/core/src/main/kotlin/io/spine/chords/core/layout/Dialog.kt index 4b138676..a898b77a 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/Dialog.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/Dialog.kt @@ -159,7 +159,8 @@ private val LocalDialogContentHeightMode = staticCompositionLocalOf { * - The buttons section. The [Dialog] class has a built-in support for * displaying the Submit and Cancel buttons, which should be opted in when * needed (see the "Predefined dialog actions" and "Customizing dialog's - * buttons" sections below). + * buttons" sections below). A dialog that has opted into neither of them has + * no buttons section at all. * * It's also possible to substitute the way how the entire buttons section is * rendered (or remove this section altogether if needed) by overriding @@ -622,11 +623,19 @@ public abstract class Dialog : Component() { * dialog's buttons, this method renders the container for those buttons, * and then invokes [buttons] to place the buttons in that container. * + * Nothing is rendered when the dialog has opted into neither the Submit, + * nor the Cancel button, since [buttons] renders nothing in that case, and + * a container that spaces its buttons reports a negative width when it has + * no buttons to space. + * * @see buttons * @see contentSection */ @Composable protected open fun buttonsSection() { + if (!submitAvailable && !cancelAvailable) { + return + } Row( modifier = Modifier.fillMaxWidth() .padding(look.buttonsPanelPadding), diff --git a/core/src/test/kotlin/io/spine/chords/core/layout/DialogSizingSpec.kt b/core/src/test/kotlin/io/spine/chords/core/layout/DialogSizingSpec.kt index 01659381..4c02c299 100644 --- a/core/src/test/kotlin/io/spine/chords/core/layout/DialogSizingSpec.kt +++ b/core/src/test/kotlin/io/spine/chords/core/layout/DialogSizingSpec.kt @@ -29,6 +29,7 @@ package io.spine.chords.core.layout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -49,12 +50,14 @@ import androidx.compose.ui.unit.dp import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test /** * Tests the way that a dialog, whose size is detected automatically, * measures its width. */ +@DisplayName("An automatically sized `Dialog` should") internal class DialogSizingSpec { /** @@ -86,13 +89,7 @@ internal class DialogSizingSpec { fun `provide the width that the dialog's buttons need`() { val dialog = dialogWithLongButtonLabels() - val buttonsWidth = measureWidth { - MaterialTheme { - Box(modifier = Modifier.width(IntrinsicSize.Max)) { - dialog.buttonsSectionInternal() - } - } - } + val buttonsWidth = buttonsWidth(dialog) val width = dialogWidth { DialogContent(dialog) } @@ -171,6 +168,49 @@ internal class DialogSizingSpec { width shouldBe with(density) { AvailableWidth.roundToPx() } } + /** + * A dialog that opts into neither the Submit nor the Cancel button has + * to be measurable just like any other one. + * + * Its buttons section used to place the buttons into a row spaced with + * `Look.buttonsSpacing`, and such a row with no children reports an + * intrinsic width of minus that spacing, which fails the measure pass. + */ + @Test + fun `measure a dialog that displays no buttons`() { + val dialog = TestDialog() + + val width = dialogWidth { + DialogContent(dialog) + } + + width shouldBe with(density) { DefaultDialogMinWidth.roundToPx() } + } + + /** + * Making an empty buttons section measurable must not cost the buttons + * that a dialog does display their spacing, so the space that separates + * them is pinned here: two buttons stay apart by `Look.buttonsSpacing`, + * and a single button, having nothing to be separated from, is not + * padded by it. + */ + @Test + fun `keep the spacing between the buttons that a dialog displays`() { + val spacing = Dialog.Look().buttonsSpacing + + val twoButtons = buttonsWidth(dialogWithButtons(submit = true, cancel = true)) + val twoUnspacedButtons = buttonsWidth( + dialogWithButtons(submit = true, cancel = true, buttonsSpacing = 0.dp) + ) + val singleButton = buttonsWidth(dialogWithButtons(cancel = true)) + val singleUnspacedButton = buttonsWidth( + dialogWithButtons(cancel = true, buttonsSpacing = 0.dp) + ) + + twoButtons - twoUnspacedButtons shouldBe with(density) { spacing.roundToPx() } + singleButton shouldBe singleUnspacedButton + } + /** * Creates a [ConfirmationDialog], whose buttons are wider than * the minimum width of an automatically sized dialog. @@ -181,6 +221,37 @@ internal class DialogSizingSpec { yesButtonText = "Discard all the changes made so far" } + /** + * Creates a dialog that displays the requested buttons with the given + * space between them. + * + * @param submit Whether the dialog has to display the Submit button. + * @param cancel Whether the dialog has to display the Cancel button. + * @param buttonsSpacing The space to separate the dialog's buttons with. + */ + private fun dialogWithButtons( + submit: Boolean = false, + cancel: Boolean = false, + buttonsSpacing: Dp = Dialog.Look().buttonsSpacing + ) = TestDialog(submit, cancel).apply { + look = look.copy(buttonsSpacing = buttonsSpacing) + } + + /** + * Measures the width, in pixels, that the buttons section of the given + * dialog asks for. + * + * @param dialog The dialog whose buttons section is to be measured. + * @return The measured width of the buttons section. + */ + private fun buttonsWidth(dialog: Dialog): Int = measureWidth { + MaterialTheme { + Box(modifier = Modifier.width(IntrinsicSize.Max)) { + dialog.buttonsSectionInternal() + } + } + } + /** * Displays the content of the given dialog's window the way that * a [WindowType] implementation does. @@ -330,6 +401,36 @@ internal class DialogSizingSpec { ) } + /** + * A minimal [Dialog] implementation, whose content is narrower than + * the minimum width of an automatically sized dialog, so that the width + * measured for it is defined by the dialog rather than by its content. + * + * @param submitAvailable Whether the dialog has to display the Submit + * button. + * @param cancelAvailable Whether the dialog has to display the Cancel + * button. + */ + private class TestDialog( + submitAvailable: Boolean = false, + cancelAvailable: Boolean = false + ) : Dialog() { + + override val title: String = "Test" + + init { + this.submitAvailable = submitAvailable + this.cancelAvailable = cancelAvailable + } + + @Composable + override fun contentSection() { + Box(modifier = Modifier.size(ContentWidth, ContentHeight)) + } + + override suspend fun submitContent(): Unit = Unit + } + private companion object { /** @@ -338,6 +439,17 @@ internal class DialogSizingSpec { */ val density = Density(2f) + /** + * The width of the content section of [TestDialog], which is narrower + * than [DefaultDialogMinWidth]. + */ + val ContentWidth = 200.dp + + /** + * The height of the content section of [TestDialog]. + */ + val ContentHeight = 100.dp + /** * The width that is available to the dialog being measured. */ From 930f0c295694945b8694b4e49e8a4fb162f29c1d Mon Sep 17 00:00:00 2001 From: Oleg-Melnik Date: Wed, 5 Aug 2026 13:44:36 +0300 Subject: [PATCH 2/3] =?UTF-8?q?Bump=20version=20=E2=80=94>=20`2.0.0-SNAPSH?= =?UTF-8?q?OT.106`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- dependencies.md | 24 ++++++++++++------------ pom.xml | 2 +- version.gradle.kts | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dependencies.md b/dependencies.md index 0464c31f..b90ffec6 100644 --- a/dependencies.md +++ b/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.chords:spine-chords-client:2.0.0-SNAPSHOT.105` +# Dependencies of `io.spine.chords:spine-chords-client:2.0.0-SNAPSHOT.106` ## Runtime 1. **Group** : cafe.adriel.voyager. **Name** : voyager-core. **Version** : 1.0.1.**No license information found** @@ -1104,12 +1104,12 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Aug 05 11:53:54 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Wed Aug 05 12:36:09 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-codegen-tests:2.0.0-SNAPSHOT.105` +# Dependencies of `io.spine.chords:spine-chords-codegen-tests:2.0.0-SNAPSHOT.106` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -1899,12 +1899,12 @@ This report was generated on **Wed Aug 05 11:53:54 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Aug 05 11:53:55 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Wed Aug 05 12:36:10 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-core:2.0.0-SNAPSHOT.105` +# Dependencies of `io.spine.chords:spine-chords-core:2.0.0-SNAPSHOT.106` ## Runtime 1. **Group** : cafe.adriel.voyager. **Name** : voyager-core. **Version** : 1.0.1. @@ -2938,12 +2938,12 @@ This report was generated on **Wed Aug 05 11:53:55 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Aug 05 11:53:56 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Wed Aug 05 12:36:11 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-proto:2.0.0-SNAPSHOT.105` +# Dependencies of `io.spine.chords:spine-chords-proto:2.0.0-SNAPSHOT.106` ## Runtime 1. **Group** : cafe.adriel.voyager. **Name** : voyager-core. **Version** : 1.0.1.**No license information found** @@ -3976,12 +3976,12 @@ This report was generated on **Wed Aug 05 11:53:56 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Aug 05 11:53:57 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Wed Aug 05 12:36:12 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-proto-values:2.0.0-SNAPSHOT.105` +# Dependencies of `io.spine.chords:spine-chords-proto-values:2.0.0-SNAPSHOT.106` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -4775,12 +4775,12 @@ This report was generated on **Wed Aug 05 11:53:57 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Aug 05 11:53:58 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Wed Aug 05 12:36:13 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-runtime:2.0.0-SNAPSHOT.105` +# Dependencies of `io.spine.chords:spine-chords-runtime:2.0.0-SNAPSHOT.106` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -5544,4 +5544,4 @@ This report was generated on **Wed Aug 05 11:53:58 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Wed Aug 05 11:53:59 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file +This report was generated on **Wed Aug 05 12:36:14 EEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file diff --git a/pom.xml b/pom.xml index 1e35f57a..af0bd7fb 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject. --> io.spine.chords Chords -2.0.0-SNAPSHOT.105 +2.0.0-SNAPSHOT.106 2015 diff --git a/version.gradle.kts b/version.gradle.kts index dbb4069a..75bce9dc 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of all Chords libraries. */ -val chordsVersion: String by extra("2.0.0-SNAPSHOT.105") +val chordsVersion: String by extra("2.0.0-SNAPSHOT.106") From a83239b68574abf2ca9e6224f978e069987745af Mon Sep 17 00:00:00 2001 From: Oleg-Melnik Date: Wed, 5 Aug 2026 14:27:18 +0300 Subject: [PATCH 3/3] Update `TestScene` to fix the CI build. --- .../io/spine/chords/core/layout/TestScene.kt | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/core/src/test/kotlin/io/spine/chords/core/layout/TestScene.kt b/core/src/test/kotlin/io/spine/chords/core/layout/TestScene.kt index e0d4082f..a5252921 100644 --- a/core/src/test/kotlin/io/spine/chords/core/layout/TestScene.kt +++ b/core/src/test/kotlin/io/spine/chords/core/layout/TestScene.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import java.awt.EventQueue.invokeAndWait +import java.awt.EventQueue.isDispatchThread as isEventDispatchThread import java.awt.Panel import java.awt.event.KeyEvent.CHAR_UNDEFINED import java.awt.event.KeyEvent.KEY_PRESSED @@ -61,6 +63,13 @@ import org.jetbrains.skia.Surface * applied to the content only upon the subsequent [render] call, just like it * is applied only in the next frame of a running application. * + * The scene itself is composed, rendered, and operated on the AWT event + * dispatch thread, like the scene of a running application is. That thread is + * where Compose Desktop delivers the apply notifications of the global + * snapshot, so keeping the scene there is what makes a state change that + * a test makes reach the composition within the [render] call that follows it, + * rather than racing with the notification that announces the change. + * * The scene has to be [closed][close] when a test is done with it, e.g. by * using the [use][kotlin.io.use] function. * @@ -83,7 +92,7 @@ internal class TestScene( /** * The scene that composes and lays out the content. */ - private val scene = ComposeScene(density = density) + private val scene = onUiThread { ComposeScene(density = density) } /** * The in-memory surface that the content is rendered onto. @@ -99,12 +108,14 @@ internal class TestScene( private var frames = 0L init { - scene.constraints = with(density) { - Constraints(maxWidth = width.roundToPx(), maxHeight = height.roundToPx()) - } - scene.setContent { - MaterialTheme { - content() + onUiThread { + scene.constraints = with(density) { + Constraints(maxWidth = width.roundToPx(), maxHeight = height.roundToPx()) + } + scene.setContent { + MaterialTheme { + content() + } } } render() @@ -114,13 +125,13 @@ internal class TestScene( * The size that the content occupies. */ val contentSize: IntSize - get() = scene.contentSize + get() = onUiThread { scene.contentSize } /** * Renders the next frame, which applies all the state changes that have * been made since the previous one. */ - fun render() { + fun render() = onUiThread { frames += 1 scene.render(surface.canvas, frames * FrameIntervalNanos) } @@ -140,7 +151,7 @@ internal class TestScene( * * @param position The position to click at, in pixels. */ - fun click(position: Offset) { + fun click(position: Offset) = onUiThread { scene.sendPointerEvent(Press, position) scene.sendPointerEvent(Release, position) } @@ -154,7 +165,7 @@ internal class TestScene( * @param modifiers The mask of the modifier keys that are held while the * key is pressed, as defined by [java.awt.event.KeyEvent]. */ - fun pressKey(keyCode: Int, modifiers: Int = NoModifierKeys) { + fun pressKey(keyCode: Int, modifiers: Int = NoModifierKeys) = onUiThread { scene.sendKeyEvent(keyEvent(KEY_PRESSED, keyCode, modifiers)) } @@ -167,7 +178,7 @@ internal class TestScene( * @param modifiers The mask of the modifier keys that are held while the * key is released, as defined by [java.awt.event.KeyEvent]. */ - fun releaseKey(keyCode: Int, modifiers: Int = NoModifierKeys) { + fun releaseKey(keyCode: Int, modifiers: Int = NoModifierKeys) = onUiThread { scene.sendKeyEvent(keyEvent(KEY_RELEASED, keyCode, modifiers)) } @@ -179,19 +190,19 @@ internal class TestScene( * @param y The vertical coordinate of the pixel. * @return The pixel's color, in the ARGB format. */ - fun pixelAt(x: Dp, y: Dp): Int { + fun pixelAt(x: Dp, y: Dp): Int = onUiThread { val bitmap = Bitmap() bitmap.allocN32Pixels(surface.width, surface.height) check(surface.readPixels(bitmap, 0, 0)) { "Cannot read the pixels rendered by the test scene." } - return bitmap.getColor(x.value.toInt(), y.value.toInt()) + bitmap.getColor(x.value.toInt(), y.value.toInt()) } /** * Closes the scene along with the surface that it renders onto. */ - override fun close() { + override fun close() = onUiThread { scene.close() surface.close() } @@ -220,6 +231,29 @@ internal class TestScene( } } +/** + * Runs the given action on the AWT event dispatch thread, and returns + * its result. + * + * The action is run right away when the calling thread is that thread already. + * + * @param T The type of the action's result. + * @param action The action to run. + * @return The value returned by the action. + */ +private fun onUiThread(action: () -> T): T { + if (isEventDispatchThread()) { + return action() + } + var outcome: Result? = null + invokeAndWait { + outcome = runCatching(action) + } + return checkNotNull(outcome) { + "The event dispatch thread has not run the action." + }.getOrThrow() +} + /** * The mask that states that no modifier key is held. */