From 2301bf65674197f3bb1ce32c2e8bf6432737d186 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:36:10 +0900 Subject: [PATCH 01/10] Feat: allow theme overrides for overlay geometry and effects (FR-74, #506) --- .../android-kotlin/shared/ComposeRustHost.kt | 1 + .../android-kotlin/shared/DesignSystem.kt | 8 + .../android-kotlin/shared/HostContainer.kt | 50 ++++++- .../android-kotlin/shared/HostDropdown.kt | 31 +++- .../android-kotlin/shared/HostOverlay.kt | 9 +- .../android-kotlin/shared/HostSheet.kt | 74 ++++++++-- .../android-kotlin/shared/NodeTable.kt | 5 + .../android-kotlin/shared/Protocol.gen.kt | 40 ++++- compose-rust/schema-hash.txt | 2 +- compose-rust/src/codegen.rs | 35 +++++ compose-rust/src/protocol.rs | 48 +++++- compose-rust/src/runtime/seam.rs | 8 + compose-rust/src/schema.rs | 103 ++++++++++++- compose-rust/src/ui/widgets.rs | 25 ++++ compose-rust/tests/fr74_overlay_tokens.rs | 67 +++++++++ compose-rust/tests/vectors/vectors.json | 2 +- docs/SPEC.md | 27 ++++ renderer/desktop/src/protocol/Protocol.gen.kt | 40 ++++- .../desktop/src/renderer/ComposeRustHost.kt | 1 + renderer/desktop/src/renderer/DesignSystem.kt | 8 + .../desktop/src/renderer/HostContainer.kt | 50 ++++++- renderer/desktop/src/renderer/HostDropdown.kt | 31 +++- renderer/desktop/src/renderer/HostOverlay.kt | 9 +- renderer/desktop/src/renderer/HostSheet.kt | 74 ++++++++-- renderer/desktop/src/renderer/NodeTable.kt | 5 + .../desktop/test/HostOverlayTokensTest.kt | 138 ++++++++++++++++++ 26 files changed, 842 insertions(+), 49 deletions(-) create mode 100644 compose-rust/tests/fr74_overlay_tokens.rs create mode 100644 renderer/desktop/test/HostOverlayTokensTest.kt 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..754d2ac6 100644 --- a/compose-rust/android-kotlin/shared/DesignSystem.kt +++ b/compose-rust/android-kotlin/shared/DesignSystem.kt @@ -180,6 +180,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 +1643,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 +1691,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..116fe2e3 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, ContainerRole.Dropdown -> 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, ContainerRole.Dropdown -> 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, ContainerRole.Dropdown -> 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..bbdcb641 100644 --- a/compose-rust/android-kotlin/shared/HostDropdown.kt +++ b/compose-rust/android-kotlin/shared/HostDropdown.kt @@ -196,6 +196,28 @@ 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 popupPadding = if (node.setsOwnPadding()) { + Modifier + } else if (theme.overlay?.popupPadding != null && theme.overlay.popupPadding > 0) { + Modifier.padding(theme.overlay.popupPadding.dp) + } else { + Modifier.padding(vertical = theme.space(SpaceRole.Xs)) + } Popup( popupPositionProvider = BelowAnchorPositionProvider, onDismissRequest = { open = false }, @@ -205,10 +227,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..d8e7ed49 100644 --- a/compose-rust/android-kotlin/shared/HostOverlay.kt +++ b/compose-rust/android-kotlin/shared/HostOverlay.kt @@ -194,6 +194,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 +209,14 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(style.scrim), + Modifier.fillMaxSize().background(scrim), 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..7f43ac61 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,64 @@ 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 sheetPadding = if (node.setsOwnPadding()) { + Modifier + } else if (theme.overlay?.sheetPadding != null && theme.overlay.sheetPadding > 0) { + Modifier.padding(theme.overlay.sheetPadding.dp) + } else { + Modifier.padding(style.padding) + } + Popup( alignment = Alignment.Center, properties = PopupProperties(focusable = true, dismissOnBackPress = false), @@ -94,7 +154,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 +167,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 +182,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 +199,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..3660b134 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -187,6 +187,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 +324,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 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 d8da241b..213e21c3 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -2153,6 +2153,7 @@ pub struct ModalScope { modifier: Modifier, widget: WidgetKind, open: bool, + scrim: Option, on_dismiss: Option>, done: bool, } @@ -2164,6 +2165,7 @@ pub fn Dialog(open: bool) -> ModalScope { modifier: Modifier::new(), widget: WidgetKind::Dialog, open, + scrim: None, on_dismiss: None, done: false, } @@ -2176,6 +2178,7 @@ pub fn Sheet(open: bool) -> ModalScope { modifier: Modifier::new(), widget: WidgetKind::Sheet, open, + scrim: None, on_dismiss: None, done: false, } @@ -2187,6 +2190,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); @@ -2195,12 +2204,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, @@ -2214,6 +2227,7 @@ container!(ModalScope); pub struct MenuScope<'a> { modifier: Modifier, expanded: bool, + scrim: Option, on_dismiss: Option>, anchor: Option>, done: bool, @@ -2225,6 +2239,7 @@ pub fn Menu<'a>(expanded: bool) -> MenuScope<'a> { MenuScope { modifier: Modifier::new(), expanded, + scrim: None, on_dismiss: None, anchor: None, done: false, @@ -2237,6 +2252,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)); @@ -2247,12 +2268,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..4d667668 --- /dev/null +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -0,0 +1,67 @@ +//! Overlay theme tokens and node geometry overrides (FR-74). + +use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; +use compose_rust::schema::{Color, 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::new(); + encoder.encode(&Mutation::SetOverlayTokens(tokens)).unwrap(); + let bytes = encoder.finish(); + + 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 d2c83f00..e2f45acc 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-55 누를 수 있는 컨테이너 (`Agreed`, 이슈 #372) 요청한 쪽: darkpyonix-ember#220(Ember UI를 compose-rust 위에 올리는 일). 포인터 구독(FR-46)만으로 만든 `Box`는 키보드 포커스, 키 활성화, 접근성 역할을 잃습니다. 눌리는 면은 컨트롤이어야 합니다. 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..754d2ac6 100644 --- a/renderer/desktop/src/renderer/DesignSystem.kt +++ b/renderer/desktop/src/renderer/DesignSystem.kt @@ -180,6 +180,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 +1643,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 +1691,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..116fe2e3 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, ContainerRole.Dropdown -> 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, ContainerRole.Dropdown -> 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, ContainerRole.Dropdown -> 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..bbdcb641 100644 --- a/renderer/desktop/src/renderer/HostDropdown.kt +++ b/renderer/desktop/src/renderer/HostDropdown.kt @@ -196,6 +196,28 @@ 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 popupPadding = if (node.setsOwnPadding()) { + Modifier + } else if (theme.overlay?.popupPadding != null && theme.overlay.popupPadding > 0) { + Modifier.padding(theme.overlay.popupPadding.dp) + } else { + Modifier.padding(vertical = theme.space(SpaceRole.Xs)) + } Popup( popupPositionProvider = BelowAnchorPositionProvider, onDismissRequest = { open = false }, @@ -205,10 +227,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..d8e7ed49 100644 --- a/renderer/desktop/src/renderer/HostOverlay.kt +++ b/renderer/desktop/src/renderer/HostOverlay.kt @@ -194,6 +194,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 +209,14 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(style.scrim), + Modifier.fillMaxSize().background(scrim), 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..7f43ac61 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,64 @@ 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 sheetPadding = if (node.setsOwnPadding()) { + Modifier + } else if (theme.overlay?.sheetPadding != null && theme.overlay.sheetPadding > 0) { + Modifier.padding(theme.overlay.sheetPadding.dp) + } else { + Modifier.padding(style.padding) + } + Popup( alignment = Alignment.Center, properties = PopupProperties(focusable = true, dismissOnBackPress = false), @@ -94,7 +154,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 +167,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 +182,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 +199,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..3660b134 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -187,6 +187,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 +324,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 diff --git a/renderer/desktop/test/HostOverlayTokensTest.kt b/renderer/desktop/test/HostOverlayTokensTest.kt new file mode 100644 index 00000000..3feba0d2 --- /dev/null +++ b/renderer/desktop/test/HostOverlayTokensTest.kt @@ -0,0 +1,138 @@ +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.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +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), + ), + ) + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(500.dp, 800.dp), + ) + } + } + waitForIdle() + + assertEquals(tokens, connection.host.table.overlayTokens) + onNodeWithTag(nodeTestTag(textId)).assertTextEquals("Settings") + + // Overriding scrim on the node directly + connection.scheduleFrame( + listOf( + Mutation.SetProp(sheetId, PropertyKind.Scrim, PropertyValue.Paint(Paint.Rgb(0x40, 0x11, 0x22, 0x33))), + ), + ) + frames.request() + waitForIdle() + + val node = connection.host.table.node(sheetId) + assertNotNull(node) + assertEquals(Paint.Rgb(0x40, 0x11, 0x22, 0x33), 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 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.Paint(Paint.Rgb(0x80, 0x00, 0x00, 0x00))), + Mutation.Create(dialogText, WidgetKind.Text), + Mutation.SetProp(dialogText, PropertyKind.Text, PropertyValue.Text("Confirm")), + Mutation.Insert(dialogId, dialogText, 0), + Mutation.Insert(root, dialogId, 0), + ), + ) + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(800.dp, 600.dp), + ) + } + } + waitForIdle() + + assertEquals(tokens, connection.host.table.overlayTokens) + onNodeWithTag(nodeTestTag(dialogText)).assertTextEquals("Confirm") + onNodeWithTag(dialogTestTag(dialogId)).assertExists() + } +} From ca5f24dfdad9d21320914a78d344c97d6b4abb79 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:40:20 +0900 Subject: [PATCH 02/10] Chore: format Rust files with rustfmt (FR-74) --- compose-rust/src/codegen.rs | 28 +++++++++++------------ compose-rust/src/protocol.rs | 25 ++++++-------------- compose-rust/src/ui/widgets.rs | 4 ++-- compose-rust/tests/fr74_overlay_tokens.rs | 7 ++++-- 4 files changed, 28 insertions(+), 36 deletions(-) diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index da7e6dc6..c9878b41 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -1,23 +1,23 @@ //! Deterministic Kotlin protocol generation from the Rust schema. use crate::protocol::{ - BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, encode_event, -}; -use crate::schema::{ - ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, BoundaryOp, BoundaryParam, Color, ColorRole, - ColorScheme, DESIGN_SYSTEM_SCHEMA, DesignSystem, EVENT_SCHEMA, EventPayloadType, FieldSchema, - FieldSlot, FieldType, HOVER_PHASE_SCHEMA, KEY_SCHEMA, Key, MODIFIER_SCHEMA, - NOTIFICATION_PERMISSION_SCHEMA, NotificationImportance, NotificationPermission, - NotificationPresentation, PROPERTY_SCHEMA, PROTOCOL_VERSION, Paint, PropertyKind, - ROLE_ENUM_SCHEMA, SCHEMA_HASH, Selection, ShapeRole, SpaceRole, Theme, WEB_BATCH_BYTES, - WEB_BATCH_FIELDS, WEB_EVENT_BUFFER_BYTES, WEB_EVENT_BUFFER_OFFSET, WEB_HOST_GLOBAL, - WEB_MODULE_GLOBAL, WEB_RENDERER_IMPORT_MODULE, WEB_RUST_REGION_BASE, WEB_START_SYMBOL, - WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA, WINDOW_SIZE_CLASS_SCHEMA, WidgetKind, + encode_event, BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, }; use crate::schema::{ AnimatedProperty, Animation, AnimationControl, AnimationEvents, ColorInterpolation, FillMode, Keyframe, KeyframeValue, PlayState, PlaybackDirection, StepPosition, Timing, TransformFunction, }; +use crate::schema::{ + BoundaryOp, BoundaryParam, Color, ColorRole, ColorScheme, DesignSystem, EventPayloadType, + FieldSchema, FieldSlot, FieldType, Key, NotificationImportance, NotificationPermission, + NotificationPresentation, Paint, PropertyKind, Selection, ShapeRole, SpaceRole, Theme, + WidgetKind, ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, DESIGN_SYSTEM_SCHEMA, EVENT_SCHEMA, + HOVER_PHASE_SCHEMA, KEY_SCHEMA, MODIFIER_SCHEMA, NOTIFICATION_PERMISSION_SCHEMA, + PROPERTY_SCHEMA, PROTOCOL_VERSION, ROLE_ENUM_SCHEMA, SCHEMA_HASH, WEB_BATCH_BYTES, + WEB_BATCH_FIELDS, WEB_EVENT_BUFFER_BYTES, WEB_EVENT_BUFFER_OFFSET, WEB_HOST_GLOBAL, + WEB_MODULE_GLOBAL, WEB_RENDERER_IMPORT_MODULE, WEB_RUST_REGION_BASE, WEB_START_SYMBOL, + WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA, WINDOW_SIZE_CLASS_SCHEMA, +}; use crate::tokens::DESIGN_TOKENS; use crate::{EventPayload, Modifier}; use std::fmt::Write as _; @@ -3586,7 +3586,7 @@ fn lower_camel(name: &str) -> String { /// The drawing command vocabulary, mirrored into Kotlin with a decoder for the byte blob a /// `Canvas` node carries. fn write_draw_commands(output: &mut String) { - use crate::drawing::{COMMAND_LEN, DRAW_COMMAND_SCHEMA, DrawFieldType}; + use crate::drawing::{DrawFieldType, COMMAND_LEN, DRAW_COMMAND_SCHEMA}; output.push_str( "/**\n * One drawing command. Coordinates are dp from the Canvas's top-left corner.\n *\n \ @@ -3860,7 +3860,7 @@ object SpanRecords { /// that record's problem rather than the batch's. fn write_code_editor_records(output: &mut String) { use crate::code::{ - DECORATION_RECORD, FOLD_RECORD, GUTTER_DECORATION_RECORD, RecordFieldType, RecordSchema, + RecordFieldType, RecordSchema, DECORATION_RECORD, FOLD_RECORD, GUTTER_DECORATION_RECORD, SYNTAX_SPAN_RECORD, }; diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index a60644c5..388e5d10 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -1,11 +1,11 @@ //! Fixed-layout little-endian boundary protocol. use crate::schema::{ - AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, Key, - MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, Modifier, MotionRole, - NotificationImportance, NotificationPermission, NotificationPresentation, Paint, PropertyKind, - SEMANTICS_STATE_MASK, Selection, SemanticsRole, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, - WidgetKind, modifier_extra_words, + modifier_extra_words, AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, + Key, MaterialRole, MessageDuration, Modifier, MotionRole, NotificationImportance, + NotificationPermission, NotificationPresentation, Paint, PropertyKind, Selection, + SemanticsRole, ShapeRole, SpaceRole, Theme, WidgetKind, MAX_MODIFIER_WORDS, + SEMANTICS_STATE_MASK, TYPE_ROLE_COUNT, }; use core::fmt; @@ -2584,18 +2584,7 @@ fn decode_paint(bits: u64) -> Result { } fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { - let [ - first, - second, - third, - fourth, - fifth, - sixth, - seventh, - eighth, - ninth, - tenth, - ] = *words; + let [first, second, third, fourth, fifth, sixth, seventh, eighth, ninth, tenth] = *words; match tag { 0 => Ok(Modifier::Empty), 1 => Ok(Modifier::Padding(f32::from_bits(first as u32))), @@ -3902,8 +3891,8 @@ mod tests { fn fr65_text_field_on_composition_change_delivers_bool() { use std::cell::RefCell; use std::rc::Rc; - use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); let calls = Arc::new(AtomicUsize::new(0)); diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 213e21c3..cb192470 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -11,8 +11,8 @@ #![allow(non_snake_case)] use super::{ - Attrs, Modifier, Mods, dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, - role_or_zero, widget_key, + dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, role_or_zero, widget_key, + Attrs, Modifier, Mods, }; use crate::drawing::DrawList; use crate::fonts::FontRefs; diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs index 4d667668..9ec36b5d 100644 --- a/compose-rust/tests/fr74_overlay_tokens.rs +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -1,6 +1,6 @@ //! Overlay theme tokens and node geometry overrides (FR-74). -use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; +use compose_rust::protocol::{decode_batch, BatchEncoder, Mutation}; use compose_rust::schema::{Color, DesignSystem, OverlayTokens, PropertyKind, Theme}; #[test] @@ -63,5 +63,8 @@ fn fr74_theme_builder_attaches_overlay_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); + assert_eq!( + PropertyKind::from_name("scrim").unwrap(), + PropertyKind::Scrim + ); } From d7feb5cf715120e0037c79a6e9f1183fd4499427 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:52:29 +0900 Subject: [PATCH 03/10] Fix: resolve rustfmt ordering and remove planning doc citation (FR-74) --- compose-rust/src/codegen.rs | 28 +++++++++++------------ compose-rust/src/protocol.rs | 25 ++++++++++++++------ compose-rust/src/ui/widgets.rs | 4 ++-- compose-rust/tests/fr74_overlay_tokens.rs | 4 ++-- 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index c9878b41..da7e6dc6 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -1,22 +1,22 @@ //! Deterministic Kotlin protocol generation from the Rust schema. use crate::protocol::{ - encode_event, BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, + BatchEncoder, HostEvent, Mutation, PropertyValue, ProtocolError, encode_event, }; use crate::schema::{ - AnimatedProperty, Animation, AnimationControl, AnimationEvents, ColorInterpolation, FillMode, - Keyframe, KeyframeValue, PlayState, PlaybackDirection, StepPosition, Timing, TransformFunction, -}; -use crate::schema::{ - BoundaryOp, BoundaryParam, Color, ColorRole, ColorScheme, DesignSystem, EventPayloadType, - FieldSchema, FieldSlot, FieldType, Key, NotificationImportance, NotificationPermission, - NotificationPresentation, Paint, PropertyKind, Selection, ShapeRole, SpaceRole, Theme, - WidgetKind, ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, DESIGN_SYSTEM_SCHEMA, EVENT_SCHEMA, - HOVER_PHASE_SCHEMA, KEY_SCHEMA, MODIFIER_SCHEMA, NOTIFICATION_PERMISSION_SCHEMA, - PROPERTY_SCHEMA, PROTOCOL_VERSION, ROLE_ENUM_SCHEMA, SCHEMA_HASH, WEB_BATCH_BYTES, + ANDROID_BRIDGE_CLASS, BOUNDARY_SCHEMA, BoundaryOp, BoundaryParam, Color, ColorRole, + ColorScheme, DESIGN_SYSTEM_SCHEMA, DesignSystem, EVENT_SCHEMA, EventPayloadType, FieldSchema, + FieldSlot, FieldType, HOVER_PHASE_SCHEMA, KEY_SCHEMA, Key, MODIFIER_SCHEMA, + NOTIFICATION_PERMISSION_SCHEMA, NotificationImportance, NotificationPermission, + NotificationPresentation, PROPERTY_SCHEMA, PROTOCOL_VERSION, Paint, PropertyKind, + ROLE_ENUM_SCHEMA, SCHEMA_HASH, Selection, ShapeRole, SpaceRole, Theme, WEB_BATCH_BYTES, WEB_BATCH_FIELDS, WEB_EVENT_BUFFER_BYTES, WEB_EVENT_BUFFER_OFFSET, WEB_HOST_GLOBAL, WEB_MODULE_GLOBAL, WEB_RENDERER_IMPORT_MODULE, WEB_RUST_REGION_BASE, WEB_START_SYMBOL, - WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA, WINDOW_SIZE_CLASS_SCHEMA, + WIDGET_SCHEMA, WINDOW_HEIGHT_CLASS_SCHEMA, WINDOW_SIZE_CLASS_SCHEMA, WidgetKind, +}; +use crate::schema::{ + AnimatedProperty, Animation, AnimationControl, AnimationEvents, ColorInterpolation, FillMode, + Keyframe, KeyframeValue, PlayState, PlaybackDirection, StepPosition, Timing, TransformFunction, }; use crate::tokens::DESIGN_TOKENS; use crate::{EventPayload, Modifier}; @@ -3586,7 +3586,7 @@ fn lower_camel(name: &str) -> String { /// The drawing command vocabulary, mirrored into Kotlin with a decoder for the byte blob a /// `Canvas` node carries. fn write_draw_commands(output: &mut String) { - use crate::drawing::{DrawFieldType, COMMAND_LEN, DRAW_COMMAND_SCHEMA}; + use crate::drawing::{COMMAND_LEN, DRAW_COMMAND_SCHEMA, DrawFieldType}; output.push_str( "/**\n * One drawing command. Coordinates are dp from the Canvas's top-left corner.\n *\n \ @@ -3860,7 +3860,7 @@ object SpanRecords { /// that record's problem rather than the batch's. fn write_code_editor_records(output: &mut String) { use crate::code::{ - RecordFieldType, RecordSchema, DECORATION_RECORD, FOLD_RECORD, GUTTER_DECORATION_RECORD, + DECORATION_RECORD, FOLD_RECORD, GUTTER_DECORATION_RECORD, RecordFieldType, RecordSchema, SYNTAX_SPAN_RECORD, }; diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 388e5d10..a60644c5 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -1,11 +1,11 @@ //! Fixed-layout little-endian boundary protocol. use crate::schema::{ - modifier_extra_words, AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, - Key, MaterialRole, MessageDuration, Modifier, MotionRole, NotificationImportance, - NotificationPermission, NotificationPresentation, Paint, PropertyKind, Selection, - SemanticsRole, ShapeRole, SpaceRole, Theme, WidgetKind, MAX_MODIFIER_WORDS, - SEMANTICS_STATE_MASK, TYPE_ROLE_COUNT, + AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, Key, + MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, Modifier, MotionRole, + NotificationImportance, NotificationPermission, NotificationPresentation, Paint, PropertyKind, + SEMANTICS_STATE_MASK, Selection, SemanticsRole, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, + WidgetKind, modifier_extra_words, }; use core::fmt; @@ -2584,7 +2584,18 @@ fn decode_paint(bits: u64) -> Result { } fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { - let [first, second, third, fourth, fifth, sixth, seventh, eighth, ninth, tenth] = *words; + let [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth, + tenth, + ] = *words; match tag { 0 => Ok(Modifier::Empty), 1 => Ok(Modifier::Padding(f32::from_bits(first as u32))), @@ -3891,8 +3902,8 @@ mod tests { fn fr65_text_field_on_composition_change_delivers_bool() { use std::cell::RefCell; use std::rc::Rc; - use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); let calls = Arc::new(AtomicUsize::new(0)); diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index cb192470..213e21c3 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -11,8 +11,8 @@ #![allow(non_snake_case)] use super::{ - dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, role_or_zero, widget_key, - Attrs, Modifier, Mods, + Attrs, Modifier, Mods, dp_or_zero, emit, optional_text, paint_or_zero, push_modifiers, + role_or_zero, widget_key, }; use crate::drawing::DrawList; use crate::fonts::FontRefs; diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs index 9ec36b5d..4b6e27bb 100644 --- a/compose-rust/tests/fr74_overlay_tokens.rs +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -1,6 +1,6 @@ -//! Overlay theme tokens and node geometry overrides (FR-74). +//! Overlay theme tokens and node geometry overrides. -use compose_rust::protocol::{decode_batch, BatchEncoder, Mutation}; +use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; use compose_rust::schema::{Color, DesignSystem, OverlayTokens, PropertyKind, Theme}; #[test] From adabb5950929de6d035f1e4acd8d12102bd4137f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:54:59 +0900 Subject: [PATCH 04/10] Fix: use BatchEncoder default and drop unused import (FR-74) --- compose-rust/tests/fr74_overlay_tokens.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs index 4b6e27bb..404581c7 100644 --- a/compose-rust/tests/fr74_overlay_tokens.rs +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -1,7 +1,7 @@ //! Overlay theme tokens and node geometry overrides. use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; -use compose_rust::schema::{Color, DesignSystem, OverlayTokens, PropertyKind, Theme}; +use compose_rust::schema::{DesignSystem, OverlayTokens, PropertyKind, Theme}; #[test] fn fr74_overlay_tokens_command_round_trips() { @@ -17,7 +17,7 @@ fn fr74_overlay_tokens_command_round_trips() { .popup_padding(8) .scrim_argb(0x80000000); - let mut encoder = BatchEncoder::new(); + let mut encoder = BatchEncoder::default(); encoder.encode(&Mutation::SetOverlayTokens(tokens)).unwrap(); let bytes = encoder.finish(); From 5a970e5e9e3b2ad6c3f13c489db1342c21f29cc9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:57:05 +0900 Subject: [PATCH 05/10] Fix: unwrap encoder finish in test (FR-74) --- compose-rust/tests/fr74_overlay_tokens.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs index 404581c7..2b5e5d51 100644 --- a/compose-rust/tests/fr74_overlay_tokens.rs +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -19,9 +19,9 @@ fn fr74_overlay_tokens_command_round_trips() { let mut encoder = BatchEncoder::default(); encoder.encode(&Mutation::SetOverlayTokens(tokens)).unwrap(); - let bytes = encoder.finish(); + let bytes = encoder.finish().unwrap(); - let decoded = decode_batch(&bytes).unwrap(); + let decoded = decode_batch(bytes).unwrap(); assert_eq!(decoded.len(), 1); match &decoded[0] { Mutation::SetOverlayTokens(actual) => { From 13f25933d0782b6678428f400e32acd958fd4986 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:01:24 +0900 Subject: [PATCH 06/10] Fix: add PropertyKind.Scrim arm to NodeTable supportsProperty (FR-74) --- compose-rust/android-kotlin/shared/NodeTable.kt | 5 +++++ renderer/desktop/src/renderer/NodeTable.kt | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 3660b134..bbc1f56e 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -979,6 +979,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/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 3660b134..bbc1f56e 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -979,6 +979,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 -> From 9f017bf61d1a11a8d359890cddb0eefe8541a1fa Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:16:43 +0900 Subject: [PATCH 07/10] Fix: import OverlayTokens and avoid shadowing in HostSheet and HostDropdown (FR-74) --- compose-rust/android-kotlin/shared/DesignSystem.kt | 1 + compose-rust/android-kotlin/shared/HostDropdown.kt | 5 +++-- compose-rust/android-kotlin/shared/HostSheet.kt | 5 +++-- compose-rust/android-kotlin/shared/NodeTable.kt | 1 + renderer/desktop/src/renderer/DesignSystem.kt | 1 + renderer/desktop/src/renderer/HostDropdown.kt | 5 +++-- renderer/desktop/src/renderer/HostSheet.kt | 5 +++-- renderer/desktop/src/renderer/NodeTable.kt | 1 + 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/compose-rust/android-kotlin/shared/DesignSystem.kt b/compose-rust/android-kotlin/shared/DesignSystem.kt index 754d2ac6..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 diff --git a/compose-rust/android-kotlin/shared/HostDropdown.kt b/compose-rust/android-kotlin/shared/HostDropdown.kt index bbdcb641..dc1fcdd9 100644 --- a/compose-rust/android-kotlin/shared/HostDropdown.kt +++ b/compose-rust/android-kotlin/shared/HostDropdown.kt @@ -211,10 +211,11 @@ private fun OpeningList( } else { theme.rules.elevation(Modifier, popupElevation, popupShape, theme) } + val explicitPopupPad = theme.overlay?.popupPadding val popupPadding = if (node.setsOwnPadding()) { Modifier - } else if (theme.overlay?.popupPadding != null && theme.overlay.popupPadding > 0) { - Modifier.padding(theme.overlay.popupPadding.dp) + } else if (explicitPopupPad != null && explicitPopupPad > 0) { + Modifier.padding(explicitPopupPad.dp) } else { Modifier.padding(vertical = theme.space(SpaceRole.Xs)) } diff --git a/compose-rust/android-kotlin/shared/HostSheet.kt b/compose-rust/android-kotlin/shared/HostSheet.kt index 7f43ac61..74cd7dce 100644 --- a/compose-rust/android-kotlin/shared/HostSheet.kt +++ b/compose-rust/android-kotlin/shared/HostSheet.kt @@ -136,10 +136,11 @@ internal fun HostSheet( Modifier } + val explicitSheetPad = theme.overlay?.sheetPadding val sheetPadding = if (node.setsOwnPadding()) { Modifier - } else if (theme.overlay?.sheetPadding != null && theme.overlay.sheetPadding > 0) { - Modifier.padding(theme.overlay.sheetPadding.dp) + } else if (explicitSheetPad != null && explicitSheetPad > 0) { + Modifier.padding(explicitSheetPad.dp) } else { Modifier.padding(style.padding) } diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index bbc1f56e..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 diff --git a/renderer/desktop/src/renderer/DesignSystem.kt b/renderer/desktop/src/renderer/DesignSystem.kt index 754d2ac6..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 diff --git a/renderer/desktop/src/renderer/HostDropdown.kt b/renderer/desktop/src/renderer/HostDropdown.kt index bbdcb641..dc1fcdd9 100644 --- a/renderer/desktop/src/renderer/HostDropdown.kt +++ b/renderer/desktop/src/renderer/HostDropdown.kt @@ -211,10 +211,11 @@ private fun OpeningList( } else { theme.rules.elevation(Modifier, popupElevation, popupShape, theme) } + val explicitPopupPad = theme.overlay?.popupPadding val popupPadding = if (node.setsOwnPadding()) { Modifier - } else if (theme.overlay?.popupPadding != null && theme.overlay.popupPadding > 0) { - Modifier.padding(theme.overlay.popupPadding.dp) + } else if (explicitPopupPad != null && explicitPopupPad > 0) { + Modifier.padding(explicitPopupPad.dp) } else { Modifier.padding(vertical = theme.space(SpaceRole.Xs)) } diff --git a/renderer/desktop/src/renderer/HostSheet.kt b/renderer/desktop/src/renderer/HostSheet.kt index 7f43ac61..74cd7dce 100644 --- a/renderer/desktop/src/renderer/HostSheet.kt +++ b/renderer/desktop/src/renderer/HostSheet.kt @@ -136,10 +136,11 @@ internal fun HostSheet( Modifier } + val explicitSheetPad = theme.overlay?.sheetPadding val sheetPadding = if (node.setsOwnPadding()) { Modifier - } else if (theme.overlay?.sheetPadding != null && theme.overlay.sheetPadding > 0) { - Modifier.padding(theme.overlay.sheetPadding.dp) + } else if (explicitSheetPad != null && explicitSheetPad > 0) { + Modifier.padding(explicitSheetPad.dp) } else { Modifier.padding(style.padding) } diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index bbc1f56e..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 From 8ea8d6a035de3178bc174e86ef81637a4640bfc9 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:35:04 +0900 Subject: [PATCH 08/10] Fix: import paintProp in HostOverlay and use ContainerRole.Menu in HostContainer --- compose-rust/android-kotlin/shared/HostContainer.kt | 6 +++--- compose-rust/android-kotlin/shared/HostOverlay.kt | 1 + renderer/desktop/src/renderer/HostContainer.kt | 6 +++--- renderer/desktop/src/renderer/HostOverlay.kt | 1 + 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostContainer.kt b/compose-rust/android-kotlin/shared/HostContainer.kt index 116fe2e3..bafe5709 100644 --- a/compose-rust/android-kotlin/shared/HostContainer.kt +++ b/compose-rust/android-kotlin/shared/HostContainer.kt @@ -114,7 +114,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { when (role) { ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } else -> null } ?: style.shape } else { @@ -125,7 +125,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { when (role) { ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } else -> null } ?: style.elevation } else { @@ -173,7 +173,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { val customPadding = when (role) { ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } else -> null } if (customPadding != null) { diff --git a/compose-rust/android-kotlin/shared/HostOverlay.kt b/compose-rust/android-kotlin/shared/HostOverlay.kt index d8e7ed49..67fa8d27 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 diff --git a/renderer/desktop/src/renderer/HostContainer.kt b/renderer/desktop/src/renderer/HostContainer.kt index 116fe2e3..bafe5709 100644 --- a/renderer/desktop/src/renderer/HostContainer.kt +++ b/renderer/desktop/src/renderer/HostContainer.kt @@ -114,7 +114,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { when (role) { ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } else -> null } ?: style.shape } else { @@ -125,7 +125,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { when (role) { ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } else -> null } ?: style.elevation } else { @@ -173,7 +173,7 @@ internal fun Modifier.containerDecoration( } else if (overlay) { val customPadding = when (role) { ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } - ContainerRole.Menu, ContainerRole.Dropdown -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } else -> null } if (customPadding != null) { diff --git a/renderer/desktop/src/renderer/HostOverlay.kt b/renderer/desktop/src/renderer/HostOverlay.kt index d8e7ed49..67fa8d27 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 From f71fccbaca1b41c98c7b0953b7540dbe39ead4cd Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:59:31 +0900 Subject: [PATCH 09/10] Fix: handle SetOverlayTokens in ProtocolVectorsTest and fix HostOverlayTokensTest --- .../desktop/test/HostOverlayTokensTest.kt | 24 ++++++++++++------- renderer/desktop/test/ProtocolVectorsTest.kt | 1 + 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/renderer/desktop/test/HostOverlayTokensTest.kt b/renderer/desktop/test/HostOverlayTokensTest.kt index 3feba0d2..d8232eb7 100644 --- a/renderer/desktop/test/HostOverlayTokensTest.kt +++ b/renderer/desktop/test/HostOverlayTokensTest.kt @@ -22,9 +22,11 @@ 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 @@ -58,34 +60,37 @@ class HostOverlayTokensTest { Mutation.Insert(sheetId, textId, 0), ), ) + lateinit var host: ComposeRustHost setContent { CompositionLocalProvider( LocalFrameRequests provides frames, LocalDensity provides Density(1f), ) { + host = rememberComposeRustHost(connection) ComposeRustContent( - rememberComposeRustHost(connection), + host, Modifier.requiredSize(500.dp, 800.dp), ) } } waitForIdle() - assertEquals(tokens, connection.host.table.overlayTokens) + 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.Paint(Paint.Rgb(0x40, 0x11, 0x22, 0x33))), + Mutation.SetProp(sheetId, PropertyKind.Scrim, PropertyValue.Integer(scrimBits)), ), ) frames.request() waitForIdle() - val node = connection.host.table.node(sheetId) + val node = host.table.node(sheetId) assertNotNull(node) - assertEquals(Paint.Rgb(0x40, 0x11, 0x22, 0x33), node.paintProp(PropertyKind.Scrim)) + assertEquals(Paint.Literal(0x40112233), node.paintProp(PropertyKind.Scrim)) } @Test @@ -102,6 +107,7 @@ class HostOverlayTokensTest { popupPadding = 8, scrim = 0x66000000L, ) + val scrimBits = (2L shl 32) or (0x80000000L and 0xFFFFFFFFL) val connection = FakeHostConnection( listOf( Mutation.SetTheme( @@ -111,27 +117,29 @@ class HostOverlayTokensTest { Mutation.Create(root, WidgetKind.Column), Mutation.Create(dialogId, WidgetKind.Dialog), Mutation.SetProp(dialogId, PropertyKind.Open, PropertyValue.Bool(true)), - Mutation.SetProp(dialogId, PropertyKind.Scrim, PropertyValue.Paint(Paint.Rgb(0x80, 0x00, 0x00, 0x00))), + 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( - rememberComposeRustHost(connection), + host, Modifier.requiredSize(800.dp, 600.dp), ) } } waitForIdle() - assertEquals(tokens, connection.host.table.overlayTokens) + assertEquals(tokens, host.table.overlayTokens) onNodeWithTag(nodeTestTag(dialogText)).assertTextEquals("Confirm") onNodeWithTag(dialogTestTag(dialogId)).assertExists() } 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. From 00274b74b6f8c89a9add53ccb8fef754ac50407f Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:22:18 +0900 Subject: [PATCH 10/10] Fix: tag dialog container box with dialogTestTag --- compose-rust/android-kotlin/shared/HostOverlay.kt | 3 +-- renderer/desktop/src/renderer/HostOverlay.kt | 3 +-- renderer/desktop/test/HostOverlayTokensTest.kt | 3 +++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/compose-rust/android-kotlin/shared/HostOverlay.kt b/compose-rust/android-kotlin/shared/HostOverlay.kt index 67fa8d27..834c0246 100644 --- a/compose-rust/android-kotlin/shared/HostOverlay.kt +++ b/compose-rust/android-kotlin/shared/HostOverlay.kt @@ -210,12 +210,11 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(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, role = ContainerRole.Dialog), ) { diff --git a/renderer/desktop/src/renderer/HostOverlay.kt b/renderer/desktop/src/renderer/HostOverlay.kt index 67fa8d27..834c0246 100644 --- a/renderer/desktop/src/renderer/HostOverlay.kt +++ b/renderer/desktop/src/renderer/HostOverlay.kt @@ -210,12 +210,11 @@ internal fun HostDialog( ) { ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(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, role = ContainerRole.Dialog), ) { diff --git a/renderer/desktop/test/HostOverlayTokensTest.kt b/renderer/desktop/test/HostOverlayTokensTest.kt index d8232eb7..49dfd4df 100644 --- a/renderer/desktop/test/HostOverlayTokensTest.kt +++ b/renderer/desktop/test/HostOverlayTokensTest.kt @@ -142,5 +142,8 @@ class HostOverlayTokensTest { 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)) } }