diff --git a/core/README.md b/core/README.md index b50d8ac3..4cd4331f 100644 --- a/core/README.md +++ b/core/README.md @@ -3,6 +3,56 @@ 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 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 +[`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 +149,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..b1d2dae7 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,7 +900,10 @@ public class DropdownListBox : Component() { * to be used. */ @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) @@ -883,10 +919,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 = resolvedItemMinHeight, onMeasureHeight = { measuredHeight -> noneItemHeight = measuredHeight }, @@ -898,11 +937,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 +956,17 @@ public class DropdownListBox : Component() { onMeasureHeight = { measuredHeight -> itemHeights[index] = measuredHeight }, - color = color + color = color, + itemMinHeight = resolvedItemMinHeight ) { itemContent(item) } } } else { - DropdownListNoItems(content = noItemsContent) + DropdownListNoItems( + itemMinHeight = resolvedItemMinHeight, + content = noItemsContent + ) } } VerticalScrollbar(scrollState) { @@ -961,13 +1006,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 +1212,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 +1222,7 @@ private fun DropdownListItem( onClick: () -> Unit, onMeasureHeight: (Int) -> Unit, color: Color?, + itemMinHeight: Dp, content: @Composable () -> Unit ) { val itemHeight = remember { mutableStateOf(0) } @@ -1179,7 +1234,7 @@ private fun DropdownListItem( onClick = onClick ) .fillMaxWidth() - .heightIn(48.dp) + .heightIn(itemMinHeight) .onGloballyPositioned { val height = it.size.height if (height != itemHeight.value) { @@ -1197,22 +1252,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 +1288,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 +1299,7 @@ private fun DropdownListNoItems(content: @Composable (() -> Unit)) { private fun DropdownListNoneItem( text: String = "", color: Color? = null, + itemMinHeight: Dp, onMeasureHeight: (Int) -> Unit, onClick: () -> Unit ) { @@ -1254,16 +1319,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..5875aa7a 100644 --- a/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt +++ b/core/src/main/kotlin/io/spine/chords/core/DropdownSelector.kt @@ -30,6 +30,7 @@ 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 @@ -37,11 +38,12 @@ 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 +58,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 +78,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.* /** @@ -130,9 +134,24 @@ public abstract class DropdownSelector : InputComponent() { /** * A [TextFieldColors] instance, which defines the color scheme for * 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 lateinit var fieldColors: TextFieldColors + /** + * 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. */ @@ -162,14 +181,7 @@ public abstract class DropdownSelector : InputComponent() { * 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 ?: LocalTextStyle.current } /** @@ -191,7 +203,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,8 +213,10 @@ public abstract class DropdownSelector : InputComponent() { @Composable override fun content(): Unit = recompositionWorkaround { - if (!::fieldColors.isInitialized) { - fieldColors = TextFieldDefaults.colors() + val selectorColors = if (::fieldColors.isInitialized) { + fieldColors + } else { + OutlinedTextFieldDefaults.colors() } val fieldText = getFieldText(searchString) @@ -222,7 +236,7 @@ public abstract class DropdownSelector : InputComponent() { val itemText = itemText(it) itemContent(it, itemText) } - invoker = { SelectorField() } + invoker = { SelectorField(selectorColors) } } } @@ -231,9 +245,9 @@ public abstract class DropdownSelector : InputComponent() { */ @Composable @OptIn(ExperimentalComposeUiApi::class) - private fun DropdownListBoxScope.SelectorField() { + private fun DropdownListBoxScope.SelectorField(selectorColors: TextFieldColors) { val validationErrorText = externalValidationMessage?.value - TextField( + OutlinedTextField( value = TextFieldValue(getFieldText(searchString), selection), singleLine = true, onValueChange = { handleDropdownInputChange(it) }, @@ -250,13 +264,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 +456,7 @@ private fun DropdownListBoxScope.TrailingIcons( enabled: Boolean ) { Row( - horizontalArrangement = spacedBy(10.dp), + horizontalArrangement = spacedBy(ChordsTheme.dimensions.spacingSmall), verticalAlignment = CenterVertically ) { if (!valueRequired && enabled) { @@ -472,7 +488,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 +514,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..a3d0b40c 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,16 @@ 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.colorScheme +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 +45,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 +59,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 +144,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 +206,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,9 +312,19 @@ 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. + * + * 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 lateinit var colors: TextFieldColors @@ -451,10 +465,12 @@ public open class InputField : InputComponent() { @Composable override fun content(): Unit = recompositionWorkaround { - if (!::colors.isInitialized) { - colors = TextFieldDefaults.colors() + val fieldColors = if (::colors.isInitialized) { + colors + } else { + OutlinedTextFieldDefaults.colors() } - val textStyle = textStyle ?: LocalTextStyle.current + val textStyle = textStyle ?: defaultTextStyle() val rawTextContent = getRawTextContent() val interactionSource = remember { MutableInteractionSource() } @@ -462,7 +478,7 @@ public open class InputField : InputComponent() { val validationErrorText = ownValidationMessage.value ?: externalValidationMessage?.value - TextField( + OutlinedTextField( value = rawTextContent, label = label?.let { { Text(text = it) } }, isError = validationErrorText != null, @@ -476,8 +492,7 @@ public open class InputField : InputComponent() { placeholder = placeholder ?: { Text( promptText ?: "", - fontFamily = textStyle.fontFamily, - color = colorScheme.secondary + fontFamily = textStyle.fontFamily ) }, prefix = prefix, @@ -488,8 +503,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 +515,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 = LocalTextStyle.current + override fun clear() { super.clear() invalidValueText = null @@ -508,10 +537,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..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 @@ -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. @@ -26,8 +26,13 @@ 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 import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon @@ -42,6 +47,7 @@ import androidx.compose.ui.Modifier 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 @@ -60,26 +66,51 @@ public fun NavigationDrawer( PermanentNavigationDrawer( modifier = Modifier.padding(top = topPadding), drawerContent = { - PermanentDrawerSheet( - modifier = modifier.width(240.dp), - drawerContainerColor = MaterialTheme.colorScheme.background + Row( + modifier = modifier.width(ChordsTheme.dimensions.navigationWidth) ) { - Spacer(modifier = Modifier.height(8.dp)) - 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 - ), - colors = NavigationDrawerItemDefaults.colors( - unselectedContainerColor = MaterialTheme.colorScheme.background + 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 + ) ) - ) + } } + 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 0342d0ed..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 @@ -16,3 +16,39 @@ 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 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 + +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..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 @@ -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. @@ -26,27 +26,32 @@ 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.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api +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.unit.dp import io.spine.chords.core.Component +import io.spine.chords.core.styling.ChordsTheme /** * Represents the TopBar, aka 'Header', of the main screen. * * @param modifier A [Modifier] for this component. */ -@OptIn(ExperimentalMaterial3Api::class) public class TopBar(private val modifier: Modifier = Modifier) : Component() { /** @@ -59,22 +64,46 @@ public class TopBar(private val modifier: Modifier = Modifier) : Component() { @Composable protected override fun content() { - TopAppBar( - title = { - Text( - app.name, - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - actions = actions, + Surface( modifier = modifier - .padding(0.dp) - .fillMaxWidth(), - windowInsets = WindowInsets(8.dp), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) + .fillMaxWidth() + .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 + ) + 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 a898b77a..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 @@ -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,8 @@ 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 +import io.spine.chords.core.styling.defaultDimensions /** * A shortcut (key combination), which invokes dialog submission. @@ -282,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() @@ -307,19 +315,67 @@ 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. + * + * 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 { + 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 + } + ) + } + /** * Specifies the way that the dialog window is displayed. * @@ -636,15 +692,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 +728,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 +849,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 +871,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..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 @@ -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 /** @@ -105,9 +108,33 @@ public sealed class WindowType { * * @param resizable Specifies whether the window can be resized by the user. */ - public open class DesktopWindow( - public val resizable: Boolean = false - ) : WindowType() { + public open class DesktopWindow(public val resizable: Boolean = false) : WindowType() { + + /** + * 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, + containerColor: Color + ) : this(resizable) { + customContainerColor = containerColor + } @Composable override fun dialogWindow(dialog: Dialog) { @@ -172,7 +199,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 +219,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 +251,8 @@ public sealed class WindowType { * ``` */ public companion object : DesktopWindow( - resizable = false + resizable = false, + containerColor = Unspecified ) } @@ -228,12 +262,84 @@ 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. */ public open class LightweightWindow( - public val backdropColor: Color = Gray.copy(alpha = 0.5f) + public val backdropColor: Color = Unspecified ) : WindowType() { + /** + * 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. + */ + @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) { Popup( @@ -249,7 +355,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 +388,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 +423,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 +628,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..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 @@ -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,9 +40,42 @@ 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. * + * 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 @@ -51,6 +84,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 +95,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..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 @@ -59,26 +59,17 @@ 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 +import io.spine.chords.core.styling.defaultDimensions /** * The base class for creating a multi-step form component known as a wizard. @@ -115,6 +106,39 @@ private object WizardContentSize { ) public abstract class Wizard : Component() { + /** + * Appearance and size values unique to a wizard. + * + * 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 = 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() + /** * The text to be the title of the wizard, or `null`, if the wizard's title * shouldn't be displayed at all. @@ -269,17 +293,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 +342,40 @@ public abstract class Wizard : Component() { onCancelClick = { cancel() }, isOnFirstPage = isOnFirstPage(), isOnLastPage = isOnLastPage(), - submitting + submitting = submitting, + buttonSpacing = currentLook.buttonSpacing ) } } } + /** + * Resolves default-valued spacing against the active theme. + * + * @return The appearance values to use for the current composition. + */ + @Composable + private fun resolvedLook(): 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 + } + ) + } + /** * 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 +488,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 +510,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 +520,8 @@ private fun NavigationPanel( onCancelClick: () -> Unit, isOnFirstPage: Boolean, isOnLastPage: Boolean, - submitting: Boolean + submitting: Boolean, + buttonSpacing: Dp ) { Row( modifier = Modifier.fillMaxWidth(), @@ -475,7 +531,7 @@ private fun NavigationPanel( Text("Cancel") } Row( - horizontalArrangement = spacedBy(8.dp) + horizontalArrangement = spacedBy(buttonSpacing) ) { TextButton( onClick = onBackClick, @@ -488,7 +544,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..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 @@ -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,51 @@ 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. + * + * 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. + * @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 +126,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,12 +174,52 @@ 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( + checked: MutableState, + onChange: ((Boolean) -> Unit)? = null, + text: String, + 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]. + * + * 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. + * @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 @@ -138,6 +233,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..e4b53da3 --- /dev/null +++ b/core/src/main/kotlin/io/spine/chords/core/styling/ChordsTheme.kt @@ -0,0 +1,389 @@ +/* + * 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 tableRowMaxHeight The maximum height of a 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 tableRowMaxHeight: Dp = 100.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 +) + +/** + * 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 + * theme properties for desktop applications. + */ +public object ChordsTheme { + + /** + * 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) +) + +/** + * 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 { defaultDimensions } + +/** + * Supplies default interaction values when no Chords theme is installed. + */ +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 66330744..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 @@ -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,15 @@ 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.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.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 +63,18 @@ 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.material3.contentColorFor 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,11 +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. * @@ -214,15 +232,55 @@ 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. */ - protected var contentPadding: PaddingValues by mutableStateOf(PaddingValues(16.dp)) + private var customContentPadding: PaddingValues? by mutableStateOf(null) /** * The color of the selected row. * - * The default value is `MaterialTheme.colorScheme.surfaceVariant`. + * The default value is `MaterialTheme.colorScheme.primaryContainer`. + */ + 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. */ - protected var selectedRowColor: Color? by mutableStateOf(null) + 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. @@ -239,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. @@ -260,26 +320,57 @@ 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() - .padding(contentPadding), - verticalArrangement = Center, + modifier = Modifier + .fillMaxSize() + .background(containerColor ?: colorScheme.surface) + .padding(resolvedContentPadding()), + verticalArrangement = Top, ) { HeaderTableRow( - columns = tableColumns, - sortingState = sortingState + columnsLayout = columnsLayout, + 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, columnsLayout) + } 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 customContentPadding + ?: PaddingValues(ChordsTheme.dimensions.spacingLarge) + } + /** * Resolves the list of entities to render, according to the current sorting configuration. * @@ -294,33 +385,39 @@ 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() - 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 { - ContentTableRow( - entity = value, - columns = columns, - modifier = contentTableRowModifier(value) - ) { - 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) } } } @@ -328,23 +425,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 +471,13 @@ 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 `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. @@ -383,9 +490,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 +506,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 +538,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 +708,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 +822,7 @@ private fun VerticalScrollBar( VerticalScrollbar( modifier = Modifier .fillMaxHeight() - .padding(vertical = 5.dp) + .padding(vertical = ChordsTheme.dimensions.spacingXSmall) .modifierExtender(), adapter = rememberScrollbarAdapter( scrollState = listState @@ -722,24 +830,43 @@ 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. */ @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 -> if (column.sortable) { Modifier @@ -789,7 +916,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 +933,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 @@ -817,65 +945,118 @@ 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. */ @Composable +@OptIn(ExperimentalComposeUiApi::class) +@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( - interactionSource = MutableInteractionSource(), + interactionSource = interactionSource, indication = null, - ) { onClick() }, + 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. * @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>, + 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() - .requiredHeightIn(70.dp, 100.dp) + .then(rowHeightModifier) .height(Min) - .then(modifier), + .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 - ) { cellContent(column) } + ) { + CompositionLocalProvider(LocalContentColor provides rowContentColor) { + ProvideTextStyle(MaterialTheme.typography.bodyMedium) { + cellContent(column) + } + } + } } } Divider( @@ -899,7 +1080,7 @@ private fun rowActionsColumn( rowActionsConfig: RowActionsConfig, onRowActionsClicked: (E) -> Unit ): TableColumn { - return TableColumn( + return TableColumn( name = "", horizontalArrangement = End, padding = rowActionsConfig.buttonPadding @@ -940,7 +1121,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 +1172,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/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 new file mode 100644 index 00000000..1a55f45c --- /dev/null +++ b/core/src/test/kotlin/io/spine/chords/core/styling/ChordsThemeSpec.kt @@ -0,0 +1,104 @@ +/* + * 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.tableRowMaxHeight shouldBe 100.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/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/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..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 @@ -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 = LocalTextStyle.current.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..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 @@ -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. @@ -33,9 +33,10 @@ 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 import io.spine.chords.proto.form.OneofRadioButton @@ -46,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]. */ @@ -56,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() @@ -71,27 +80,47 @@ public class PaymentMethodEditor : CustomMessageForm( * the controls within the component. */ public data class Look( - public var interFieldPadding: Dp = 40.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 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 { 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..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 @@ -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 = LocalTextStyle.current.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 } 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")