diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index 03c40c11..681dcea3 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -484,6 +484,7 @@ private fun ComposeRustTree( chainOf = { asset -> (host.table.assets.asset(asset) as? Asset.Font)?.chain }, brushOf = { asset -> (host.table.assets.asset(asset) as? Asset.Brush)?.brush }, windowBackdrop = asked && platformBacksWindowWithMaterial(), + overlay = host.table.overlayTokens, ) // The animations the Host handed over are played here, on this side's frame clock, and // the Host is not called while they play unless one asked for events. The loop waits diff --git a/compose-rust/android-kotlin/shared/DesignSystem.kt b/compose-rust/android-kotlin/shared/DesignSystem.kt index 682b9586..f603e80f 100644 --- a/compose-rust/android-kotlin/shared/DesignSystem.kt +++ b/compose-rust/android-kotlin/shared/DesignSystem.kt @@ -22,6 +22,7 @@ import dev.darkpyonix.composerust.protocol.DesignTokens import androidx.compose.animation.core.FiniteAnimationSpec import dev.darkpyonix.composerust.protocol.MaterialRole import dev.darkpyonix.composerust.protocol.MotionRole +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.protocol.SpaceRole @@ -180,6 +181,11 @@ class ResolvedTheme( * given is enough to find the rest of the stack: see [chainFor]. */ val fontChains: List = emptyList(), + + /** + * Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + */ + val overlay: OverlayTokens? = null, ) { /** * The stack [family] is the first family of, or null where it is not one. @@ -1638,6 +1644,8 @@ fun resolveTheme( * family lacks in the families after it. */ chainOf: (Int) -> FontChain? = { null }, + /** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + overlay: OverlayTokens? = null, ): ResolvedTheme { val system = when { theme == null -> adaptiveSystem(platform, DesignSystem.Material3) @@ -1684,6 +1692,7 @@ fun resolveTheme( platform, application, chains, + overlay, ) } diff --git a/compose-rust/android-kotlin/shared/HostContainer.kt b/compose-rust/android-kotlin/shared/HostContainer.kt index 9914be33..bafe5709 100644 --- a/compose-rust/android-kotlin/shared/HostContainer.kt +++ b/compose-rust/android-kotlin/shared/HostContainer.kt @@ -7,6 +7,7 @@ import dev.darkpyonix.composerust.protocol.TypeRole import dev.darkpyonix.composerust.ui.textStyle import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,12 +46,20 @@ import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.weightOf import dev.darkpyonix.composerust.design.liftsOffThePage +/** True when the Host set a shape modifier of its own, which replaces the default shape. */ +internal fun Node.setsOwnShape(): Boolean = + modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + /** * The corner this container is drawn with: the one the Host asked for if it asked, and the * design system's corner for this kind of container otherwise. */ internal fun Node.containerShape(style: ContainerStyle, theme: ResolvedTheme): Shape = - if (modifiers.any { it is ProtocolModifier.Shape || it is ProtocolModifier.ShapeRole || it is ProtocolModifier.CornerEach }) { + if (setsOwnShape()) { modifiers.resolvedShape(theme) } else { style.shape @@ -98,12 +107,34 @@ internal fun Modifier.containerDecoration( theme: ResolvedTheme, /** True for a menu, a dialog or a tooltip: a sheet over the page rather than in it. */ overlay: Boolean = false, + role: ContainerRole? = null, ): Modifier { - val shape = node.containerShape(style, theme) - val raised = if (node.setsOwnElevation()) { + val shape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + else -> null + } ?: style.shape + } else { + style.shape + } + val effectiveElevation = if (node.setsOwnElevation()) { + 0.dp + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + else -> null + } ?: style.elevation + } else { + style.elevation + } + val raised = if (node.setsOwnElevation() || effectiveElevation.value <= 0f) { this } else { - theme.rules.elevation(this, style.elevation, shape, theme) + theme.rules.elevation(this, effectiveElevation, shape, theme) } val material = style.material // A Host that named its own Background has already had it applied, so painting the @@ -139,6 +170,17 @@ internal fun Modifier.containerDecoration( .then( if (node.setsOwnPadding()) { Modifier + } else if (overlay) { + val customPadding = when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + else -> null + } + if (customPadding != null) { + Modifier.padding(customPadding) + } else { + Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) + } } else { Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) }, diff --git a/compose-rust/android-kotlin/shared/HostDropdown.kt b/compose-rust/android-kotlin/shared/HostDropdown.kt index 52b3f526..dc1fcdd9 100644 --- a/compose-rust/android-kotlin/shared/HostDropdown.kt +++ b/compose-rust/android-kotlin/shared/HostDropdown.kt @@ -196,6 +196,29 @@ private fun OpeningList( ) } if (open) { + val popupShape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else { + theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { androidx.compose.foundation.shape.RoundedCornerShape(it.dp) } ?: style.shape + } + val popupElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val popupRaised = if (node.setsOwnElevation() || popupElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, popupElevation, popupShape, theme) + } + val explicitPopupPad = theme.overlay?.popupPadding + val popupPadding = if (node.setsOwnPadding()) { + Modifier + } else if (explicitPopupPad != null && explicitPopupPad > 0) { + Modifier.padding(explicitPopupPad.dp) + } else { + Modifier.padding(vertical = theme.space(SpaceRole.Xs)) + } Popup( popupPositionProvider = BelowAnchorPositionProvider, onDismissRequest = { open = false }, @@ -205,10 +228,11 @@ private fun OpeningList( Column( Modifier .testTag(dropdownPopupTestTag(node.id)) - .clip(style.shape) - .background(style.container, style.shape) - .border(style.borderWidth, style.borderColor, style.shape) - .padding(vertical = theme.space(SpaceRole.Xs)), + .then(popupRaised) + .clip(popupShape) + .background(style.container, popupShape) + .border(style.borderWidth, style.borderColor, popupShape) + .then(popupPadding), ) { options.forEachIndexed { index, childId -> key(childId) { diff --git a/compose-rust/android-kotlin/shared/HostOverlay.kt b/compose-rust/android-kotlin/shared/HostOverlay.kt index 43483ea3..834c0246 100644 --- a/compose-rust/android-kotlin/shared/HostOverlay.kt +++ b/compose-rust/android-kotlin/shared/HostOverlay.kt @@ -50,6 +50,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.design.LocalGlassBackdrop import androidx.compose.runtime.CompositionLocalProvider @@ -194,6 +195,9 @@ internal fun HostDialog( val open = rememberOverlayOpen(node) if (!open.value) return val style = theme.rules.container(ContainerRole.Dialog, theme) + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim val surfaceRequester = remember(node.id) { FocusRequester() } val onEscape = { dismiss(node, dispatcher) } Popup( @@ -206,14 +210,13 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(style.scrim), + Modifier.fillMaxSize().background(scrim).testTag(dialogTestTag(node.id)), contentAlignment = Alignment.Center, ) { Column( modifier - .testTag(dialogTestTag(node.id)) .modalOverlaySurface(node.id, surfaceRequester) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Dialog), ) { node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } @@ -265,7 +268,7 @@ internal fun HostMenu( Column( Modifier .testTag(menuPopupTestTag(node.id)) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Menu), ) { entries.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } diff --git a/compose-rust/android-kotlin/shared/HostSheet.kt b/compose-rust/android-kotlin/shared/HostSheet.kt index f5a12bb9..74cd7dce 100644 --- a/compose-rust/android-kotlin/shared/HostSheet.kt +++ b/compose-rust/android-kotlin/shared/HostSheet.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.design.SheetEdge +import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.runtime.LocalWindowSizeClass @@ -41,6 +42,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import kotlin.math.roundToInt /** How far a sheet has to be pulled towards its edge before letting go closes it. */ @@ -84,6 +86,65 @@ internal fun HostSheet( val surfaceRequester = remember(node.id) { FocusRequester() } val onEscape = { dismiss(node, dispatcher) } + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim + + val shape = if (node.setsOwnShape()) { + node.containerShape( + dev.darkpyonix.composerust.design.ContainerStyle( + container = style.container, + content = androidx.compose.ui.graphics.Color.Unspecified, + shape = style.shape, + elevation = style.elevation, + borderWidth = style.borderWidth, + borderColor = style.borderColor, + horizontalPadding = 0.dp, + verticalPadding = 0.dp, + separator = null, + scrim = style.scrim, + typeRole = dev.darkpyonix.composerust.protocol.TypeRole.Body, + ), + theme, + ) + } else { + theme.overlay?.sheetCornerRadius?.takeIf { it > 0 }?.let { + if (bottom) { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, it.dp, 0.dp, 0.dp) + } else { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, 0.dp, 0.dp, it.dp) + } + } ?: style.shape + } + + val sheetElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.sheetElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val raised = if (node.setsOwnElevation() || sheetElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, sheetElevation, shape, theme) + } + + val containerBg = if (node.setsOwnBackground()) Modifier else Modifier.background(style.container) + + val outline = if (style.borderWidth.value > 0f) { + Modifier.border(style.borderWidth, style.borderColor, shape) + } else { + Modifier + } + + val explicitSheetPad = theme.overlay?.sheetPadding + val sheetPadding = if (node.setsOwnPadding()) { + Modifier + } else if (explicitSheetPad != null && explicitSheetPad > 0) { + Modifier.padding(explicitSheetPad.dp) + } else { + Modifier.padding(style.padding) + } + Popup( alignment = Alignment.Center, properties = PopupProperties(focusable = true, dismissOnBackPress = false), @@ -94,7 +155,7 @@ internal fun HostSheet( Modifier .testTag(sheetScrimTestTag(node.id)) .fillMaxSize() - .background(style.scrim) + .background(scrim) // Pressing what the sheet covers is the other way of asking to close it, // and it gets the same answer: one dismissal. A raw tap detector rather // than `clickable`, because a scrim is not a button and saying it is one @@ -107,11 +168,6 @@ internal fun HostSheet( } else { Modifier.fillMaxHeight().fillMaxWidth(style.widthFraction) } - val outline = if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, style.borderColor, style.shape) - } else { - Modifier - } // Two boxes rather than one: the outer is the sheet itself, which is what is // dragged and what the design system sized, and the inner carries whatever // Modifiers the Host put on the node, which belong inside its surface. @@ -127,8 +183,9 @@ internal fun HostSheet( IntOffset(travel.roundToInt(), 0) } } - .clip(style.shape) - .background(style.container) + .then(raised) + .clip(shape) + .then(containerBg) .then(outline) .draggable( orientation = if (bottom) Orientation.Vertical else Orientation.Horizontal, @@ -143,7 +200,7 @@ internal fun HostSheet( // swallowed here rather than reaching the scrim behind. .pointerInput(Unit) { detectTapGestures { } }, ) { - Column(modifier.fillMaxWidth().padding(style.padding)) { + Column(modifier.fillMaxWidth().then(sheetPadding)) { style.handle?.let { handle -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 1d4dab3f..e74429f5 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import dev.darkpyonix.composerust.foundation.ChromeInsets import dev.darkpyonix.composerust.foundation.MessageQueue import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.Theme @@ -187,6 +188,10 @@ class NodeTable { var theme: Theme? by mutableStateOf(null) private set + /** Null until the Host sends an overlay tokens record. */ + var overlayTokens: OverlayTokens? by mutableStateOf(null) + private set + /** Null until the Host sends its first `SetWindow` record. */ var window: Window? by mutableStateOf(null) private set @@ -320,6 +325,7 @@ class NodeTable { fail(TableError.INVALID_PALETTE, "theme palette: $problem") } } + is Mutation.SetOverlayTokens -> overlayTokens = mutation.overlay // Read before there is a window to apply it to. The platform layer asks for // this out of the first batch and builds the window from it, so by the time // the batch is applied the window already looks the way it says. Keeping it @@ -974,6 +980,11 @@ class NodeTable { widget == WidgetKind.Menu || // A sheet is an overlay on the same terms as a dialog. widget == WidgetKind.Sheet + + // A custom scrim colour for modal and popup overlays. + PropertyKind.Scrim -> widget == WidgetKind.Dialog || + widget == WidgetKind.Menu || + widget == WidgetKind.Sheet // A Dropdown's selection is a position in its own children, the same thing // a tab strip's selection is. PropertyKind.SelectedIndex -> diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index a3a53b00..93583d78 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Scrim, Progress } enum class Key { Enter, Other } @@ -195,6 +195,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -1279,6 +1293,9 @@ sealed interface Mutation { val name: String, val filters: String, ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } sealed interface HostEvent { @@ -1334,7 +1351,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 6134367089496884194L + const val SCHEMA_HASH: Long = 2307080319921313708L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1364,6 +1381,7 @@ object Protocol { private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1530,6 +1548,23 @@ object Protocol { readString(batch, base, available, offset + 40), ) } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the @@ -2283,6 +2318,7 @@ object Protocol { 125 -> PropertyKind.CheckColor 126 -> PropertyKind.ControlSize 127 -> PropertyKind.ReportSize + 128 -> PropertyKind.Scrim 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index b543eb68..03b6c45d 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x5521a65ddb4f03e2 +0x20046553d93457ac diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index 918a18c0..da7e6dc6 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -148,6 +148,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -488,6 +502,9 @@ sealed interface Mutation { val name: String, val filters: String, ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } "#, @@ -626,6 +643,7 @@ object Protocol { private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -792,6 +810,23 @@ object Protocol { readString(batch, base, available, offset + 40), ) } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index f87d19f1..a60644c5 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -42,6 +42,10 @@ const TAG_SHOW_FILE_DIALOG: u16 = 26; /// `ShowFileDialog` after its header: the request, the window, the kind and the default /// filter, then four string references. 44 bytes, so the record is 48. const SHOW_FILE_DIALOG_PAYLOAD_LEN: usize = 4 + 4 + 2 + 2 + 4 * 8; +// 27 sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. +const TAG_SET_OVERLAY_TOKENS: u16 = 27; +/// 9 u16 fields (18 bytes) + 1 u16 reserved (2 bytes) + 1 u32 scrim (4 bytes) = 24 bytes. +const OVERLAY_TOKENS_PAYLOAD_LEN: usize = 24; /// The head of a `StartAnimation` record, before its keyframes. const ANIMATION_HEADER_LEN: usize = 52; /// A keyframe before its value: offset, timing, step position, flags and four numbers. @@ -268,6 +272,8 @@ pub enum Mutation<'a> { name: &'a str, filters: &'a str, }, + /// Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + SetOverlayTokens(crate::schema::OverlayTokens), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1507,6 +1513,20 @@ impl BatchEncoder { self.put_string_ref(name)?; self.put_string_ref(filters)?; } + Mutation::SetOverlayTokens(overlay) => { + self.begin_record(TAG_SET_OVERLAY_TOKENS, OVERLAY_TOKENS_PAYLOAD_LEN as u16); + self.put_u16(overlay.sheet_corner_radius); + self.put_u16(overlay.dialog_corner_radius); + self.put_u16(overlay.popup_corner_radius); + self.put_u16(overlay.sheet_elevation); + self.put_u16(overlay.popup_elevation); + self.put_u16(overlay.dialog_elevation); + self.put_u16(overlay.sheet_padding); + self.put_u16(overlay.dialog_padding); + self.put_u16(overlay.popup_padding); + self.put_u16(0); + self.put_u32(overlay.scrim); + } Mutation::SetWindow(window) => { self.begin_record(TAG_SET_WINDOW, 28); self.put_u16(window.chrome as u16); @@ -1827,6 +1847,7 @@ fn decode_theme(bytes: &[u8], payload: usize, records_len: usize) -> Result Result>, ProtocolError> { } } TAG_SHOW_FILE_DIALOG => return Err(ProtocolError::InvalidRecordLength), + TAG_SET_OVERLAY_TOKENS if len == 4 + OVERLAY_TOKENS_PAYLOAD_LEN => { + let sheet_corner_radius = read_u16(bytes, payload)?; + let dialog_corner_radius = read_u16(bytes, payload + 2)?; + let popup_corner_radius = read_u16(bytes, payload + 4)?; + let sheet_elevation = read_u16(bytes, payload + 6)?; + let popup_elevation = read_u16(bytes, payload + 8)?; + let dialog_elevation = read_u16(bytes, payload + 10)?; + let sheet_padding = read_u16(bytes, payload + 12)?; + let dialog_padding = read_u16(bytes, payload + 14)?; + let popup_padding = read_u16(bytes, payload + 16)?; + let scrim = read_u32(bytes, payload + 20)?; + Mutation::SetOverlayTokens(crate::schema::OverlayTokens { + sheet_corner_radius, + dialog_corner_radius, + popup_corner_radius, + sheet_elevation, + popup_elevation, + dialog_elevation, + sheet_padding, + dialog_padding, + popup_padding, + scrim, + }) + } + TAG_SET_OVERLAY_TOKENS => return Err(ProtocolError::InvalidRecordLength), TAG_SET_WINDOW if len == 32 => { let chrome = read_u16(bytes, payload)?; let title_bar = read_u16(bytes, payload + 2)?; @@ -2136,7 +2182,7 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { | TAG_START_ANIMATION | TAG_CONTROL_ANIMATION | TAG_SET_SELECTION..=TAG_SET_FOLDED - | TAG_SET_WINDOW_THEME..=TAG_REQUEST_FOCUS => { + | TAG_SET_WINDOW_THEME..=TAG_SET_OVERLAY_TOKENS => { return Err(ProtocolError::InvalidRecordLength); } other => return Err(ProtocolError::InvalidTag(other)), diff --git a/compose-rust/src/runtime/seam.rs b/compose-rust/src/runtime/seam.rs index ea62e8b6..cf9e7612 100644 --- a/compose-rust/src/runtime/seam.rs +++ b/compose-rust/src/runtime/seam.rs @@ -86,6 +86,14 @@ impl Batch { /// The root theme record. Written once per rebuild, never per frame. pub fn set_theme(&mut self, theme: Theme) { self.write(Mutation::SetTheme(theme)); + if let Some(overlay) = theme.overlay { + self.write(Mutation::SetOverlayTokens(*overlay)); + } + } + + /// Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + pub fn set_overlay_tokens(&mut self, tokens: crate::schema::OverlayTokens) { + self.write(Mutation::SetOverlayTokens(tokens)); } /// The root window record. Written once per rebuild, never per frame. diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index bfbe3f08..dd90e7ed 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -206,13 +206,13 @@ pub struct EventSchema { pub const SCHEMA_DESCRIPTOR: &str = concat!( "compose-rust/v1;", "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox,Window;", - "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,on_focus_gained,on_key_up,accessible_name,track_color,thumb_color,active_track_color,container_color,check_color,control_size,report_size;", + "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,on_focus_gained,on_key_up,accessible_name,track_color,thumb_color,active_track_color,container_color,check_color,control_size,report_size,scrim;", "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,Transform3D,MatchParentSize,WindowDrag,PressFeedback,Semantics;", "keys=Enter,Other;", "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen,FocusGained,KeyUp,FileDialogResult;", "windowsizeclasses=Compact,Medium,Expanded;", "windowheightclasses=Compact,Medium,Expanded;", - "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus,ShowFileDialog" + "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus,ShowFileDialog,SetOverlayTokens" ); const fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { @@ -1797,11 +1797,100 @@ pub struct Theme { /// platform. A palette built while the application runs is handed to `use_theme`, /// which owns it, so application code never has to give it that lifetime itself. pub palette: Option<&'static crate::palette::Palette>, + /// Application-level overlay theme tokens for Dialog, Menu, Dropdown, and Sheet. + pub overlay: Option<&'static OverlayTokens>, } /// How many type roles there are, which is how many font slots a theme carries. pub const TYPE_ROLE_COUNT: usize = 9; +/// Application-level overlay theme tokens for Dialog, Menu, Dropdown, and Sheet. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct OverlayTokens { + pub sheet_corner_radius: u16, + pub dialog_corner_radius: u16, + pub popup_corner_radius: u16, + pub sheet_elevation: u16, + pub popup_elevation: u16, + pub dialog_elevation: u16, + pub sheet_padding: u16, + pub dialog_padding: u16, + pub popup_padding: u16, + pub scrim: u32, +} + +impl OverlayTokens { + pub const fn new() -> Self { + Self { + sheet_corner_radius: 0, + dialog_corner_radius: 0, + popup_corner_radius: 0, + sheet_elevation: 0, + popup_elevation: 0, + dialog_elevation: 0, + sheet_padding: 0, + dialog_padding: 0, + popup_padding: 0, + scrim: 0, + } + } + + pub const fn sheet_corner_radius(mut self, radius: u16) -> Self { + self.sheet_corner_radius = radius; + self + } + + pub const fn dialog_corner_radius(mut self, radius: u16) -> Self { + self.dialog_corner_radius = radius; + self + } + + pub const fn popup_corner_radius(mut self, radius: u16) -> Self { + self.popup_corner_radius = radius; + self + } + + pub const fn sheet_elevation(mut self, elevation: u16) -> Self { + self.sheet_elevation = elevation; + self + } + + pub const fn popup_elevation(mut self, elevation: u16) -> Self { + self.popup_elevation = elevation; + self + } + + pub const fn dialog_elevation(mut self, elevation: u16) -> Self { + self.dialog_elevation = elevation; + self + } + + pub const fn sheet_padding(mut self, padding: u16) -> Self { + self.sheet_padding = padding; + self + } + + pub const fn dialog_padding(mut self, padding: u16) -> Self { + self.dialog_padding = padding; + self + } + + pub const fn popup_padding(mut self, padding: u16) -> Self { + self.popup_padding = padding; + self + } + + pub const fn scrim(mut self, color: Color) -> Self { + self.scrim = color.to_argb(); + self + } + + pub const fn scrim_argb(mut self, argb: u32) -> Self { + self.scrim = argb; + self + } +} + impl Theme { /// The same design system on every platform. pub const fn unified(design_system: DesignSystem) -> Self { @@ -1812,6 +1901,7 @@ impl Theme { adaptive: false, fonts: [0; TYPE_ROLE_COUNT], palette: None, + overlay: None, } } @@ -1824,6 +1914,7 @@ impl Theme { adaptive: true, fonts: [0; TYPE_ROLE_COUNT], palette: None, + overlay: None, } } @@ -1879,6 +1970,12 @@ impl Theme { self } + /// Overrides overlay geometry, spacing, elevation and scrim for Dialog, Menu, Dropdown and Sheet. + pub const fn with_overlay_tokens(mut self, overlay: &'static OverlayTokens) -> Self { + self.overlay = Some(overlay); + self + } + /// The font this role resolves to, or `None` for the system font. pub const fn font(&self, role: TypeRole) -> Option { if let TypeRole::None = role { @@ -3159,6 +3256,8 @@ crate::extensions::define_property_schema_with_extensions!(define_wire_enum; PRO // whole dp, changes, and not only when the size class does. A `Window` node's boolean; // absent is false. ReportSize = 127, + // Scrim colour for modal overlays (Dialog, Sheet, Menu), as a `Paint` bit value. + Scrim = 128, }); #[derive(Clone, Debug, PartialEq)] diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 8b4643c1..b7f5a52c 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -2211,6 +2211,7 @@ pub struct ModalScope { modifier: Modifier, widget: WidgetKind, open: bool, + scrim: Option, on_dismiss: Option>, done: bool, } @@ -2222,6 +2223,7 @@ pub fn Dialog(open: bool) -> ModalScope { modifier: Modifier::new(), widget: WidgetKind::Dialog, open, + scrim: None, on_dismiss: None, done: false, } @@ -2234,6 +2236,7 @@ pub fn Sheet(open: bool) -> ModalScope { modifier: Modifier::new(), widget: WidgetKind::Sheet, open, + scrim: None, on_dismiss: None, done: false, } @@ -2245,6 +2248,12 @@ impl ModalScope { self } + /// Sets the modal scrim colour, overriding the design system default. + pub fn scrim(mut self, paint: impl Into) -> Self { + self.scrim = Some(paint.into()); + self + } + fn compose(&mut self, content: impl FnOnce()) { let modifier = self.modifier; let (widget, open) = (self.widget, self.open); @@ -2253,12 +2262,16 @@ impl ModalScope { } else { widget_key("compose_rust::ui::Sheet") }; + let scrim = self.scrim; emit( key, widget, |attrs| { push_modifiers(&modifier, Mods::Standard, attrs); attrs.push(("open", OwnedAttr::Bool(open))); + if let Some(scrim) = scrim { + attrs.push(("scrim", super::paint(Some(scrim)))); + } }, vec![("ondismiss", unit(self.on_dismiss.take()))], content, @@ -2272,6 +2285,7 @@ container!(ModalScope); pub struct MenuScope<'a> { modifier: Modifier, expanded: bool, + scrim: Option, on_dismiss: Option>, anchor: Option>, done: bool, @@ -2283,6 +2297,7 @@ pub fn Menu<'a>(expanded: bool) -> MenuScope<'a> { MenuScope { modifier: Modifier::new(), expanded, + scrim: None, on_dismiss: None, anchor: None, done: false, @@ -2295,6 +2310,12 @@ impl<'a> MenuScope<'a> { self } + /// Sets the popup scrim colour, overriding the design system default. + pub fn scrim(mut self, paint: impl Into) -> Self { + self.scrim = Some(paint.into()); + self + } + /// The widget the menu hangs off. It is the first child on the wire. pub fn anchor(mut self, anchor: impl FnOnce() + 'a) -> Self { self.anchor = Some(std::boxed::Box::new(anchor)); @@ -2305,12 +2326,16 @@ impl<'a> MenuScope<'a> { let modifier = self.modifier; let expanded = self.expanded; let anchor = self.anchor.take(); + let scrim = self.scrim; emit( widget_key("compose_rust::ui::Menu"), WidgetKind::Menu, |attrs| { push_modifiers(&modifier, Mods::Standard, attrs); attrs.push(("open", OwnedAttr::Bool(expanded))); + if let Some(scrim) = scrim { + attrs.push(("scrim", super::paint(Some(scrim)))); + } }, vec![("ondismiss", unit(self.on_dismiss.take()))], || { diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs new file mode 100644 index 00000000..2b5e5d51 --- /dev/null +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -0,0 +1,70 @@ +//! Overlay theme tokens and node geometry overrides. + +use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; +use compose_rust::schema::{DesignSystem, OverlayTokens, PropertyKind, Theme}; + +#[test] +fn fr74_overlay_tokens_command_round_trips() { + let tokens = OverlayTokens::new() + .sheet_corner_radius(24) + .dialog_corner_radius(16) + .popup_corner_radius(12) + .sheet_elevation(8) + .dialog_elevation(6) + .popup_elevation(4) + .sheet_padding(20) + .dialog_padding(16) + .popup_padding(8) + .scrim_argb(0x80000000); + + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::SetOverlayTokens(tokens)).unwrap(); + let bytes = encoder.finish().unwrap(); + + let decoded = decode_batch(bytes).unwrap(); + assert_eq!(decoded.len(), 1); + match &decoded[0] { + Mutation::SetOverlayTokens(actual) => { + assert_eq!(*actual, tokens); + assert_eq!(actual.sheet_corner_radius, 24); + assert_eq!(actual.dialog_corner_radius, 16); + assert_eq!(actual.popup_corner_radius, 12); + assert_eq!(actual.sheet_elevation, 8); + assert_eq!(actual.dialog_elevation, 6); + assert_eq!(actual.popup_elevation, 4); + assert_eq!(actual.sheet_padding, 20); + assert_eq!(actual.dialog_padding, 16); + assert_eq!(actual.popup_padding, 8); + assert_eq!(actual.scrim, 0x80000000); + } + other => panic!("expected SetOverlayTokens, got {other:?}"), + } +} + +#[test] +fn fr74_theme_builder_attaches_overlay_tokens() { + static TOKENS: OverlayTokens = OverlayTokens { + sheet_corner_radius: 24, + dialog_corner_radius: 16, + popup_corner_radius: 12, + sheet_elevation: 8, + popup_elevation: 4, + dialog_elevation: 6, + sheet_padding: 20, + dialog_padding: 16, + popup_padding: 8, + scrim: 0x66000000, + }; + + let theme = Theme::unified(DesignSystem::Cupertino).with_overlay_tokens(&TOKENS); + assert_eq!(theme.overlay, Some(&TOKENS)); +} + +#[test] +fn fr74_property_kind_scrim_has_correct_wire_tag() { + assert_eq!(PropertyKind::Scrim as u16, 128); + assert_eq!( + PropertyKind::from_name("scrim").unwrap(), + PropertyKind::Scrim + ); +} diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index c25568d0..cf0dd8b1 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,6 +1,6 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "6134367089496884194", + "schemaHashUnsigned": "2307080319921313708", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", diff --git a/docs/SPEC.md b/docs/SPEC.md index 1b501b3a..cdb09d75 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -4122,6 +4122,33 @@ Host API는 `ctx.show_file_dialog(FileDialog::open_files().title("...").filter(" 5. 클립보드를 쓸 수 없는 환경에서 명령은 아무 일도 하지 않고 프로세스는 계속 돕니다(시험 이름 fr73_unavailable_clipboard_is_silently_ignored). 6. Renderer의 어느 로그 줄에도 `text`의 내용이 들어가지 않습니다(시험 이름 fr73_clipboard_text_is_never_logged). +### FR-74 오버레이 테마 토큰과 기하 구조 제어 (`Draft`, 이슈 #506) + +요청한 쪽: darkpyonix-ember(#739, #740, `docs/DESIGN.md` §2, §4.6). Ember는 모든 OS에서 24px 연속 곡률 모서리(`corner-xl`), 지정된 팝업과 시트 그림자, 여백, 반투명 배경(scrim)을 쓰는 단일 디자인을 필요로 합니다. `Theme::with_palette`는 색상 역할만 재정의하므로 툴킷의 `Dialog`, `Menu`, `Dropdown`, `Sheet`에 대한 모양(shape), 여백(padding), 그림자/고도(elevation), 스크림(scrim)을 앱 단위로 일괄 지정할 수 없습니다. 이 항목은 애플리케이션 레벨의 오버레이 테마 토큰과 개별 노드 속성을 제공하여 기본 동작을 해치지 않고 디자인을 맞출 수 있게 합니다. + +- **명령 `SetOverlayTokens`(명령 태그 27)**: 오버레이 기하 구조와 효과를 지정하는 고정 길이 레코드입니다. + - `sheet_corner_radius: u16`: 시트 모서리 반지름(dp, 0은 시스템 기본). + - `dialog_corner_radius: u16`: 대화상자 모서리 반지름(dp, 0은 시스템 기본). + - `popup_corner_radius: u16`: 메뉴/드롭다운 모서리 반지름(dp, 0은 시스템 기본). + - `sheet_elevation: u16`: 시트 고도(dp, 0은 시스템 기본). + - `popup_elevation: u16`: 팝업 고도(dp, 0은 시스템 기본). + - `dialog_elevation: u16`: 대화상자 고도(dp, 0은 시스템 기본). + - `sheet_padding: u16`: 시트 내용 여백(dp, 0은 시스템 기본). + - `dialog_padding: u16`: 대화상자 내용 여백(dp, 0은 시스템 기본). + - `scrim: u32`: ARGB 스크림 색상(0은 시스템 기본). +- **노드 레벨 우선순위**: + - `Sheet`는 노드가 자체 모양(`node.containerShape`), 자체 여백(`node.setsOwnPadding()`), 자체 배경(`node.setsOwnBackground()`), 자체 고도(`node.setsOwnElevation()`)를 지정하면 시스템 기본이나 테마 토큰보다 노드의 값을 우선 적용합니다. + - `PropertyKind::Scrim(128)`: 개별 `Sheet`, `Dialog`, `Menu` 노드에 지정 가능한 스크림 `Paint` 속성입니다. +- **Host API**: + - `OverlayTokens`: 빌더 패턴으로 각 필드를 설정하는 `Copy` 구조체입니다. + - `Theme::with_overlay_tokens(self, &'static OverlayTokens) -> Self`. + - `Sheet`, `Dialog`, `Menu` 빌더에 `.scrim(paint)` 지원. + +수용 기준: +1. `Theme::with_overlay_tokens`가 설정되면 `SetOverlayTokens`(명령 태그 27)가 인코딩되어 렌더러로 전달됩니다(시험 이름 fr74_overlay_tokens_command_round_trips). +2. `Sheet`가 테마의 `sheet_corner_radius`와 `scrim`을 반영하여 렌더링되고, 노드에 직접 지정한 모서리 반지름과 스크림이 테마 토큰보다 우선합니다(시험 이름 fr74_sheet_honors_overlay_tokens_and_own_modifiers). +3. `Dialog`와 `Menu`가 테마의 스크림과 모서리 설정을 반영하고, 개별 노드의 `scrim` 속성을 존중합니다(시험 이름 fr74_dialog_and_menu_honor_scrim_and_tokens). + ### FR-75 텍스트 필드 타이포그래피와 아이콘 버튼 레이아웃 제어 (`Draft`, 이슈 #507) 요청한 쪽: darkpyonix-ember(#739, #740, `docs/DESIGN.md` §2, §4.1). Ember NFR-L3은 여러 OS에서 동일한 컴포저와 버튼 모양을 필요로 합니다. `HostTextField`는 타이포그래피와 내부 여백을 테마 기본 규칙(`theme.rules.field`)에서 파생하고, `HostButton`은 명시적 크기를 가진 아이콘 버튼에도 테마 버튼의 최소 높이와 여백을 강제 적용하여 전역 테마 변경 시 컴포저 플레이스홀더와 플러스 버튼의 위치와 크기가 달라지는 문제가 있었습니다. 이 항목은 텍스트 필드의 명시적 타이포그래피와 내용 여백 제어, 아이콘 버튼의 크기 존중 레이아웃을 제공합니다. diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index a3a53b00..93583d78 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -8,7 +8,7 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Scrim, Progress } enum class Key { Enter, Other } @@ -195,6 +195,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -1279,6 +1293,9 @@ sealed interface Mutation { val name: String, val filters: String, ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } sealed interface HostEvent { @@ -1334,7 +1351,7 @@ class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 6134367089496884194L + const val SCHEMA_HASH: Long = 2307080319921313708L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1364,6 +1381,7 @@ object Protocol { private const val TAG_FOCUS_WINDOW = 24 private const val TAG_REQUEST_FOCUS = 25 private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1530,6 +1548,23 @@ object Protocol { readString(batch, base, available, offset + 40), ) } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the @@ -2283,6 +2318,7 @@ object Protocol { 125 -> PropertyKind.CheckColor 126 -> PropertyKind.ControlSize 127 -> PropertyKind.ReportSize + 128 -> PropertyKind.Scrim 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index 03c40c11..681dcea3 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -484,6 +484,7 @@ private fun ComposeRustTree( chainOf = { asset -> (host.table.assets.asset(asset) as? Asset.Font)?.chain }, brushOf = { asset -> (host.table.assets.asset(asset) as? Asset.Brush)?.brush }, windowBackdrop = asked && platformBacksWindowWithMaterial(), + overlay = host.table.overlayTokens, ) // The animations the Host handed over are played here, on this side's frame clock, and // the Host is not called while they play unless one asked for events. The loop waits diff --git a/renderer/desktop/src/renderer/DesignSystem.kt b/renderer/desktop/src/renderer/DesignSystem.kt index 682b9586..f603e80f 100644 --- a/renderer/desktop/src/renderer/DesignSystem.kt +++ b/renderer/desktop/src/renderer/DesignSystem.kt @@ -22,6 +22,7 @@ import dev.darkpyonix.composerust.protocol.DesignTokens import androidx.compose.animation.core.FiniteAnimationSpec import dev.darkpyonix.composerust.protocol.MaterialRole import dev.darkpyonix.composerust.protocol.MotionRole +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.protocol.SpaceRole @@ -180,6 +181,11 @@ class ResolvedTheme( * given is enough to find the rest of the stack: see [chainFor]. */ val fontChains: List = emptyList(), + + /** + * Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + */ + val overlay: OverlayTokens? = null, ) { /** * The stack [family] is the first family of, or null where it is not one. @@ -1638,6 +1644,8 @@ fun resolveTheme( * family lacks in the families after it. */ chainOf: (Int) -> FontChain? = { null }, + /** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + overlay: OverlayTokens? = null, ): ResolvedTheme { val system = when { theme == null -> adaptiveSystem(platform, DesignSystem.Material3) @@ -1684,6 +1692,7 @@ fun resolveTheme( platform, application, chains, + overlay, ) } diff --git a/renderer/desktop/src/renderer/HostContainer.kt b/renderer/desktop/src/renderer/HostContainer.kt index 9914be33..bafe5709 100644 --- a/renderer/desktop/src/renderer/HostContainer.kt +++ b/renderer/desktop/src/renderer/HostContainer.kt @@ -7,6 +7,7 @@ import dev.darkpyonix.composerust.protocol.TypeRole import dev.darkpyonix.composerust.ui.textStyle import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,12 +46,20 @@ import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.weightOf import dev.darkpyonix.composerust.design.liftsOffThePage +/** True when the Host set a shape modifier of its own, which replaces the default shape. */ +internal fun Node.setsOwnShape(): Boolean = + modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + /** * The corner this container is drawn with: the one the Host asked for if it asked, and the * design system's corner for this kind of container otherwise. */ internal fun Node.containerShape(style: ContainerStyle, theme: ResolvedTheme): Shape = - if (modifiers.any { it is ProtocolModifier.Shape || it is ProtocolModifier.ShapeRole || it is ProtocolModifier.CornerEach }) { + if (setsOwnShape()) { modifiers.resolvedShape(theme) } else { style.shape @@ -98,12 +107,34 @@ internal fun Modifier.containerDecoration( theme: ResolvedTheme, /** True for a menu, a dialog or a tooltip: a sheet over the page rather than in it. */ overlay: Boolean = false, + role: ContainerRole? = null, ): Modifier { - val shape = node.containerShape(style, theme) - val raised = if (node.setsOwnElevation()) { + val shape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + else -> null + } ?: style.shape + } else { + style.shape + } + val effectiveElevation = if (node.setsOwnElevation()) { + 0.dp + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + else -> null + } ?: style.elevation + } else { + style.elevation + } + val raised = if (node.setsOwnElevation() || effectiveElevation.value <= 0f) { this } else { - theme.rules.elevation(this, style.elevation, shape, theme) + theme.rules.elevation(this, effectiveElevation, shape, theme) } val material = style.material // A Host that named its own Background has already had it applied, so painting the @@ -139,6 +170,17 @@ internal fun Modifier.containerDecoration( .then( if (node.setsOwnPadding()) { Modifier + } else if (overlay) { + val customPadding = when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + else -> null + } + if (customPadding != null) { + Modifier.padding(customPadding) + } else { + Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) + } } else { Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) }, diff --git a/renderer/desktop/src/renderer/HostDropdown.kt b/renderer/desktop/src/renderer/HostDropdown.kt index 52b3f526..dc1fcdd9 100644 --- a/renderer/desktop/src/renderer/HostDropdown.kt +++ b/renderer/desktop/src/renderer/HostDropdown.kt @@ -196,6 +196,29 @@ private fun OpeningList( ) } if (open) { + val popupShape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else { + theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { androidx.compose.foundation.shape.RoundedCornerShape(it.dp) } ?: style.shape + } + val popupElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val popupRaised = if (node.setsOwnElevation() || popupElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, popupElevation, popupShape, theme) + } + val explicitPopupPad = theme.overlay?.popupPadding + val popupPadding = if (node.setsOwnPadding()) { + Modifier + } else if (explicitPopupPad != null && explicitPopupPad > 0) { + Modifier.padding(explicitPopupPad.dp) + } else { + Modifier.padding(vertical = theme.space(SpaceRole.Xs)) + } Popup( popupPositionProvider = BelowAnchorPositionProvider, onDismissRequest = { open = false }, @@ -205,10 +228,11 @@ private fun OpeningList( Column( Modifier .testTag(dropdownPopupTestTag(node.id)) - .clip(style.shape) - .background(style.container, style.shape) - .border(style.borderWidth, style.borderColor, style.shape) - .padding(vertical = theme.space(SpaceRole.Xs)), + .then(popupRaised) + .clip(popupShape) + .background(style.container, popupShape) + .border(style.borderWidth, style.borderColor, popupShape) + .then(popupPadding), ) { options.forEachIndexed { index, childId -> key(childId) { diff --git a/renderer/desktop/src/renderer/HostOverlay.kt b/renderer/desktop/src/renderer/HostOverlay.kt index 43483ea3..834c0246 100644 --- a/renderer/desktop/src/renderer/HostOverlay.kt +++ b/renderer/desktop/src/renderer/HostOverlay.kt @@ -50,6 +50,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.design.LocalGlassBackdrop import androidx.compose.runtime.CompositionLocalProvider @@ -194,6 +195,9 @@ internal fun HostDialog( val open = rememberOverlayOpen(node) if (!open.value) return val style = theme.rules.container(ContainerRole.Dialog, theme) + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim val surfaceRequester = remember(node.id) { FocusRequester() } val onEscape = { dismiss(node, dispatcher) } Popup( @@ -206,14 +210,13 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(style.scrim), + Modifier.fillMaxSize().background(scrim).testTag(dialogTestTag(node.id)), contentAlignment = Alignment.Center, ) { Column( modifier - .testTag(dialogTestTag(node.id)) .modalOverlaySurface(node.id, surfaceRequester) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Dialog), ) { node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } @@ -265,7 +268,7 @@ internal fun HostMenu( Column( Modifier .testTag(menuPopupTestTag(node.id)) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Menu), ) { entries.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } diff --git a/renderer/desktop/src/renderer/HostSheet.kt b/renderer/desktop/src/renderer/HostSheet.kt index f5a12bb9..74cd7dce 100644 --- a/renderer/desktop/src/renderer/HostSheet.kt +++ b/renderer/desktop/src/renderer/HostSheet.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.design.SheetEdge +import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.runtime.LocalWindowSizeClass @@ -41,6 +42,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import kotlin.math.roundToInt /** How far a sheet has to be pulled towards its edge before letting go closes it. */ @@ -84,6 +86,65 @@ internal fun HostSheet( val surfaceRequester = remember(node.id) { FocusRequester() } val onEscape = { dismiss(node, dispatcher) } + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim + + val shape = if (node.setsOwnShape()) { + node.containerShape( + dev.darkpyonix.composerust.design.ContainerStyle( + container = style.container, + content = androidx.compose.ui.graphics.Color.Unspecified, + shape = style.shape, + elevation = style.elevation, + borderWidth = style.borderWidth, + borderColor = style.borderColor, + horizontalPadding = 0.dp, + verticalPadding = 0.dp, + separator = null, + scrim = style.scrim, + typeRole = dev.darkpyonix.composerust.protocol.TypeRole.Body, + ), + theme, + ) + } else { + theme.overlay?.sheetCornerRadius?.takeIf { it > 0 }?.let { + if (bottom) { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, it.dp, 0.dp, 0.dp) + } else { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, 0.dp, 0.dp, it.dp) + } + } ?: style.shape + } + + val sheetElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.sheetElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val raised = if (node.setsOwnElevation() || sheetElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, sheetElevation, shape, theme) + } + + val containerBg = if (node.setsOwnBackground()) Modifier else Modifier.background(style.container) + + val outline = if (style.borderWidth.value > 0f) { + Modifier.border(style.borderWidth, style.borderColor, shape) + } else { + Modifier + } + + val explicitSheetPad = theme.overlay?.sheetPadding + val sheetPadding = if (node.setsOwnPadding()) { + Modifier + } else if (explicitSheetPad != null && explicitSheetPad > 0) { + Modifier.padding(explicitSheetPad.dp) + } else { + Modifier.padding(style.padding) + } + Popup( alignment = Alignment.Center, properties = PopupProperties(focusable = true, dismissOnBackPress = false), @@ -94,7 +155,7 @@ internal fun HostSheet( Modifier .testTag(sheetScrimTestTag(node.id)) .fillMaxSize() - .background(style.scrim) + .background(scrim) // Pressing what the sheet covers is the other way of asking to close it, // and it gets the same answer: one dismissal. A raw tap detector rather // than `clickable`, because a scrim is not a button and saying it is one @@ -107,11 +168,6 @@ internal fun HostSheet( } else { Modifier.fillMaxHeight().fillMaxWidth(style.widthFraction) } - val outline = if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, style.borderColor, style.shape) - } else { - Modifier - } // Two boxes rather than one: the outer is the sheet itself, which is what is // dragged and what the design system sized, and the inner carries whatever // Modifiers the Host put on the node, which belong inside its surface. @@ -127,8 +183,9 @@ internal fun HostSheet( IntOffset(travel.roundToInt(), 0) } } - .clip(style.shape) - .background(style.container) + .then(raised) + .clip(shape) + .then(containerBg) .then(outline) .draggable( orientation = if (bottom) Orientation.Vertical else Orientation.Horizontal, @@ -143,7 +200,7 @@ internal fun HostSheet( // swallowed here rather than reaching the scrim behind. .pointerInput(Unit) { detectTapGestures { } }, ) { - Column(modifier.fillMaxWidth().padding(style.padding)) { + Column(modifier.fillMaxWidth().then(sheetPadding)) { style.handle?.let { handle -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 1d4dab3f..e74429f5 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import dev.darkpyonix.composerust.foundation.ChromeInsets import dev.darkpyonix.composerust.foundation.MessageQueue import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.Theme @@ -187,6 +188,10 @@ class NodeTable { var theme: Theme? by mutableStateOf(null) private set + /** Null until the Host sends an overlay tokens record. */ + var overlayTokens: OverlayTokens? by mutableStateOf(null) + private set + /** Null until the Host sends its first `SetWindow` record. */ var window: Window? by mutableStateOf(null) private set @@ -320,6 +325,7 @@ class NodeTable { fail(TableError.INVALID_PALETTE, "theme palette: $problem") } } + is Mutation.SetOverlayTokens -> overlayTokens = mutation.overlay // Read before there is a window to apply it to. The platform layer asks for // this out of the first batch and builds the window from it, so by the time // the batch is applied the window already looks the way it says. Keeping it @@ -974,6 +980,11 @@ class NodeTable { widget == WidgetKind.Menu || // A sheet is an overlay on the same terms as a dialog. widget == WidgetKind.Sheet + + // A custom scrim colour for modal and popup overlays. + PropertyKind.Scrim -> widget == WidgetKind.Dialog || + widget == WidgetKind.Menu || + widget == WidgetKind.Sheet // A Dropdown's selection is a position in its own children, the same thing // a tab strip's selection is. PropertyKind.SelectedIndex -> diff --git a/renderer/desktop/test/HostOverlayTokensTest.kt b/renderer/desktop/test/HostOverlayTokensTest.kt new file mode 100644 index 00000000..49dfd4df --- /dev/null +++ b/renderer/desktop/test/HostOverlayTokensTest.kt @@ -0,0 +1,149 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.foundation.dialogTestTag +import dev.darkpyonix.composerust.foundation.sheetTestTag +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens +import dev.darkpyonix.composerust.protocol.Paint +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalTestApi::class) +class HostOverlayTokensTest { + private val frames = FrameRequestSource() + + @Test + fun fr74_sheet_honors_overlay_tokens_and_own_modifiers() = runComposeUiTest { + val sheetId = 1 + val textId = 2 + val tokens = OverlayTokens( + sheetCornerRadius = 24, + sheetElevation = 8, + sheetPadding = 20, + scrim = 0x80000000L, + ) + val connection = FakeHostConnection( + listOf( + Mutation.SetTheme( + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false), + ), + Mutation.SetOverlayTokens(tokens), + Mutation.Create(sheetId, WidgetKind.Sheet), + Mutation.SetProp(sheetId, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.Create(textId, WidgetKind.Text), + Mutation.SetProp(textId, PropertyKind.Text, PropertyValue.Text("Settings")), + Mutation.Insert(sheetId, textId, 0), + ), + ) + lateinit var host: ComposeRustHost + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + host = rememberComposeRustHost(connection) + ComposeRustContent( + host, + Modifier.requiredSize(500.dp, 800.dp), + ) + } + } + waitForIdle() + + assertEquals(tokens, host.table.overlayTokens) + onNodeWithTag(nodeTestTag(textId)).assertTextEquals("Settings") + + // Overriding scrim on the node directly + val scrimBits = (2L shl 32) or (0x40112233L and 0xFFFFFFFFL) + connection.scheduleFrame( + listOf( + Mutation.SetProp(sheetId, PropertyKind.Scrim, PropertyValue.Integer(scrimBits)), + ), + ) + frames.request() + waitForIdle() + + val node = host.table.node(sheetId) + assertNotNull(node) + assertEquals(Paint.Literal(0x40112233), node.paintProp(PropertyKind.Scrim)) + } + + @Test + fun fr74_dialog_and_menu_honor_scrim_and_tokens() = runComposeUiTest { + val root = 10 + val dialogId = 11 + val dialogText = 12 + val tokens = OverlayTokens( + dialogCornerRadius = 16, + popupCornerRadius = 12, + dialogElevation = 6, + popupElevation = 4, + dialogPadding = 16, + popupPadding = 8, + scrim = 0x66000000L, + ) + val scrimBits = (2L shl 32) or (0x80000000L and 0xFFFFFFFFL) + val connection = FakeHostConnection( + listOf( + Mutation.SetTheme( + Theme(DesignSystem.Cupertino, DesignSystem.Cupertino, ColorScheme.Light, adaptive = false), + ), + Mutation.SetOverlayTokens(tokens), + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(dialogId, WidgetKind.Dialog), + Mutation.SetProp(dialogId, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(dialogId, PropertyKind.Scrim, PropertyValue.Integer(scrimBits)), + Mutation.Create(dialogText, WidgetKind.Text), + Mutation.SetProp(dialogText, PropertyKind.Text, PropertyValue.Text("Confirm")), + Mutation.Insert(dialogId, dialogText, 0), + Mutation.Insert(root, dialogId, 0), + ), + ) + lateinit var host: ComposeRustHost + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + host = rememberComposeRustHost(connection) + ComposeRustContent( + host, + Modifier.requiredSize(800.dp, 600.dp), + ) + } + } + waitForIdle() + + assertEquals(tokens, host.table.overlayTokens) + onNodeWithTag(nodeTestTag(dialogText)).assertTextEquals("Confirm") + onNodeWithTag(dialogTestTag(dialogId)).assertExists() + val dialogNode = host.table.node(dialogId) + assertNotNull(dialogNode) + assertEquals(Paint.Literal(0x80000000.toInt()), dialogNode.paintProp(PropertyKind.Scrim)) + } +} diff --git a/renderer/desktop/test/ProtocolVectorsTest.kt b/renderer/desktop/test/ProtocolVectorsTest.kt index 98f70f56..1418fa11 100644 --- a/renderer/desktop/test/ProtocolVectorsTest.kt +++ b/renderer/desktop/test/ProtocolVectorsTest.kt @@ -101,6 +101,7 @@ class ProtocolVectorsTest { is Mutation.AppendText -> mutation.nodeId !in created // The theme applies to the tree, not to a node, so it is never a bad record. is Mutation.SetTheme -> false + is Mutation.SetOverlayTokens -> false // Nor does the window: it is about the frame around the tree. is Mutation.SetWindow -> false // A window's theme and bringing one forward name a node the vector created.