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
4 changes: 3 additions & 1 deletion core/src/main/kotlin/io/spine/chords/core/Component.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.spine.chords.core.appshell.Props
import kotlin.reflect.javaType
import kotlin.reflect.full.allSupertypes
Expand Down Expand Up @@ -612,7 +614,7 @@ public abstract class Component : DefaultPropsOwnerBase() {
* [ComponentSetup.invoke] or an analogous component
* declaration function.
*/
internal var props: Props<Component>? = null
internal var props: Props<Component>? by mutableStateOf(null)

/**
* A state variable that specifies whether the component's [initialize]
Expand Down
30 changes: 29 additions & 1 deletion core/src/main/kotlin/io/spine/chords/core/table/Table.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -302,6 +303,7 @@ public abstract class Table<E> : Component() {
columns: List<TableColumn<E>>
) {
val listState = rememberLazyListState()
SelectedEntityVisibilityEffect(entities, listState)
if (selectedRowColor == null) {
selectedRowColor = colorScheme.surfaceVariant
}
Expand All @@ -313,7 +315,7 @@ public abstract class Table<E> : Component() {
state = listState
) {
entities.forEach { value ->
item {
item(key = extractEntityId(value)) {
ContentTableRow(
entity = value,
columns = columns,
Expand All @@ -328,6 +330,32 @@ public abstract class Table<E> : Component() {
}
}

/**
* Restores visibility when sorting moves the selected entity outside the viewport.
*
* User scrolling does not restart this effect while the selected entity keeps the same index.
*/
@Composable
private fun SelectedEntityVisibilityEffect(
entities: List<E>,
listState: LazyListState
) {
val selectedEntityId = selectedEntity.value?.let(::extractEntityId)
val selectedEntityIndex = selectedEntityId?.let { entityId ->
entities.indexOfFirst { entity ->
extractEntityId(entity) == entityId
}.takeIf { index -> index >= 0 }
}
LaunchedEffect(selectedEntityId, selectedEntityIndex) {
val isVisible = listState.layoutInfo.visibleItemsInfo.any { item ->
item.index == selectedEntityIndex
}
if (selectedEntityIndex != null && !isVisible) {
listState.animateScrollToItem(selectedEntityIndex)
}
}
}

private fun contentTableRowModifier(entity: E): Modifier {
val selectedEntityValue = selectedEntity.value
return if (selectedEntityValue != null &&
Expand Down
114 changes: 114 additions & 0 deletions core/src/test/kotlin/io/spine/chords/core/ComponentSpec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright 2026, TeamDev. All rights reserved.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.chords.core

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeSameInstanceAs
import io.spine.chords.core.layout.TestScene
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

/**
* Verifies the configuration lifecycle of class-based components.
*/
@DisplayName("`Component` should")
internal class ComponentSpec {

/**
* Protects property updates captured from a recomposing parent scope.
*/
@Test
fun `apply updated properties to a remembered instance`() {
var configuredValue by mutableStateOf("first")
lateinit var component: PropertyComponent
TestScene {
val currentValue = configuredValue
component = PropertyComponent {
value = currentValue
}
}.use { scene ->
val initialComponent = component
component.renderedValue shouldBe "first"

configuredValue = "second"
scene.render()

component shouldBeSameInstanceAs initialComponent
component.renderedValue shouldBe "second"
}
}

/**
* Installs the application that supplies shared component defaults.
*/
private companion object {

/**
* Initializes the application required by the component lifecycle.
*/
@JvmStatic
@BeforeAll
fun setUpApplication() {
TestApplication.install()
}
}
}

/**
* Records the property value observed during its latest composition.
*/
private class PropertyComponent : Component() {

/**
* Declares remembered instances of this component.
*/
companion object : ComponentSetup<PropertyComponent>({ PropertyComponent() })

/**
* The value supplied by the current component declaration.
*/
var value: String by mutableStateOf("")

/**
* The value observed during the latest composition.
*/
var renderedValue: String = ""
private set

/**
* Records the configured value rendered by this component.
*/
@Composable
override fun content() {
renderedValue = value
}
}
166 changes: 166 additions & 0 deletions core/src/test/kotlin/io/spine/chords/core/table/TableSpec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Copyright 2026, TeamDev. All rights reserved.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.chords.core.table

import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import io.kotest.matchers.collections.shouldContain
import io.spine.chords.core.ComponentSetup
import io.spine.chords.core.TestApplication
import io.spine.chords.core.layout.TestScene
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

/**
* Verifies the selection behavior of entity tables.
*/
@DisplayName("`Table` should")
internal class TableSpec {

/**
* Protects selected rows that move outside the viewport after resorting.
*/
@Test
fun `keep the selected entity visible after its sorting position changes`() {
val initialEntities = (0 until EntityCount).map { entityId ->
TableEntity(entityId, entityId)
}
val visibleEntityIds = mutableSetOf<Int>()
val selectedEntity = mutableStateOf<TableEntity?>(null)
var entities by mutableStateOf(initialEntities)
TestScene(width = 300.dp, height = 300.dp) {
val currentEntities = entities
VisibilityTrackingTable {
this.entities = currentEntities
this.selectedEntity = selectedEntity
this.visibleEntityIds = visibleEntityIds
}
}.use { scene ->
repeat(4) {
scene.click(x = 280.dp, y = 270.dp)
scene.render()
}
val selectedEntityId = EntityCount - 3
visibleEntityIds shouldContain selectedEntityId
selectedEntity.value = initialEntities[selectedEntityId]
scene.render()

entities = initialEntities.map { entity ->
if (entity.id == selectedEntityId) entity.copy(sortingPosition = -1)
else entity
}
repeat(60) {
scene.render()
}

visibleEntityIds shouldContain selectedEntityId
}
}

/**
* Installs the application that supplies shared component defaults.
*/
private companion object {

/**
* The number of rows needed to make the table scroll vertically.
*/
const val EntityCount: Int = 10

/**
* Initializes the application required by the component lifecycle.
*/
@JvmStatic
@BeforeAll
fun setUpApplication() {
TestApplication.install()
}
}
}

/**
* A table that exposes which entity rows are currently composed.
*/
private class VisibilityTrackingTable : Table<TableEntity>() {

/**
* Declares remembered instances of this component.
*/
companion object : ComponentSetup<VisibilityTrackingTable>({ VisibilityTrackingTable() })

/**
* The IDs of rows currently composed by the lazy list.
*/
lateinit var visibleEntityIds: MutableSet<Int>

init {
defaultComparator = compareBy(TableEntity::sortingPosition)
columns = listOf(TableColumn(name = "Value") { entity ->
TrackVisibility(entity)
})
}

/**
* Returns the stable identity of the given row.
*/
override fun extractEntityId(entity: TableEntity): Any = entity.id

/**
* Provides no content because the test always supplies entities.
*/
@Composable
override fun ColumnScope.EmptyTableContent() = Unit

/**
* Tracks the lifetime of the row composition for the given entity.
*/
@Composable
private fun TrackVisibility(entity: TableEntity) {
DisposableEffect(entity.id) {
visibleEntityIds.add(entity.id)
onDispose {
visibleEntityIds.remove(entity.id)
}
}
Text(entity.id.toString())
}
}

/**
* A sortable row used to exercise stable table selection.
*/
private data class TableEntity(
val id: Int,
val sortingPosition: Int
)
Loading
Loading