diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index b1e52571..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 @@ -48,6 +49,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 +132,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 +165,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 +185,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 +290,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..8b4643c1 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 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 as f64))); + } + if let Some(letter_spacing) = letter_spacing { + attrs.push(("letter_spacing", OwnedAttr::Float(letter_spacing as f64))); + } + 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..1147195d --- /dev/null +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -0,0 +1,84 @@ +//! TextField typography and control over composer / icon button tokens. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +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() + .text("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().text("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=Integer("), "missing Color in {dump}"); + assert!( + dump.contains(&format!("Color=Integer({})", CUSTOM_COLOR.to_bits() as i64)), + "missing Color 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..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 @@ -48,6 +49,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 +132,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 +165,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 +185,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 +290,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..208bcf04 --- /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 + +/** + * 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", + ) + } +}