diff --git a/src/ComposeNet.Compose/ComposeBridges.cs b/src/ComposeNet.Compose/ComposeBridges.cs index 48cea820..b9728f8b 100644 --- a/src/ComposeNet.Compose/ComposeBridges.cs +++ b/src/ComposeNet.Compose/ComposeBridges.cs @@ -259,11 +259,19 @@ internal static IntPtr ModifierClipRoundedCorners(IntPtr modifier, float dp) public static partial void Text( string text, IModifier? modifier, + long? color, Sp? fontSize, + FontStyle? fontStyle, FontWeight? fontWeight, + FontFamily? fontFamily, Sp? letterSpacing, TextDecoration? decoration, + TextAlign? align, Sp? lineHeight, + TextOverflow? overflow, + bool? softWrap, + int? maxLines, + int? minLines, IComposer composer); // androidx.compose.material3.ButtonKt.Button @@ -605,8 +613,24 @@ public static partial void IconPainter( Signature = TextFieldStringSig, Defaults = typeof(TextFieldDefault))] [ComposeFacade] - public static partial void TextField(string value, [Callback(typeof(string))] IFunction1 onValueChange, - IModifier? modifier, IComposer composer); + public static partial void TextField( + string value, + [Callback(typeof(string))] IFunction1 onValueChange, + IModifier? modifier, + bool? enabled, + bool? readOnly, + IFunction2? label, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction2? prefix, + IFunction2? suffix, + IFunction2? supportingText, + bool? isError, + bool? singleLine, + int? maxLines, + int? minLines, + IComposer composer); [ComposeBridge( Class = "androidx/compose/material3/OutlinedTextFieldKt", @@ -614,8 +638,24 @@ public static partial void TextField(string value, [Callback(typeof(string))] IF Signature = TextFieldStringSig, Defaults = typeof(TextFieldDefault))] [ComposeFacade] - public static partial void OutlinedTextField(string value, [Callback(typeof(string))] IFunction1 onValueChange, - IModifier? modifier, IComposer composer); + public static partial void OutlinedTextField( + string value, + [Callback(typeof(string))] IFunction1 onValueChange, + IModifier? modifier, + bool? enabled, + bool? readOnly, + IFunction2? label, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction2? prefix, + IFunction2? suffix, + IFunction2? supportingText, + bool? isError, + bool? singleLine, + int? maxLines, + int? minLines, + IComposer composer); // androidx.compose.material3.SecureTextFieldKt.{SecureTextField,OutlinedSecureTextField}-XvU6IwQ. // Both overloads have identical 23-user-param signatures: the diff --git a/src/ComposeNet.Compose/FontFamily.cs b/src/ComposeNet.Compose/FontFamily.cs new file mode 100644 index 00000000..2ea83f04 --- /dev/null +++ b/src/ComposeNet.Compose/FontFamily.cs @@ -0,0 +1,83 @@ +using Android.Runtime; + +namespace ComposeNet; + +/// +/// C# wrapper around androidx.compose.ui.text.font.FontFamily. +/// Compose's FontFamily is a real Kotlin class, but Compose +/// 1.11.2.1's ui-text-android package is shipped as a +/// Java-library-only stub with zero exported types — so the .NET +/// binding doesn't expose it yet. We subclass +/// directly and resolve the Kotlin Companion instances via JNI; +/// the bridge generator's reference-type code path +/// (x is null ? IntPtr.Zero : x.Handle) passes the handle through +/// to the JNI L slot. +/// +/// Will swap to bound AndroidX.Compose.UI.Text.Font.FontFamily +/// once +/// ships and we adopt the next Xamarin.AndroidX.Compose.UI.Text.Android +/// release. +/// +public sealed class FontFamily : Java.Lang.Object +{ + FontFamily(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + // FontFamily.Companion exposes generic-font-family getters: + // FontFamily$Companion.getDefault() : FontFamily + // FontFamily$Companion.getSansSerif() : FontFamily + // FontFamily$Companion.getSerif() : FontFamily + // FontFamily$Companion.getMonospace() : FontFamily + // FontFamily$Companion.getCursive() : FontFamily + static IntPtr s_companion; + static unsafe IntPtr Companion() + { + if (s_companion == IntPtr.Zero) + { + IntPtr fontFamilyCls = JNIEnv.FindClass("androidx/compose/ui/text/font/FontFamily"); + IntPtr fid = JNIEnv.GetStaticFieldID(fontFamilyCls, "Companion", "Landroidx/compose/ui/text/font/FontFamily$Companion;"); + IntPtr local = JNIEnv.GetStaticObjectField(fontFamilyCls, fid); + s_companion = JNIEnv.NewGlobalRef(local); + JNIEnv.DeleteLocalRef(local); + } + return s_companion; + } + + static FontFamily Resolve(string getterName, string returnDescriptor) + { + // FontFamily$Companion getters return concrete subtypes: + // getDefault() -> SystemFontFamily + // getSansSerif() -> GenericFontFamily + // getSerif() -> GenericFontFamily + // getMonospace() -> GenericFontFamily + // getCursive() -> GenericFontFamily + // All of these extend FontFamily, so we wrap as our base type. + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/font/FontFamily$Companion"); + IntPtr mid = JNIEnv.GetMethodID(cls, getterName, $"(){returnDescriptor}"); + IntPtr companion = Companion(); + IntPtr value = JNIEnv.CallObjectMethod(companion, mid); + return new FontFamily(value, JniHandleOwnership.TransferLocalRef); + } + + const string SystemFontFamilyDescriptor = "Landroidx/compose/ui/text/font/SystemFontFamily;"; + const string GenericFontFamilyDescriptor = "Landroidx/compose/ui/text/font/GenericFontFamily;"; + + static FontFamily? s_default, s_sansSerif, s_serif, s_monospace, s_cursive; + + /// + /// FontFamily.Default — the platform's system font family. + /// + public static FontFamily Default => s_default ??= Resolve("getDefault", SystemFontFamilyDescriptor); + + /// Generic sans-serif family (FontFamily.SansSerif). + public static FontFamily SansSerif => s_sansSerif ??= Resolve("getSansSerif", GenericFontFamilyDescriptor); + + /// Generic serif family (FontFamily.Serif). + public static FontFamily Serif => s_serif ??= Resolve("getSerif", GenericFontFamilyDescriptor); + + /// Generic monospace family (FontFamily.Monospace). + public static FontFamily Monospace => s_monospace ??= Resolve("getMonospace", GenericFontFamilyDescriptor); + + /// Generic cursive family (FontFamily.Cursive). + public static FontFamily Cursive => s_cursive ??= Resolve("getCursive", GenericFontFamilyDescriptor); +} diff --git a/src/ComposeNet.Compose/FontStyle.cs b/src/ComposeNet.Compose/FontStyle.cs new file mode 100644 index 00000000..4dd6df7f --- /dev/null +++ b/src/ComposeNet.Compose/FontStyle.cs @@ -0,0 +1,75 @@ +using Android.Runtime; + +namespace ComposeNet; + +/// +/// C# wrapper around androidx.compose.ui.text.font.FontStyle. +/// In Kotlin source, FontStyle is a @JvmInline value class +/// wrapping an Int, but every @Composable function in +/// Material 3 declares it as nullable (fontStyle: FontStyle? +/// = null) — so at the JNI boundary it travels as a boxed +/// androidx/compose/ui/text/font/FontStyle; reference, not a +/// packed int. The bridge generator's reference-type path +/// passes the handle through to the JNI L slot. +/// +/// Same trick as : call the mangled +/// Companion.getNormal-_-LCdwA()I for the packed int, then +/// route through static FontStyle.box-impl(I)LFontStyle;. +/// +/// Will swap to bound AndroidX.Compose.UI.Text.Font.FontStyle +/// once +/// ships. +/// +public sealed class FontStyle : Java.Lang.Object +{ + FontStyle(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + static IntPtr s_companion; + static IntPtr s_box; + + static unsafe IntPtr Companion() + { + if (s_companion == IntPtr.Zero) + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/font/FontStyle"); + IntPtr fid = JNIEnv.GetStaticFieldID(cls, "Companion", "Landroidx/compose/ui/text/font/FontStyle$Companion;"); + IntPtr local = JNIEnv.GetStaticObjectField(cls, fid); + s_companion = JNIEnv.NewGlobalRef(local); + JNIEnv.DeleteLocalRef(local); + } + return s_companion; + } + + static IntPtr BoxMethod() + { + if (s_box == IntPtr.Zero) + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/font/FontStyle"); + s_box = JNIEnv.GetStaticMethodID(cls, "box-impl", "(I)Landroidx/compose/ui/text/font/FontStyle;"); + } + return s_box; + } + + static unsafe FontStyle Resolve(string mangledGetter) + { + IntPtr companionCls = JNIEnv.FindClass("androidx/compose/ui/text/font/FontStyle$Companion"); + IntPtr getter = JNIEnv.GetMethodID(companionCls, mangledGetter, "()I"); + int packed = JNIEnv.CallIntMethod(Companion(), getter); + + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/font/FontStyle"); + JValue* args = stackalloc JValue[1]; + args[0] = new JValue(packed); + IntPtr boxed = JNIEnv.CallStaticObjectMethod(cls, BoxMethod(), args); + return new FontStyle(boxed, JniHandleOwnership.TransferLocalRef); + } + + static FontStyle? s_normal, s_italic; + + /// Upright glyphs (the default). + public static FontStyle Normal => s_normal ??= Resolve("getNormal-_-LCdwA"); + + /// Italic / slanted glyphs. + public static FontStyle Italic => s_italic ??= Resolve("getItalic-_-LCdwA"); +} + diff --git a/src/ComposeNet.Compose/PublicAPI.Unshipped.txt b/src/ComposeNet.Compose/PublicAPI.Unshipped.txt index 88521ead..9c96527f 100644 --- a/src/ComposeNet.Compose/PublicAPI.Unshipped.txt +++ b/src/ComposeNet.Compose/PublicAPI.Unshipped.txt @@ -1,7 +1,7 @@ #nullable enable -~override ComposeNet.TextAlign.Equals(object obj) -> bool -~override ComposeNet.TextAlign.ToString() -> string +~override ComposeNet.TextOverflow.Equals(object obj) -> bool +~override ComposeNet.TextOverflow.ToString() -> string ComposeNet.AlertDialog ComposeNet.AlertDialog.AlertDialog(System.Action! onDismissRequest) -> void ComposeNet.AlertDialog.ConfirmButton.get -> ComposeNet.ComposableNode? @@ -221,6 +221,8 @@ ComposeNet.FlexibleBottomAppBar ComposeNet.FlexibleBottomAppBar.FlexibleBottomAppBar() -> void ComposeNet.FloatingActionButton ComposeNet.FloatingActionButton.FloatingActionButton(System.Action! onClick) -> void +ComposeNet.FontFamily +ComposeNet.FontStyle ComposeNet.FontWeight ComposeNet.GridCells ComposeNet.HorizontalCenteredHeroCarousel @@ -463,8 +465,34 @@ ComposeNet.OutlinedSecureTextField.SupportingText.set -> void ComposeNet.OutlinedSecureTextField.TrailingIcon.get -> ComposeNet.ComposableNode? ComposeNet.OutlinedSecureTextField.TrailingIcon.set -> void ComposeNet.OutlinedTextField +ComposeNet.OutlinedTextField.Enabled.get -> bool? +ComposeNet.OutlinedTextField.Enabled.set -> void +ComposeNet.OutlinedTextField.IsError.get -> bool? +ComposeNet.OutlinedTextField.IsError.set -> void +ComposeNet.OutlinedTextField.Label.get -> ComposeNet.ComposableNode? +ComposeNet.OutlinedTextField.Label.set -> void +ComposeNet.OutlinedTextField.LeadingIcon.get -> ComposeNet.ComposableNode? +ComposeNet.OutlinedTextField.LeadingIcon.set -> void +ComposeNet.OutlinedTextField.MaxLines.get -> int? +ComposeNet.OutlinedTextField.MaxLines.set -> void +ComposeNet.OutlinedTextField.MinLines.get -> int? +ComposeNet.OutlinedTextField.MinLines.set -> void ComposeNet.OutlinedTextField.OutlinedTextField(ComposeNet.MutableState! state) -> void ComposeNet.OutlinedTextField.OutlinedTextField(string! value, System.Action! onValueChange) -> void +ComposeNet.OutlinedTextField.Placeholder.get -> ComposeNet.ComposableNode? +ComposeNet.OutlinedTextField.Placeholder.set -> void +ComposeNet.OutlinedTextField.Prefix.get -> ComposeNet.ComposableNode? +ComposeNet.OutlinedTextField.Prefix.set -> void +ComposeNet.OutlinedTextField.ReadOnly.get -> bool? +ComposeNet.OutlinedTextField.ReadOnly.set -> void +ComposeNet.OutlinedTextField.SingleLine.get -> bool? +ComposeNet.OutlinedTextField.SingleLine.set -> void +ComposeNet.OutlinedTextField.Suffix.get -> ComposeNet.ComposableNode? +ComposeNet.OutlinedTextField.Suffix.set -> void +ComposeNet.OutlinedTextField.SupportingText.get -> ComposeNet.ComposableNode? +ComposeNet.OutlinedTextField.SupportingText.set -> void +ComposeNet.OutlinedTextField.TrailingIcon.get -> ComposeNet.ComposableNode? +ComposeNet.OutlinedTextField.TrailingIcon.set -> void ComposeNet.PermanentDrawerSheet ComposeNet.PermanentDrawerSheet.ContainerColor.get -> long ComposeNet.PermanentDrawerSheet.ContainerColor.set -> void @@ -612,30 +640,73 @@ ComposeNet.Tab.Text.set -> void ComposeNet.TabRow ComposeNet.TabRow.TabRow(int selectedTabIndex) -> void ComposeNet.Text +ComposeNet.Text.Align.get -> ComposeNet.TextAlign? +ComposeNet.Text.Align.set -> void +ComposeNet.Text.Color.get -> long? +ComposeNet.Text.Color.set -> void ComposeNet.Text.Decoration.get -> ComposeNet.TextDecoration? ComposeNet.Text.Decoration.set -> void +ComposeNet.Text.FontFamily.get -> ComposeNet.FontFamily? +ComposeNet.Text.FontFamily.set -> void ComposeNet.Text.FontSize.get -> ComposeNet.Sp? ComposeNet.Text.FontSize.set -> void +ComposeNet.Text.FontStyle.get -> ComposeNet.FontStyle? +ComposeNet.Text.FontStyle.set -> void ComposeNet.Text.FontWeight.get -> ComposeNet.FontWeight? ComposeNet.Text.FontWeight.set -> void ComposeNet.Text.LetterSpacing.get -> ComposeNet.Sp? ComposeNet.Text.LetterSpacing.set -> void ComposeNet.Text.LineHeight.get -> ComposeNet.Sp? ComposeNet.Text.LineHeight.set -> void +ComposeNet.Text.MaxLines.get -> int? +ComposeNet.Text.MaxLines.set -> void +ComposeNet.Text.MinLines.get -> int? +ComposeNet.Text.MinLines.set -> void +ComposeNet.Text.Overflow.get -> ComposeNet.TextOverflow? +ComposeNet.Text.Overflow.set -> void +ComposeNet.Text.SoftWrap.get -> bool? +ComposeNet.Text.SoftWrap.set -> void ComposeNet.Text.Text(string! text) -> void ComposeNet.TextAlign -ComposeNet.TextAlign.Deconstruct(out int Value) -> void -ComposeNet.TextAlign.Equals(ComposeNet.TextAlign other) -> bool -ComposeNet.TextAlign.TextAlign() -> void -ComposeNet.TextAlign.TextAlign(int Value) -> void -ComposeNet.TextAlign.Value.get -> int -ComposeNet.TextAlign.Value.init -> void ComposeNet.TextButton ComposeNet.TextButton.TextButton(System.Action! onClick) -> void ComposeNet.TextDecoration ComposeNet.TextField +ComposeNet.TextField.Enabled.get -> bool? +ComposeNet.TextField.Enabled.set -> void +ComposeNet.TextField.IsError.get -> bool? +ComposeNet.TextField.IsError.set -> void +ComposeNet.TextField.Label.get -> ComposeNet.ComposableNode? +ComposeNet.TextField.Label.set -> void +ComposeNet.TextField.LeadingIcon.get -> ComposeNet.ComposableNode? +ComposeNet.TextField.LeadingIcon.set -> void +ComposeNet.TextField.MaxLines.get -> int? +ComposeNet.TextField.MaxLines.set -> void +ComposeNet.TextField.MinLines.get -> int? +ComposeNet.TextField.MinLines.set -> void +ComposeNet.TextField.Placeholder.get -> ComposeNet.ComposableNode? +ComposeNet.TextField.Placeholder.set -> void +ComposeNet.TextField.Prefix.get -> ComposeNet.ComposableNode? +ComposeNet.TextField.Prefix.set -> void +ComposeNet.TextField.ReadOnly.get -> bool? +ComposeNet.TextField.ReadOnly.set -> void +ComposeNet.TextField.SingleLine.get -> bool? +ComposeNet.TextField.SingleLine.set -> void +ComposeNet.TextField.Suffix.get -> ComposeNet.ComposableNode? +ComposeNet.TextField.Suffix.set -> void +ComposeNet.TextField.SupportingText.get -> ComposeNet.ComposableNode? +ComposeNet.TextField.SupportingText.set -> void ComposeNet.TextField.TextField(ComposeNet.MutableState! state) -> void ComposeNet.TextField.TextField(string! value, System.Action! onValueChange) -> void +ComposeNet.TextField.TrailingIcon.get -> ComposeNet.ComposableNode? +ComposeNet.TextField.TrailingIcon.set -> void +ComposeNet.TextOverflow +ComposeNet.TextOverflow.Deconstruct(out int Value) -> void +ComposeNet.TextOverflow.Equals(ComposeNet.TextOverflow other) -> bool +ComposeNet.TextOverflow.TextOverflow() -> void +ComposeNet.TextOverflow.TextOverflow(int Value) -> void +ComposeNet.TextOverflow.Value.get -> int +ComposeNet.TextOverflow.Value.init -> void ComposeNet.TimePicker ComposeNet.TimePicker.TimePicker(ComposeNet.TimePickerState? state = null) -> void ComposeNet.TimePickerDialog @@ -703,7 +774,7 @@ override ComposeNet.MutableState.ToString() -> string! override ComposeNet.Sp.Equals(object? obj) -> bool override ComposeNet.Sp.GetHashCode() -> int override ComposeNet.Sp.ToString() -> string! -override ComposeNet.TextAlign.GetHashCode() -> int +override ComposeNet.TextOverflow.GetHashCode() -> int static ComposeNet.Arrangement.Bottom.get -> ComposeNet.Arrangement! static ComposeNet.Arrangement.Center.get -> ComposeNet.Arrangement! static ComposeNet.Arrangement.End.get -> ComposeNet.Arrangement! @@ -720,6 +791,13 @@ static ComposeNet.Dp.operator !=(ComposeNet.Dp left, ComposeNet.Dp right) -> boo static ComposeNet.Dp.operator ==(ComposeNet.Dp left, ComposeNet.Dp right) -> bool static ComposeNet.Dp.Pack(ComposeNet.Dp? value) -> float static ComposeNet.Dp.Zero.get -> ComposeNet.Dp +static ComposeNet.FontFamily.Cursive.get -> ComposeNet.FontFamily! +static ComposeNet.FontFamily.Default.get -> ComposeNet.FontFamily! +static ComposeNet.FontFamily.Monospace.get -> ComposeNet.FontFamily! +static ComposeNet.FontFamily.SansSerif.get -> ComposeNet.FontFamily! +static ComposeNet.FontFamily.Serif.get -> ComposeNet.FontFamily! +static ComposeNet.FontStyle.Italic.get -> ComposeNet.FontStyle! +static ComposeNet.FontStyle.Normal.get -> ComposeNet.FontStyle! static ComposeNet.FontWeight.Black.get -> ComposeNet.FontWeight! static ComposeNet.FontWeight.Bold.get -> ComposeNet.FontWeight! static ComposeNet.FontWeight.ExtraBold.get -> ComposeNet.FontWeight! @@ -740,18 +818,23 @@ static ComposeNet.Sp.operator !=(ComposeNet.Sp left, ComposeNet.Sp right) -> boo static ComposeNet.Sp.operator ==(ComposeNet.Sp left, ComposeNet.Sp right) -> bool static ComposeNet.Sp.Pack(ComposeNet.Sp? value) -> long static ComposeNet.Sp.Zero.get -> ComposeNet.Sp -static ComposeNet.TextAlign.Center.get -> ComposeNet.TextAlign -static ComposeNet.TextAlign.End.get -> ComposeNet.TextAlign -static ComposeNet.TextAlign.Justify.get -> ComposeNet.TextAlign -static ComposeNet.TextAlign.Left.get -> ComposeNet.TextAlign -static ComposeNet.TextAlign.operator !=(ComposeNet.TextAlign left, ComposeNet.TextAlign right) -> bool -static ComposeNet.TextAlign.operator ==(ComposeNet.TextAlign left, ComposeNet.TextAlign right) -> bool -static ComposeNet.TextAlign.Pack(ComposeNet.TextAlign? value) -> int -static ComposeNet.TextAlign.Right.get -> ComposeNet.TextAlign -static ComposeNet.TextAlign.Start.get -> ComposeNet.TextAlign -static ComposeNet.TextAlign.Unspecified.get -> ComposeNet.TextAlign +static ComposeNet.TextAlign.Center.get -> ComposeNet.TextAlign! +static ComposeNet.TextAlign.End.get -> ComposeNet.TextAlign! +static ComposeNet.TextAlign.Justify.get -> ComposeNet.TextAlign! +static ComposeNet.TextAlign.Left.get -> ComposeNet.TextAlign! +static ComposeNet.TextAlign.Right.get -> ComposeNet.TextAlign! +static ComposeNet.TextAlign.Start.get -> ComposeNet.TextAlign! +static ComposeNet.TextAlign.Unspecified.get -> ComposeNet.TextAlign! static ComposeNet.TextDecoration.LineThrough.get -> ComposeNet.TextDecoration! static ComposeNet.TextDecoration.None.get -> ComposeNet.TextDecoration! static ComposeNet.TextDecoration.Underline.get -> ComposeNet.TextDecoration! +static ComposeNet.TextOverflow.Clip.get -> ComposeNet.TextOverflow +static ComposeNet.TextOverflow.Ellipsis.get -> ComposeNet.TextOverflow +static ComposeNet.TextOverflow.MiddleEllipsis.get -> ComposeNet.TextOverflow +static ComposeNet.TextOverflow.operator !=(ComposeNet.TextOverflow left, ComposeNet.TextOverflow right) -> bool +static ComposeNet.TextOverflow.operator ==(ComposeNet.TextOverflow left, ComposeNet.TextOverflow right) -> bool +static ComposeNet.TextOverflow.Pack(ComposeNet.TextOverflow? value) -> int +static ComposeNet.TextOverflow.StartEllipsis.get -> ComposeNet.TextOverflow +static ComposeNet.TextOverflow.Visible.get -> ComposeNet.TextOverflow virtual ComposeNet.MutableState.Value.get -> T virtual ComposeNet.MutableState.Value.set -> void diff --git a/src/ComposeNet.Compose/TextAlign.cs b/src/ComposeNet.Compose/TextAlign.cs index 5d3d5be1..ec01f1bf 100644 --- a/src/ComposeNet.Compose/TextAlign.cs +++ b/src/ComposeNet.Compose/TextAlign.cs @@ -1,49 +1,94 @@ +using Android.Runtime; + namespace ComposeNet; /// -/// C# mirror of Kotlin's androidx.compose.ui.text.style.TextAlign -/// — a @JvmInline value class wrapping an Int. The bridge -/// generator lowers TextAlign? to the underlying int JNI -/// slot. +/// C# wrapper around androidx.compose.ui.text.style.TextAlign. +/// In Kotlin source, TextAlign is a @JvmInline value class +/// wrapping an Int, but every @Composable function in +/// Material 3 declares it as nullable (textAlign: TextAlign? +/// = null) — so at the JNI boundary it travels as a boxed +/// androidx/compose/ui/text/style/TextAlign; reference, not a +/// packed int. The bridge generator's reference-type path +/// passes the handle through to the JNI L slot. +/// +/// To produce that boxed reference the constants here call the +/// inline-class's mangled Companion.getCenter-e0LSkKk()I +/// (and friends) for the packed int, then route it through the +/// synthesized static TextAlign.box-impl(I)LTextAlign; to wrap. /// -/// Values mirror the Kotlin TextAlign.Companion constants: -/// -/// = 1 -/// = 2 -/// = 3 -/// = 4 -/// = 5 -/// = 6 -/// +/// Will swap to bound AndroidX.Compose.UI.Text.Style.TextAlign +/// once +/// ships and we adopt the next Xamarin.AndroidX.Compose.UI.Text.Android +/// release. /// -public readonly record struct TextAlign(int Value) +public sealed class TextAlign : Java.Lang.Object { + TextAlign(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + static IntPtr s_companion; + static IntPtr s_box; + + static unsafe IntPtr Companion() + { + if (s_companion == IntPtr.Zero) + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/style/TextAlign"); + IntPtr fid = JNIEnv.GetStaticFieldID(cls, "Companion", "Landroidx/compose/ui/text/style/TextAlign$Companion;"); + IntPtr local = JNIEnv.GetStaticObjectField(cls, fid); + s_companion = JNIEnv.NewGlobalRef(local); + JNIEnv.DeleteLocalRef(local); + } + return s_companion; + } + + static IntPtr BoxMethod() + { + if (s_box == IntPtr.Zero) + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/style/TextAlign"); + s_box = JNIEnv.GetStaticMethodID(cls, "box-impl", "(I)Landroidx/compose/ui/text/style/TextAlign;"); + } + return s_box; + } + + static unsafe TextAlign Resolve(string mangledGetter) + { + // 1. Companion.()I returns the packed int. + IntPtr companionCls = JNIEnv.FindClass("androidx/compose/ui/text/style/TextAlign$Companion"); + IntPtr getter = JNIEnv.GetMethodID(companionCls, mangledGetter, "()I"); + int packed = JNIEnv.CallIntMethod(Companion(), getter); + + // 2. TextAlign.box-impl(int) -> Landroidx/.../TextAlign; + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/style/TextAlign"); + JValue* args = stackalloc JValue[1]; + args[0] = new JValue(packed); + IntPtr boxed = JNIEnv.CallStaticObjectMethod(cls, BoxMethod(), args); + return new TextAlign(boxed, JniHandleOwnership.TransferLocalRef); + } + + static TextAlign? s_left, s_right, s_center, s_justify, s_start, s_end, s_unspecified; + /// Align text to the left edge of the container. - public static TextAlign Left => new(1); + public static TextAlign Left => s_left ??= Resolve("getLeft-e0LSkKk"); /// Align text to the right edge of the container. - public static TextAlign Right => new(2); + public static TextAlign Right => s_right ??= Resolve("getRight-e0LSkKk"); /// Center text within the container. - public static TextAlign Center => new(3); + public static TextAlign Center => s_center ??= Resolve("getCenter-e0LSkKk"); /// Stretch lines to fill the container width. - public static TextAlign Justify => new(4); + public static TextAlign Justify => s_justify ??= Resolve("getJustify-e0LSkKk"); /// Align text to the layout-direction start edge. - public static TextAlign Start => new(5); + public static TextAlign Start => s_start ??= Resolve("getStart-e0LSkKk"); /// Align text to the layout-direction end edge. - public static TextAlign End => new(6); - - /// Kotlin's TextAlign.Unspecified sentinel. - public static TextAlign Unspecified => new(int.MinValue); - - /// - /// Pack a nullable into the raw int - /// the JNI slot expects. null0; the auto-mask - /// leaves the $default bit set so Kotlin's real default - /// applies. - /// - public static int Pack(TextAlign? value) => value?.Value ?? 0; + public static TextAlign End => s_end ??= Resolve("getEnd-e0LSkKk"); + + /// The unspecified-alignment sentinel value (Kotlin's default for nullable slots). + public static TextAlign Unspecified => s_unspecified ??= Resolve("getUnspecified-e0LSkKk"); } + diff --git a/src/ComposeNet.Compose/TextOverflow.cs b/src/ComposeNet.Compose/TextOverflow.cs new file mode 100644 index 00000000..a2386969 --- /dev/null +++ b/src/ComposeNet.Compose/TextOverflow.cs @@ -0,0 +1,42 @@ +namespace ComposeNet; + +/// +/// C# mirror of Kotlin's androidx.compose.ui.text.style.TextOverflow +/// — a @JvmInline value class wrapping an Int. The bridge +/// generator lowers TextOverflow? to the underlying int +/// JNI slot. +/// +/// Values mirror the Kotlin TextOverflow.Companion constants: +/// +/// = 1 — truncate at the container edge. +/// = 2 — replace overflow with "…". +/// = 3 — render past the container bounds. +/// = 4 — leading ellipsis. +/// = 5 — middle ellipsis. +/// +/// +public readonly record struct TextOverflow(int Value) +{ + /// Truncate the text at the edge of the container. + public static TextOverflow Clip => new(1); + + /// Replace the overflowing text with an ellipsis (default for single-line). + public static TextOverflow Ellipsis => new(2); + + /// Render the text outside the container bounds (no clipping). + public static TextOverflow Visible => new(3); + + /// Place the ellipsis at the start of the text. + public static TextOverflow StartEllipsis => new(4); + + /// Place the ellipsis in the middle of the text. + public static TextOverflow MiddleEllipsis => new(5); + + /// + /// Pack a nullable into the raw + /// int the JNI slot expects. null0; the + /// auto-mask leaves the $default bit set so Kotlin's real + /// default applies. + /// + public static int Pack(TextOverflow? value) => value?.Value ?? 0; +} diff --git a/src/ComposeNet.Sample/MainActivity.cs b/src/ComposeNet.Sample/MainActivity.cs index 832ed2c4..9002206c 100644 --- a/src/ComposeNet.Sample/MainActivity.cs +++ b/src/ComposeNet.Sample/MainActivity.cs @@ -180,6 +180,64 @@ protected override void OnCreate(Bundle? savedInstanceState) LineHeight = 22, Modifier = Modifier.Companion.Padding(8), }, + // Issue #58: text styling additions — color, italic / + // family, alignment, overflow, line clamping. Each + // property flows through the [ComposeFacade] / + // [ComposeBridge] generators: Color/MaxLines/MinLines/ + // SoftWrap use the new nullable-primitive path, + // FontStyle/FontFamily/TextAlign use the nullable + // reference-wrapper path, and TextOverflow uses the + // packed @JvmInline value-class path. + new Text("Issue #58 text styling:") + { + Modifier = Modifier.Companion.Padding(top: 8, bottom: 4, start: 0, end: 0), + FontWeight = ComposeNet.FontWeight.Bold, + }, + new Text("Italic serif red, centered") + { + Color = ColorKt.Color(red: 0xC6, green: 0x28, blue: 0x28, alpha: 0xFF), + FontStyle = ComposeNet.FontStyle.Italic, + FontFamily = ComposeNet.FontFamily.Serif, + Align = ComposeNet.TextAlign.Center, + Modifier = Modifier.Companion.FillMaxWidth(), + }, + new Text("Monospace, end-aligned") + { + FontFamily = ComposeNet.FontFamily.Monospace, + Align = ComposeNet.TextAlign.End, + Modifier = Modifier.Companion.FillMaxWidth(), + }, + new Text("This long line should clip with an ellipsis because we cap it at MaxLines=1 and force overflow.") + { + MaxLines = 1, + Overflow = ComposeNet.TextOverflow.Ellipsis, + SoftWrap = false, + }, + new Text("This paragraph wraps to at most two lines and uses a non-default minLines so the slot reserves vertical space even when the content is shorter than the maximum allowed.") + { + MaxLines = 2, + MinLines = 2, + Overflow = ComposeNet.TextOverflow.Ellipsis, + }, + // TextField with new slots: leading/trailing icons, + // label, supporting text, prefix, suffix. + new TextField(name) + { + Label = new Text("Your name"), + Placeholder = new Text("Type something…"), + LeadingIcon = new Text("👤"), + TrailingIcon = new Text("✎"), + SupportingText = new Text("Powered by issue #58 slots"), + SingleLine = true, + }, + new OutlinedTextField(name) + { + Label = new Text("Outlined variant"), + Prefix = new Text("@"), + Suffix = new Text(".dev"), + SupportingText = new Text($"len={name.Value.Length}"), + SingleLine = true, + }, // Phase 2 modifier demo — clickable rounded chip painted with // Background + Border + Clip; tapping it increments the counter. new Text($"Phase 2 modifiers (tap me): {count}") diff --git a/src/ComposeNet.SourceGenerators.Tests/BridgeGeneratorTests.cs b/src/ComposeNet.SourceGenerators.Tests/BridgeGeneratorTests.cs index e58528d7..eb057108 100644 --- a/src/ComposeNet.SourceGenerators.Tests/BridgeGeneratorTests.cs +++ b/src/ComposeNet.SourceGenerators.Tests/BridgeGeneratorTests.cs @@ -78,13 +78,16 @@ public readonly record struct Em(float Value) { public static long Pack(Em? value) => default; } - public readonly record struct TextAlign(int Value) - { - public static int Pack(TextAlign? value) => value?.Value ?? 0; - } public sealed class Shape : Java.Lang.Object { } public sealed class FontWeight : Java.Lang.Object { } + public sealed class FontStyle : Java.Lang.Object { } + public sealed class FontFamily : Java.Lang.Object { } + public sealed class TextAlign : Java.Lang.Object { } public sealed class TextDecoration : Java.Lang.Object { } + public readonly record struct TextOverflow(int Value) + { + public static int Pack(TextOverflow? value) => value?.Value ?? 0; + } } """; @@ -1132,15 +1135,19 @@ public static partial class ComposeBridges } [Fact] - public void ValueType_SpTextAlign_LowerCorrectly() + public void ValueType_SpTextOverflow_LowerCorrectly() { - // @Composable bridge: (Sp fontSize, TextAlign align, Composer). + // @Composable bridge: (Sp fontSize, TextOverflow overflow, Composer). // JNI: 2 user slots (J/I) + Composer (L) + 1 $changed (I) + 1 $default (I). + // Sp lowers to packed long; TextOverflow lowers to packed int — + // both are non-nullable in their real-world Compose @Composable + // declarations, so they travel as primitives rather than boxed + // references. var code = """ using ComposeNet; using AndroidX.Compose.Runtime; - [assembly: ComposeDefaults("FontDefault", "fontSize", "align")] + [assembly: ComposeDefaults("FontDefault", "fontSize", "overflow")] namespace ComposeNet { @@ -1151,7 +1158,7 @@ public static partial class ComposeBridges JvmName = "f", Signature = "(JILandroidx/compose/runtime/Composer;II)V", Defaults = typeof(FontDefault))] - public static partial void F(Sp? fontSize, TextAlign? align, IComposer composer); + public static partial void F(Sp? fontSize, TextOverflow? overflow, IComposer composer); } } """; @@ -1160,7 +1167,7 @@ public static partial class ComposeBridges Assert.Empty(diags.Where(d => d.Severity == DiagnosticSeverity.Error)); Assert.NotNull(emitted); Assert.Contains("global::ComposeNet.Sp.Pack(fontSize)", emitted); - Assert.Contains("global::ComposeNet.TextAlign.Pack(align)", emitted); + Assert.Contains("global::ComposeNet.TextOverflow.Pack(overflow)", emitted); } [Fact] @@ -1253,4 +1260,75 @@ public static partial class ComposeBridges Assert.Contains("weight is null ? global::System.IntPtr.Zero : ((global::Java.Lang.Object)weight).Handle", emitted); Assert.Contains("if (weight is not null) defaults &= ~(int)global::ComposeNet.WeightedDefault.Weight", emitted); } + + [Fact] + public void NullablePrimitive_BoolIntLong_LowerToZeroDefaults() + { + // bool? / int? / long? params surface as nullable so the caller + // can leave the Kotlin default in place (auto-mask bit stays + // set). When the caller supplies a value, the mask bit clears + // and the value is passed through to the JNI primitive slot. + var code = """ + using ComposeNet; + using AndroidX.Compose.Runtime; + + [assembly: ComposeDefaults("PrimDefault", + "softWrap", "maxLines", "color")] + + namespace ComposeNet + { + public static partial class ComposeBridges + { + [ComposeBridge( + Class = "x/Y", + JvmName = "f", + Signature = "(ZIJLandroidx/compose/runtime/Composer;II)V", + Defaults = typeof(PrimDefault))] + public static partial void F(bool? softWrap, int? maxLines, long? color, IComposer composer); + } + } + """; + + var (_, diags, emitted) = Run(code); + Assert.Empty(diags.Where(d => d.Severity == DiagnosticSeverity.Error)); + Assert.NotNull(emitted); + // Lowering: pass underlying primitive with null → zero literal. + Assert.Contains("(softWrap ?? false)", emitted); + Assert.Contains("(maxLines ?? 0)", emitted); + Assert.Contains("(color ?? 0L)", emitted); + // Auto-mask: clear the bit only when a value was supplied. + Assert.Contains("if (softWrap is not null) defaults &= ~(int)global::ComposeNet.PrimDefault.SoftWrap", emitted); + Assert.Contains("if (maxLines is not null) defaults &= ~(int)global::ComposeNet.PrimDefault.MaxLines", emitted); + Assert.Contains("if (color is not null) defaults &= ~(int)global::ComposeNet.PrimDefault.Color", emitted); + } + + [Fact] + public void NullablePrimitive_FloatDouble_LowerCorrectly() + { + var code = """ + using ComposeNet; + using AndroidX.Compose.Runtime; + + [assembly: ComposeDefaults("FloatDefault", "ratio", "scale")] + + namespace ComposeNet + { + public static partial class ComposeBridges + { + [ComposeBridge( + Class = "x/Y", + JvmName = "f", + Signature = "(FDLandroidx/compose/runtime/Composer;II)V", + Defaults = typeof(FloatDefault))] + public static partial void F(float? ratio, double? scale, IComposer composer); + } + } + """; + + var (_, diags, emitted) = Run(code); + Assert.Empty(diags.Where(d => d.Severity == DiagnosticSeverity.Error)); + Assert.NotNull(emitted); + Assert.Contains("(ratio ?? 0f)", emitted); + Assert.Contains("(scale ?? 0d)", emitted); + } } diff --git a/src/ComposeNet.SourceGenerators.Tests/FacadeGeneratorTests.cs b/src/ComposeNet.SourceGenerators.Tests/FacadeGeneratorTests.cs index 0208426b..4deb40bc 100644 --- a/src/ComposeNet.SourceGenerators.Tests/FacadeGeneratorTests.cs +++ b/src/ComposeNet.SourceGenerators.Tests/FacadeGeneratorTests.cs @@ -104,13 +104,16 @@ public readonly struct Em public float Value { get; } public static long Pack(Em? e) => 0L; } - public readonly struct TextAlign + public readonly struct TextOverflow { - public TextAlign(int v) { Value = v; } + public TextOverflow(int v) { Value = v; } public int Value { get; } - public static int Pack(TextAlign? a) => 0; + public static int Pack(TextOverflow? a) => 0; } public class FontWeight : Java.Lang.Object { } + public class FontStyle : Java.Lang.Object { } + public class FontFamily : Java.Lang.Object { } + public class TextAlign : Java.Lang.Object { } public class TextDecoration : Java.Lang.Object { } public class Shape : Java.Lang.Object { } public abstract class ComposableNode @@ -1562,8 +1565,11 @@ public static partial void Text( } [Fact] - public void OptionalValue_TextAlignAndDpAreClassifiedAsValueTypes() + public void OptionalValue_TextOverflowAndDpAreClassifiedAsValueTypes() { + // TextOverflow is a non-nullable @JvmInline value class in + // Compose source — it travels as packed `I`. Dp is also packed + // (`F`). Both surface as nullable auto-properties. var code = """ using AndroidX.Compose.Runtime; using AndroidX.Compose.UI; @@ -1571,7 +1577,7 @@ public void OptionalValue_TextAlignAndDpAreClassifiedAsValueTypes() using Kotlin.Jvm.Functions; [assembly: ComposeDefaults("FooDefault", - "!a", "align", "size")] + "!a", "overflow", "size")] namespace ComposeNet { @@ -1581,7 +1587,7 @@ public static partial class ComposeBridges Signature="(Ljava/lang/String;IFLandroidx/compose/runtime/Composer;II)V", Defaults=typeof(FooDefault))] [ComposeFacade] - public static partial void Foo(string a, TextAlign? align, Dp? size, IComposer composer); + public static partial void Foo(string a, TextOverflow? overflow, Dp? size, IComposer composer); } } """; @@ -1589,7 +1595,7 @@ public static partial class ComposeBridges var (output, diags, emitted) = Run(code, "Foo"); Assert.Empty(diags.Where(d => d.Severity == DiagnosticSeverity.Error)); Assert.NotNull(emitted); - Assert.Contains("public global::ComposeNet.TextAlign? Align { get; set; }", emitted); + Assert.Contains("public global::ComposeNet.TextOverflow? Overflow { get; set; }", emitted); Assert.Contains("public global::ComposeNet.Dp? Size { get; set; }", emitted); var errors = output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToArray(); @@ -1639,4 +1645,54 @@ public static partial void Bar( var errors = output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToArray(); Assert.Empty(errors); } + + [Fact] + public void OptionalValue_NullablePrimitivesEmitNullableAutoProperties() + { + // bool? / int? / long? params surface as Optional auto-properties + // — null leaves the Kotlin default in place via the auto-mask + // bit, a value clears the bit and lowers to the JNI primitive + // slot. + var code = """ + using AndroidX.Compose.Runtime; + using AndroidX.Compose.UI; + using ComposeNet; + using Kotlin.Jvm.Functions; + + [assembly: ComposeDefaults("PrimDefault", + "!text", "modifier", "softWrap", "maxLines", "color")] + + namespace ComposeNet + { + public static partial class ComposeBridges + { + [ComposeBridge(Class="x/Y", JvmName="F", + Signature="(Ljava/lang/String;Landroidx/compose/ui/Modifier;ZIJLandroidx/compose/runtime/Composer;II)V", + Defaults=typeof(PrimDefault))] + [ComposeFacade] + public static partial void F( + string text, IModifier? modifier, + bool? softWrap, int? maxLines, long? color, + IComposer composer); + } + } + """; + + var (output, diags, emitted) = Run(code, "F"); + Assert.Empty(diags.Where(d => d.Severity == DiagnosticSeverity.Error)); + Assert.NotNull(emitted); + // Each nullable primitive surfaces as an auto-property. + Assert.Contains("public bool? SoftWrap { get; set; }", emitted); + Assert.Contains("public int? MaxLines { get; set; }", emitted); + Assert.Contains("public long? Color { get; set; }", emitted); + // The bridge call passes the property names through. + Assert.Contains("global::ComposeNet.ComposeBridges.F(_text, BuildModifier(), SoftWrap, MaxLines, Color, composer);", emitted); + // Not surfaced as ctor parameters. + Assert.DoesNotContain("bool? softWrap", emitted); + Assert.DoesNotContain("int? maxLines", emitted); + Assert.DoesNotContain("long? color", emitted); + + var errors = output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToArray(); + Assert.Empty(errors); + } } diff --git a/src/ComposeNet.SourceGenerators/ComposeBridgeGenerator.cs b/src/ComposeNet.SourceGenerators/ComposeBridgeGenerator.cs index c693b629..6be62284 100644 --- a/src/ComposeNet.SourceGenerators/ComposeBridgeGenerator.cs +++ b/src/ComposeNet.SourceGenerators/ComposeBridgeGenerator.cs @@ -532,7 +532,7 @@ static string Emit( var member = PascalCase(p.Name); bool nullableValueType = ComposeValueTypes.TryGet(p.Type, out _, out _); - if (p.NullableAnnotation == NullableAnnotation.Annotated || p.Type.IsReferenceType || nullableValueType || IsNullableIntPtr(p.Type)) + if (p.NullableAnnotation == NullableAnnotation.Annotated || p.Type.IsReferenceType || nullableValueType || IsNullableIntPtr(p.Type) || IsNullablePrimitive(p.Type)) { sb.Append(" if (").Append(EscapeIdent(p.Name)).Append(" is not null) defaults &= ~(int)global::ComposeNet.") .Append(enumName).Append('.').Append(member).AppendLine(";"); @@ -710,6 +710,16 @@ static void EmitUserArgValue(StringBuilder sb, IParameterSymbol p, JniType sigTy sb.Append('(').Append(p.Name).Append(" ?? global::System.IntPtr.Zero)"); return; } + if (IsNullablePrimitive(p.Type)) + { + // `bool? / int? / long? / float? / double?` → null means + // "let the Kotlin default kick in"; pass the zero literal + // in that slot. The auto-mask logic clears the matching + // `$default` bit only when the caller supplied a value. + var defaultLit = NullablePrimitiveZeroLiteral(p.Type); + sb.Append('(').Append(EscapeIdent(p.Name)).Append(" ?? ").Append(defaultLit).Append(')'); + return; + } if (ComposeValueTypes.TryGet(p.Type, out _, out var info)) { // Recognized Compose @JvmInline value class. The lowering @@ -757,7 +767,8 @@ or SpecialType.System_IntPtr or SpecialType.System_String) return false; if (IsNullableIntPtr(p.Type)) return false; - // Recognized Compose value types (Color/Dp/Sp/Em/TextAlign) lower + if (IsNullablePrimitive(p.Type)) return false; + // Recognized Compose value types (Dp/Sp/Em/TextOverflow) lower // to JNI primitives — no managed handle to keep alive. if (ComposeValueTypes.TryGet(p.Type, out _, out _)) return false; return true; @@ -769,6 +780,48 @@ t is INamedTypeSymbol n && n.TypeArguments.Length == 1 && n.TypeArguments[0].SpecialType == SpecialType.System_IntPtr; + /// + /// True for parameters typed as Nullable<T> where + /// T is one of the primitive JNI-friendly value types + /// (bool, int, long, float, + /// double). These let a bridge expose an "optional" Compose + /// param: null leaves the $default bit set so Kotlin's + /// real default applies; a value clears the bit and is passed + /// through to the JNI primitive slot. + /// + static bool IsNullablePrimitive(ITypeSymbol t) + { + if (t is not INamedTypeSymbol n) return false; + if (n.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T) return false; + if (n.TypeArguments.Length != 1) return false; + return n.TypeArguments[0].SpecialType is + SpecialType.System_Boolean + or SpecialType.System_Int32 + or SpecialType.System_Int64 + or SpecialType.System_Single + or SpecialType.System_Double; + } + + /// + /// Zero-literal expression for the underlying primitive of a + /// type. Used as the + /// ?? default fallback in + /// when the caller passes null. + /// + static string NullablePrimitiveZeroLiteral(ITypeSymbol t) + { + var inner = ((INamedTypeSymbol)t).TypeArguments[0].SpecialType; + return inner switch + { + SpecialType.System_Boolean => "false", + SpecialType.System_Int32 => "0", + SpecialType.System_Int64 => "0L", + SpecialType.System_Single => "0f", + SpecialType.System_Double => "0d", + _ => "default", + }; + } + static bool IsModifierType(ITypeSymbol t) { if (t is INamedTypeSymbol n) diff --git a/src/ComposeNet.SourceGenerators/ComposeFacadeGenerator.cs b/src/ComposeNet.SourceGenerators/ComposeFacadeGenerator.cs index 8749fa2d..3350330b 100644 --- a/src/ComposeNet.SourceGenerators/ComposeFacadeGenerator.cs +++ b/src/ComposeNet.SourceGenerators/ComposeFacadeGenerator.cs @@ -934,16 +934,24 @@ or SpecialType.System_Single /// /// True for parameters typed as Nullable<T> where T /// is a recognized Compose @JvmInline value class (Dp/Sp/Em/ - /// TextAlign), or for nullable reference-typed wrappers in - /// . Both shapes surface as - /// auto-properties on - /// the generated facade. Non-nullable reference wrappers do not - /// qualify — emitting a nullable auto-property for them would - /// pass null to a non-nullable bridge parameter. + /// TextOverflow), for nullable reference-typed wrappers in + /// (FontWeight/FontStyle/ + /// FontFamily/TextAlign/TextDecoration/Shape), or for + /// Nullable<T> where T is a JNI-friendly + /// primitive (bool, int, long, float, + /// double) — the "optional Compose primitive" shape: + /// null → leave the $default bit set; a value clears + /// the bit and lowers to the primitive JNI slot. All three shapes + /// surface as + /// auto-properties on the generated facade. Non-nullable reference + /// wrappers do not qualify — emitting a nullable auto-property for + /// them would pass null to a non-nullable bridge parameter. /// static bool IsOptionalValueType(ITypeSymbol type, NullableAnnotation annotation) { if (ComposeValueTypes.TryGet(type, out _, out _)) return true; + // Nullable: bool?, int?, long?, float?, double?. + if (IsNullablePrimitive(type)) return true; // Nullable reference-type wrapper: T? where T is recognized. // The C# nullable-annotation flow gives us the underlying type // directly (no Nullable wrapping) for reference types, so @@ -955,6 +963,26 @@ static bool IsOptionalValueType(ITypeSymbol type, NullableAnnotation annotation) return false; } + /// + /// Mirror of ComposeBridgeGenerator.IsNullablePrimitive — + /// see that helper for the rationale. Kept private here because + /// the bridge generator's copy lives in a separate translation + /// unit and the registry of "primitive Nullable types" is small + /// and stable. + /// + static bool IsNullablePrimitive(ITypeSymbol t) + { + if (t is not INamedTypeSymbol n) return false; + if (n.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T) return false; + if (n.TypeArguments.Length != 1) return false; + return n.TypeArguments[0].SpecialType is + SpecialType.System_Boolean + or SpecialType.System_Int32 + or SpecialType.System_Int64 + or SpecialType.System_Single + or SpecialType.System_Double; + } + static bool IsKnownScopeKind(Compilation compilation, string scope) { var enumType = compilation.GetTypeByMetadataName("ComposeNet.ScopeKind"); diff --git a/src/ComposeNet.SourceGenerators/ComposeReferenceTypes.cs b/src/ComposeNet.SourceGenerators/ComposeReferenceTypes.cs index d386f2c7..fc83e59f 100644 --- a/src/ComposeNet.SourceGenerators/ComposeReferenceTypes.cs +++ b/src/ComposeNet.SourceGenerators/ComposeReferenceTypes.cs @@ -31,6 +31,9 @@ internal static class ComposeReferenceTypes public static readonly IReadOnlyCollection Recognized = new HashSet { "ComposeNet.FontWeight", + "ComposeNet.FontFamily", + "ComposeNet.FontStyle", + "ComposeNet.TextAlign", "ComposeNet.TextDecoration", "ComposeNet.Shape", }; diff --git a/src/ComposeNet.SourceGenerators/ComposeValueTypes.cs b/src/ComposeNet.SourceGenerators/ComposeValueTypes.cs index 56f98ae4..91f1bbf6 100644 --- a/src/ComposeNet.SourceGenerators/ComposeValueTypes.cs +++ b/src/ComposeNet.SourceGenerators/ComposeValueTypes.cs @@ -41,9 +41,13 @@ internal static class ComposeValueTypes ["ComposeNet.Sp"] = ('J', "global::ComposeNet.Sp.Pack({0})"), - // androidx.compose.ui.text.style.TextAlign → JNI int. - ["ComposeNet.TextAlign"] = - ('I', "global::ComposeNet.TextAlign.Pack({0})"), + // androidx.compose.ui.text.style.TextOverflow → JNI int. + // (Compose declares TextOverflow as non-nullable in + // @Composable signatures, so it lowers as packed `I` rather + // than the boxed `L` reference seen for nullable inline + // classes like TextAlign / FontStyle.) + ["ComposeNet.TextOverflow"] = + ('I', "global::ComposeNet.TextOverflow.Pack({0})"), // androidx.compose.ui.graphics.Color is bound by // Xamarin.AndroidX.Compose.UI.Graphics 1.11.2.1, but Kotlin's