Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion core/src/main/kotlin/io/spine/chords/core/layout/Dialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
126 changes: 119 additions & 7 deletions core/src/test/kotlin/io/spine/chords/core/layout/DialogSizingSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {

/**
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 {

/**
Expand All @@ -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.
*/
Expand Down
64 changes: 49 additions & 15 deletions core/src/test/kotlin/io/spine/chords/core/layout/TestScene.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -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.
Expand All @@ -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()
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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))
}

Expand All @@ -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))
}

Expand All @@ -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()
}
Expand Down Expand Up @@ -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 <T> onUiThread(action: () -> T): T {
if (isEventDispatchThread()) {
return action()
}
var outcome: Result<T>? = 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.
*/
Expand Down
Loading
Loading