From 7fc14a7dda786bb23ebd919b41884418bc8b7115 Mon Sep 17 00:00:00 2001 From: Oleg-Melnik Date: Tue, 11 Aug 2026 18:21:41 +0300 Subject: [PATCH 1/3] Modernize the default desktop look and feel. --- core/README.md | 50 ++- .../io/spine/chords/core/DropdownListBox.kt | 111 ++++-- .../io/spine/chords/core/DropdownSelector.kt | 87 +++-- .../kotlin/io/spine/chords/core/InputField.kt | 75 +++- .../chords/core/appshell/AppViewScaffold.kt | 248 ++++++++++++ .../spine/chords/core/appshell/Application.kt | 19 +- .../chords/core/appshell/NavigationDrawer.kt | 47 ++- .../io/spine/chords/core/appshell/README.md | 34 ++ .../io/spine/chords/core/appshell/TopBar.kt | 37 +- .../io/spine/chords/core/layout/Dialog.kt | 63 +++- .../chords/core/layout/ProgressOverlay.kt | 4 +- .../io/spine/chords/core/layout/WindowType.kt | 103 ++++- .../spine/chords/core/layout/WithTooltip.kt | 65 +++- .../io/spine/chords/core/layout/Wizard.kt | 79 ++-- .../io/spine/chords/core/layout/WizardPage.kt | 10 +- .../chords/core/primitive/CheckboxWithText.kt | 105 +++++- .../core/primitive/RadioButtonWithText.kt | 29 +- .../spine/chords/core/styling/ChordsTheme.kt | 357 ++++++++++++++++++ .../io/spine/chords/core/table/Table.kt | 204 +++++++--- .../chords/core/styling/ChordsThemeSpec.kt | 103 +++++ .../chords/core/table/TableColumnSpec.kt | 15 + .../io/spine/chords/proto/money/MoneyField.kt | 32 +- .../chords/proto/money/PaymentMethodEditor.kt | 22 +- .../spine/chords/proto/time/DateTimeField.kt | 38 +- 24 files changed, 1698 insertions(+), 239 deletions(-) create mode 100644 core/src/main/kotlin/io/spine/chords/core/appshell/AppViewScaffold.kt create mode 100644 core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt create mode 100644 core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt diff --git a/core/README.md b/core/README.md index b50d8ac3..74cd11a8 100644 --- a/core/README.md +++ b/core/README.md @@ -3,6 +3,53 @@ Facilities and components for writing desktop applications with the [Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/) framework. +## Default look and feel + +Chords applications use a compact Material 3 desktop theme by default. It +provides neutral work surfaces, semantic light and dark color schemes, +restrained corner radii, compact typography, and shared dimensions for common +controls, navigation, tables, dialogs, and supporting panes. The dark scheme is +selected automatically when the operating system uses dark appearance. + +The standard Material values are available through `MaterialTheme`. Desktop +values that Material does not define are available through +[`ChordsTheme`](src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt): + +```kotlin +val rowHeight = ChordsTheme.dimensions.tableRowHeight +val hoverAlpha = ChordsTheme.interaction.hoveredStateAlpha +``` + +An application can replace the theme in one place by overriding +`Application.ApplicationTheme`: + +```kotlin +@Composable +override fun ApplicationTheme(content: @Composable () -> Unit) { + ChordsTheme( + colorScheme = myColorScheme, + typography = myTypography, + shapes = myShapes, + dimensions = ChordsDimensions( + controlHeight = 48.dp, + tableRowHeight = 44.dp + ), + content = content + ) +} +``` + +Component properties take precedence over theme values. Class-based +components can also be customized application-wide with `sharedDefaults`. +The effective order is: instance properties, shared component defaults, Chords +desktop tokens, and finally Material theme values. + +Text inputs and selectors expose their text style, shape, modifier, and colors. +Dropdowns expose popup shape, elevations, item height, padding, and selection +colors. Tables expose content padding, container/header/row colors, and row +heights. Dialogs, lightweight windows, and wizards expose their unique sizing, +spacing, surface, shape, border, and elevation values. + ## Using Spine Chords Core in a Gradle project Add a dependency to the library as follows: @@ -99,7 +146,8 @@ In addition to components, the library includes such facilities: - Extension functions to address common tasks or current shortcomings in Compose, like ensuring the usual focus traversal with the Tab key for text - fields (see [Modifier.moveFocusOnTab()](src/main/kotlin/io/spine/chords/core/primitive/TextFieldExts.kt)). + fields (see + [Modifier.moveFocusOnTab()](src/main/kotlin/io/spine/chords/core/primitive/TextFieldExts.kt)). - **Some simple components** that address common needs like [CheckboxWithText](src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt), diff --git a/core/src/main/kotlin/io/spine/chords/core/DropdownListBox.kt b/core/src/main/kotlin/io/spine/chords/core/DropdownListBox.kt index b558c253..1ceedcba 100644 --- a/core/src/main/kotlin/io/spine/chords/core/DropdownListBox.kt +++ b/core/src/main/kotlin/io/spine/chords/core/DropdownListBox.kt @@ -26,6 +26,7 @@ package io.spine.chords.core +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -46,10 +47,10 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.ProvideTextStyle -import androidx.compose.material3.ShapeDefaults.ExtraSmall import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -78,6 +79,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset.Companion.Zero import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.Key.Companion.DirectionDown import androidx.compose.ui.input.key.Key.Companion.DirectionUp import androidx.compose.ui.input.key.Key.Companion.Escape @@ -113,6 +115,7 @@ import io.spine.chords.core.keyboard.KeyRange import io.spine.chords.core.keyboard.key import io.spine.chords.core.keyboard.matches import io.spine.chords.core.primitive.VerticalScrollbar +import io.spine.chords.core.styling.ChordsTheme import java.awt.event.KeyEvent.CHAR_UNDEFINED import java.lang.Character.UnicodeBlock import java.lang.Character.UnicodeBlock.SPECIALS @@ -288,6 +291,41 @@ public class DropdownListBox : Component() { */ public var unfocusInvoker: (() -> Unit)? = null + /** + * Minimum item height, or `null` to use the current Chords theme value. + */ + public var itemMinHeight: Dp? by mutableStateOf(null) + + /** + * Vertical content padding, or `null` to use the current Chords theme value. + */ + public var listContentPadding: Dp? by mutableStateOf(null) + + /** + * The popup shape, or `null` to use the current Material small shape. + */ + public var listShape: Shape? by mutableStateOf(null) + + /** + * The popup's tonal elevation. + */ + public var listTonalElevation: Dp by mutableStateOf(0.dp) + + /** + * The popup's shadow elevation. + */ + public var listShadowElevation: Dp by mutableStateOf(8.dp) + + /** + * Selected item background, or `null` to use the theme selection color. + */ + public var selectedItemColor: Color? by mutableStateOf(null) + + /** + * Keyboard-preselected item background, or `null` to use the theme hover color. + */ + public var preselectedItemColor: Color? by mutableStateOf(null) + /** * A density of the screen, it is used when calculating which part * of drop-down list should be visible to user. @@ -384,11 +422,6 @@ public class DropdownListBox : Component() { */ private var totalItemsHeight by mutableStateOf(0.dp) - /** - * Vertical padding of drop-down list content. - */ - private val listVerticalPadding = 8.dp - /** * Heights of none item in drop-down list. */ @@ -867,6 +900,7 @@ public class DropdownListBox : Component() { * to be used. */ @Composable + @Suppress("LongMethod") // Keeps selection ordering and item rendering in one list pass. private fun BoxScope.DropdownListContent() { Column( modifier = Modifier @@ -883,10 +917,13 @@ public class DropdownListBox : Component() { DropdownListNoneItem( text = noneItemText, color = if (preselectedItemIndex == -1) { - colorScheme.primary.copy(alpha = 0.1f) + preselectedItemColor ?: colorScheme.primary.copy( + alpha = ChordsTheme.interaction.hoveredStateAlpha + ) } else { null }, + itemMinHeight = itemMinHeight ?: ChordsTheme.dimensions.dropdownItemHeight, onMeasureHeight = { measuredHeight -> noneItemHeight = measuredHeight }, @@ -898,11 +935,13 @@ public class DropdownListBox : Component() { items.forEachIndexed { index, item -> val color = when (index) { selectedItemIndex -> { - colorScheme.primary.copy(alpha = 0.2f) + selectedItemColor ?: colorScheme.primaryContainer } preselectedItemIndex -> { - colorScheme.primary.copy(alpha = 0.1f) + preselectedItemColor ?: colorScheme.primary.copy( + alpha = ChordsTheme.interaction.hoveredStateAlpha + ) } else -> { @@ -915,13 +954,17 @@ public class DropdownListBox : Component() { onMeasureHeight = { measuredHeight -> itemHeights[index] = measuredHeight }, - color = color + color = color, + itemMinHeight = itemMinHeight ?: ChordsTheme.dimensions.dropdownItemHeight ) { itemContent(item) } } } else { - DropdownListNoItems(content = noItemsContent) + DropdownListNoItems( + itemMinHeight = itemMinHeight ?: ChordsTheme.dimensions.dropdownItemHeight, + content = noItemsContent + ) } } VerticalScrollbar(scrollState) { @@ -961,13 +1004,20 @@ public class DropdownListBox : Component() { properties = PopupProperties(focusable = searchSelectionEnabled), onPreviewKeyEvent = { handleKeyEventWhenDropdownExpanded(it) } ) { - Surface(shape = ExtraSmall, tonalElevation = 3.0.dp, shadowElevation = 3.0.dp) { + val contentPadding = listContentPadding ?: ChordsTheme.dimensions.spacingXSmall + Surface( + shape = listShape ?: MaterialTheme.shapes.small, + color = colorScheme.surface, + tonalElevation = listTonalElevation, + shadowElevation = listShadowElevation, + border = BorderStroke(1.dp, colorScheme.outlineVariant) + ) { visibleListHeight = min( - totalItemsHeight, listAvailableHeight - listVerticalPadding * 2 + totalItemsHeight, listAvailableHeight - contentPadding * 2 ) Box( modifier = Modifier - .padding(vertical = listVerticalPadding) + .padding(vertical = contentPadding) .height(visibleListHeight) ) { if (scrollPositionRequested != null) { @@ -1160,6 +1210,8 @@ private class DropdownListBoxScopeImpl( * callback that is invoked when item is positioned. * @param color * the background color of drop-down list item. + * @param itemMinHeight + * the minimum height of the item. * @param content * content to be displayed inside drop-down list item. */ @@ -1168,6 +1220,7 @@ private fun DropdownListItem( onClick: () -> Unit, onMeasureHeight: (Int) -> Unit, color: Color?, + itemMinHeight: Dp, content: @Composable () -> Unit ) { val itemHeight = remember { mutableStateOf(0) } @@ -1179,7 +1232,7 @@ private fun DropdownListItem( onClick = onClick ) .fillMaxWidth() - .heightIn(48.dp) + .heightIn(itemMinHeight) .onGloballyPositioned { val height = it.size.height if (height != itemHeight.value) { @@ -1197,22 +1250,29 @@ private fun DropdownListItem( /** * The drop-down list without items. * + * @param itemMinHeight + * the minimum height of the item. * @param content * the content to be shown when drop-down list doesn't have any items. */ @Composable -private fun DropdownListNoItems(content: @Composable (() -> Unit)) { +private fun DropdownListNoItems( + itemMinHeight: Dp, + content: @Composable (() -> Unit) +) { Row( modifier = Modifier .fillMaxWidth() - .heightIn(48.dp) - .padding(horizontal = 12.dp) + .heightIn(itemMinHeight) + .padding(horizontal = ChordsTheme.dimensions.spacingMedium) .background(Transparent), verticalAlignment = CenterVertically, horizontalArrangement = Center ) { StyledContent( - contentColor = colorScheme.secondary.copy(alpha = 0.5f), + contentColor = colorScheme.onSurfaceVariant.copy( + alpha = ChordsTheme.interaction.disabledContentAlpha + ), textStyle = typography.titleSmall, content = content ) @@ -1226,6 +1286,8 @@ private fun DropdownListNoItems(content: @Composable (() -> Unit)) { * the text to be displayed for drop-down list none item. * @param color * the background color of drop-down list none item. + * @param itemMinHeight + * the minimum height of the item. * @param onMeasureHeight * callback that is invoked when item is positioned. * @param onClick @@ -1235,6 +1297,7 @@ private fun DropdownListNoItems(content: @Composable (() -> Unit)) { private fun DropdownListNoneItem( text: String = "", color: Color? = null, + itemMinHeight: Dp, onMeasureHeight: (Int) -> Unit, onClick: () -> Unit ) { @@ -1254,16 +1317,20 @@ private fun DropdownListNoneItem( } } .fillMaxWidth() - .heightIn(48.dp) + .heightIn(itemMinHeight) .background(color ?: Transparent), verticalAlignment = CenterVertically ) { StyledContent( - contentColor = colorScheme.secondary.copy(alpha = 0.5f), + contentColor = colorScheme.onSurfaceVariant.copy( + alpha = ChordsTheme.interaction.disabledContentAlpha + ), content = { Text( text = text, - modifier = Modifier.padding(horizontal = 12.dp) + modifier = Modifier.padding( + horizontal = ChordsTheme.dimensions.spacingMedium + ) ) } ) diff --git a/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt b/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt index 80eb8576..610593f3 100644 --- a/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt +++ b/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt @@ -30,18 +30,19 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuDefaults.TrailingIcon import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldColors -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable @@ -56,6 +57,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.pointer.PointerEventType.Companion.Enter import androidx.compose.ui.input.pointer.PointerEventType.Companion.Exit @@ -75,6 +77,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import io.spine.chords.core.primitive.moveFocusOnTab import io.spine.chords.core.primitive.preventWidthAutogrowing +import io.spine.chords.core.styling.ChordsTheme import java.util.* /** @@ -129,9 +132,26 @@ public abstract class DropdownSelector : InputComponent() { /** * A [TextFieldColors] instance, which defines the color scheme for - * the selector's field. + * the selector's field. If it is not assigned, colors are resolved from + * the current theme on every composition. */ - public lateinit var fieldColors: TextFieldColors + public var fieldColors: TextFieldColors + get() = customFieldColors ?: checkNotNull(currentThemeFieldColors) { + "The dropdown selector has not been composed and has no custom colors." + } + set(value) { + customFieldColors = value + } + + /** + * A text style for the selector field, or `null` to use the theme default. + */ + public var textStyle: TextStyle? by mutableStateOf(null) + + /** + * The selector field's shape, or `null` to use the current Material small shape. + */ + public var shape: Shape? by mutableStateOf(null) /** * Indicates whether the drop-down menu is expanded or not. @@ -157,19 +177,22 @@ public abstract class DropdownSelector : InputComponent() { */ private var dirty = false + /** + * A component-specific colors override, or `null` while theme colors apply. + */ + private var customFieldColors: TextFieldColors? by mutableStateOf(null) + + /** + * The most recently composed theme colors, retained for property reads. + */ + private var currentThemeFieldColors: TextFieldColors? = null + /** * The field's text style based on whether a drop-down item * is selected or not. */ private val fieldTextStyle: TextStyle @Composable get() { - val currentTextStyle = LocalTextStyle.current - return if (selectedItem != null) { - currentTextStyle - } else { - currentTextStyle.copy( - currentTextStyle.color.copy(0.5f) - ) - } + return textStyle ?: MaterialTheme.typography.bodyMedium } /** @@ -191,7 +214,7 @@ public abstract class DropdownSelector : InputComponent() { @Composable protected open fun itemContent(item: I, itemText: String): Unit = recompositionWorkaround { Text( - modifier = Modifier.padding(horizontal = 12.dp), + modifier = Modifier.padding(horizontal = ChordsTheme.dimensions.spacingMedium), text = itemText.annotateSubstring( searchString, SpanStyle(fontWeight = Bold) @@ -201,9 +224,8 @@ public abstract class DropdownSelector : InputComponent() { @Composable override fun content(): Unit = recompositionWorkaround { - if (!::fieldColors.isInitialized) { - fieldColors = TextFieldDefaults.colors() - } + currentThemeFieldColors = OutlinedTextFieldDefaults.colors() + val selectorColors = customFieldColors ?: checkNotNull(currentThemeFieldColors) val fieldText = getFieldText(searchString) SideEffect { @@ -222,7 +244,7 @@ public abstract class DropdownSelector : InputComponent() { val itemText = itemText(it) itemContent(it, itemText) } - invoker = { SelectorField() } + invoker = { SelectorField(selectorColors) } } } @@ -231,16 +253,21 @@ public abstract class DropdownSelector : InputComponent() { */ @Composable @OptIn(ExperimentalComposeUiApi::class) - private fun DropdownListBoxScope.SelectorField() { + private fun DropdownListBoxScope.SelectorField(selectorColors: TextFieldColors) { val validationErrorText = externalValidationMessage?.value - TextField( + if (validationErrorText == null) { + SideEffect { + adjustPositionBasedOnSupportingTextHeight(0) + } + } + OutlinedTextField( value = TextFieldValue(getFieldText(searchString), selection), singleLine = true, onValueChange = { handleDropdownInputChange(it) }, enabled = enabled, label = { Text(text = label) }, isError = validationErrorText != null, - supportingText = (validationErrorText ?: "").let { + supportingText = validationErrorText?.let { { Text( text = it, @@ -250,13 +277,15 @@ public abstract class DropdownSelector : InputComponent() { } }, textStyle = fieldTextStyle, - colors = fieldColors, + colors = selectorColors, + shape = shape ?: MaterialTheme.shapes.small, modifier = modifier + .heightIn(min = ChordsTheme.dimensions.controlHeight) .focusRequester(this@DropdownSelector.focusRequester) .moveFocusOnTab() // This approach with using `Modifier.onPointerEvent` is needed, - // because `Modifier.clickable` won't work when `TextField` + // because `Modifier.clickable` won't work when `OutlinedTextField` // is enabled. // See: https://github.com/JetBrains/compose-multiplatform/issues/220 .onPointerEvent(Press) { handleClick() } @@ -440,7 +469,7 @@ private fun DropdownListBoxScope.TrailingIcons( enabled: Boolean ) { Row( - horizontalArrangement = spacedBy(10.dp), + horizontalArrangement = spacedBy(ChordsTheme.dimensions.spacingSmall), verticalAlignment = CenterVertically ) { if (!valueRequired && enabled) { @@ -472,7 +501,9 @@ private fun DropdownListBoxScope.ClearValueIcon(containsValue: Boolean) { .onPointerEvent(Exit) { isClearIconHovered = false } .background( if (isClearIconHovered) { - colorScheme.primary.copy(alpha = 0.1f) + colorScheme.primary.copy( + alpha = ChordsTheme.interaction.hoveredStateAlpha + ) } else { Transparent } @@ -496,12 +527,14 @@ private fun DropdownExpansionIcon(expanded: Boolean, enabled: Boolean) { Box( modifier = Modifier .pointerHoverIcon(if (enabled) Hand else Text) - .padding(end = 10.dp) + .padding(end = ChordsTheme.dimensions.spacingSmall) .onPointerEvent(Enter) { isTrailingIconHovered = true } .onPointerEvent(Exit) { isTrailingIconHovered = false } .background( if (isTrailingIconHovered && enabled) { - colorScheme.primary.copy(alpha = 0.1f) + colorScheme.primary.copy( + alpha = ChordsTheme.interaction.hoveredStateAlpha + ) } else { Transparent } diff --git a/core/src/main/kotlin/io/spine/chords/core/InputField.kt b/core/src/main/kotlin/io/spine/chords/core/InputField.kt index 8a64b7b6..3ebc7686 100644 --- a/core/src/main/kotlin/io/spine/chords/core/InputField.kt +++ b/core/src/main/kotlin/io/spine/chords/core/InputField.kt @@ -28,14 +28,15 @@ package io.spine.chords.core import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldColors -import androidx.compose.material3.TextFieldDefaults 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 @@ -43,6 +44,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.text.TextRange @@ -56,6 +58,7 @@ import io.spine.chords.core.keyboard.KeyRange.Companion.Digit import io.spine.chords.core.keyboard.KeyRange.Companion.Whitespace import io.spine.chords.core.keyboard.matches import io.spine.chords.core.primitive.preventWidthAutogrowing +import io.spine.chords.core.styling.ChordsTheme import java.util.* import kotlin.Int.Companion.MAX_VALUE import kotlin.math.min @@ -140,7 +143,7 @@ public typealias RawTextContent = TextFieldValue * * It is also possible to change the way how the raw text is actually presented * to the user by specifying the [visualTransformation] function. This works in - * the same way as the `visualTransformation` parameter of the [TextField] + * the same way as the `visualTransformation` parameter of the [OutlinedTextField] * component. * * Unlike [inputReviser], any modifications applied using @@ -202,7 +205,7 @@ public typealias RawTextContent = TextFieldValue * * #### A placeholder and prompt text * - * Just like the standard `TextField` component, it is possible to specify + * Just like the standard [OutlinedTextField], it is possible to specify * a content that will be displayed in the focused field when nothing is entered * in it (see the [placeholder] property). It can be specified as an arbitrary * composable content. @@ -308,11 +311,23 @@ public open class InputField : InputComponent() { */ public var textStyle: TextStyle? by mutableStateOf(null) + /** + * The field's shape, or `null` to use the current Material small shape. + */ + public var shape: Shape? by mutableStateOf(null) + /** * A [TextFieldColors] instance, which defines the color scheme for - * this field. + * this field. If it is not assigned, colors are resolved from the current + * theme on every composition. */ - public lateinit var colors: TextFieldColors + public var colors: TextFieldColors + get() = customColors ?: checkNotNull(currentThemeColors) { + "The input field has not been composed and has no custom colors." + } + set(value) { + customColors = value + } /** * An [InputReviser] that can be specified to modify the user's input before @@ -320,6 +335,16 @@ public open class InputField : InputComponent() { */ protected open var inputReviser: InputReviser? by mutableStateOf(null) + /** + * A component-specific colors override, or `null` while theme colors apply. + */ + private var customColors: TextFieldColors? by mutableStateOf(null) + + /** + * The most recently composed theme colors, retained for property reads. + */ + private var currentThemeColors: TextFieldColors? = null + /** * Transforms the raw text typed in by the user to form a text displayed in * the field (without affecting how the text is stored in raw text, or @@ -451,10 +476,9 @@ public open class InputField : InputComponent() { @Composable override fun content(): Unit = recompositionWorkaround { - if (!::colors.isInitialized) { - colors = TextFieldDefaults.colors() - } - val textStyle = textStyle ?: LocalTextStyle.current + currentThemeColors = OutlinedTextFieldDefaults.colors() + val fieldColors = customColors ?: checkNotNull(currentThemeColors) + val textStyle = textStyle ?: defaultTextStyle() val rawTextContent = getRawTextContent() val interactionSource = remember { MutableInteractionSource() } @@ -462,11 +486,11 @@ public open class InputField : InputComponent() { val validationErrorText = ownValidationMessage.value ?: externalValidationMessage?.value - TextField( + OutlinedTextField( value = rawTextContent, label = label?.let { { Text(text = it) } }, isError = validationErrorText != null, - supportingText = (validationErrorText ?: "").let { + supportingText = validationErrorText?.let { { supportingText(it) } @@ -476,8 +500,7 @@ public open class InputField : InputComponent() { placeholder = placeholder ?: { Text( promptText ?: "", - fontFamily = textStyle.fontFamily, - color = colorScheme.secondary + fontFamily = textStyle.fontFamily ) }, prefix = prefix, @@ -488,8 +511,10 @@ public open class InputField : InputComponent() { maxLines = if (multiline) maxLines else 1, enabled = enabled, textStyle = textStyle, - colors = colors, + colors = fieldColors, + shape = shape ?: MaterialTheme.shapes.small, modifier = modifier(modifier) + .heightIn(min = ChordsTheme.dimensions.controlHeight) .focusRequester(focusRequester) .preventWidthAutogrowing() .onPreviewKeyEvent { @@ -498,6 +523,18 @@ public open class InputField : InputComponent() { ) } + /** + * Provides the field text style used when [textStyle] is not assigned. + * + * Subclasses can override this function to retain a specialized font while + * still allowing the public component property to take precedence. + * + * @return The default text style for this field implementation. + */ + @Composable + @ReadOnlyComposable + protected open fun defaultTextStyle(): TextStyle = MaterialTheme.typography.bodyMedium + override fun clear() { super.clear() invalidValueText = null @@ -508,10 +545,10 @@ public open class InputField : InputComponent() { /** * Given a modifier, which is going to be applied to the displayed - * [TextField], provides an opportunity to modify it according to any + * [OutlinedTextField], provides an opportunity to modify it according to any * requirements of the respective input field implementation. * - * @param modifier A [Modifier], which is going to be set to the [TextField] + * @param modifier A [Modifier], which is going to be set to the [OutlinedTextField] * displayed by this `InputField`. * @return A modified version of a given [modifier] if any modifications * are required. diff --git a/core/src/main/kotlin/io/spine/chords/core/appshell/AppViewScaffold.kt b/core/src/main/kotlin/io/spine/chords/core/appshell/AppViewScaffold.kt new file mode 100644 index 00000000..317d079e --- /dev/null +++ b/core/src/main/kotlin/io/spine/chords/core/appshell/AppViewScaffold.kt @@ -0,0 +1,248 @@ +/* + * 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.appshell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.SpaceBetween +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.spine.chords.core.styling.ChordsTheme + +/** + * Lays out a standard desktop application view with actions and an optional + * toolbar and supporting details pane. + * + * The central surface is suitable for a table, form, or other primary work + * area. Applications can omit the supporting pane for list-only or form-only + * screens and override every color, inset, and pane width independently. + * + * @param title The view title. + * @param modifier A modifier applied to the complete view. + * @param description Optional supporting text displayed below the title. + * @param containerColor The page background, or [Color.Unspecified] to use the + * current Material background color. + * @param workAreaColor The work-area surface, or [Color.Unspecified] to use the + * current Material surface color. + * @param outlineColor The work-area border, or [Color.Unspecified] to use the + * current Material outline variant. + * @param pagePadding The page inset, or `null` to use the Chords theme value. + * @param contentPadding Padding around the primary work area content. + * @param workAreaShape The work-area shape, or `null` to use the current + * Material medium shape. + * @param toolbarHeight The toolbar height, or `null` to use the current Chords + * control height. + * @param supportingPaneWidth The details pane width, or `null` to use the + * current Chords theme value. + * @param supportingPanePadding The details pane inset, or `null` to use the + * current Chords theme value. + * @param actions Primary and secondary page actions displayed near the title. + * @param toolbar Optional controls displayed above the work area. + * @param supportingPane Optional details or contextual content displayed on + * the right side of the work area. + * @param content The primary work area content. + */ +@Composable +@Suppress( + "LongMethod", // The complete scaffold hierarchy is clearer in one composable. + "LongParameterList" // Each parameter is an independent layout override point. +) +public fun AppViewScaffold( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + containerColor: Color = Color.Unspecified, + workAreaColor: Color = Color.Unspecified, + outlineColor: Color = Color.Unspecified, + pagePadding: PaddingValues? = null, + contentPadding: PaddingValues = PaddingValues(), + workAreaShape: Shape? = null, + toolbarHeight: Dp? = null, + supportingPaneWidth: Dp? = null, + supportingPanePadding: PaddingValues? = null, + actions: @Composable RowScope.() -> Unit = {}, + toolbar: (@Composable RowScope.() -> Unit)? = null, + supportingPane: (@Composable ColumnScope.() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit +) { + val pageColor = if (containerColor == Color.Unspecified) { + MaterialTheme.colorScheme.background + } else { + containerColor + } + val surfaceColor = if (workAreaColor == Color.Unspecified) { + MaterialTheme.colorScheme.surface + } else { + workAreaColor + } + val borderColor = if (outlineColor == Color.Unspecified) { + MaterialTheme.colorScheme.outlineVariant + } else { + outlineColor + } + Surface( + modifier = modifier.fillMaxSize(), + color = pageColor + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding( + pagePadding ?: PaddingValues(ChordsTheme.dimensions.spacingLarge) + ), + verticalArrangement = spacedBy(ChordsTheme.dimensions.spacingLarge) + ) { + ViewHeader(title, description, actions) + Surface( + modifier = Modifier + .fillMaxWidth() + .weight(1F), + shape = workAreaShape ?: MaterialTheme.shapes.medium, + color = surfaceColor, + border = BorderStroke(1.dp, borderColor) + ) { + Column(modifier = Modifier.fillMaxSize()) { + if (toolbar != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn( + min = toolbarHeight + ?: ChordsTheme.dimensions.controlHeight + ) + .padding(horizontal = ChordsTheme.dimensions.spacingMedium), + horizontalArrangement = spacedBy( + ChordsTheme.dimensions.spacingSmall + ), + verticalAlignment = CenterVertically, + content = toolbar + ) + Divider(color = borderColor) + } + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1F) + ) { + Box( + modifier = Modifier + .weight(1F) + .fillMaxHeight() + .padding(contentPadding), + content = content + ) + if (supportingPane != null) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .background(borderColor) + ) + Column( + modifier = Modifier + .width( + supportingPaneWidth + ?: ChordsTheme.dimensions.supportingPaneWidth + ) + .fillMaxHeight() + .padding( + supportingPanePadding ?: PaddingValues( + ChordsTheme.dimensions.spacingLarge + ) + ), + content = supportingPane + ) + } + } + } + } + } + } +} + +/** + * Renders an application view's heading and actions. + * + * @param title The view title. + * @param description Optional text displayed below the title. + * @param actions Page actions placed at the end of the heading row. + */ +@Composable +private fun ViewHeader( + title: String, + description: String?, + actions: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = SpaceBetween, + verticalAlignment = CenterVertically + ) { + Column(modifier = Modifier.weight(1F)) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Row( + horizontalArrangement = spacedBy(ChordsTheme.dimensions.spacingSmall), + verticalAlignment = CenterVertically, + content = actions + ) + } +} diff --git a/core/src/main/kotlin/io/spine/chords/core/appshell/Application.kt b/core/src/main/kotlin/io/spine/chords/core/appshell/Application.kt index 8665253b..32a6c8e9 100644 --- a/core/src/main/kotlin/io/spine/chords/core/appshell/Application.kt +++ b/core/src/main/kotlin/io/spine/chords/core/appshell/Application.kt @@ -39,6 +39,7 @@ import io.spine.chords.core.layout.ConfirmationDialog import io.spine.chords.core.layout.Dialog import io.spine.chords.core.layout.DialogSetup import io.spine.chords.core.layout.WindowType +import io.spine.chords.core.styling.ChordsTheme import io.spine.chords.core.writeOnce import java.awt.Dimension @@ -215,11 +216,27 @@ public open class Application( appWindow } if (mainWindowVisible) { - appWindowContent(appWindow) + ApplicationTheme { + appWindowContent(appWindow) + } } } } + /** + * Applies the application's theme to all window content. + * + * The default implementation installs [ChordsTheme]. Applications can + * override this function to supply different Material colors, typography, + * shapes, Chords desktop dimensions, or an entirely custom theme. + * + * @param content The application content to which the theme is applied. + */ + @Composable + protected open fun ApplicationTheme(content: @Composable () -> Unit) { + ChordsTheme(content = content) + } + /** * Renders the [TopBar.actions] of the top app bar. * diff --git a/core/src/main/kotlin/io/spine/chords/core/appshell/NavigationDrawer.kt b/core/src/main/kotlin/io/spine/chords/core/appshell/NavigationDrawer.kt index 93e0cf66..c3bcec3b 100644 --- a/core/src/main/kotlin/io/spine/chords/core/appshell/NavigationDrawer.kt +++ b/core/src/main/kotlin/io/spine/chords/core/appshell/NavigationDrawer.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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. @@ -28,6 +28,7 @@ package io.spine.chords.core.appshell import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon @@ -39,9 +40,12 @@ import androidx.compose.material3.PermanentNavigationDrawer import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import cafe.adriel.voyager.navigator.CurrentScreen +import io.spine.chords.core.styling.ChordsTheme /** * Represents a navigation bar that changes the current view @@ -57,26 +61,51 @@ public fun NavigationDrawer( topPadding: Dp, modifier: Modifier = Modifier, ) { + val dividerColor = MaterialTheme.colorScheme.outlineVariant PermanentNavigationDrawer( modifier = Modifier.padding(top = topPadding), drawerContent = { PermanentDrawerSheet( - modifier = modifier.width(240.dp), - drawerContainerColor = MaterialTheme.colorScheme.background + modifier = modifier + .width(ChordsTheme.dimensions.navigationWidth) + .drawBehind { + val thickness = 1.dp.toPx() + val x = size.width - thickness / 2 + drawLine( + color = dividerColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = thickness + ) + }, + drawerContainerColor = MaterialTheme.colorScheme.surface ) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(ChordsTheme.dimensions.spacingSmall)) appViews.forEach { view -> NavigationDrawerItem( icon = { Icon(view.icon, contentDescription = null) }, label = { Text(view.name) }, selected = app.ui.currentView == view, onClick = { app.ui.select(view) }, - modifier = Modifier.padding( - horizontal = 12.dp, - vertical = 4.dp - ), + modifier = Modifier + .padding( + horizontal = ChordsTheme.dimensions.spacingSmall, + vertical = ChordsTheme.dimensions.spacingXSmall + ) + .heightIn(min = ChordsTheme.dimensions.navigationItemHeight), + shape = MaterialTheme.shapes.small, colors = NavigationDrawerItemDefaults.colors( - unselectedContainerColor = MaterialTheme.colorScheme.background + selectedContainerColor = + MaterialTheme.colorScheme.primaryContainer, + selectedIconColor = + MaterialTheme.colorScheme.onPrimaryContainer, + selectedTextColor = + MaterialTheme.colorScheme.onPrimaryContainer, + unselectedContainerColor = MaterialTheme.colorScheme.surface, + unselectedIconColor = + MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = + MaterialTheme.colorScheme.onSurfaceVariant ) ) } diff --git a/core/src/main/kotlin/io/spine/chords/core/appshell/README.md b/core/src/main/kotlin/io/spine/chords/core/appshell/README.md index 0342d0ed..2de29725 100644 --- a/core/src/main/kotlin/io/spine/chords/core/appshell/README.md +++ b/core/src/main/kotlin/io/spine/chords/core/appshell/README.md @@ -16,3 +16,37 @@ application-wide APIs from within any component. See the details in the [Application](Application.kt) and [AppView](AppView.kt) KDocs. + +### Theme customization + +`Application` installs the compact Chords Material 3 theme around all window +and dialog content. Override `ApplicationTheme` to replace its color scheme, +typography, shapes, desktop dimensions, or interaction values. The default +theme follows the operating system's light or dark appearance. + +### Standard application view layout + +Use [AppViewScaffold](AppViewScaffold.kt) for a conventional business +application screen with a heading, page actions, an optional toolbar, a main +work area, and an optional supporting details pane. Its dimensions and colors +follow the active theme and remain overridable at each usage site. + +```kotlin +AppViewScaffold( + title = "Customers", + actions = { + Button(onClick = ::createCustomer) { + Text("New customer") + } + }, + toolbar = { + SearchField() + FilterButton() + }, + supportingPane = { + CustomerDetails(selectedCustomer) + } +) { + CustomersTable() +} +``` diff --git a/core/src/main/kotlin/io/spine/chords/core/appshell/TopBar.kt b/core/src/main/kotlin/io/spine/chords/core/appshell/TopBar.kt index 8e838967..f29cffcc 100644 --- a/core/src/main/kotlin/io/spine/chords/core/appshell/TopBar.kt +++ b/core/src/main/kotlin/io/spine/chords/core/appshell/TopBar.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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. @@ -29,7 +29,7 @@ package io.spine.chords.core.appshell import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -38,8 +38,11 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp import io.spine.chords.core.Component +import io.spine.chords.core.styling.ChordsTheme /** * Represents the TopBar, aka 'Header', of the main screen. @@ -59,21 +62,39 @@ public class TopBar(private val modifier: Modifier = Modifier) : Component() { @Composable protected override fun content() { + val dividerColor = MaterialTheme.colorScheme.outlineVariant TopAppBar( title = { Text( app.name, - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface ) }, actions = actions, modifier = modifier - .padding(0.dp) - .fillMaxWidth(), - windowInsets = WindowInsets(8.dp), + .fillMaxWidth() + .height(ChordsTheme.dimensions.appBarHeight) + .drawBehind { + val thickness = 1.dp.toPx() + val y = size.height - thickness / 2 + drawLine( + color = dividerColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = thickness + ) + }, + windowInsets = WindowInsets( + left = ChordsTheme.dimensions.spacingMedium, + top = 0.dp, + right = ChordsTheme.dimensions.spacingSmall, + bottom = 0.dp + ), colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant ) ) } 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 a898b77a..2aa1b1f7 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 @@ -34,12 +34,14 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -61,6 +63,7 @@ import io.spine.chords.core.appshell.app import io.spine.chords.core.keyboard.KeyModifiers.Companion.Ctrl import io.spine.chords.core.keyboard.key import io.spine.chords.core.layout.WindowType.DesktopWindow +import io.spine.chords.core.styling.ChordsTheme /** * A shortcut (key combination), which invokes dialog submission. @@ -320,6 +323,33 @@ public abstract class Dialog : Component() { public var buttonsSpacing: Dp = 12.dp ) + /** + * Resolves the dialog look against the active theme. + * + * An unchanged default [Look] follows the global Chords spacing scale. A + * customized look retains its component-specific values and takes + * precedence over the theme. + * + * @return The appearance values to use for the current composition. + */ + @Composable + internal fun resolvedLook(): Look { + return if (look == Look()) { + Look( + padding = PaddingValues(ChordsTheme.dimensions.spacingXLarge), + titlePadding = PaddingValues( + bottom = ChordsTheme.dimensions.spacingLarge + ), + buttonsPanelPadding = PaddingValues( + top = ChordsTheme.dimensions.spacingXLarge + ), + buttonsSpacing = ChordsTheme.dimensions.spacingMedium + ) + } else { + look + } + } + /** * Specifies the way that the dialog window is displayed. * @@ -636,15 +666,16 @@ public abstract class Dialog : Component() { if (!submitAvailable && !cancelAvailable) { return } + val currentLook = resolvedLook() Row( modifier = Modifier.fillMaxWidth() - .padding(look.buttonsPanelPadding), + .padding(currentLook.buttonsPanelPadding), horizontalArrangement = End, verticalAlignment = Bottom ) { Row( modifier = Modifier.width(IntrinsicSize.Max), - horizontalArrangement = spacedBy(look.buttonsSpacing) + horizontalArrangement = spacedBy(currentLook.buttonsSpacing) ) { buttons() } @@ -671,12 +702,16 @@ public abstract class Dialog : Component() { @Composable protected fun buttons() { if (cancelAvailable) { - DialogButton(cancelButtonText) { + DialogButton(cancelButtonText, primary = false) { cancel() } } if (submitAvailable) { - DialogButton(submitButtonText, !submitting) { + DialogButton( + label = submitButtonText, + enabled = !submitting, + primary = true + ) { submit() } } @@ -788,15 +823,17 @@ public open class DialogSetup( * @param label The label of the button. * @param enabled Specifies whether the button should appear and behave as * an enabled one. + * @param primary Whether this is the dialog's emphasized action. * @param onClick The callback triggered on the button click. */ @Composable private fun DialogButton( label: String, enabled: Boolean = true, + primary: Boolean, onClick: () -> Unit ) { - Button(onClick = onClick, enabled = enabled) { + val content: @Composable () -> Unit = { Row( verticalAlignment = CenterVertically ) { @@ -808,4 +845,20 @@ private fun DialogButton( ) } } + val modifier = Modifier.heightIn(min = ChordsTheme.dimensions.compactControlHeight) + if (primary) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + content = { content() } + ) + } else { + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + content = { content() } + ) + } } diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/ProgressOverlay.kt b/core/src/main/kotlin/io/spine/chords/core/layout/ProgressOverlay.kt index a04803f4..531d2799 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/ProgressOverlay.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/ProgressOverlay.kt @@ -46,7 +46,7 @@ import io.spine.chords.core.keyboard.matches * The background dims the covered content enough for it to read as * unavailable, while keeping it recognizable. */ -private const val DefaultOverlayAlpha = 0.6f +private const val DefaultOverlayAlpha = 0.72f /** * Displays the given [content], and covers it with a progress overlay while @@ -91,7 +91,7 @@ private const val DefaultOverlayAlpha = 0.6f public fun ProgressOverlay( active: Boolean, modifier: Modifier = Modifier, - background: Color = colorScheme.background.copy(alpha = DefaultOverlayAlpha), + background: Color = colorScheme.surface.copy(alpha = DefaultOverlayAlpha), indicator: @Composable () -> Unit = { CircularProgressIndicator() }, content: @Composable () -> Unit ) { diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt b/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt index 610e2f1a..18aaa0f6 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt @@ -26,6 +26,7 @@ package io.spine.chords.core.layout +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box @@ -43,6 +44,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -53,9 +55,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Color.Companion.Gray +import androidx.compose.ui.graphics.Color.Companion.Unspecified +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.IntrinsicMeasurable @@ -82,6 +84,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import io.spine.chords.core.keyboard.matches +import io.spine.chords.core.styling.ChordsTheme import kotlin.math.ceil /** @@ -104,11 +107,21 @@ public sealed class WindowType { * as a separate desktop window. * * @param resizable Specifies whether the window can be resized by the user. + * @param containerColor The content background, or [Unspecified] to use the + * current Material surface color. */ public open class DesktopWindow( - public val resizable: Boolean = false + public val resizable: Boolean = false, + public val containerColor: Color = Unspecified ) : WindowType() { + /** + * Creates a desktop dialog window with the theme surface color. + * + * @param resizable Specifies whether the window can be resized. + */ + public constructor(resizable: Boolean) : this(resizable, Unspecified) + @Composable override fun dialogWindow(dialog: Dialog) { val initialSize = DpSize(dialog.width, dialog.height) @@ -172,7 +185,13 @@ public sealed class WindowType { } Column( modifier = sizeModifier - .background(colorScheme.background), + .background( + if (containerColor == Unspecified) { + colorScheme.surface + } else { + containerColor + } + ), ) { val heightMode = if (dialog.height.isSpecified || contentFittedSize != null) { @@ -186,7 +205,7 @@ public sealed class WindowType { Modifier.fillMaxWidth() } Column( - modifier = contentModifier.padding(dialog.look.padding), + modifier = contentModifier.padding(dialog.resolvedLook().padding), ) { dialog.windowContentInternal(heightMode) dialog.nestedDialog?.Content() @@ -218,7 +237,8 @@ public sealed class WindowType { * ``` */ public companion object : DesktopWindow( - resizable = false + resizable = false, + containerColor = Unspecified ) } @@ -228,12 +248,38 @@ public sealed class WindowType { * * @param backdropColor The color of the surface that covers the entire * content of the current desktop window behind the dialog's modal popup - * displayed in this window. + * displayed in this window, or [Unspecified] to use the theme scrim. + * @param containerColor The dialog surface, or [Unspecified] to use the + * current Material surface color. + * @param shape The dialog shape, or `null` to use the current Material + * large shape. + * @param shadowElevation The dialog shadow elevation. + * @param borderColor The dialog border, or [Unspecified] to use the + * current Material outline variant. */ + @Suppress("LongParameterList") // These are independent visual override points. public open class LightweightWindow( - public val backdropColor: Color = Gray.copy(alpha = 0.5f) + public val backdropColor: Color = Unspecified, + public val containerColor: Color = Unspecified, + public val shape: Shape? = null, + public val shadowElevation: Dp = 16.dp, + public val borderColor: Color = Unspecified ) : WindowType() { + /** + * Creates a lightweight dialog with a custom backdrop and theme-based + * frame appearance. + * + * @param backdropColor The modal backdrop color. + */ + public constructor(backdropColor: Color) : this( + backdropColor = backdropColor, + containerColor = Unspecified, + shape = null, + shadowElevation = 16.dp, + borderColor = Unspecified + ) + @Composable override fun dialogWindow(dialog: Dialog) { Popup( @@ -249,7 +295,15 @@ public sealed class WindowType { BoxWithConstraints( modifier = Modifier .fillMaxSize() - .background(backdropColor), + .background( + if (backdropColor == Unspecified) { + colorScheme.scrim.copy( + alpha = ChordsTheme.interaction.scrimAlpha + ) + } else { + backdropColor + } + ), contentAlignment = Center ) { val availableWidth = maxWidth @@ -274,17 +328,30 @@ public sealed class WindowType { maxWidth: Dp, maxHeight: Dp ) { - Column( + Surface( modifier = Modifier - .clip(shapes.large) .dialogSize( dialog.width, dialog.height, maxWidth, maxHeight, fillSpecifiedDimensions = false - ) - .background(colorScheme.background), + ), + shape = shape ?: shapes.large, + color = if (containerColor == Unspecified) { + colorScheme.surface + } else { + containerColor + }, + shadowElevation = shadowElevation, + border = BorderStroke( + width = 1.dp, + color = if (borderColor == Unspecified) { + colorScheme.outlineVariant + } else { + borderColor + } + ) ) { val heightMode = if (dialog.height.isSpecified) { DialogContentHeightMode.Exact @@ -296,12 +363,13 @@ public sealed class WindowType { } else { Modifier.fillMaxWidth() } + val currentLook = dialog.resolvedLook() Column( - modifier = contentModifier.padding(dialog.look.padding), + modifier = contentModifier.padding(currentLook.padding) ) { - DialogTitle(dialog.title, dialog.look.titlePadding) + DialogTitle(dialog.title, currentLook.titlePadding) dialog.windowContentInternal(heightMode) - dialog.nestedDialog ?.Content() + dialog.nestedDialog?.Content() } } } @@ -500,6 +568,7 @@ private fun DialogTitle( .padding(padding) .preferUnwrappedWidth(), text = text, - style = typography.headlineLarge + style = typography.titleLarge, + color = colorScheme.onSurface ) } diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/WithTooltip.kt b/core/src/main/kotlin/io/spine/chords/core/layout/WithTooltip.kt index e6d0ebdf..cc2e3d66 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/WithTooltip.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/WithTooltip.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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. @@ -40,6 +40,35 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +/** + * Displays the given [content] with a tooltip using theme content colors. + * + * @param tooltip The text shown when the mouse hovers over the content. + * @param modifier The [Modifier] applied to the tooltip area. + * @param tooltipCardColor The tooltip background, or [Color.Unspecified] to + * use the current theme's inverse surface color. + * @param shape The tooltip container shape. + * @param content The content to which the tooltip is assigned. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +public fun WithTooltip( + tooltip: String, + modifier: Modifier = Modifier, + tooltipCardColor: Color = Color.Unspecified, + shape: RoundedCornerShape = RoundedCornerShape(6.dp), + content: @Composable () -> Unit +) { + WithTooltip( + tooltip = tooltip, + modifier = modifier, + tooltipCardColor = tooltipCardColor, + shape = shape, + tooltipContentColor = Color.Unspecified, + content = content + ) +} + /** * Displays the given `content` with assigning a tooltip for it. * @@ -51,6 +80,9 @@ import androidx.compose.ui.unit.dp * the background color of the tooltip container. * @param shape * the shape of the card for which the tip is shown. + * @param tooltipContentColor + * the color of the tooltip text, or [Color.Unspecified] to use the + * current theme's inverse surface content color. * @param content * the content to which assign the tooltip. */ @@ -59,31 +91,36 @@ import androidx.compose.ui.unit.dp public fun WithTooltip( tooltip: String, modifier: Modifier = Modifier, - tooltipCardColor: Color = Color.LightGray, - shape: RoundedCornerShape = RoundedCornerShape( - topStart = 0.dp, - topEnd = 8.dp, - bottomEnd = 8.dp, - bottomStart = 8.dp - ), + tooltipCardColor: Color = Color.Unspecified, + shape: RoundedCornerShape = RoundedCornerShape(6.dp), + tooltipContentColor: Color, content: @Composable () -> Unit ) { TooltipArea( tooltip = { Card( shape = shape, - colors = CardDefaults.outlinedCardColors( - containerColor = tooltipCardColor + colors = CardDefaults.cardColors( + containerColor = if (tooltipCardColor == Color.Unspecified) { + MaterialTheme.colorScheme.inverseSurface + } else { + tooltipCardColor + } ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), modifier = Modifier - .padding(10.dp) - .widthIn(min = 90.dp, max = 210.dp) + .padding(8.dp) + .widthIn(min = 64.dp, max = 320.dp) ) { Text( tooltip, style = MaterialTheme.typography.bodySmall, - color = Color.Black, - modifier = Modifier.padding(10.dp) + color = if (tooltipContentColor == Color.Unspecified) { + MaterialTheme.colorScheme.inverseOnSurface + } else { + tooltipContentColor + }, + modifier = Modifier.padding(8.dp) ) } }, diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt b/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt index c3b24fd1..c289662d 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt @@ -59,26 +59,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key.Companion.DirectionLeft import androidx.compose.ui.input.key.Key.Companion.DirectionRight import androidx.compose.ui.input.key.Key.Companion.Enter +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.spine.chords.core.Component import io.spine.chords.core.keyboard.KeyModifiers.Companion.Alt import io.spine.chords.core.keyboard.KeyModifiers.Companion.Ctrl import io.spine.chords.core.keyboard.key import io.spine.chords.core.keyboard.on -import io.spine.chords.core.layout.WizardContentSize.maxHeight -import io.spine.chords.core.layout.WizardContentSize.minHeight -import io.spine.chords.core.layout.WizardContentSize.width import io.spine.chords.core.primitive.HorizontalScrollbar import io.spine.chords.core.primitive.VerticalScrollbar - -/** - * Bounds of the wizard's content pane. - */ -private object WizardContentSize { - val width = 670.dp - val minHeight = 400.dp - val maxHeight = 700.dp -} +import io.spine.chords.core.styling.ChordsTheme /** * The base class for creating a multi-step form component known as a wizard. @@ -115,6 +105,30 @@ private object WizardContentSize { ) public abstract class Wizard : Component() { + /** + * Appearance and size values unique to a wizard. + * + * @property width The width of the wizard content pane. + * @property minHeight The minimum height of the wizard content pane. + * @property maxHeight The maximum height of the wizard content pane. + * @property padding The inset around wizard content. + * @property sectionSpacing The gap between the title, page, and actions. + * @property buttonSpacing The gap between adjacent navigation buttons. + */ + public data class Look( + public val width: Dp = 720.dp, + public val minHeight: Dp = 420.dp, + public val maxHeight: Dp = 760.dp, + public val padding: Dp = 32.dp, + public val sectionSpacing: Dp = 16.dp, + public val buttonSpacing: Dp = 8.dp + ) + + /** + * Specifies appearance-related values that are unique to this wizard. + */ + public var look: Look = Look() + /** * The text to be the title of the wizard, or `null`, if the wizard's title * shouldn't be displayed at all. @@ -269,17 +283,18 @@ public abstract class Wizard : Component() { @Composable override fun content() { + val currentLook = resolvedLook() Box( modifier = Modifier - .width(width) - .heightIn(minHeight, maxHeight), + .width(currentLook.width) + .heightIn(currentLook.minHeight, currentLook.maxHeight), contentAlignment = Center ) { Column( modifier = Modifier .fillMaxWidth() - .padding(32.dp), - verticalArrangement = spacedBy(16.dp) + .padding(currentLook.padding), + verticalArrangement = spacedBy(currentLook.sectionSpacing) ) { if (title != null) { Title(title!!) @@ -317,12 +332,31 @@ public abstract class Wizard : Component() { onCancelClick = { cancel() }, isOnFirstPage = isOnFirstPage(), isOnLastPage = isOnLastPage(), - submitting + submitting = submitting, + buttonSpacing = currentLook.buttonSpacing ) } } } + /** + * Resolves an unchanged default look against the active theme spacing. + * + * @return The appearance values to use for the current composition. + */ + @Composable + private fun resolvedLook(): Look { + return if (look == Look()) { + look.copy( + padding = ChordsTheme.dimensions.spacingXXLarge, + sectionSpacing = ChordsTheme.dimensions.spacingLarge, + buttonSpacing = ChordsTheme.dimensions.spacingSmall + ) + } else { + look + } + } + /** * Completes the given page, which either navigates the wizard to the next * page, or submits the wizard, if [currentPage] is the last one. @@ -435,7 +469,8 @@ public abstract class Wizard : Component() { private fun Title(text: String) { Text( text = text, - style = MaterialTheme.typography.headlineLarge + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface ) } @@ -456,6 +491,7 @@ private fun Title(text: String) { * is the last one. * @param submitting Specifies whether wizard's submission is currently * in progress. + * @param buttonSpacing The gap between adjacent navigation buttons. */ @Composable private fun NavigationPanel( @@ -465,7 +501,8 @@ private fun NavigationPanel( onCancelClick: () -> Unit, isOnFirstPage: Boolean, isOnLastPage: Boolean, - submitting: Boolean + submitting: Boolean, + buttonSpacing: Dp ) { Row( modifier = Modifier.fillMaxWidth(), @@ -475,7 +512,7 @@ private fun NavigationPanel( Text("Cancel") } Row( - horizontalArrangement = spacedBy(8.dp) + horizontalArrangement = spacedBy(buttonSpacing) ) { TextButton( onClick = onBackClick, @@ -488,7 +525,7 @@ private fun NavigationPanel( Text("Finish") } } else { - TextButton(onClick = onNextClick, enabled = !submitting) { + Button(onClick = onNextClick, enabled = !submitting) { Text("Next") } } diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/WizardPage.kt b/core/src/main/kotlin/io/spine/chords/core/layout/WizardPage.kt index a16909f3..bb3b9fa4 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/WizardPage.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/WizardPage.kt @@ -37,7 +37,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import io.spine.chords.core.styling.ChordsTheme /** * Represents a single page within the wizard. @@ -94,10 +94,10 @@ public abstract class AbstractWizardPage( @Composable public fun SubheaderText(text: String) { Row( - modifier = Modifier.padding(bottom = 16.dp), + modifier = Modifier.padding(bottom = ChordsTheme.dimensions.spacingLarge), horizontalArrangement = Start ) { - Text(text, style = MaterialTheme.typography.titleMedium) + Text(text, style = MaterialTheme.typography.titleSmall) } } @@ -117,7 +117,7 @@ public fun InputColumn( ) { Column( modifier = modifier.padding(padding), - verticalArrangement = spacedBy(16.dp) + verticalArrangement = spacedBy(ChordsTheme.dimensions.spacingMedium) ) { content() } @@ -136,7 +136,7 @@ public fun InputRow( ) { InputRow( modifier = Modifier, - horizontalArrangement = spacedBy(40.dp), + horizontalArrangement = spacedBy(ChordsTheme.dimensions.spacingLarge), padding = padding, content = content ) diff --git a/core/src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt b/core/src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt index 96d09e35..b114a7ce 100644 --- a/core/src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt +++ b/core/src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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. @@ -30,7 +30,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -39,12 +42,13 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key.Companion.Spacebar import androidx.compose.ui.semantics.Role.Companion.Checkbox -import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.TextStyle import io.spine.chords.core.FocusRequestDispatcher import io.spine.chords.core.ValidationErrorText import io.spine.chords.core.focusRequestDispatcher import io.spine.chords.core.keyboard.key import io.spine.chords.core.keyboard.on +import io.spine.chords.core.styling.ChordsTheme /** * A checkbox that includes a given text on the right. @@ -69,12 +73,48 @@ public fun CheckboxWithText( enabled: Boolean = true, focusRequestDispatcher: FocusRequestDispatcher? = null, externalValidationMessage: State? = null +) { + CheckboxWithText( + checked = checked, + onChange = onChange, + text = text, + modifier = Modifier, + enabled = enabled, + focusRequestDispatcher = focusRequestDispatcher, + externalValidationMessage = externalValidationMessage + ) +} + +/** + * A styled overload of [CheckboxWithText] with layout and text overrides. + * + * @param checked Indicates whether the checkbox is checked. + * @param onChange Invoked when the user tries to change the checked state. + * @param text A text displayed to the right of the checkbox. + * @param modifier A modifier applied to the complete labeled control. + * @param textStyle A text style, or `null` to use the current theme default. + * @param enabled Indicates whether the component accepts user input. + * @param focusRequestDispatcher Specifies when the component should be focused. + * @param externalValidationMessage A validation error displayed by the component. + */ +@Composable +@Suppress("LongParameterList") // Preserves the original API while adding visual overrides. +public fun CheckboxWithText( + checked: Boolean, + onChange: (Boolean) -> Unit, + text: String, + modifier: Modifier, + textStyle: TextStyle? = null, + enabled: Boolean = true, + focusRequestDispatcher: FocusRequestDispatcher? = null, + externalValidationMessage: State? = null ) { fun toggle() = onChange(!checked) Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() + .heightIn(min = ChordsTheme.dimensions.compactControlHeight) .clickable( enabled = enabled, onClick = { toggle() }, @@ -83,15 +123,27 @@ public fun CheckboxWithText( toggle() }, verticalAlignment = CenterVertically, - horizontalArrangement = spacedBy(8.dp) + horizontalArrangement = spacedBy(ChordsTheme.dimensions.spacingSmall) ) { Checkbox( checked = checked, onCheckedChange = null, enabled = enabled, - modifier = Modifier.focusRequestDispatcher(focusRequestDispatcher) + modifier = Modifier + .size(ChordsTheme.dimensions.compactControlHeight) + .focusRequestDispatcher(focusRequestDispatcher) + ) + Text( + text = text, + style = textStyle ?: MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (enabled) { + 1f + } else { + ChordsTheme.interaction.disabledContentAlpha + } + ) ) - Text(text) } if (externalValidationMessage?.value != null) { Row( @@ -119,6 +171,8 @@ public fun CheckboxWithText( * the user input. * @param focusRequestDispatcher A [FocusRequestDispatcher], which specifies * when the component should be focused. + * @param externalValidationMessage A validation error that should be displayed + * by the component. */ @Composable public fun CheckboxWithText( @@ -128,6 +182,41 @@ public fun CheckboxWithText( enabled: Boolean = true, focusRequestDispatcher: FocusRequestDispatcher? = null, externalValidationMessage: State? = null +) { + CheckboxWithText( + checked = checked, + onChange = onChange, + text = text, + modifier = Modifier, + enabled = enabled, + focusRequestDispatcher = focusRequestDispatcher, + externalValidationMessage = externalValidationMessage + ) +} + +/** + * A state-backed styled overload of [CheckboxWithText]. + * + * @param checked The state that stores the checked value. + * @param onChange Invoked after the checked value changes. + * @param text A text displayed to the right of the checkbox. + * @param modifier A modifier applied to the complete labeled control. + * @param textStyle A text style, or `null` to use the current theme default. + * @param enabled Indicates whether the component accepts user input. + * @param focusRequestDispatcher Specifies when the component should be focused. + * @param externalValidationMessage A validation error displayed by the component. + */ +@Composable +@Suppress("LongParameterList") // Preserves the original API while adding visual overrides. +public fun CheckboxWithText( + checked: MutableState, + onChange: ((Boolean) -> Unit)? = null, + text: String, + modifier: Modifier, + textStyle: TextStyle? = null, + enabled: Boolean = true, + focusRequestDispatcher: FocusRequestDispatcher? = null, + externalValidationMessage: State? = null ) { CheckboxWithText( checked.value ?: false, @@ -138,6 +227,8 @@ public fun CheckboxWithText( text = text, enabled = enabled, focusRequestDispatcher = focusRequestDispatcher, - externalValidationMessage = externalValidationMessage + externalValidationMessage = externalValidationMessage, + modifier = modifier, + textStyle = textStyle ) } diff --git a/core/src/main/kotlin/io/spine/chords/core/primitive/RadioButtonWithText.kt b/core/src/main/kotlin/io/spine/chords/core/primitive/RadioButtonWithText.kt index 88d7bab9..2da718ab 100644 --- a/core/src/main/kotlin/io/spine/chords/core/primitive/RadioButtonWithText.kt +++ b/core/src/main/kotlin/io/spine/chords/core/primitive/RadioButtonWithText.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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. @@ -28,9 +28,10 @@ package io.spine.chords.core.primitive import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.selectable -import androidx.compose.material.ContentAlpha.disabled -import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -38,11 +39,11 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.semantics.Role.Companion.RadioButton -import androidx.compose.ui.unit.dp import io.spine.chords.core.FocusRequestDispatcher import io.spine.chords.core.focusRequestDispatcher import io.spine.chords.core.keyboard.key import io.spine.chords.core.keyboard.on +import io.spine.chords.core.styling.ChordsTheme /** @@ -54,6 +55,8 @@ import io.spine.chords.core.keyboard.on * function has to be implemented in a way that makes the [selected] * parameter to be updated accordingly. * @param text A text displayed to the right of the radio button. + * @param enabled Whether the radio button accepts input. + * @param modifier A modifier applied to the complete labeled control. * @param focusRequestDispatcher A [FocusRequestDispatcher], which should be * attached to by this field for receiving and handling field focus requests. */ @@ -68,6 +71,7 @@ public fun RadioButtonWithText( ) { Row( modifier = modifier + .heightIn(min = ChordsTheme.dimensions.compactControlHeight) .focusRequestDispatcher(focusRequestDispatcher) .run { if (enabled) { @@ -83,13 +87,24 @@ public fun RadioButtonWithText( } }, verticalAlignment = CenterVertically, - horizontalArrangement = spacedBy(8.dp) + horizontalArrangement = spacedBy(ChordsTheme.dimensions.spacingSmall) ) { RadioButton( selected = selected, enabled = enabled, - onClick = null + onClick = null, + modifier = Modifier.size(ChordsTheme.dimensions.compactControlHeight) + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (enabled) { + 1f + } else { + ChordsTheme.interaction.disabledContentAlpha + } + ) ) - Text(text, color = if (enabled) colors.onSurface else colors.onSurface.copy(disabled)) } } diff --git a/core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt b/core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt new file mode 100644 index 00000000..d7db1711 --- /dev/null +++ b/core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt @@ -0,0 +1,357 @@ +/* + * 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.styling + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Desktop layout dimensions shared by Chords components. + * + * Applications can replace this value in [ChordsTheme] to change component + * density without configuring every component independently. Component-level + * appearance properties, when supplied, take precedence over these defaults. + * + * @property spacingXSmall The smallest gap between closely related elements. + * @property spacingSmall A small gap or inset. + * @property spacingMedium The ordinary gap between controls. + * @property spacingLarge The ordinary content inset. + * @property spacingXLarge The inset between page or dialog regions. + * @property spacingXXLarge The largest standard section inset. + * @property appBarHeight The height of the application top bar. + * @property navigationWidth The width of expanded application navigation. + * @property navigationItemHeight The height of an application navigation item. + * @property controlHeight The minimum height of an ordinary input control. + * @property compactControlHeight The minimum height of a compact control. + * @property iconButtonSize The default pointer target of an icon button. + * @property dropdownItemHeight The minimum height of a dropdown item. + * @property tableHeaderHeight The height of a table header. + * @property tableRowHeight The height of an ordinary table row. + * @property supportingPaneWidth The default width of a supporting details pane. + */ +@Immutable +@Suppress("LongParameterList") // A theme token group is clearer as one immutable value. +public data class ChordsDimensions( + public val spacingXSmall: Dp = 4.dp, + public val spacingSmall: Dp = 8.dp, + public val spacingMedium: Dp = 12.dp, + public val spacingLarge: Dp = 16.dp, + public val spacingXLarge: Dp = 24.dp, + public val spacingXXLarge: Dp = 32.dp, + public val appBarHeight: Dp = 52.dp, + public val navigationWidth: Dp = 224.dp, + public val navigationItemHeight: Dp = 40.dp, + public val controlHeight: Dp = 44.dp, + public val compactControlHeight: Dp = 36.dp, + public val iconButtonSize: Dp = 40.dp, + public val dropdownItemHeight: Dp = 40.dp, + public val tableHeaderHeight: Dp = 40.dp, + public val tableRowHeight: Dp = 40.dp, + public val supportingPaneWidth: Dp = 360.dp +) + +/** + * Opacity values used to communicate common interaction states. + * + * @property hoveredStateAlpha The opacity of a hover state layer. + * @property focusedStateAlpha The opacity of a focused state layer. + * @property pressedStateAlpha The opacity of a pressed state layer. + * @property disabledContentAlpha The opacity of disabled content. + * @property scrimAlpha The opacity of a lightweight modal backdrop. + */ +@Immutable +public data class ChordsInteraction( + public val hoveredStateAlpha: Float = 0.06f, + public val focusedStateAlpha: Float = 0.08f, + public val pressedStateAlpha: Float = 0.10f, + public val disabledContentAlpha: Float = 0.38f, + public val scrimAlpha: Float = 0.32f +) + +/** + * The default Chords Material 3 theme and its desktop-specific tokens. + * + * Standard Material values remain available through [MaterialTheme]. Chords + * adds only the layout and interaction values that Material does not expose as + * theme properties for desktop applications. + */ +public object ChordsTheme { + + /** + * Applies the default Chords look and feel to [content]. + * + * @param darkTheme Whether the dark color scheme should be used. + * @param colorScheme The Material color scheme applied to all components. + * @param typography The Material typography applied to all components. + * @param shapes The Material shape scale applied to all components. + * @param dimensions Desktop layout dimensions shared by Chords components. + * @param interaction Common interaction-state opacity values. + * @param content The content to which the theme is applied. + */ + @Composable + @Suppress("LongParameterList") // Mirrors MaterialTheme and keeps all theme inputs explicit. + public operator fun invoke( + darkTheme: Boolean = isSystemInDarkTheme(), + colorScheme: ColorScheme = if (darkTheme) { + chordsDarkColorScheme() + } else { + chordsLightColorScheme() + }, + typography: Typography = chordsTypography(), + shapes: Shapes = chordsShapes(), + dimensions: ChordsDimensions = ChordsDimensions(), + interaction: ChordsInteraction = ChordsInteraction(), + content: @Composable () -> Unit + ) { + CompositionLocalProvider( + LocalChordsDimensions provides dimensions, + LocalChordsInteraction provides interaction + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + shapes = shapes, + content = content + ) + } + } + + /** + * The desktop layout dimensions active in the current composition. + */ + public val dimensions: ChordsDimensions + @Composable + @ReadOnlyComposable + get() = LocalChordsDimensions.current + + /** + * The interaction-state opacity values active in the current composition. + */ + public val interaction: ChordsInteraction + @Composable + @ReadOnlyComposable + get() = LocalChordsInteraction.current +} + +/** + * Creates the modern Chords light color scheme. + * + * The scheme uses neutral surfaces and reserves blue for selection, focus, + * links, and primary actions. Applications can pass a different Material + * [ColorScheme] to [ChordsTheme] to replace all brand colors at once. + */ +@Suppress("MagicNumber") // Hex literals make the complete role-based palette auditable. +public fun chordsLightColorScheme(): ColorScheme = lightColorScheme( + primary = Color(0xFF2563EB), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFE7EFFF), + onPrimaryContainer = Color(0xFF153E75), + inversePrimary = Color(0xFFAFC6FF), + secondary = Color(0xFF475569), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE2E8F0), + onSecondaryContainer = Color(0xFF1E293B), + tertiary = Color(0xFF287A5B), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFD8F3E7), + onTertiaryContainer = Color(0xFF0B3B2B), + background = Color(0xFFF6F8FB), + onBackground = Color(0xFF1F2937), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1F2937), + surfaceVariant = Color(0xFFEEF2F6), + onSurfaceVariant = Color(0xFF5F6B7A), + surfaceTint = Color(0xFF2563EB), + inverseSurface = Color(0xFF2B3038), + inverseOnSurface = Color(0xFFF4F6F8), + error = Color(0xFFB42318), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFEE4E2), + onErrorContainer = Color(0xFF7A271A), + outline = Color(0xFFB8C0CC), + outlineVariant = Color(0xFFDDE2E8), + scrim = Color(0xFF000000) +) + +/** + * Creates the modern Chords dark color scheme. + * + * Tonal charcoal surfaces distinguish regions without relying on heavy + * shadows, while a brighter blue keeps selection and keyboard focus visible. + */ +@Suppress("MagicNumber") // Hex literals make the complete role-based palette auditable. +public fun chordsDarkColorScheme(): ColorScheme = darkColorScheme( + primary = Color(0xFF6EA8FE), + onPrimary = Color(0xFF082E63), + primaryContainer = Color(0xFF1C3558), + onPrimaryContainer = Color(0xFFD7E6FF), + inversePrimary = Color(0xFF2563EB), + secondary = Color(0xFFAAB2BF), + onSecondary = Color(0xFF26303D), + secondaryContainer = Color(0xFF343B45), + onSecondaryContainer = Color(0xFFE0E5ED), + tertiary = Color(0xFF77C99A), + onTertiary = Color(0xFF073824), + tertiaryContainer = Color(0xFF1D4D38), + onTertiaryContainer = Color(0xFFC2F1D6), + background = Color(0xFF111318), + onBackground = Color(0xFFEDF0F5), + surface = Color(0xFF181B20), + onSurface = Color(0xFFEDF0F5), + surfaceVariant = Color(0xFF242932), + onSurfaceVariant = Color(0xFFAAB2BF), + surfaceTint = Color(0xFF6EA8FE), + inverseSurface = Color(0xFFE2E6EC), + inverseOnSurface = Color(0xFF252930), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + outline = Color(0xFF46505D), + outlineVariant = Color(0xFF343B45), + scrim = Color(0xFF000000) +) + +/** + * Creates the compact desktop typography used by Chords. + */ +@Suppress("LongMethod") // Keeping the complete type scale together makes it auditable. +public fun chordsTypography(): Typography = Typography( + displayLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 36.sp, + lineHeight = 44.sp + ), + displayMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp + ), + displaySmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 26.sp, + lineHeight = 34.sp + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 28.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp + ), + titleSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 18.sp + ), + labelLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + lineHeight = 18.sp + ), + labelMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp + ) +) + +/** + * Creates the restrained corner-radius scale used by Chords. + */ +public fun chordsShapes(): Shapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(6.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(12.dp), + extraLarge = RoundedCornerShape(16.dp) +) + +/** + * Supplies default desktop dimensions when no Chords theme is installed. + */ +private val LocalChordsDimensions = staticCompositionLocalOf { ChordsDimensions() } + +/** + * Supplies default interaction values when no Chords theme is installed. + */ +private val LocalChordsInteraction = staticCompositionLocalOf { ChordsInteraction() } diff --git a/core/src/main/kotlin/io/spine/chords/core/table/Table.kt b/core/src/main/kotlin/io/spine/chords/core/table/Table.kt index 66330744..38ea9a8c 100644 --- a/core/src/main/kotlin/io/spine/chords/core/table/Table.kt +++ b/core/src/main/kotlin/io/spine/chords/core/table/Table.kt @@ -34,6 +34,8 @@ import androidx.compose.foundation.layout.Arrangement.Center import androidx.compose.foundation.layout.Arrangement.End import androidx.compose.foundation.layout.Arrangement.Horizontal import androidx.compose.foundation.layout.Arrangement.SpaceBetween +import androidx.compose.foundation.layout.Arrangement.Start +import androidx.compose.foundation.layout.Arrangement.Top import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -44,15 +46,13 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp @@ -61,11 +61,17 @@ import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -83,8 +89,10 @@ import androidx.compose.ui.input.pointer.PointerEventType.Companion.Exit import androidx.compose.ui.input.pointer.PointerIcon.Companion.Hand import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.spine.chords.core.Component +import io.spine.chords.core.styling.ChordsTheme import io.spine.chords.core.table.TableSortingDirection.ASCENDING import io.spine.chords.core.table.TableSortingDirection.DESCENDING @@ -215,14 +223,39 @@ public abstract class Table : Component() { /** * The padding applied to the entire content of the table. */ - protected var contentPadding: PaddingValues by mutableStateOf(PaddingValues(16.dp)) + public var contentPadding: PaddingValues by mutableStateOf(PaddingValues(16.dp)) /** * The color of the selected row. * - * The default value is `MaterialTheme.colorScheme.surfaceVariant`. + * The default value is `MaterialTheme.colorScheme.primaryContainer`. */ - protected var selectedRowColor: Color? by mutableStateOf(null) + public var selectedRowColor: Color? by mutableStateOf(null) + + /** + * The color of a row under the pointer, or `null` to use the theme default. + */ + public var hoveredRowColor: Color? by mutableStateOf(null) + + /** + * The table header background, or `null` to use the theme default. + */ + public var headerColor: Color? by mutableStateOf(null) + + /** + * The table background, or `null` to use the theme surface color. + */ + public var containerColor: Color? by mutableStateOf(null) + + /** + * The header height, or `null` to use the current Chords theme value. + */ + public var headerHeight: Dp? by mutableStateOf(null) + + /** + * The minimum data-row height, or `null` to use the current Chords theme value. + */ + public var rowHeight: Dp? by mutableStateOf(null) /** * Specifies the content to be displayed when the table has no entities. @@ -264,22 +297,46 @@ public abstract class Table : Component() { tableColumns.add(rowActionsColumn(rowActions!!, ::changeSelectedEntity)) } Column( - modifier = Modifier.fillMaxSize() - .padding(contentPadding), - verticalArrangement = Center, + modifier = Modifier + .fillMaxSize() + .background(containerColor ?: colorScheme.surface) + .padding(resolvedContentPadding()), + verticalArrangement = Top, ) { HeaderTableRow( columns = tableColumns, - sortingState = sortingState + sortingState = sortingState, + height = headerHeight ?: ChordsTheme.dimensions.tableHeaderHeight, + backgroundColor = headerColor ?: colorScheme.surfaceVariant ) - if (entities.isNotEmpty()) { - ContentList(sortedEntities, tableColumns) - } else { - EmptyContentList() + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1F) + ) { + if (entities.isNotEmpty()) { + ContentList(sortedEntities, tableColumns) + } else { + EmptyContentList() + } } } } + /** + * Resolves unchanged default table padding against the active theme. + * + * @return The padding to apply to the table content. + */ + @Composable + private fun resolvedContentPadding(): PaddingValues { + return if (contentPadding == PaddingValues(16.dp)) { + PaddingValues(ChordsTheme.dimensions.spacingLarge) + } else { + contentPadding + } + } + /** * Resolves the list of entities to render, according to the current sorting configuration. * @@ -302,22 +359,26 @@ public abstract class Table : Component() { columns: List> ) { val listState = rememberLazyListState() - if (selectedRowColor == null) { - selectedRowColor = colorScheme.surfaceVariant - } Box( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.fillMaxSize(), ) { LazyColumn( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.fillMaxSize(), state = listState ) { entities.forEach { value -> item { + val selected = isSelected(value) ContentTableRow( entity = value, columns = columns, - modifier = contentTableRowModifier(value) + modifier = contentTableRowModifier(value, selected), + selected = selected, + height = rowHeight ?: ChordsTheme.dimensions.tableRowHeight, + selectedColor = selectedRowColor ?: colorScheme.primaryContainer, + hoveredColor = hoveredRowColor ?: colorScheme.primary.copy( + alpha = ChordsTheme.interaction.hoveredStateAlpha + ) ) { changeSelectedEntity(value) } @@ -328,23 +389,32 @@ public abstract class Table : Component() { } } - private fun contentTableRowModifier(entity: E): Modifier { - val selectedEntityValue = selectedEntity.value - return if (selectedEntityValue != null && - extractEntityId(selectedEntityValue) == extractEntityId(entity) - ) { + private fun contentTableRowModifier(entity: E, selected: Boolean): Modifier { + return if (selected) { + val selectedEntityValue = checkNotNull(selectedEntity.value) if (entity != selectedEntityValue) { // Make sure that selected entity value is always up to date // with the `entities` list if it contains an updated // entity value. changeSelectedEntity(entity) } - rowModifier(entity).background(selectedRowColor!!) + rowModifier(entity) } else { rowModifier(entity) } } + /** + * Checks whether the given entity represents the currently selected row. + * + * @param entity The entity whose selection state should be checked. + * @return `true` if the entity represents the selected row. + */ + private fun isSelected(entity: E): Boolean { + val selectedEntityValue = selectedEntity.value ?: return false + return extractEntityId(selectedEntityValue) == extractEntityId(entity) + } + /** * Displays the empty state of the table. */ @@ -365,12 +435,12 @@ public abstract class Table : Component() { * * @param name The name of the column to be displayed in a header. * @param horizontalArrangement The horizontal arrangement of the column's content. - * The default value is `Arrangement.Center`. + * The default value is `Arrangement.Start`. * @param weight The proportional width to allocate to this column * relative to other columns. Must be positive. The default value is `1F` * meaning that if all columns have this `weight` value, their width is equal. * @param padding The padding values of each cell's content in this column. - * By default, no padding is applied. + * By default, compact horizontal padding is applied. * @param columnKey A stable identifier of the column used to keep track of the sorting state. * By default, the column [name] is used. * @param sorting Optional sorting configuration for this column. @@ -383,9 +453,9 @@ public abstract class Table : Component() { */ public data class TableColumn( val name: String, - val horizontalArrangement: Horizontal = Center, + val horizontalArrangement: Horizontal = Start, val weight: Float = 1F, - val padding: PaddingValues = PaddingValues(), + val padding: PaddingValues = PaddingValues(horizontal = 12.dp), val columnKey: Any = name, val sorting: TableColumnSorting? = null, val value: ((E) -> Comparable<*>?)?, @@ -399,9 +469,9 @@ public data class TableColumn( */ public constructor( name: String, - horizontalArrangement: Horizontal = Center, + horizontalArrangement: Horizontal = Start, weight: Float = 1F, - padding: PaddingValues = PaddingValues(), + padding: PaddingValues = PaddingValues(horizontal = 12.dp), columnKey: Any = name, sorting: TableColumnSorting? = null, cellContent: @Composable (E) -> Unit @@ -431,9 +501,9 @@ public data class TableColumn( public constructor( name: String, value: (E) -> Comparable<*>?, - horizontalArrangement: Horizontal = Center, + horizontalArrangement: Horizontal = Start, weight: Float = 1F, - padding: PaddingValues = PaddingValues(), + padding: PaddingValues = PaddingValues(horizontal = 12.dp), columnKey: Any = name, sorting: TableColumnSorting? = null ) : this( @@ -601,7 +671,8 @@ public class TableSortingState( private set /** - * Applies sorting for the given column or toggles the direction if the column is already sorted. + * Applies sorting for the given column or toggles the direction if the + * column is already sorted. * * If the column is not sortable, the state remains unchanged. */ @@ -714,7 +785,7 @@ private fun VerticalScrollBar( VerticalScrollbar( modifier = Modifier .fillMaxHeight() - .padding(vertical = 5.dp) + .padding(vertical = ChordsTheme.dimensions.spacingXSmall) .modifierExtender(), adapter = rememberScrollbarAdapter( scrollState = listState @@ -731,15 +802,21 @@ private fun VerticalScrollBar( * @param columns A list of column configuration objects * with information about headers. * @param sortingState The current interactive sorting state of the table. + * @param height The header row height. + * @param backgroundColor The header row background. */ @OptIn(ExperimentalComposeUiApi::class) @Composable private fun HeaderTableRow( columns: List>, sortingState: TableSortingState, + height: Dp, + backgroundColor: Color ) { TableRow( columns = columns, + height = height, + backgroundColor = backgroundColor, cellModifier = { column -> if (column.sortable) { Modifier @@ -789,7 +866,8 @@ private fun HeaderCell( ) { Text( text = column.name, - style = typography.titleMedium + style = typography.labelMedium, + color = colorScheme.onSurfaceVariant ) val direction = if (isSortable) { sortingState.directionFor(column) @@ -805,7 +883,7 @@ private fun HeaderCell( }, contentDescription = null, modifier = Modifier - .padding(start = 4.dp) + .padding(start = ChordsTheme.dimensions.spacingXSmall) .size(18.dp) .alpha(if (direction != null || isHovered) 1f else 0f), tint = colorScheme.onSurfaceVariant @@ -820,23 +898,40 @@ private fun HeaderCell( * @param columns A list of columns from which the row consists. * @param entity The entity to represent in a row. * @param modifier The [Modifier] to be applied to this row. + * @param selected Whether this row is selected. + * @param height The minimum row height. + * @param selectedColor The selected row background. + * @param hoveredColor The hovered row background. * @param onClick A callback that is triggered when a user clicks on a row. */ @Composable +@OptIn(ExperimentalComposeUiApi::class) +@Suppress("LongParameterList") // Row interaction states are supplied by the table look. private fun ContentTableRow( entity: E, columns: List>, modifier: Modifier, + selected: Boolean, + height: Dp, + selectedColor: Color, + hoveredColor: Color, onClick: () -> Unit ) { + var hovered by remember { mutableStateOf(false) } + val backgroundColor = when { + selected -> selectedColor + hovered -> hoveredColor + else -> Color.Transparent + } TableRow( columns = columns, + height = height, + backgroundColor = backgroundColor, modifier = Modifier .then(modifier) - .clickable( - interactionSource = MutableInteractionSource(), - indication = null, - ) { onClick() }, + .onPointerEvent(Enter) { hovered = true } + .onPointerEvent(Exit) { hovered = false } + .clickable(onClick = onClick), ) { column -> column.cellContent(entity) } } @@ -845,24 +940,30 @@ private fun ContentTableRow( * * @param columns A list of columns from which the row consists. * @param modifier The [Modifier] to be applied to this row. + * @param height The minimum height of the row. + * @param backgroundColor The row background. * @param cellModifier A callback that provides an additional [Modifier] * for each individual cell. * @param cellContent A callback that specifies what element to display * inside each cell of this column. */ @Composable +@Suppress("LongParameterList") // Keeps the shared header and content row layout consistent. private fun TableRow( columns: List>, modifier: Modifier = Modifier, + height: Dp, + backgroundColor: Color = Color.Transparent, cellModifier: (TableColumn) -> Modifier = { Modifier }, cellContent: @Composable (TableColumn) -> Unit ) { Row( modifier = Modifier .fillMaxWidth() - .requiredHeightIn(70.dp, 100.dp) + .heightIn(min = height) .height(Min) - .then(modifier), + .then(modifier) + .background(backgroundColor), horizontalArrangement = SpaceBetween, verticalAlignment = CenterVertically ) { @@ -875,7 +976,13 @@ private fun TableRow( .padding(column.padding), horizontalArrangement = column.horizontalArrangement, verticalAlignment = CenterVertically - ) { cellContent(column) } + ) { + CompositionLocalProvider(LocalContentColor provides colorScheme.onSurface) { + ProvideTextStyle(MaterialTheme.typography.bodyMedium) { + cellContent(column) + } + } + } } } Divider( @@ -902,6 +1009,7 @@ private fun rowActionsColumn( return TableColumn( name = "", horizontalArrangement = End, + weight = 0.2F, padding = rowActionsConfig.buttonPadding ) { entity -> val rowActionsVisible = remember { mutableStateOf(false) } @@ -940,7 +1048,7 @@ private fun RowActionsButton( onRowActionsClicked: (E) -> Unit, ) { IconButton( - modifier = Modifier.size(48.dp), + modifier = Modifier.size(ChordsTheme.dimensions.iconButtonSize), onClick = { onRowActionsClicked(entity) visibility.value = true @@ -991,7 +1099,9 @@ private fun RowActionsDropdown( it.onClick(value) }, enabled = it.enabled(value), - modifier = look.modifier, + modifier = look.modifier.heightIn( + min = ChordsTheme.dimensions.dropdownItemHeight + ), colors = MenuDefaults.itemColors( textColor = look.textColor ), diff --git a/core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt b/core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt new file mode 100644 index 00000000..a67ade49 --- /dev/null +++ b/core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt @@ -0,0 +1,103 @@ +/* + * 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.styling + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.spine.chords.core.layout.TestScene +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * Tests the public Chords theme contract. + */ +@DisplayName("`ChordsTheme` should") +internal class ChordsThemeSpec { + + /** + * Theme inputs supplied by an application must reach all descendants. + */ + @Test + fun `install application overrides`() { + val expectedDimensions = ChordsDimensions( + controlHeight = 48.dp, + supportingPaneWidth = 420.dp + ) + val expectedInteraction = ChordsInteraction(hoveredStateAlpha = 0.12f) + val expectedScheme = chordsDarkColorScheme() + lateinit var observedDimensions: ChordsDimensions + lateinit var observedInteraction: ChordsInteraction + var observedPrimary = Color.Unspecified + + TestScene { + ChordsTheme( + colorScheme = expectedScheme, + dimensions = expectedDimensions, + interaction = expectedInteraction + ) { + observedDimensions = ChordsTheme.dimensions + observedInteraction = ChordsTheme.interaction + observedPrimary = MaterialTheme.colorScheme.primary + } + }.use { } + + observedDimensions shouldBe expectedDimensions + observedInteraction shouldBe expectedInteraction + observedPrimary shouldBe expectedScheme.primary + } + + /** + * The default density must remain compact enough for desktop forms and + * data grids. + */ + @Test + fun `provide compact desktop dimensions`() { + val dimensions = ChordsDimensions() + + dimensions.controlHeight shouldBe 44.dp + dimensions.dropdownItemHeight shouldBe 40.dp + dimensions.tableRowHeight shouldBe 40.dp + dimensions.navigationItemHeight shouldBe 40.dp + } + + /** + * Light and dark palettes must provide distinct semantic surfaces and + * accents instead of tinting a single palette. + */ + @Test + fun `provide distinct light and dark palettes`() { + val light = chordsLightColorScheme() + val dark = chordsDarkColorScheme() + + light.background shouldNotBe dark.background + light.surface shouldNotBe dark.surface + light.primary shouldNotBe dark.primary + } +} diff --git a/core/src/test/kotlin/io/spine/chords/core/table/TableColumnSpec.kt b/core/src/test/kotlin/io/spine/chords/core/table/TableColumnSpec.kt index 4fed2fe2..34dafd3c 100644 --- a/core/src/test/kotlin/io/spine/chords/core/table/TableColumnSpec.kt +++ b/core/src/test/kotlin/io/spine/chords/core/table/TableColumnSpec.kt @@ -26,12 +26,27 @@ package io.spine.chords.core.table +import androidx.compose.foundation.layout.Arrangement.Start +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import io.kotest.matchers.shouldBe import io.spine.chords.core.table.TableSortingDirection.DESCENDING import org.junit.jupiter.api.Test internal class TableColumnSpec { + /** + * Default columns should follow compact desktop table alignment and inset. + */ + @Test + fun `use compact left-aligned layout by default`() { + val column = TableColumn(name = "Name") { } + + column.horizontalArrangement shouldBe Start + column.padding.calculateLeftPadding(LayoutDirection.Ltr) shouldBe 12.dp + column.padding.calculateRightPadding(LayoutDirection.Ltr) shouldBe 12.dp + } + @Test fun `store the extracted column value`() { val column = valuedColumn() diff --git a/proto/src/main/kotlin/io/spine/chords/proto/money/MoneyField.kt b/proto/src/main/kotlin/io/spine/chords/proto/money/MoneyField.kt index 57fa7caa..001b3aef 100644 --- a/proto/src/main/kotlin/io/spine/chords/proto/money/MoneyField.kt +++ b/proto/src/main/kotlin/io/spine/chords/proto/money/MoneyField.kt @@ -32,7 +32,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuDefaults.TrailingIcon -import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable @@ -51,9 +51,11 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily.Companion.Monospace import androidx.compose.ui.text.font.FontWeight.Companion.SemiBold import androidx.compose.ui.text.input.getSelectedText +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.spine.chords.core.ComponentSetup import io.spine.chords.core.DropdownListBox @@ -65,6 +67,7 @@ import io.spine.chords.core.InputField import io.spine.chords.core.InputReviser import io.spine.chords.core.RawTextContent import io.spine.chords.core.exceptionBasedParser +import io.spine.chords.core.styling.ChordsTheme import io.spine.money.Currency import io.spine.money.Currency.CURRENCY_UNDEFINED import io.spine.money.Currency.UNRECOGNIZED @@ -113,6 +116,11 @@ public class MoneyField : InputField() { */ private var selectedCurrency by mutableStateOf(defaultCurrency) + /** + * Width reserved for a currency code in the dropdown list. + */ + public var currencyCodeWidth: Dp by mutableStateOf(56.dp) + init { inputReviser = MoneyFieldReviser(defaultCurrency) } @@ -122,9 +130,17 @@ public class MoneyField : InputField() { override fun beforeComposeContent() { super.beforeComposeContent() inputReviser = MoneyFieldReviser(selectedCurrency) - textStyle = LocalTextStyle.current.copy(fontFamily = Monospace) } + /** + * Uses a monospaced font for aligned monetary input by default. + */ + @Composable + @ReadOnlyComposable + override fun defaultTextStyle(): TextStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = Monospace + ) + override fun parseValue(rawText: String): Money = exceptionBasedParser( IllegalArgumentException::class, "Invalid format" @@ -150,16 +166,22 @@ public class MoneyField : InputField() { noneItemEnabled = false itemContent = { Row( - modifier = Modifier.padding(horizontal = 12.dp), + modifier = Modifier.padding( + horizontal = ChordsTheme.dimensions.spacingMedium + ), verticalAlignment = CenterVertically ) { Text( text = it.name, - modifier = Modifier.width(50.dp), + modifier = Modifier.width(currencyCodeWidth), fontWeight = SemiBold ) Text( - modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + modifier = Modifier.padding( + start = ChordsTheme.dimensions.spacingSmall, + top = ChordsTheme.dimensions.spacingXSmall, + bottom = ChordsTheme.dimensions.spacingXSmall + ), text = it.options.name ) } diff --git a/proto/src/main/kotlin/io/spine/chords/proto/money/PaymentMethodEditor.kt b/proto/src/main/kotlin/io/spine/chords/proto/money/PaymentMethodEditor.kt index 6a634508..aeeba41c 100644 --- a/proto/src/main/kotlin/io/spine/chords/proto/money/PaymentMethodEditor.kt +++ b/proto/src/main/kotlin/io/spine/chords/proto/money/PaymentMethodEditor.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * 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. @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.spine.chords.core.ComponentSetup import io.spine.chords.core.ValidationErrorText +import io.spine.chords.core.styling.ChordsTheme import io.spine.chords.proto.form.CustomMessageForm import io.spine.chords.proto.form.FormPartScope import io.spine.chords.proto.form.OneofRadioButton @@ -71,27 +72,36 @@ public class PaymentMethodEditor : CustomMessageForm( * the controls within the component. */ public data class Look( - public var interFieldPadding: Dp = 40.dp, + public var interFieldPadding: Dp = 24.dp, public var selectorsOffset: Dp = 8.dp, public var optionalCheckboxOffset: Dp = 16.dp ) @Composable override fun FormPartScope.customContent() { + val currentLook = if (look == Look()) { + Look( + interFieldPadding = ChordsTheme.dimensions.spacingXLarge, + selectorsOffset = ChordsTheme.dimensions.spacingSmall, + optionalCheckboxOffset = ChordsTheme.dimensions.spacingLarge + ) + } else { + look + } Column { if (!required) { Row { OptionalMessageCheckbox("Specify payment method") } - Row(modifier = Modifier.height(look.optionalCheckboxOffset)) {} + Row(modifier = Modifier.height(currentLook.optionalCheckboxOffset)) {} } OneOfFields(method) { - Row(horizontalArrangement = spacedBy(look.interFieldPadding)) { - Column(verticalArrangement = spacedBy(look.selectorsOffset)) { + Row(horizontalArrangement = spacedBy(currentLook.interFieldPadding)) { + Column(verticalArrangement = spacedBy(currentLook.selectorsOffset)) { OneofRadioButton(paymentCard, "Payment card") PaymentCardNumberField(paymentCard) } - Column(verticalArrangement = spacedBy(look.selectorsOffset)) { + Column(verticalArrangement = spacedBy(currentLook.selectorsOffset)) { OneofRadioButton(bankAccount, "Bank Account") BankAccountField(bankAccount) } diff --git a/proto/src/main/kotlin/io/spine/chords/proto/time/DateTimeField.kt b/proto/src/main/kotlin/io/spine/chords/proto/time/DateTimeField.kt index 684ecd3c..54b949e2 100644 --- a/proto/src/main/kotlin/io/spine/chords/proto/time/DateTimeField.kt +++ b/proto/src/main/kotlin/io/spine/chords/proto/time/DateTimeField.kt @@ -34,7 +34,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable @@ -68,6 +68,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily.Companion.Monospace import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText @@ -88,6 +89,8 @@ import io.spine.chords.core.InputReviser.Companion.maxLength import io.spine.chords.core.RawTextContent import io.spine.chords.core.ParseException import io.spine.chords.core.layout.WithTooltip +import io.spine.chords.core.styling.ChordsInteraction +import io.spine.chords.core.styling.ChordsTheme import io.spine.chords.core.time.WallClock import io.spine.chords.proto.value.time.DefaultDatePattern import java.time.Instant @@ -106,16 +109,6 @@ private const val DefaultDateTimeFormat = "$DefaultDatePattern HH:mm" */ private const val NowButtonDescription = "Set to the current date and time" -/** - * The opacity of the "now" button's state layer while it is hovered. - */ -private const val HoveredStateLayerAlpha = 0.08f - -/** - * The opacity of the "now" button's state layer while it is pressed. - */ -private const val PressedStateLayerAlpha = 0.12f - /** * The overall size of the "now" button (its round state layer). * @@ -204,7 +197,6 @@ public class DateTimeField : InputField() { override fun beforeComposeContent() { super.beforeComposeContent() inputReviser = DateTimeFieldReviser(dateTimePattern) - textStyle = LocalTextStyle.current.copy(fontFamily = Monospace) val secondaryColor = colorScheme.secondary visualTransformation = VisualTransformation { complementWithPattern( @@ -225,6 +217,15 @@ public class DateTimeField : InputField() { } } + /** + * Uses a monospaced font for aligned date and time input by default. + */ + @Composable + @ReadOnlyComposable + override fun defaultTextStyle(): TextStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = Monospace + ) + override fun handleKeyEvent(keyEvent: KeyEvent): Boolean { if (nowOptionEnabled && enabled && keyEvent matches Ctrl(Key.N.key).down) { fillNow() @@ -285,7 +286,11 @@ private fun NowButton(enabled: Boolean, onClick: () -> Unit) { var pressed by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) } val stateLayerColor = nowButtonStateLayerColor( - enabled, pressed, hovered || focused, colorScheme.onSurface + enabled = enabled, + pressed = pressed, + active = hovered || focused, + baseColor = colorScheme.onSurface, + interaction = ChordsTheme.interaction ) WithTooltip(tooltip = NowButtonDescription) { Box( @@ -323,11 +328,12 @@ private fun nowButtonStateLayerColor( enabled: Boolean, pressed: Boolean, active: Boolean, - baseColor: Color + baseColor: Color, + interaction: ChordsInteraction ): Color = when { !enabled -> Color.Transparent - pressed -> baseColor.copy(alpha = PressedStateLayerAlpha) - active -> baseColor.copy(alpha = HoveredStateLayerAlpha) + pressed -> baseColor.copy(alpha = interaction.pressedStateAlpha) + active -> baseColor.copy(alpha = interaction.hoveredStateAlpha) else -> Color.Transparent } From 4b125bc195c7a2fc62929a9592fc8668678d4c63 Mon Sep 17 00:00:00 2001 From: Oleg-Melnik Date: Tue, 11 Aug 2026 18:21:52 +0300 Subject: [PATCH 2/3] =?UTF-8?q?Bump=20version=20=E2=80=94>=20`2.0.0-SNAPSH?= =?UTF-8?q?OT.110`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 f9c48f96..a14c31a1 100644 --- a/dependencies.md +++ b/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.chords:spine-chords-client:2.0.0-SNAPSHOT.109` +# Dependencies of `io.spine.chords:spine-chords-client:2.0.0-SNAPSHOT.110` ## 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 **Fri Aug 07 21:18:24 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 **Tue Aug 11 15:53:45 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.109` +# Dependencies of `io.spine.chords:spine-chords-codegen-tests:2.0.0-SNAPSHOT.110` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -1899,12 +1899,12 @@ This report was generated on **Fri Aug 07 21:18:24 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Aug 07 21:18:26 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 **Tue Aug 11 15:53:47 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.109` +# Dependencies of `io.spine.chords:spine-chords-core:2.0.0-SNAPSHOT.110` ## Runtime 1. **Group** : cafe.adriel.voyager. **Name** : voyager-core. **Version** : 1.0.1. @@ -2938,12 +2938,12 @@ This report was generated on **Fri Aug 07 21:18:26 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Aug 07 21:18:28 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 **Tue Aug 11 15:53:48 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.109` +# Dependencies of `io.spine.chords:spine-chords-proto:2.0.0-SNAPSHOT.110` ## 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 **Fri Aug 07 21:18:28 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Aug 07 21:18:29 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 **Tue Aug 11 15:53:49 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.109` +# Dependencies of `io.spine.chords:spine-chords-proto-values:2.0.0-SNAPSHOT.110` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -4775,12 +4775,12 @@ This report was generated on **Fri Aug 07 21:18:29 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Aug 07 21:18:30 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 **Tue Aug 11 15:53:50 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.109` +# Dependencies of `io.spine.chords:spine-chords-runtime:2.0.0-SNAPSHOT.110` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -5544,4 +5544,4 @@ This report was generated on **Fri Aug 07 21:18:30 EEST 2026** using [Gradle-Lic The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Aug 07 21:18:31 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 **Tue Aug 11 15:53:51 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 695babe5..10323f3f 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.109 +2.0.0-SNAPSHOT.110 2015 diff --git a/version.gradle.kts b/version.gradle.kts index d1bf6c3e..65f83421 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.109") +val chordsVersion: String by extra("2.0.0-SNAPSHOT.110") From 3c20cf7d0a982d6b9c9b336216c83118260a37d9 Mon Sep 17 00:00:00 2001 From: Oleg-Melnik Date: Wed, 12 Aug 2026 03:01:51 +0300 Subject: [PATCH 3/3] Address review feedback for the default look and feel. --- core/README.md | 5 +- .../io/spine/chords/core/DropdownListBox.kt | 8 +- .../io/spine/chords/core/DropdownSelector.kt | 43 ++--- .../kotlin/io/spine/chords/core/InputField.kt | 38 ++-- .../chords/core/appshell/NavigationDrawer.kt | 88 ++++----- .../io/spine/chords/core/appshell/README.md | 4 +- .../io/spine/chords/core/appshell/TopBar.kt | 86 +++++---- .../io/spine/chords/core/layout/Dialog.kt | 78 +++++--- .../io/spine/chords/core/layout/WindowType.kt | 120 +++++++++---- .../spine/chords/core/layout/WithTooltip.kt | 4 + .../io/spine/chords/core/layout/Wizard.kt | 53 ++++-- .../chords/core/primitive/CheckboxWithText.kt | 6 + .../spine/chords/core/styling/ChordsTheme.kt | 116 +++++++----- .../io/spine/chords/core/table/Table.kt | 167 +++++++++++++----- .../io/spine/chords/core/layout/DialogSpec.kt | 32 ++++ .../chords/core/styling/ChordsThemeSpec.kt | 1 + .../io/spine/chords/proto/money/MoneyField.kt | 4 +- .../chords/proto/money/PaymentMethodEditor.kt | 45 +++-- .../spine/chords/proto/time/DateTimeField.kt | 4 +- 19 files changed, 585 insertions(+), 317 deletions(-) diff --git a/core/README.md b/core/README.md index 74cd11a8..4cd4331f 100644 --- a/core/README.md +++ b/core/README.md @@ -9,7 +9,10 @@ Chords applications use a compact Material 3 desktop theme by default. It provides neutral work surfaces, semantic light and dark color schemes, restrained corner radii, compact typography, and shared dimensions for common controls, navigation, tables, dialogs, and supporting panes. The dark scheme is -selected automatically when the operating system uses dark appearance. +selected from the operating system appearance observed at application startup. +Changes to the system appearance while the application is running are not +observed automatically; override `ApplicationTheme` when the application needs +a live theme switch. The standard Material values are available through `MaterialTheme`. Desktop values that Material does not define are available through diff --git a/core/src/main/kotlin/io/spine/chords/core/DropdownListBox.kt b/core/src/main/kotlin/io/spine/chords/core/DropdownListBox.kt index 1ceedcba..b1d2dae7 100644 --- a/core/src/main/kotlin/io/spine/chords/core/DropdownListBox.kt +++ b/core/src/main/kotlin/io/spine/chords/core/DropdownListBox.kt @@ -902,6 +902,8 @@ public class DropdownListBox : Component() { @Composable @Suppress("LongMethod") // Keeps selection ordering and item rendering in one list pass. private fun BoxScope.DropdownListContent() { + val resolvedItemMinHeight = + itemMinHeight ?: ChordsTheme.dimensions.dropdownItemHeight Column( modifier = Modifier .width(MinWidth) @@ -923,7 +925,7 @@ public class DropdownListBox : Component() { } else { null }, - itemMinHeight = itemMinHeight ?: ChordsTheme.dimensions.dropdownItemHeight, + itemMinHeight = resolvedItemMinHeight, onMeasureHeight = { measuredHeight -> noneItemHeight = measuredHeight }, @@ -955,14 +957,14 @@ public class DropdownListBox : Component() { itemHeights[index] = measuredHeight }, color = color, - itemMinHeight = itemMinHeight ?: ChordsTheme.dimensions.dropdownItemHeight + itemMinHeight = resolvedItemMinHeight ) { itemContent(item) } } } else { DropdownListNoItems( - itemMinHeight = itemMinHeight ?: ChordsTheme.dimensions.dropdownItemHeight, + itemMinHeight = resolvedItemMinHeight, content = noItemsContent ) } diff --git a/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt b/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt index 610593f3..5875aa7a 100644 --- a/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt +++ b/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt @@ -37,6 +37,7 @@ import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuDefaults.TrailingIcon import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.OutlinedTextField @@ -132,16 +133,14 @@ public abstract class DropdownSelector : InputComponent() { /** * A [TextFieldColors] instance, which defines the color scheme for - * the selector's field. If it is not assigned, colors are resolved from - * the current theme on every composition. + * the selector's field. + * + * When left unassigned, colors are resolved from the current theme during + * composition without initializing this property. Therefore, reading it is + * only valid after assigning a custom value; use + * `::fieldColors.isInitialized` to distinguish that case. */ - public var fieldColors: TextFieldColors - get() = customFieldColors ?: checkNotNull(currentThemeFieldColors) { - "The dropdown selector has not been composed and has no custom colors." - } - set(value) { - customFieldColors = value - } + public lateinit var fieldColors: TextFieldColors /** * A text style for the selector field, or `null` to use the theme default. @@ -177,22 +176,12 @@ public abstract class DropdownSelector : InputComponent() { */ private var dirty = false - /** - * A component-specific colors override, or `null` while theme colors apply. - */ - private var customFieldColors: TextFieldColors? by mutableStateOf(null) - - /** - * The most recently composed theme colors, retained for property reads. - */ - private var currentThemeFieldColors: TextFieldColors? = null - /** * The field's text style based on whether a drop-down item * is selected or not. */ private val fieldTextStyle: TextStyle @Composable get() { - return textStyle ?: MaterialTheme.typography.bodyMedium + return textStyle ?: LocalTextStyle.current } /** @@ -224,8 +213,11 @@ public abstract class DropdownSelector : InputComponent() { @Composable override fun content(): Unit = recompositionWorkaround { - currentThemeFieldColors = OutlinedTextFieldDefaults.colors() - val selectorColors = customFieldColors ?: checkNotNull(currentThemeFieldColors) + val selectorColors = if (::fieldColors.isInitialized) { + fieldColors + } else { + OutlinedTextFieldDefaults.colors() + } val fieldText = getFieldText(searchString) SideEffect { @@ -255,11 +247,6 @@ public abstract class DropdownSelector : InputComponent() { @OptIn(ExperimentalComposeUiApi::class) private fun DropdownListBoxScope.SelectorField(selectorColors: TextFieldColors) { val validationErrorText = externalValidationMessage?.value - if (validationErrorText == null) { - SideEffect { - adjustPositionBasedOnSupportingTextHeight(0) - } - } OutlinedTextField( value = TextFieldValue(getFieldText(searchString), selection), singleLine = true, @@ -267,7 +254,7 @@ public abstract class DropdownSelector : InputComponent() { enabled = enabled, label = { Text(text = label) }, isError = validationErrorText != null, - supportingText = validationErrorText?.let { + supportingText = (validationErrorText ?: "").let { { Text( text = it, diff --git a/core/src/main/kotlin/io/spine/chords/core/InputField.kt b/core/src/main/kotlin/io/spine/chords/core/InputField.kt index 3ebc7686..a3d0b40c 100644 --- a/core/src/main/kotlin/io/spine/chords/core/InputField.kt +++ b/core/src/main/kotlin/io/spine/chords/core/InputField.kt @@ -29,6 +29,7 @@ package io.spine.chords.core import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults @@ -318,16 +319,14 @@ public open class InputField : InputComponent() { /** * A [TextFieldColors] instance, which defines the color scheme for - * this field. If it is not assigned, colors are resolved from the current - * theme on every composition. + * this field. + * + * When left unassigned, colors are resolved from the current theme during + * composition without initializing this property. Therefore, reading it is + * only valid after assigning a custom value; use `::colors.isInitialized` + * to distinguish that case. */ - public var colors: TextFieldColors - get() = customColors ?: checkNotNull(currentThemeColors) { - "The input field has not been composed and has no custom colors." - } - set(value) { - customColors = value - } + public lateinit var colors: TextFieldColors /** * An [InputReviser] that can be specified to modify the user's input before @@ -335,16 +334,6 @@ public open class InputField : InputComponent() { */ protected open var inputReviser: InputReviser? by mutableStateOf(null) - /** - * A component-specific colors override, or `null` while theme colors apply. - */ - private var customColors: TextFieldColors? by mutableStateOf(null) - - /** - * The most recently composed theme colors, retained for property reads. - */ - private var currentThemeColors: TextFieldColors? = null - /** * Transforms the raw text typed in by the user to form a text displayed in * the field (without affecting how the text is stored in raw text, or @@ -476,8 +465,11 @@ public open class InputField : InputComponent() { @Composable override fun content(): Unit = recompositionWorkaround { - currentThemeColors = OutlinedTextFieldDefaults.colors() - val fieldColors = customColors ?: checkNotNull(currentThemeColors) + val fieldColors = if (::colors.isInitialized) { + colors + } else { + OutlinedTextFieldDefaults.colors() + } val textStyle = textStyle ?: defaultTextStyle() val rawTextContent = getRawTextContent() @@ -490,7 +482,7 @@ public open class InputField : InputComponent() { value = rawTextContent, label = label?.let { { Text(text = it) } }, isError = validationErrorText != null, - supportingText = validationErrorText?.let { + supportingText = (validationErrorText ?: "").let { { supportingText(it) } @@ -533,7 +525,7 @@ public open class InputField : InputComponent() { */ @Composable @ReadOnlyComposable - protected open fun defaultTextStyle(): TextStyle = MaterialTheme.typography.bodyMedium + protected open fun defaultTextStyle(): TextStyle = LocalTextStyle.current override fun clear() { super.clear() diff --git a/core/src/main/kotlin/io/spine/chords/core/appshell/NavigationDrawer.kt b/core/src/main/kotlin/io/spine/chords/core/appshell/NavigationDrawer.kt index c3bcec3b..b294d4ed 100644 --- a/core/src/main/kotlin/io/spine/chords/core/appshell/NavigationDrawer.kt +++ b/core/src/main/kotlin/io/spine/chords/core/appshell/NavigationDrawer.kt @@ -26,7 +26,11 @@ package io.spine.chords.core.appshell +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -40,8 +44,6 @@ import androidx.compose.material3.PermanentNavigationDrawer import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import cafe.adriel.voyager.navigator.CurrentScreen @@ -61,54 +63,54 @@ public fun NavigationDrawer( topPadding: Dp, modifier: Modifier = Modifier, ) { - val dividerColor = MaterialTheme.colorScheme.outlineVariant PermanentNavigationDrawer( modifier = Modifier.padding(top = topPadding), drawerContent = { - PermanentDrawerSheet( - modifier = modifier - .width(ChordsTheme.dimensions.navigationWidth) - .drawBehind { - val thickness = 1.dp.toPx() - val x = size.width - thickness / 2 - drawLine( - color = dividerColor, - start = Offset(x, 0f), - end = Offset(x, size.height), - strokeWidth = thickness - ) - }, - drawerContainerColor = MaterialTheme.colorScheme.surface + Row( + modifier = modifier.width(ChordsTheme.dimensions.navigationWidth) ) { - Spacer(modifier = Modifier.height(ChordsTheme.dimensions.spacingSmall)) - appViews.forEach { view -> - NavigationDrawerItem( - icon = { Icon(view.icon, contentDescription = null) }, - label = { Text(view.name) }, - selected = app.ui.currentView == view, - onClick = { app.ui.select(view) }, - modifier = Modifier - .padding( - horizontal = ChordsTheme.dimensions.spacingSmall, - vertical = ChordsTheme.dimensions.spacingXSmall + PermanentDrawerSheet( + modifier = Modifier + .weight(1F) + .fillMaxHeight(), + drawerContainerColor = MaterialTheme.colorScheme.surface + ) { + Spacer(modifier = Modifier.height(ChordsTheme.dimensions.spacingSmall)) + appViews.forEach { view -> + NavigationDrawerItem( + icon = { Icon(view.icon, contentDescription = null) }, + label = { Text(view.name) }, + selected = app.ui.currentView == view, + onClick = { app.ui.select(view) }, + modifier = Modifier + .padding( + horizontal = ChordsTheme.dimensions.spacingSmall, + vertical = ChordsTheme.dimensions.spacingXSmall + ) + .heightIn(min = ChordsTheme.dimensions.navigationItemHeight), + shape = MaterialTheme.shapes.small, + colors = NavigationDrawerItemDefaults.colors( + selectedContainerColor = + MaterialTheme.colorScheme.primaryContainer, + selectedIconColor = + MaterialTheme.colorScheme.onPrimaryContainer, + selectedTextColor = + MaterialTheme.colorScheme.onPrimaryContainer, + unselectedContainerColor = MaterialTheme.colorScheme.surface, + unselectedIconColor = + MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = + MaterialTheme.colorScheme.onSurfaceVariant ) - .heightIn(min = ChordsTheme.dimensions.navigationItemHeight), - shape = MaterialTheme.shapes.small, - colors = NavigationDrawerItemDefaults.colors( - selectedContainerColor = - MaterialTheme.colorScheme.primaryContainer, - selectedIconColor = - MaterialTheme.colorScheme.onPrimaryContainer, - selectedTextColor = - MaterialTheme.colorScheme.onPrimaryContainer, - unselectedContainerColor = MaterialTheme.colorScheme.surface, - unselectedIconColor = - MaterialTheme.colorScheme.onSurfaceVariant, - unselectedTextColor = - MaterialTheme.colorScheme.onSurfaceVariant ) - ) + } } + Box( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant) + ) } }, content = { CurrentScreen() } diff --git a/core/src/main/kotlin/io/spine/chords/core/appshell/README.md b/core/src/main/kotlin/io/spine/chords/core/appshell/README.md index 2de29725..8ff80c75 100644 --- a/core/src/main/kotlin/io/spine/chords/core/appshell/README.md +++ b/core/src/main/kotlin/io/spine/chords/core/appshell/README.md @@ -22,7 +22,9 @@ See the details in the [Application](Application.kt) and `Application` installs the compact Chords Material 3 theme around all window and dialog content. Override `ApplicationTheme` to replace its color scheme, typography, shapes, desktop dimensions, or interaction values. The default -theme follows the operating system's light or dark appearance. +theme selects its initial light or dark palette from the operating system +appearance. It does not observe later system appearance changes; override +`ApplicationTheme` when the application needs a live theme switch. ### Standard application view layout diff --git a/core/src/main/kotlin/io/spine/chords/core/appshell/TopBar.kt b/core/src/main/kotlin/io/spine/chords/core/appshell/TopBar.kt index f29cffcc..25336cc9 100644 --- a/core/src/main/kotlin/io/spine/chords/core/appshell/TopBar.kt +++ b/core/src/main/kotlin/io/spine/chords/core/appshell/TopBar.kt @@ -26,20 +26,23 @@ package io.spine.chords.core.appshell +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Divider import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp import io.spine.chords.core.Component import io.spine.chords.core.styling.ChordsTheme @@ -49,7 +52,6 @@ import io.spine.chords.core.styling.ChordsTheme * * @param modifier A [Modifier] for this component. */ -@OptIn(ExperimentalMaterial3Api::class) public class TopBar(private val modifier: Modifier = Modifier) : Component() { /** @@ -62,40 +64,46 @@ public class TopBar(private val modifier: Modifier = Modifier) : Component() { @Composable protected override fun content() { - val dividerColor = MaterialTheme.colorScheme.outlineVariant - TopAppBar( - title = { - Text( - app.name, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - }, - actions = actions, + Surface( modifier = modifier .fillMaxWidth() - .height(ChordsTheme.dimensions.appBarHeight) - .drawBehind { - val thickness = 1.dp.toPx() - val y = size.height - thickness / 2 - drawLine( - color = dividerColor, - start = Offset(0f, y), - end = Offset(size.width, y), - strokeWidth = thickness + .height(ChordsTheme.dimensions.appBarHeight), + color = MaterialTheme.colorScheme.surface + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1F) + .padding( + start = ChordsTheme.dimensions.spacingMedium, + end = ChordsTheme.dimensions.spacingSmall + ), + verticalAlignment = CenterVertically + ) { + Text( + app.name, + modifier = Modifier.weight(1F), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface ) - }, - windowInsets = WindowInsets( - left = ChordsTheme.dimensions.spacingMedium, - top = 0.dp, - right = ChordsTheme.dimensions.spacingSmall, - bottom = 0.dp - ), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant + ) { + Row( + horizontalArrangement = spacedBy( + ChordsTheme.dimensions.spacingXSmall + ), + verticalAlignment = CenterVertically, + content = actions + ) + } + } + Divider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + } } } 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 2aa1b1f7..c415d445 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 @@ -64,6 +64,7 @@ import io.spine.chords.core.keyboard.KeyModifiers.Companion.Ctrl import io.spine.chords.core.keyboard.key import io.spine.chords.core.layout.WindowType.DesktopWindow import io.spine.chords.core.styling.ChordsTheme +import io.spine.chords.core.styling.defaultDimensions /** * A shortcut (key combination), which invokes dialog submission. @@ -285,6 +286,10 @@ public abstract class Dialog : Component() { /** * Specifies appearance-related parameters. + * + * Each value left at its [Look] default follows the corresponding Chords + * theme token. Customized values take precedence independently, so changing + * one value does not detach the other values from the active theme. */ public var look: Look = Look() @@ -310,44 +315,65 @@ public abstract class Dialog : Component() { /** * An object allowing adjustments of visual appearance parameters. * - * @param padding The padding applied to the entire content of the dialog. - * @param titlePadding The padding applied to the title of the dialog. - * @param buttonsPanelPadding The padding applied to the buttons panel of - * the dialog. - * @param buttonsSpacing The space between the buttons of the dialog. + * These constructor defaults use the default Chords spacing scale. When a + * [Look] is assigned to [Dialog.look], each unchanged value follows the + * corresponding token from the active theme. + * + * @property padding The padding applied to the entire content of the dialog. + * Defaults to `24.dp` on every side. + * @property titlePadding The padding applied to the title of the dialog. + * Defaults to `16.dp` at the bottom and zero on the other sides. + * @property buttonsPanelPadding The padding applied to the buttons panel of + * the dialog. Defaults to `24.dp` at the top and zero on the other sides. + * @property buttonsSpacing The space between the buttons of the dialog. + * Defaults to `12.dp`. */ public data class Look( - public var padding: PaddingValues = PaddingValues(24.dp), - public var titlePadding: PaddingValues = PaddingValues(bottom = 16.dp), - public var buttonsPanelPadding: PaddingValues = PaddingValues(top = 24.dp), - public var buttonsSpacing: Dp = 12.dp + public var padding: PaddingValues = PaddingValues(defaultDimensions.spacingXLarge), + public var titlePadding: PaddingValues = PaddingValues( + bottom = defaultDimensions.spacingLarge + ), + public var buttonsPanelPadding: PaddingValues = PaddingValues( + top = defaultDimensions.spacingXLarge + ), + public var buttonsSpacing: Dp = defaultDimensions.spacingMedium ) /** * Resolves the dialog look against the active theme. * - * An unchanged default [Look] follows the global Chords spacing scale. A - * customized look retains its component-specific values and takes - * precedence over the theme. + * Values left at their [Look] defaults follow the global Chords spacing + * scale, while customized values take precedence independently. * * @return The appearance values to use for the current composition. */ @Composable internal fun resolvedLook(): Look { - return if (look == Look()) { - Look( - padding = PaddingValues(ChordsTheme.dimensions.spacingXLarge), - titlePadding = PaddingValues( - bottom = ChordsTheme.dimensions.spacingLarge - ), - buttonsPanelPadding = PaddingValues( - top = ChordsTheme.dimensions.spacingXLarge - ), - buttonsSpacing = ChordsTheme.dimensions.spacingMedium - ) - } else { - look - } + val defaultLook = Look() + return look.copy( + padding = if (look.padding == defaultLook.padding) { + PaddingValues(ChordsTheme.dimensions.spacingXLarge) + } else { + look.padding + }, + titlePadding = if (look.titlePadding == defaultLook.titlePadding) { + PaddingValues(bottom = ChordsTheme.dimensions.spacingLarge) + } else { + look.titlePadding + }, + buttonsPanelPadding = if ( + look.buttonsPanelPadding == defaultLook.buttonsPanelPadding + ) { + PaddingValues(top = ChordsTheme.dimensions.spacingXLarge) + } else { + look.buttonsPanelPadding + }, + buttonsSpacing = if (look.buttonsSpacing == defaultLook.buttonsSpacing) { + ChordsTheme.dimensions.spacingMedium + } else { + look.buttonsSpacing + } + ) } /** diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt b/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt index 18aaa0f6..4797aac8 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt @@ -107,20 +107,34 @@ public sealed class WindowType { * as a separate desktop window. * * @param resizable Specifies whether the window can be resized by the user. - * @param containerColor The content background, or [Unspecified] to use the - * current Material surface color. */ - public open class DesktopWindow( - public val resizable: Boolean = false, - public val containerColor: Color = Unspecified - ) : WindowType() { + public open class DesktopWindow(public val resizable: Boolean = false) : WindowType() { /** - * Creates a desktop dialog window with the theme surface color. + * The content background, or [Unspecified] to use the current Material + * surface color. + */ + public val containerColor: Color + get() = customContainerColor + + /** + * Stores the content background supplied to the extended constructor. + */ + private var customContainerColor: Color = Unspecified + + /** + * Creates a desktop window with a configurable content background. * * @param resizable Specifies whether the window can be resized. + * @param containerColor The content background, or [Unspecified] to use + * the current Material surface color. */ - public constructor(resizable: Boolean) : this(resizable, Unspecified) + public constructor( + resizable: Boolean, + containerColor: Color + ) : this(resizable) { + customContainerColor = containerColor + } @Composable override fun dialogWindow(dialog: Dialog) { @@ -249,36 +263,82 @@ public sealed class WindowType { * @param backdropColor The color of the surface that covers the entire * content of the current desktop window behind the dialog's modal popup * displayed in this window, or [Unspecified] to use the theme scrim. - * @param containerColor The dialog surface, or [Unspecified] to use the - * current Material surface color. - * @param shape The dialog shape, or `null` to use the current Material - * large shape. - * @param shadowElevation The dialog shadow elevation. - * @param borderColor The dialog border, or [Unspecified] to use the - * current Material outline variant. */ - @Suppress("LongParameterList") // These are independent visual override points. public open class LightweightWindow( - public val backdropColor: Color = Unspecified, - public val containerColor: Color = Unspecified, - public val shape: Shape? = null, - public val shadowElevation: Dp = 16.dp, - public val borderColor: Color = Unspecified + public val backdropColor: Color = Unspecified ) : WindowType() { /** - * Creates a lightweight dialog with a custom backdrop and theme-based - * frame appearance. + * The dialog surface, or [Unspecified] to use the current Material + * surface color. + */ + public val containerColor: Color + get() = customContainerColor + + /** + * The dialog shape, or `null` to use the current Material large shape. + */ + public val shape: Shape? + get() = customShape + + /** + * The dialog shadow elevation. + */ + public val shadowElevation: Dp + get() = customShadowElevation + + /** + * The dialog border, or [Unspecified] to use the current Material + * outline variant. + */ + public val borderColor: Color + get() = customBorderColor + + /** + * Stores the dialog surface supplied to the extended constructor. + */ + private var customContainerColor: Color = Unspecified + + /** + * Stores the dialog shape supplied to the extended constructor. + */ + private var customShape: Shape? = null + + /** + * Stores the shadow elevation supplied to the extended constructor. + */ + private var customShadowElevation: Dp = 16.dp + + /** + * Stores the border color supplied to the extended constructor. + */ + private var customBorderColor: Color = Unspecified + + /** + * Creates a lightweight dialog with configurable frame appearance. * * @param backdropColor The modal backdrop color. + * @param containerColor The dialog surface, or [Unspecified] to use the + * current Material surface color. + * @param shape The dialog shape, or `null` to use the current Material + * large shape. + * @param shadowElevation The dialog shadow elevation. + * @param borderColor The dialog border, or [Unspecified] to use the + * current Material outline variant. */ - public constructor(backdropColor: Color) : this( - backdropColor = backdropColor, - containerColor = Unspecified, - shape = null, - shadowElevation = 16.dp, - borderColor = Unspecified - ) + @Suppress("LongParameterList") // These are independent visual override points. + public constructor( + backdropColor: Color, + containerColor: Color, + shape: Shape?, + shadowElevation: Dp, + borderColor: Color + ) : this(backdropColor) { + customContainerColor = containerColor + customShape = shape + customShadowElevation = shadowElevation + customBorderColor = borderColor + } @Composable override fun dialogWindow(dialog: Dialog) { diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/WithTooltip.kt b/core/src/main/kotlin/io/spine/chords/core/layout/WithTooltip.kt index cc2e3d66..8886d96f 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/WithTooltip.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/WithTooltip.kt @@ -72,6 +72,10 @@ public fun WithTooltip( /** * Displays the given `content` with assigning a tooltip for it. * + * This overload adds a text-color override while the original five-parameter + * function remains available for source and binary compatibility. The required + * [tooltipContentColor] keeps calls to the two overloads unambiguous. + * * @param tooltip * the text shown when the mouse hovers over the content. * @param modifier diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt b/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt index c289662d..dd7901f9 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt @@ -69,6 +69,7 @@ import io.spine.chords.core.keyboard.on import io.spine.chords.core.primitive.HorizontalScrollbar import io.spine.chords.core.primitive.VerticalScrollbar import io.spine.chords.core.styling.ChordsTheme +import io.spine.chords.core.styling.defaultDimensions /** * The base class for creating a multi-step form component known as a wizard. @@ -108,24 +109,33 @@ public abstract class Wizard : Component() { /** * Appearance and size values unique to a wizard. * - * @property width The width of the wizard content pane. - * @property minHeight The minimum height of the wizard content pane. - * @property maxHeight The maximum height of the wizard content pane. - * @property padding The inset around wizard content. + * The spacing constructor defaults use the default Chords scale. When a + * [Look] is assigned to [Wizard.look], each unchanged spacing value follows + * the corresponding token from the active theme. + * + * @property width The width of the wizard content pane. Defaults to `720.dp`. + * @property minHeight The minimum content-pane height. Defaults to `420.dp`. + * @property maxHeight The maximum content-pane height. Defaults to `760.dp`. + * @property padding The inset around wizard content. Defaults to `32.dp`. * @property sectionSpacing The gap between the title, page, and actions. + * Defaults to `16.dp`. * @property buttonSpacing The gap between adjacent navigation buttons. + * Defaults to `8.dp`. */ public data class Look( public val width: Dp = 720.dp, public val minHeight: Dp = 420.dp, public val maxHeight: Dp = 760.dp, - public val padding: Dp = 32.dp, - public val sectionSpacing: Dp = 16.dp, - public val buttonSpacing: Dp = 8.dp + public val padding: Dp = defaultDimensions.spacingXXLarge, + public val sectionSpacing: Dp = defaultDimensions.spacingLarge, + public val buttonSpacing: Dp = defaultDimensions.spacingSmall ) /** * Specifies appearance-related values that are unique to this wizard. + * + * Spacing values left at their [Look] defaults follow the corresponding + * Chords theme tokens. Customized values take precedence independently. */ public var look: Look = Look() @@ -340,21 +350,30 @@ public abstract class Wizard : Component() { } /** - * Resolves an unchanged default look against the active theme spacing. + * Resolves default-valued spacing against the active theme. * * @return The appearance values to use for the current composition. */ @Composable private fun resolvedLook(): Look { - return if (look == Look()) { - look.copy( - padding = ChordsTheme.dimensions.spacingXXLarge, - sectionSpacing = ChordsTheme.dimensions.spacingLarge, - buttonSpacing = ChordsTheme.dimensions.spacingSmall - ) - } else { - look - } + val defaultLook = Look() + return look.copy( + padding = if (look.padding == defaultLook.padding) { + ChordsTheme.dimensions.spacingXXLarge + } else { + look.padding + }, + sectionSpacing = if (look.sectionSpacing == defaultLook.sectionSpacing) { + ChordsTheme.dimensions.spacingLarge + } else { + look.sectionSpacing + }, + buttonSpacing = if (look.buttonSpacing == defaultLook.buttonSpacing) { + ChordsTheme.dimensions.spacingSmall + } else { + look.buttonSpacing + } + ) } /** diff --git a/core/src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt b/core/src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt index b114a7ce..0f029351 100644 --- a/core/src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt +++ b/core/src/main/kotlin/io/spine/chords/core/primitive/CheckboxWithText.kt @@ -88,6 +88,9 @@ public fun CheckboxWithText( /** * A styled overload of [CheckboxWithText] with layout and text overrides. * + * The original overload remains unchanged for source and binary compatibility. + * The required [modifier] keeps calls to the two overloads unambiguous. + * * @param checked Indicates whether the checkbox is checked. * @param onChange Invoked when the user tries to change the checked state. * @param text A text displayed to the right of the checkbox. @@ -197,6 +200,9 @@ public fun CheckboxWithText( /** * A state-backed styled overload of [CheckboxWithText]. * + * The original state-backed overload remains unchanged for source and binary + * compatibility. The required [modifier] keeps overload resolution unambiguous. + * * @param checked The state that stores the checked value. * @param onChange Invoked after the checked value changes. * @param text A text displayed to the right of the checkbox. diff --git a/core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt b/core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt index d7db1711..e4b53da3 100644 --- a/core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt +++ b/core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.unit.sp * @property dropdownItemHeight The minimum height of a dropdown item. * @property tableHeaderHeight The height of a table header. * @property tableRowHeight The height of an ordinary table row. + * @property tableRowMaxHeight The maximum height of a table row. * @property supportingPaneWidth The default width of a supporting details pane. */ @Immutable @@ -88,6 +89,7 @@ public data class ChordsDimensions( public val dropdownItemHeight: Dp = 40.dp, public val tableHeaderHeight: Dp = 40.dp, public val tableRowHeight: Dp = 40.dp, + public val tableRowMaxHeight: Dp = 100.dp, public val supportingPaneWidth: Dp = 360.dp ) @@ -110,7 +112,46 @@ public data class ChordsInteraction( ) /** - * The default Chords Material 3 theme and its desktop-specific tokens. + * Applies the default Chords look and feel to [content]. + * + * @param darkTheme Whether the dark color scheme should be used. + * @param colorScheme The Material color scheme applied to all components. + * @param typography The Material typography applied to all components. + * @param shapes The Material shape scale applied to all components. + * @param dimensions Desktop layout dimensions shared by Chords components. + * @param interaction Common interaction-state opacity values. + * @param content The content to which the theme is applied. + */ +@Composable +@Suppress("LongParameterList") // Mirrors MaterialTheme and keeps all theme inputs explicit. +public fun ChordsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + colorScheme: ColorScheme = if (darkTheme) { + defaultDarkColorScheme + } else { + defaultLightColorScheme + }, + typography: Typography = defaultTypography, + shapes: Shapes = defaultShapes, + dimensions: ChordsDimensions = defaultDimensions, + interaction: ChordsInteraction = defaultInteraction, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalChordsDimensions provides dimensions, + LocalChordsInteraction provides interaction + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + shapes = shapes, + content = content + ) + } +} + +/** + * Provides the desktop-specific tokens installed by [ChordsTheme]. * * Standard Material values remain available through [MaterialTheme]. Chords * adds only the layout and interaction values that Material does not expose as @@ -118,45 +159,6 @@ public data class ChordsInteraction( */ public object ChordsTheme { - /** - * Applies the default Chords look and feel to [content]. - * - * @param darkTheme Whether the dark color scheme should be used. - * @param colorScheme The Material color scheme applied to all components. - * @param typography The Material typography applied to all components. - * @param shapes The Material shape scale applied to all components. - * @param dimensions Desktop layout dimensions shared by Chords components. - * @param interaction Common interaction-state opacity values. - * @param content The content to which the theme is applied. - */ - @Composable - @Suppress("LongParameterList") // Mirrors MaterialTheme and keeps all theme inputs explicit. - public operator fun invoke( - darkTheme: Boolean = isSystemInDarkTheme(), - colorScheme: ColorScheme = if (darkTheme) { - chordsDarkColorScheme() - } else { - chordsLightColorScheme() - }, - typography: Typography = chordsTypography(), - shapes: Shapes = chordsShapes(), - dimensions: ChordsDimensions = ChordsDimensions(), - interaction: ChordsInteraction = ChordsInteraction(), - content: @Composable () -> Unit - ) { - CompositionLocalProvider( - LocalChordsDimensions provides dimensions, - LocalChordsInteraction provides interaction - ) { - MaterialTheme( - colorScheme = colorScheme, - typography = typography, - shapes = shapes, - content = content - ) - } - } - /** * The desktop layout dimensions active in the current composition. */ @@ -346,12 +348,42 @@ public fun chordsShapes(): Shapes = Shapes( extraLarge = RoundedCornerShape(16.dp) ) +/** + * The light color scheme reused by the default theme. + */ +private val defaultLightColorScheme: ColorScheme = chordsLightColorScheme() + +/** + * The dark color scheme reused by the default theme. + */ +private val defaultDarkColorScheme: ColorScheme = chordsDarkColorScheme() + +/** + * The typography reused by the default theme. + */ +private val defaultTypography: Typography = chordsTypography() + +/** + * The shape scale reused by the default theme. + */ +private val defaultShapes: Shapes = chordsShapes() + +/** + * The desktop dimensions reused by the default theme and non-composable core defaults. + */ +internal val defaultDimensions: ChordsDimensions = ChordsDimensions() + +/** + * The interaction values reused by the default theme. + */ +private val defaultInteraction: ChordsInteraction = ChordsInteraction() + /** * Supplies default desktop dimensions when no Chords theme is installed. */ -private val LocalChordsDimensions = staticCompositionLocalOf { ChordsDimensions() } +private val LocalChordsDimensions = staticCompositionLocalOf { defaultDimensions } /** * Supplies default interaction values when no Chords theme is installed. */ -private val LocalChordsInteraction = staticCompositionLocalOf { ChordsInteraction() } +private val LocalChordsInteraction = staticCompositionLocalOf { defaultInteraction } diff --git a/core/src/main/kotlin/io/spine/chords/core/table/Table.kt b/core/src/main/kotlin/io/spine/chords/core/table/Table.kt index 38ea9a8c..9b2765af 100644 --- a/core/src/main/kotlin/io/spine/chords/core/table/Table.kt +++ b/core/src/main/kotlin/io/spine/chords/core/table/Table.kt @@ -49,8 +49,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.material.icons.Icons @@ -70,6 +72,7 @@ import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MenuDefaults import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState @@ -89,13 +92,20 @@ import androidx.compose.ui.input.pointer.PointerEventType.Companion.Exit import androidx.compose.ui.input.pointer.PointerIcon.Companion.Hand import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.spine.chords.core.Component import io.spine.chords.core.styling.ChordsTheme +import io.spine.chords.core.styling.defaultDimensions import io.spine.chords.core.table.TableSortingDirection.ASCENDING import io.spine.chords.core.table.TableSortingDirection.DESCENDING +/** + * The table padding used when no composable theme value is available. + */ +private val defaultTableContentPadding = PaddingValues(defaultDimensions.spacingLarge) + /** * A list of entities in a tabular format. * @@ -222,8 +232,23 @@ public abstract class Table : Component() { /** * The padding applied to the entire content of the table. + * + * When it is not assigned, the current Chords theme's large spacing is + * used. Assigning a value, including the default `16.dp`, pins that value. + * Reading an unassigned property returns the default theme's baseline + * padding. Rendering resolves the effective value from the active theme, + * so a custom theme can produce a different on-screen value. + */ + public var contentPadding: PaddingValues + get() = customContentPadding ?: defaultTableContentPadding + set(value) { + customContentPadding = value + } + + /** + * A component-specific content-padding override. */ - public var contentPadding: PaddingValues by mutableStateOf(PaddingValues(16.dp)) + private var customContentPadding: PaddingValues? by mutableStateOf(null) /** * The color of the selected row. @@ -272,7 +297,9 @@ public abstract class Table : Component() { * * The ID's equality is determined using structural equality operator (`==`). * Therefore, the returned identifier should be a type that supports - * meaningful structural equality. + * meaningful structural equality. It must also be unique among all entities + * displayed at the same time because it is used as the stable key of each + * lazily composed row. * * @param entity An entity from which to extract the identifier. * @return The ID of an entity. @@ -293,9 +320,19 @@ public abstract class Table : Component() { override fun content() { val sortedEntities = sortedEntities() val tableColumns = columns.toMutableList() - if (rowActions != null) { - tableColumns.add(rowActionsColumn(rowActions!!, ::changeSelectedEntity)) + var lastColumnWidth: Dp? = null + rowActions?.let { config -> + val layoutDirection = LocalLayoutDirection.current + val horizontalPadding = + config.buttonPadding.calculateLeftPadding(layoutDirection) + + config.buttonPadding.calculateRightPadding(layoutDirection) + lastColumnWidth = ChordsTheme.dimensions.iconButtonSize + horizontalPadding + tableColumns.add( + rowActionsColumn(config, ::changeSelectedEntity) + ) } + // The actions column is appended last, so its fixed width belongs to the final column. + val columnsLayout = TableColumnsLayout(tableColumns, lastColumnWidth) Column( modifier = Modifier .fillMaxSize() @@ -304,7 +341,7 @@ public abstract class Table : Component() { verticalArrangement = Top, ) { HeaderTableRow( - columns = tableColumns, + columnsLayout = columnsLayout, sortingState = sortingState, height = headerHeight ?: ChordsTheme.dimensions.tableHeaderHeight, backgroundColor = headerColor ?: colorScheme.surfaceVariant @@ -315,7 +352,7 @@ public abstract class Table : Component() { .weight(1F) ) { if (entities.isNotEmpty()) { - ContentList(sortedEntities, tableColumns) + ContentList(sortedEntities, columnsLayout) } else { EmptyContentList() } @@ -330,11 +367,8 @@ public abstract class Table : Component() { */ @Composable private fun resolvedContentPadding(): PaddingValues { - return if (contentPadding == PaddingValues(16.dp)) { - PaddingValues(ChordsTheme.dimensions.spacingLarge) - } else { - contentPadding - } + return customContentPadding + ?: PaddingValues(ChordsTheme.dimensions.spacingLarge) } /** @@ -351,12 +385,12 @@ public abstract class Table : Component() { * * @param entities The list of entities with data that * should be displayed in table rows. - * @param columns A list of columns to be displayed in the table. + * @param columnsLayout The columns and any fixed width used by the final column. */ @Composable private fun ContentList( entities: List, - columns: List> + columnsLayout: TableColumnsLayout ) { val listState = rememberLazyListState() Box( @@ -366,22 +400,24 @@ public abstract class Table : Component() { modifier = Modifier.fillMaxSize(), state = listState ) { - entities.forEach { value -> - item { - val selected = isSelected(value) - ContentTableRow( - entity = value, - columns = columns, - modifier = contentTableRowModifier(value, selected), - selected = selected, - height = rowHeight ?: ChordsTheme.dimensions.tableRowHeight, - selectedColor = selectedRowColor ?: colorScheme.primaryContainer, - hoveredColor = hoveredRowColor ?: colorScheme.primary.copy( - alpha = ChordsTheme.interaction.hoveredStateAlpha - ) - ) { - changeSelectedEntity(value) - } + items( + items = entities, + key = { extractEntityId(it) } + ) { value -> + val selected = isSelected(value) + ContentTableRow( + entity = value, + columnsLayout = columnsLayout, + modifier = contentTableRowModifier(value, selected), + selected = selected, + height = rowHeight ?: ChordsTheme.dimensions.tableRowHeight, + maxHeight = ChordsTheme.dimensions.tableRowMaxHeight, + selectedColor = selectedRowColor ?: colorScheme.primaryContainer, + hoveredColor = hoveredRowColor ?: colorScheme.primary.copy( + alpha = ChordsTheme.interaction.hoveredStateAlpha + ) + ) { + changeSelectedEntity(value) } } } @@ -440,7 +476,8 @@ public abstract class Table : Component() { * relative to other columns. Must be positive. The default value is `1F` * meaning that if all columns have this `weight` value, their width is equal. * @param padding The padding values of each cell's content in this column. - * By default, compact horizontal padding is applied. + * By default, compact `12.dp` horizontal padding is applied, matching the + * default Chords medium spacing token. * @param columnKey A stable identifier of the column used to keep track of the sorting state. * By default, the column [name] is used. * @param sorting Optional sorting configuration for this column. @@ -793,14 +830,27 @@ private fun VerticalScrollBar( ) } +/** + * Describes the columns in a table row and an optional fixed width for the final column. + * + * The fixed width is layout metadata rather than part of a public [TableColumn] value. + * + * @param E The type of entity represented by the columns. + * @param columns The columns to lay out. + * @param lastColumnWidth A fixed width for the final column, or `null` to use its weight. + */ +private data class TableColumnsLayout( + val columns: List>, + val lastColumnWidth: Dp? +) + /** * Table row with headers. * * NOTE: the Pointer Hover API used in this method is experimental * in the current version of Compose (1.5.12). * - * @param columns A list of column configuration objects - * with information about headers. + * @param columnsLayout The columns and any fixed width used by the final column. * @param sortingState The current interactive sorting state of the table. * @param height The header row height. * @param backgroundColor The header row background. @@ -808,13 +858,13 @@ private fun VerticalScrollBar( @OptIn(ExperimentalComposeUiApi::class) @Composable private fun HeaderTableRow( - columns: List>, + columnsLayout: TableColumnsLayout, sortingState: TableSortingState, height: Dp, backgroundColor: Color ) { TableRow( - columns = columns, + columnsLayout = columnsLayout, height = height, backgroundColor = backgroundColor, cellModifier = { column -> @@ -895,11 +945,12 @@ private fun HeaderCell( /** * Table row component that supports a click action. * - * @param columns A list of columns from which the row consists. + * @param columnsLayout The columns and any fixed width used by the final column. * @param entity The entity to represent in a row. * @param modifier The [Modifier] to be applied to this row. * @param selected Whether this row is selected. * @param height The minimum row height. + * @param maxHeight The maximum row height. * @param selectedColor The selected row background. * @param hoveredColor The hovered row background. * @param onClick A callback that is triggered when a user clicks on a row. @@ -909,38 +960,46 @@ private fun HeaderCell( @Suppress("LongParameterList") // Row interaction states are supplied by the table look. private fun ContentTableRow( entity: E, - columns: List>, + columnsLayout: TableColumnsLayout, modifier: Modifier, selected: Boolean, height: Dp, + maxHeight: Dp, selectedColor: Color, hoveredColor: Color, onClick: () -> Unit ) { var hovered by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } val backgroundColor = when { selected -> selectedColor hovered -> hoveredColor else -> Color.Transparent } TableRow( - columns = columns, + columnsLayout = columnsLayout, height = height, backgroundColor = backgroundColor, modifier = Modifier .then(modifier) .onPointerEvent(Enter) { hovered = true } .onPointerEvent(Exit) { hovered = false } - .clickable(onClick = onClick), + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ), + maxHeight = maxHeight, ) { column -> column.cellContent(entity) } } /** * Table row component. * - * @param columns A list of columns from which the row consists. + * @param columnsLayout The columns and any fixed width used by the final column. * @param modifier The [Modifier] to be applied to this row. * @param height The minimum height of the row. + * @param maxHeight The maximum height of the row, or `null` for no maximum. * @param backgroundColor The row background. * @param cellModifier A callback that provides an additional [Modifier] * for each individual cell. @@ -950,34 +1009,49 @@ private fun ContentTableRow( @Composable @Suppress("LongParameterList") // Keeps the shared header and content row layout consistent. private fun TableRow( - columns: List>, + columnsLayout: TableColumnsLayout, modifier: Modifier = Modifier, height: Dp, + maxHeight: Dp? = null, backgroundColor: Color = Color.Transparent, cellModifier: (TableColumn) -> Modifier = { Modifier }, cellContent: @Composable (TableColumn) -> Unit ) { + val rowHeightModifier = if (maxHeight == null) { + Modifier.heightIn(min = height) + } else { + Modifier.heightIn(min = height, max = maxHeight) + } + val rowContentColor = contentColorFor(backgroundColor).let { + if (it == Color.Unspecified) colorScheme.onSurface else it + } Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = height) + .then(rowHeightModifier) .height(Min) .then(modifier) .background(backgroundColor), horizontalArrangement = SpaceBetween, verticalAlignment = CenterVertically ) { - columns.forEach { column -> + columnsLayout.columns.forEachIndexed { index, column -> + val fixedWidth = if (index == columnsLayout.columns.lastIndex) { + columnsLayout.lastColumnWidth + } else { + null + } + val widthModifier = fixedWidth?.let { Modifier.width(it) } + ?: Modifier.weight(column.weight) Row( - modifier = Modifier - .weight(column.weight) + modifier = widthModifier .fillMaxHeight() .then(cellModifier(column)) .padding(column.padding), horizontalArrangement = column.horizontalArrangement, verticalAlignment = CenterVertically ) { - CompositionLocalProvider(LocalContentColor provides colorScheme.onSurface) { + CompositionLocalProvider(LocalContentColor provides rowContentColor) { ProvideTextStyle(MaterialTheme.typography.bodyMedium) { cellContent(column) } @@ -1006,10 +1080,9 @@ private fun rowActionsColumn( rowActionsConfig: RowActionsConfig, onRowActionsClicked: (E) -> Unit ): TableColumn { - return TableColumn( + return TableColumn( name = "", horizontalArrangement = End, - weight = 0.2F, padding = rowActionsConfig.buttonPadding ) { entity -> val rowActionsVisible = remember { mutableStateOf(false) } diff --git a/core/src/test/kotlin/io/spine/chords/core/layout/DialogSpec.kt b/core/src/test/kotlin/io/spine/chords/core/layout/DialogSpec.kt index a7549ebc..f36c4bbd 100644 --- a/core/src/test/kotlin/io/spine/chords/core/layout/DialogSpec.kt +++ b/core/src/test/kotlin/io/spine/chords/core/layout/DialogSpec.kt @@ -40,10 +40,13 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.kotest.matchers.shouldBe import io.spine.chords.core.TestApplication import io.spine.chords.core.layout.WindowType.LightweightWindow +import io.spine.chords.core.styling.ChordsDimensions +import io.spine.chords.core.styling.ChordsTheme import java.awt.event.KeyEvent.CTRL_DOWN_MASK import java.awt.event.KeyEvent.KEY_PRESSED import java.awt.event.KeyEvent.KEY_RELEASED @@ -104,6 +107,35 @@ internal class DialogSpec { dialog.height shouldBe 400.dp } + /** + * Customizing one look value must not detach the remaining default values + * from the active theme. + */ + @Test + fun `resolve default look values from the theme independently`() { + val dialog = TestDialog().apply { + look = Dialog.Look(buttonsSpacing = 5.dp) + } + lateinit var resolvedLook: Dialog.Look + + TestScene { + ChordsTheme( + dimensions = ChordsDimensions( + spacingMedium = 14.dp, + spacingLarge = 18.dp, + spacingXLarge = 30.dp + ) + ) { + resolvedLook = dialog.resolvedLook() + } + }.use { } + + resolvedLook.padding.calculateLeftPadding(LayoutDirection.Ltr) shouldBe 30.dp + resolvedLook.titlePadding.calculateBottomPadding() shouldBe 18.dp + resolvedLook.buttonsPanelPadding.calculateTopPadding() shouldBe 30.dp + resolvedLook.buttonsSpacing shouldBe 5.dp + } + /** * The content of a dialog that is not submitting anything is operated * as usual, which is what the cases below observe the absence of while diff --git a/core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt b/core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt index a67ade49..1a55f45c 100644 --- a/core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt +++ b/core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt @@ -84,6 +84,7 @@ internal class ChordsThemeSpec { dimensions.controlHeight shouldBe 44.dp dimensions.dropdownItemHeight shouldBe 40.dp dimensions.tableRowHeight shouldBe 40.dp + dimensions.tableRowMaxHeight shouldBe 100.dp dimensions.navigationItemHeight shouldBe 40.dp } diff --git a/proto/src/main/kotlin/io/spine/chords/proto/money/MoneyField.kt b/proto/src/main/kotlin/io/spine/chords/proto/money/MoneyField.kt index 001b3aef..bd5aaf59 100644 --- a/proto/src/main/kotlin/io/spine/chords/proto/money/MoneyField.kt +++ b/proto/src/main/kotlin/io/spine/chords/proto/money/MoneyField.kt @@ -32,7 +32,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuDefaults.TrailingIcon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable @@ -137,7 +137,7 @@ public class MoneyField : InputField() { */ @Composable @ReadOnlyComposable - override fun defaultTextStyle(): TextStyle = MaterialTheme.typography.bodyMedium.copy( + override fun defaultTextStyle(): TextStyle = LocalTextStyle.current.copy( fontFamily = Monospace ) diff --git a/proto/src/main/kotlin/io/spine/chords/proto/money/PaymentMethodEditor.kt b/proto/src/main/kotlin/io/spine/chords/proto/money/PaymentMethodEditor.kt index aeeba41c..9057bbf0 100644 --- a/proto/src/main/kotlin/io/spine/chords/proto/money/PaymentMethodEditor.kt +++ b/proto/src/main/kotlin/io/spine/chords/proto/money/PaymentMethodEditor.kt @@ -33,9 +33,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import io.spine.chords.core.ComponentSetup import io.spine.chords.core.ValidationErrorText +import io.spine.chords.core.styling.ChordsDimensions import io.spine.chords.core.styling.ChordsTheme import io.spine.chords.proto.form.CustomMessageForm import io.spine.chords.proto.form.FormPartScope @@ -47,6 +47,11 @@ import io.spine.chords.proto.value.money.PaymentMethodDef.bankAccount import io.spine.chords.proto.value.money.PaymentMethodDef.method import io.spine.chords.proto.value.money.PaymentMethodDef.paymentCard +/** + * The default dimensions used by non-composable [PaymentMethodEditor.Look] defaults. + */ +private val defaultLookDimensions = ChordsDimensions() + /** * A component that edits a [PaymentMethod]. */ @@ -57,6 +62,9 @@ public class PaymentMethodEditor : CustomMessageForm( /** * Identifies the component's appearance parameters. + * + * Values left at their [Look] defaults follow the corresponding Chords + * theme tokens. Customized values take precedence independently. */ public var look: Look = Look() @@ -72,22 +80,33 @@ public class PaymentMethodEditor : CustomMessageForm( * the controls within the component. */ public data class Look( - public var interFieldPadding: Dp = 24.dp, - public var selectorsOffset: Dp = 8.dp, - public var optionalCheckboxOffset: Dp = 16.dp + public var interFieldPadding: Dp = defaultLookDimensions.spacingXLarge, + public var selectorsOffset: Dp = defaultLookDimensions.spacingSmall, + public var optionalCheckboxOffset: Dp = defaultLookDimensions.spacingLarge ) @Composable override fun FormPartScope.customContent() { - val currentLook = if (look == Look()) { - Look( - interFieldPadding = ChordsTheme.dimensions.spacingXLarge, - selectorsOffset = ChordsTheme.dimensions.spacingSmall, - optionalCheckboxOffset = ChordsTheme.dimensions.spacingLarge - ) - } else { - look - } + val defaultLook = Look() + val currentLook = look.copy( + interFieldPadding = if (look.interFieldPadding == defaultLook.interFieldPadding) { + ChordsTheme.dimensions.spacingXLarge + } else { + look.interFieldPadding + }, + selectorsOffset = if (look.selectorsOffset == defaultLook.selectorsOffset) { + ChordsTheme.dimensions.spacingSmall + } else { + look.selectorsOffset + }, + optionalCheckboxOffset = if ( + look.optionalCheckboxOffset == defaultLook.optionalCheckboxOffset + ) { + ChordsTheme.dimensions.spacingLarge + } else { + look.optionalCheckboxOffset + } + ) Column { if (!required) { Row { diff --git a/proto/src/main/kotlin/io/spine/chords/proto/time/DateTimeField.kt b/proto/src/main/kotlin/io/spine/chords/proto/time/DateTimeField.kt index 54b949e2..d3606005 100644 --- a/proto/src/main/kotlin/io/spine/chords/proto/time/DateTimeField.kt +++ b/proto/src/main/kotlin/io/spine/chords/proto/time/DateTimeField.kt @@ -34,7 +34,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable @@ -222,7 +222,7 @@ public class DateTimeField : InputField() { */ @Composable @ReadOnlyComposable - override fun defaultTextStyle(): TextStyle = MaterialTheme.typography.bodyMedium.copy( + override fun defaultTextStyle(): TextStyle = LocalTextStyle.current.copy( fontFamily = Monospace )