From 88b6d4833e926a140fe75667b79d627009b3c8c1 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:31:45 +0900 Subject: [PATCH 1/8] Feat: allow explicit text field typography and icon button layout (FR-75, #507) --- .../android-kotlin/shared/HostButton.kt | 49 ++++++++--- .../android-kotlin/shared/HostTextField.kt | 40 ++++++--- .../android-kotlin/shared/ModifierChain.kt | 16 +++- .../android-kotlin/shared/RenderNode.kt | 1 + compose-rust/src/ui/widgets.rs | 58 ++++++++++++ compose-rust/tests/fr75_composer_tokens.rs | 60 +++++++++++++ docs/SPEC.md | 13 +++ renderer/desktop/src/renderer/HostButton.kt | 49 ++++++++--- .../desktop/src/renderer/HostTextField.kt | 40 ++++++--- .../desktop/src/renderer/ModifierChain.kt | 16 +++- renderer/desktop/src/renderer/RenderNode.kt | 1 + .../desktop/test/HostComposerTokensTest.kt | 88 +++++++++++++++++++ 12 files changed, 375 insertions(+), 56 deletions(-) create mode 100644 compose-rust/tests/fr75_composer_tokens.rs create mode 100644 renderer/desktop/test/HostComposerTokensTest.kt diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index b1e52571..81588068 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -48,6 +48,7 @@ import dev.darkpyonix.composerust.protocol.TextAlign import dev.darkpyonix.composerust.design.compositeOver import dev.darkpyonix.composerust.design.ensureContrast import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ButtonStyle import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -130,6 +131,29 @@ internal fun HostButton( val available = if (enabled) Modifier else Modifier.groupAlpha { style.disabledAlpha } val overlayFocusStack = LocalOverlayFocusStack.current val focusRequester = remember(node.id) { FocusRequester() } + val ownShape = node.modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + val shape = if (ownShape) node.modifiers.resolvedShape(theme) else style.shape + val hasExplicitSize = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Width || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + val setsOwnHeight = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + // Read before the decoration, because how much room the button needs depends on it. + val wordless = node.text(PropertyKind.Text).isEmpty() && + node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null + val labelPadH = if (wordless && hasExplicitSize) 0.dp else if (wordless) style.verticalPadding else style.horizontalPadding + val labelPadV = if (wordless && hasExplicitSize) 0.dp else style.verticalPadding + val clickable = if (handlerId == null) { available.then(modifier) } else { @@ -140,7 +164,7 @@ internal fun HostButton( .focusRequester(focusRequester) .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } .pressFeedback( - interactions, enabled, theme, style.shape, + interactions, enabled, theme, shape, node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, ).clickable( @@ -160,30 +184,29 @@ internal fun HostButton( // pill, and asking for a variant instead hands the question to whichever design // system is active, which answers with its own accent. val ownFill = node.setsOwnBackground() - // Read before the decoration, because how much room the button needs depends on it. - val wordless = node.text(PropertyKind.Text).isEmpty() && - node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null val decorated = theme.rules - .elevation(clickable, elevation, style.shape, theme) - .clip(style.shape) - .then(if (ownFill) Modifier else Modifier.background(container, style.shape)) + .elevation(clickable, elevation, shape, theme) + .clip(shape) + .then(if (ownFill) Modifier else Modifier.background(container, shape)) .then( if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, borderColor, style.shape) + Modifier.border(style.borderWidth, borderColor, shape) } else { Modifier }, ) .topHighlight(style.topHighlight) - .defaultMinSize(minHeight = style.minHeight) + .let { + if (setsOwnHeight || (wordless && hasExplicitSize)) it else it.defaultMinSize(minHeight = style.minHeight) + } // A button with a glyph and no word gets the same room on both axes. The // horizontal padding is there to keep a word off the button's ends, and a button // with no word has nothing to keep off them: using it anyway makes every icon // control half as wide again as it needs to be, which two of them in a floating // group and one at the head of a composer both showed. .labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ) @@ -266,8 +289,8 @@ internal fun HostButton( } Box( Modifier.matchParentSize().labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ), contentAlignment = alignment, diff --git a/compose-rust/android-kotlin/shared/HostTextField.kt b/compose-rust/android-kotlin/shared/HostTextField.kt index 008e1b18..a7ad4401 100644 --- a/compose-rust/android-kotlin/shared/HostTextField.kt +++ b/compose-rust/android-kotlin/shared/HostTextField.kt @@ -62,6 +62,10 @@ import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.node.HostText import dev.darkpyonix.composerust.design.LocalGlassDepth +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier /** * The uncontrolled TextField. @@ -272,6 +276,12 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.Height || it is dev.darkpyonix.composerust.protocol.Modifier.Size } + val setsOwnHeight = node.modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.FillMaxHeight || + it is dev.darkpyonix.composerust.protocol.Modifier.Height || + it is dev.darkpyonix.composerust.protocol.Modifier.Size || + it is dev.darkpyonix.composerust.protocol.Modifier.RequiredSize + } // What the node named for itself replaces what the design system would have drawn // The system's frame is the default for a field that said nothing, and a field @@ -287,11 +297,6 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole || it is dev.darkpyonix.composerust.protocol.Modifier.CornerEach } - val ownRoom = node.modifiers.any { - it is dev.darkpyonix.composerust.protocol.Modifier.Padding || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingEach || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingRole - } val frameShape = if (ownShape) node.modifiers.resolvedShape(theme) else frame.shape BasicTextField( @@ -312,19 +317,14 @@ internal fun HostTextField( // could only put the caret in by hitting the words. .fillMaxWidth() .let { if (fillsHeight) it.fillMaxHeight() else it } - .defaultMinSize(minHeight = frame.minHeight) + .let { if (setsOwnHeight) it else it.defaultMinSize(minHeight = frame.minHeight) } .clip(frameShape) .let { if (ownFill) it else it.background(if (focused) frame.containerFocused else frame.container) } .let { if (ownLine) it else it.fieldBorder(frame.copy(shape = frameShape), focused) } .let { if (ownLine || ownFill) it else it.fieldUnderline(frame.underline, focused) } - .let { - if (ownRoom) it else it.padding( - horizontal = frame.horizontalPadding, - vertical = frame.verticalPadding, - ) - }, + .padding(node.contentPadding(theme, frame.horizontalPadding, frame.verticalPadding)), contentAlignment = if (multiline) Alignment.TopStart else Alignment.CenterStart, ) { if (value.text.isEmpty() && placeholder.isNotEmpty()) { @@ -436,3 +436,19 @@ private class FontChainTransformation(private val chain: FontChain) : VisualTran override fun hashCode(): Int = chain.hashCode() } + +internal fun Node.contentPadding( + theme: ResolvedTheme, + defaultHorizontal: Dp, + defaultVertical: Dp, +): PaddingValues { + val padEach = modifiers.filterIsInstance().lastOrNull() + if (padEach != null) { + return PaddingValues(padEach.start.dp, padEach.top.dp, padEach.end.dp, padEach.bottom.dp) + } + val pad = modifiers.filterIsInstance().lastOrNull() + if (pad != null) return PaddingValues(pad.value.dp) + val padRole = modifiers.filterIsInstance().lastOrNull() + if (padRole != null) return PaddingValues(theme.space(padRole.role)) + return PaddingValues(horizontal = defaultHorizontal, vertical = defaultVertical) +} diff --git a/compose-rust/android-kotlin/shared/ModifierChain.kt b/compose-rust/android-kotlin/shared/ModifierChain.kt index 38ca7e20..7c32bc33 100644 --- a/compose-rust/android-kotlin/shared/ModifierChain.kt +++ b/compose-rust/android-kotlin/shared/ModifierChain.kt @@ -111,6 +111,7 @@ internal fun List.toComposeModifier( containerShape: Shape? = null, pressColor: Color = theme.color(ColorRole.OnSurface), allowPress: Boolean = true, + allowPadding: Boolean = true, ): Modifier { // The last Shape or ShapeRole in the list is what clips, what the border // follows and what the background fills, whatever their order in the chain. @@ -192,7 +193,8 @@ internal fun List.toComposeModifier( // Applied with the node's accessible name in one place, `accessibility`. is ProtocolModifier.Semantics -> chain is ProtocolModifier.Empty -> chain - is ProtocolModifier.Padding -> attachPressableIfNeeded(chain).padding(value.value.dp) + is ProtocolModifier.Padding -> + if (allowPadding) attachPressableIfNeeded(chain).padding(value.value.dp) else chain is ProtocolModifier.MatchParentSize -> chain is ProtocolModifier.FillMaxWidth -> chain.fillMaxWidth() is ProtocolModifier.FillMaxHeight -> chain.fillMaxHeight() @@ -213,9 +215,17 @@ internal fun List.toComposeModifier( } is ProtocolModifier.PaddingEach -> - attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + } else { + chain + } is ProtocolModifier.PaddingRole -> - attachPressableIfNeeded(chain).padding(theme.space(value.role)) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(theme.space(value.role)) + } else { + chain + } is ProtocolModifier.Shape -> chain.clip(shape) is ProtocolModifier.ShapeRole -> chain.clip(shape) is ProtocolModifier.Border -> theme.brush(value.paint)?.let { brush -> diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index d37a9a01..78d2dc93 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -229,6 +229,7 @@ fun RenderNode( containerShape = if (clickHandler != null) node.pressedShape(theme) else null, pressColor = node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: theme.color(ColorRole.OnSurface), allowPress = node.widget != WidgetKind.TextField, + allowPadding = node.widget != WidgetKind.TextField, ), ) .testTag(nodeTestTag(nodeId)) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index d8da241b..a7d26af0 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -949,6 +949,11 @@ pub struct TextFieldScope { enabled: bool, multiline: bool, type_role: Option, + font_size: Option, + font_weight: Option, + line_height: Option, + letter_spacing: Option, + color: Option, font: FontRefs, on_value_change: Option>, on_submit: Option>, @@ -971,6 +976,11 @@ pub fn TextField() -> TextFieldScope { enabled: true, multiline: false, type_role: None, + font_size: None, + font_weight: None, + line_height: None, + letter_spacing: None, + color: None, font: FontRefs::default(), on_value_change: None, on_submit: None, @@ -1014,6 +1024,36 @@ impl TextFieldScope { self } + /// Explicit font size in sp. Overrides the type role's size. + pub fn font_size(mut self, sp: f32) -> Self { + self.font_size = Some(sp); + self + } + + /// Explicit font weight (e.g. 400, 500, 600, 700). Overrides the type role's weight. + pub fn font_weight(mut self, weight: u16) -> Self { + self.font_weight = Some(weight); + self + } + + /// Explicit line height in sp. + pub fn line_height(mut self, sp: f32) -> Self { + self.line_height = Some(sp); + self + } + + /// Explicit letter spacing in sp. + pub fn letter_spacing(mut self, sp: f32) -> Self { + self.letter_spacing = Some(sp); + self + } + + /// Explicit text colour. + pub fn color(mut self, paint: Paint) -> Self { + self.color = Some(paint); + self + } + /// The fonts to try for the field's text, in order. Taken only with `TypeRole::None`. pub fn font(mut self, font: FontRefs) -> Self { self.font = font; @@ -1064,6 +1104,9 @@ impl TextFieldScope { let text = std::mem::take(&mut self.text); let placeholder = std::mem::take(&mut self.placeholder); let (enabled, multiline, type_role) = (self.enabled, self.multiline, self.type_role); + let (font_size, font_weight, line_height) = + (self.font_size, self.font_weight, self.line_height); + let (letter_spacing, color) = (self.letter_spacing, self.color); let font = std::mem::take(&mut self.font); let key_down: EventCallback = match self.on_key_down.take() { Some(handler) => EventCallback::Key(Rc::new(RefCell::new(handler))), @@ -1106,6 +1149,21 @@ impl TextFieldScope { attrs.push(("enabled", OwnedAttr::Bool(enabled))); attrs.push(("multiline", OwnedAttr::Bool(multiline))); attrs.push(("type_role", role_or_zero(type_role))); + if let Some(font_size) = font_size { + attrs.push(("font_size", OwnedAttr::Float(font_size))); + } + if let Some(font_weight) = font_weight { + attrs.push(("font_weight", OwnedAttr::Int(font_weight as i64))); + } + if let Some(line_height) = line_height { + attrs.push(("line_height", OwnedAttr::Float(line_height))); + } + if let Some(letter_spacing) = letter_spacing { + attrs.push(("letter_spacing", OwnedAttr::Float(letter_spacing))); + } + if let Some(color) = color { + attrs.push(("color", super::paint(Some(color)))); + } attrs.push(("font", font_bytes(&font))); }, listeners, diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs new file mode 100644 index 00000000..18fac7d6 --- /dev/null +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -0,0 +1,60 @@ +//! FR-75: TextField typography and control over composer / icon button tokens. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::schema::PropertyKind; +use compose_rust::ui::*; +use support::Tree; + +fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +const CUSTOM_COLOR: Paint = Paint::Literal(Color::rgb(0x123456)); + +#[Composable] +fn StyledField() { + Column().content(|| { + TextField("custom") + .font_size(15.0) + .font_weight(600) + .line_height(22.0) + .letter_spacing(0.5) + .color(CUSTOM_COLOR); + }); +} + +#[Composable] +fn PlainField() { + Column().content(|| { + TextField("plain"); + }); +} + +#[test] +fn fr75_text_field_typography_round_trips() { + let tree = start(StyledField); + let dump = tree.dump(); + assert!(dump.contains("TextField"), "missing TextField: {dump}"); + assert!(dump.contains("FontSize=Float(15.0)"), "missing FontSize in {dump}"); + assert!(dump.contains("FontWeight=Integer(600)"), "missing FontWeight in {dump}"); + assert!(dump.contains("LineHeight=Float(22.0)"), "missing LineHeight in {dump}"); + assert!(dump.contains("LetterSpacing=Float(0.5)"), "missing LetterSpacing in {dump}"); + assert!(dump.contains("Color=Paint("), "missing Color in {dump}"); + assert!(dump.contains("1193046"), "missing Color rgb in {dump}"); +} + +#[test] +fn fr75_text_field_plain_does_not_send_typography_properties() { + let tree = start(PlainField); + let dump = tree.dump(); + assert!(dump.contains("TextField"), "missing TextField: {dump}"); + for prop in ["FontSize", "FontWeight", "LineHeight", "LetterSpacing", "Color"] { + assert!(!dump.contains(prop), "{prop} should not be sent for plain field: {dump}"); + } +} diff --git a/docs/SPEC.md b/docs/SPEC.md index d2c83f00..1b501b3a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -4122,6 +4122,19 @@ 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-75 텍스트 필드 타이포그래피와 아이콘 버튼 레이아웃 제어 (`Draft`, 이슈 #507) + +요청한 쪽: darkpyonix-ember(#739, #740, `docs/DESIGN.md` §2, §4.1). Ember NFR-L3은 여러 OS에서 동일한 컴포저와 버튼 모양을 필요로 합니다. `HostTextField`는 타이포그래피와 내부 여백을 테마 기본 규칙(`theme.rules.field`)에서 파생하고, `HostButton`은 명시적 크기를 가진 아이콘 버튼에도 테마 버튼의 최소 높이와 여백을 강제 적용하여 전역 테마 변경 시 컴포저 플레이스홀더와 플러스 버튼의 위치와 크기가 달라지는 문제가 있었습니다. 이 항목은 텍스트 필드의 명시적 타이포그래피와 내용 여백 제어, 아이콘 버튼의 크기 존중 레이아웃을 제공합니다. + +- **TextField 타이포그래피**: `TextFieldScope`에 `font_size`, `font_weight`, `line_height`, `letter_spacing`, `color` 빌더 메서드를 제공하여 테마 기본값에 의존하지 않고 명시적 텍스트 스타일을 지정할 수 있습니다. +- **TextField 내용 여백과 높이**: 노드에 명시적 높이(`Modifier.height`, `size`, `fill_max_height`)가 지정된 경우 테마 최소 높이 강제를 건너뛰며, `Modifier.padding`이 지정된 경우 해당 여백을 필드 장식 상자 내부의 내용 여백으로 적용합니다. +- **아이콘 버튼 레이아웃 제어**: 텍스트 없이 아이콘만 가진 버튼(`wordless`)에 명시적 크기(`Modifier.size`, `width`, `height`)가 지정된 경우, 테마 최소 높이(`minHeight`) 강제를 건너뛰고 내부 여백을 0dp로 처리하여 지정된 크기를 유지합니다. 또한 `corner_radius` 등 자체 모양 지정 시 해당 모양을 클리핑 및 테두리에 적용합니다. + +수용 기준: +1. `TextField`가 명시적 타이포그래피 속성을 인코딩하여 렌더러로 전달합니다(시험 이름 fr75_text_field_typography_round_trips). +2. `TextField`에 명시적 여백이나 높이가 지정되면 렌더러가 지정된 여백과 높이를 적용합니다(시험 이름 fr75_text_field_honors_content_padding_and_height). +3. 명시적 크기를 가진 아이콘 버튼이 테마의 최소 높이에 의해 확장되지 않고 지정된 크기로 렌더링됩니다(시험 이름 fr75_icon_button_honors_explicit_size). + ### FR-55 누를 수 있는 컨테이너 (`Agreed`, 이슈 #372) 요청한 쪽: darkpyonix-ember#220(Ember UI를 compose-rust 위에 올리는 일). 포인터 구독(FR-46)만으로 만든 `Box`는 키보드 포커스, 키 활성화, 접근성 역할을 잃습니다. 눌리는 면은 컨트롤이어야 합니다. diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index b1e52571..81588068 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -48,6 +48,7 @@ import dev.darkpyonix.composerust.protocol.TextAlign import dev.darkpyonix.composerust.design.compositeOver import dev.darkpyonix.composerust.design.ensureContrast import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ButtonStyle import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -130,6 +131,29 @@ internal fun HostButton( val available = if (enabled) Modifier else Modifier.groupAlpha { style.disabledAlpha } val overlayFocusStack = LocalOverlayFocusStack.current val focusRequester = remember(node.id) { FocusRequester() } + val ownShape = node.modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + val shape = if (ownShape) node.modifiers.resolvedShape(theme) else style.shape + val hasExplicitSize = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Width || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + val setsOwnHeight = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + // Read before the decoration, because how much room the button needs depends on it. + val wordless = node.text(PropertyKind.Text).isEmpty() && + node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null + val labelPadH = if (wordless && hasExplicitSize) 0.dp else if (wordless) style.verticalPadding else style.horizontalPadding + val labelPadV = if (wordless && hasExplicitSize) 0.dp else style.verticalPadding + val clickable = if (handlerId == null) { available.then(modifier) } else { @@ -140,7 +164,7 @@ internal fun HostButton( .focusRequester(focusRequester) .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } .pressFeedback( - interactions, enabled, theme, style.shape, + interactions, enabled, theme, shape, node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, ).clickable( @@ -160,30 +184,29 @@ internal fun HostButton( // pill, and asking for a variant instead hands the question to whichever design // system is active, which answers with its own accent. val ownFill = node.setsOwnBackground() - // Read before the decoration, because how much room the button needs depends on it. - val wordless = node.text(PropertyKind.Text).isEmpty() && - node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null val decorated = theme.rules - .elevation(clickable, elevation, style.shape, theme) - .clip(style.shape) - .then(if (ownFill) Modifier else Modifier.background(container, style.shape)) + .elevation(clickable, elevation, shape, theme) + .clip(shape) + .then(if (ownFill) Modifier else Modifier.background(container, shape)) .then( if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, borderColor, style.shape) + Modifier.border(style.borderWidth, borderColor, shape) } else { Modifier }, ) .topHighlight(style.topHighlight) - .defaultMinSize(minHeight = style.minHeight) + .let { + if (setsOwnHeight || (wordless && hasExplicitSize)) it else it.defaultMinSize(minHeight = style.minHeight) + } // A button with a glyph and no word gets the same room on both axes. The // horizontal padding is there to keep a word off the button's ends, and a button // with no word has nothing to keep off them: using it anyway makes every icon // control half as wide again as it needs to be, which two of them in a floating // group and one at the head of a composer both showed. .labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ) @@ -266,8 +289,8 @@ internal fun HostButton( } Box( Modifier.matchParentSize().labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ), contentAlignment = alignment, diff --git a/renderer/desktop/src/renderer/HostTextField.kt b/renderer/desktop/src/renderer/HostTextField.kt index 008e1b18..a7ad4401 100644 --- a/renderer/desktop/src/renderer/HostTextField.kt +++ b/renderer/desktop/src/renderer/HostTextField.kt @@ -62,6 +62,10 @@ import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.node.HostText import dev.darkpyonix.composerust.design.LocalGlassDepth +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier /** * The uncontrolled TextField. @@ -272,6 +276,12 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.Height || it is dev.darkpyonix.composerust.protocol.Modifier.Size } + val setsOwnHeight = node.modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.FillMaxHeight || + it is dev.darkpyonix.composerust.protocol.Modifier.Height || + it is dev.darkpyonix.composerust.protocol.Modifier.Size || + it is dev.darkpyonix.composerust.protocol.Modifier.RequiredSize + } // What the node named for itself replaces what the design system would have drawn // The system's frame is the default for a field that said nothing, and a field @@ -287,11 +297,6 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole || it is dev.darkpyonix.composerust.protocol.Modifier.CornerEach } - val ownRoom = node.modifiers.any { - it is dev.darkpyonix.composerust.protocol.Modifier.Padding || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingEach || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingRole - } val frameShape = if (ownShape) node.modifiers.resolvedShape(theme) else frame.shape BasicTextField( @@ -312,19 +317,14 @@ internal fun HostTextField( // could only put the caret in by hitting the words. .fillMaxWidth() .let { if (fillsHeight) it.fillMaxHeight() else it } - .defaultMinSize(minHeight = frame.minHeight) + .let { if (setsOwnHeight) it else it.defaultMinSize(minHeight = frame.minHeight) } .clip(frameShape) .let { if (ownFill) it else it.background(if (focused) frame.containerFocused else frame.container) } .let { if (ownLine) it else it.fieldBorder(frame.copy(shape = frameShape), focused) } .let { if (ownLine || ownFill) it else it.fieldUnderline(frame.underline, focused) } - .let { - if (ownRoom) it else it.padding( - horizontal = frame.horizontalPadding, - vertical = frame.verticalPadding, - ) - }, + .padding(node.contentPadding(theme, frame.horizontalPadding, frame.verticalPadding)), contentAlignment = if (multiline) Alignment.TopStart else Alignment.CenterStart, ) { if (value.text.isEmpty() && placeholder.isNotEmpty()) { @@ -436,3 +436,19 @@ private class FontChainTransformation(private val chain: FontChain) : VisualTran override fun hashCode(): Int = chain.hashCode() } + +internal fun Node.contentPadding( + theme: ResolvedTheme, + defaultHorizontal: Dp, + defaultVertical: Dp, +): PaddingValues { + val padEach = modifiers.filterIsInstance().lastOrNull() + if (padEach != null) { + return PaddingValues(padEach.start.dp, padEach.top.dp, padEach.end.dp, padEach.bottom.dp) + } + val pad = modifiers.filterIsInstance().lastOrNull() + if (pad != null) return PaddingValues(pad.value.dp) + val padRole = modifiers.filterIsInstance().lastOrNull() + if (padRole != null) return PaddingValues(theme.space(padRole.role)) + return PaddingValues(horizontal = defaultHorizontal, vertical = defaultVertical) +} diff --git a/renderer/desktop/src/renderer/ModifierChain.kt b/renderer/desktop/src/renderer/ModifierChain.kt index 38ca7e20..7c32bc33 100644 --- a/renderer/desktop/src/renderer/ModifierChain.kt +++ b/renderer/desktop/src/renderer/ModifierChain.kt @@ -111,6 +111,7 @@ internal fun List.toComposeModifier( containerShape: Shape? = null, pressColor: Color = theme.color(ColorRole.OnSurface), allowPress: Boolean = true, + allowPadding: Boolean = true, ): Modifier { // The last Shape or ShapeRole in the list is what clips, what the border // follows and what the background fills, whatever their order in the chain. @@ -192,7 +193,8 @@ internal fun List.toComposeModifier( // Applied with the node's accessible name in one place, `accessibility`. is ProtocolModifier.Semantics -> chain is ProtocolModifier.Empty -> chain - is ProtocolModifier.Padding -> attachPressableIfNeeded(chain).padding(value.value.dp) + is ProtocolModifier.Padding -> + if (allowPadding) attachPressableIfNeeded(chain).padding(value.value.dp) else chain is ProtocolModifier.MatchParentSize -> chain is ProtocolModifier.FillMaxWidth -> chain.fillMaxWidth() is ProtocolModifier.FillMaxHeight -> chain.fillMaxHeight() @@ -213,9 +215,17 @@ internal fun List.toComposeModifier( } is ProtocolModifier.PaddingEach -> - attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + } else { + chain + } is ProtocolModifier.PaddingRole -> - attachPressableIfNeeded(chain).padding(theme.space(value.role)) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(theme.space(value.role)) + } else { + chain + } is ProtocolModifier.Shape -> chain.clip(shape) is ProtocolModifier.ShapeRole -> chain.clip(shape) is ProtocolModifier.Border -> theme.brush(value.paint)?.let { brush -> diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index d37a9a01..78d2dc93 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -229,6 +229,7 @@ fun RenderNode( containerShape = if (clickHandler != null) node.pressedShape(theme) else null, pressColor = node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: theme.color(ColorRole.OnSurface), allowPress = node.widget != WidgetKind.TextField, + allowPadding = node.widget != WidgetKind.TextField, ), ) .testTag(nodeTestTag(nodeId)) diff --git a/renderer/desktop/test/HostComposerTokensTest.kt b/renderer/desktop/test/HostComposerTokensTest.kt new file mode 100644 index 00000000..648d657a --- /dev/null +++ b/renderer/desktop/test/HostComposerTokensTest.kt @@ -0,0 +1,88 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.IconRole +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +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 kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * FR-75: TextField typography and control over composer and icon button tokens. + */ +@OptIn(ExperimentalTestApi::class) +class HostComposerTokensTest { + private val root = 1 + private val control = 2 + + @Test + fun fr75_text_field_honors_explicit_height_and_content_padding() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false)), + Mutation.Create(root, WidgetKind.Box), + Mutation.Create(control, WidgetKind.TextField), + Mutation.SetProp(control, PropertyKind.Text, PropertyValue.Text("hello")), + Mutation.SetModifier(control, 0, ProtocolModifier.Height(36f)), + Mutation.SetModifier(control, 1, ProtocolModifier.PaddingEach(12f, 6f, 12f, 6f)), + Mutation.Insert(root, control, 0), + ) + + setContent { + ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) + } + waitForIdle() + + val bounds = onNodeWithTag(nodeTestTag(control)).getBoundsInRoot() + val height = bounds.bottom - bounds.top + assertTrue( + abs(height.value - 36f) <= 1.5f, + "field with explicit height 36dp measured $height instead of adhering to height", + ) + } + + @Test + fun fr75_icon_button_honors_explicit_size() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false)), + Mutation.Create(root, WidgetKind.Box), + Mutation.Create(control, WidgetKind.Button), + Mutation.SetProp(control, PropertyKind.Text, PropertyValue.Text("")), + Mutation.SetProp(control, PropertyKind.Icon, PropertyValue.Integer(IconRole.Add.ordinal + 1L)), + Mutation.SetModifier(control, 0, ProtocolModifier.Size(32f, 32f)), + Mutation.SetModifier(control, 1, ProtocolModifier.CornerEach(8f, 8f, 8f, 8f)), + Mutation.Insert(root, control, 0), + ) + + setContent { + ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) + } + waitForIdle() + + val bounds = onNodeWithTag(nodeTestTag(control)).getBoundsInRoot() + val width = bounds.right - bounds.left + val height = bounds.bottom - bounds.top + assertTrue( + abs(width.value - 32f) <= 1.5f, + "wordless button with explicit size 32dp measured width $width", + ) + assertTrue( + abs(height.value - 32f) <= 1.5f, + "wordless button with explicit size 32dp measured height $height", + ) + } +} From b7c89ccf60f7cbbd460d10ce6045b1d655441201 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:40:39 +0900 Subject: [PATCH 2/8] Chore: format Rust files with rustfmt (FR-75) --- compose-rust/src/ui/widgets.rs | 4 +-- compose-rust/tests/fr75_composer_tokens.rs | 35 +++++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index a7d26af0..190cbbb3 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/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index 18fac7d6..51cddaa3 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -2,10 +2,10 @@ mod support; -use compose_rust::ComposeHost; use compose_rust::runtime::*; use compose_rust::schema::PropertyKind; use compose_rust::ui::*; +use compose_rust::ComposeHost; use support::Tree; fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { @@ -41,10 +41,22 @@ fn fr75_text_field_typography_round_trips() { let tree = start(StyledField); let dump = tree.dump(); assert!(dump.contains("TextField"), "missing TextField: {dump}"); - assert!(dump.contains("FontSize=Float(15.0)"), "missing FontSize in {dump}"); - assert!(dump.contains("FontWeight=Integer(600)"), "missing FontWeight in {dump}"); - assert!(dump.contains("LineHeight=Float(22.0)"), "missing LineHeight in {dump}"); - assert!(dump.contains("LetterSpacing=Float(0.5)"), "missing LetterSpacing in {dump}"); + assert!( + dump.contains("FontSize=Float(15.0)"), + "missing FontSize in {dump}" + ); + assert!( + dump.contains("FontWeight=Integer(600)"), + "missing FontWeight in {dump}" + ); + assert!( + dump.contains("LineHeight=Float(22.0)"), + "missing LineHeight in {dump}" + ); + assert!( + dump.contains("LetterSpacing=Float(0.5)"), + "missing LetterSpacing in {dump}" + ); assert!(dump.contains("Color=Paint("), "missing Color in {dump}"); assert!(dump.contains("1193046"), "missing Color rgb in {dump}"); } @@ -54,7 +66,16 @@ fn fr75_text_field_plain_does_not_send_typography_properties() { let tree = start(PlainField); let dump = tree.dump(); assert!(dump.contains("TextField"), "missing TextField: {dump}"); - for prop in ["FontSize", "FontWeight", "LineHeight", "LetterSpacing", "Color"] { - assert!(!dump.contains(prop), "{prop} should not be sent for plain field: {dump}"); + for prop in [ + "FontSize", + "FontWeight", + "LineHeight", + "LetterSpacing", + "Color", + ] { + assert!( + !dump.contains(prop), + "{prop} should not be sent for plain field: {dump}" + ); } } From 40f17705bce72a1f86711b89fa5c4e679c07d946 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:43:02 +0900 Subject: [PATCH 3/8] Fix: pass f64 to OwnedAttr::Float in TextFieldScope attrs (FR-75) --- compose-rust/src/ui/widgets.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 190cbbb3..4b08c4fe 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -1150,16 +1150,16 @@ impl TextFieldScope { attrs.push(("multiline", OwnedAttr::Bool(multiline))); attrs.push(("type_role", role_or_zero(type_role))); if let Some(font_size) = font_size { - attrs.push(("font_size", OwnedAttr::Float(font_size))); + attrs.push(("font_size", OwnedAttr::Float(font_size as f64))); } if let Some(font_weight) = font_weight { attrs.push(("font_weight", OwnedAttr::Int(font_weight as i64))); } if let Some(line_height) = line_height { - attrs.push(("line_height", OwnedAttr::Float(line_height))); + attrs.push(("line_height", OwnedAttr::Float(line_height as f64))); } if let Some(letter_spacing) = letter_spacing { - attrs.push(("letter_spacing", OwnedAttr::Float(letter_spacing))); + attrs.push(("letter_spacing", OwnedAttr::Float(letter_spacing as f64))); } if let Some(color) = color { attrs.push(("color", super::paint(Some(color)))); From b9daae0872dda0363b9081d6342fee90902e5f78 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:51:12 +0900 Subject: [PATCH 4/8] Fix: resolve rustfmt ordering and remove planning doc citation (FR-75) --- compose-rust/src/ui/widgets.rs | 4 ++-- compose-rust/tests/fr75_composer_tokens.rs | 2 +- renderer/desktop/test/HostComposerTokensTest.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 4b08c4fe..8b4643c1 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/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index 51cddaa3..bf6bccac 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -1,4 +1,4 @@ -//! FR-75: TextField typography and control over composer / icon button tokens. +//! TextField typography and control over composer / icon button tokens. mod support; diff --git a/renderer/desktop/test/HostComposerTokensTest.kt b/renderer/desktop/test/HostComposerTokensTest.kt index 648d657a..208bcf04 100644 --- a/renderer/desktop/test/HostComposerTokensTest.kt +++ b/renderer/desktop/test/HostComposerTokensTest.kt @@ -23,7 +23,7 @@ import kotlin.test.Test import kotlin.test.assertTrue /** - * FR-75: TextField typography and control over composer and icon button tokens. + * TextField typography and control over composer and icon button tokens. */ @OptIn(ExperimentalTestApi::class) class HostComposerTokensTest { From 0a39fef2f4b81425299a5258805b47e78601b485 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:53:45 +0900 Subject: [PATCH 5/8] Fix: sort ComposeHost import in test (FR-75) --- compose-rust/tests/fr75_composer_tokens.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index bf6bccac..365efe7c 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -2,10 +2,10 @@ mod support; +use compose_rust::ComposeHost; use compose_rust::runtime::*; use compose_rust::schema::PropertyKind; use compose_rust::ui::*; -use compose_rust::ComposeHost; use support::Tree; fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { From fb0c2d177ed620d552e55a21e796d5cfc7350f0d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:56:12 +0900 Subject: [PATCH 6/8] Fix: use TextField text builder method in test (FR-75) --- compose-rust/tests/fr75_composer_tokens.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index 365efe7c..a5ef2fed 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -4,7 +4,6 @@ mod support; use compose_rust::ComposeHost; use compose_rust::runtime::*; -use compose_rust::schema::PropertyKind; use compose_rust::ui::*; use support::Tree; @@ -20,7 +19,8 @@ const CUSTOM_COLOR: Paint = Paint::Literal(Color::rgb(0x123456)); #[Composable] fn StyledField() { Column().content(|| { - TextField("custom") + TextField() + .text("custom") .font_size(15.0) .font_weight(600) .line_height(22.0) @@ -32,7 +32,7 @@ fn StyledField() { #[Composable] fn PlainField() { Column().content(|| { - TextField("plain"); + TextField().text("plain"); }); } From ff06fc2afceef668065bca82eb2ec222e5922f9d Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:59:22 +0900 Subject: [PATCH 7/8] Fix: assert paint bits integer in typography test (FR-75) --- compose-rust/tests/fr75_composer_tokens.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs index a5ef2fed..1147195d 100644 --- a/compose-rust/tests/fr75_composer_tokens.rs +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -57,8 +57,11 @@ fn fr75_text_field_typography_round_trips() { dump.contains("LetterSpacing=Float(0.5)"), "missing LetterSpacing in {dump}" ); - assert!(dump.contains("Color=Paint("), "missing Color in {dump}"); - assert!(dump.contains("1193046"), "missing Color rgb in {dump}"); + assert!(dump.contains("Color=Integer("), "missing Color in {dump}"); + assert!( + dump.contains(&format!("Color=Integer({})", CUSTOM_COLOR.to_bits() as i64)), + "missing Color in {dump}" + ); } #[test] From b7a2eb5bd2889a9b71ad7ca49e21f3cb5e36d349 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:13:19 +0900 Subject: [PATCH 8/8] Fix: import androidx.compose.ui.unit.dp in HostButton (FR-75) --- compose-rust/android-kotlin/shared/HostButton.kt | 1 + renderer/desktop/src/renderer/HostButton.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index 81588068..430e9728 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index 81588068..430e9728 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected