Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# compose-net — agent instructions

A C#-only .NET-for-Android app hosting Jetpack Compose UI through the
official `Xamarin.AndroidX.Compose.*` bindings. No Kotlin sources, no
custom bindings, no `[InterceptsLocation]` magic. Every C# composable
either calls a generated binding method directly or a JNI bridge in
`ComposeBridges.cs`.

Read `README.md` and `NOTES.md` for the why. This file documents the
conventions an agent **must** follow when changing code.

## Layout

- `src/ComposeNet.Compose/` — the public C# facade (`Text`, `Column`,
`Button`, `MaterialTheme`, `AlertDialog`, …). One file per concept;
`Composables.cs` holds the user-facing types, `ComposeBridges.cs`
holds raw-JNI bridges, `ComposeDefaults.cs` holds *only* assembly
attributes that drive the source generator.
- `src/ComposeNet.SourceGenerators/` — Roslyn incremental generator
(`ComposeDefaultsGenerator`) that emits `[Flags]` enums for Kotlin's
`$default` bitmask.
- `src/ComposeNet.SourceGenerators.Tests/` — xUnit tests, **no Android
SDK required**. Run with `dotnet test src/ComposeNet.SourceGenerators.Tests`.
- `src/ComposeNet.Sample/` — runnable Android app. Build with
`dotnet build src/ComposeNet.Sample` (needs the `android` workload).

## Build / test / run

```pwsh
dotnet test src/ComposeNet.SourceGenerators.Tests # generator unit tests
dotnet build src/ComposeNet.Compose # facade only
dotnet build src/ComposeNet.Sample # full Android build
dotnet build src/ComposeNet.Sample -t:Run # deploy to device
```

Run the generator tests on every change to `ComposeNet.SourceGenerators`
or `ComposeDefaults.cs`. Run the facade build on every change to
`ComposeNet.Compose`.

## Generated `$default` enums — DO NOT hand-roll

Every Compose `@Composable` function takes a trailing `int $default`
bitmask: bit *N* set means "parameter *N* was not supplied; use the
Kotlin default." We need a `[Flags]` enum naming each bit so call sites
read as `(int)ButtonDefault.All` instead of magic numbers.

**Always generate these enums via `ComposeDefaultsAttribute`. Never
write a `[Flags] enum FooDefault { … }` by hand.** All such attributes
live in `src/ComposeNet.Compose/ComposeDefaults.cs`.

Two forms, pick by whether the binding exposes the Kt method:

### 1. Generic form — preferred when the Kt method is bindable

```csharp
[assembly: ComposeDefaults<ColumnKt>("Column", "ColumnDefault")]
```

The generator picks the longest static overload of `ColumnKt.Column`,
walks parameters up to the first `IComposer`, names each bit after the
parameter (PascalCased), skips `Kotlin.Jvm.Functions.IFunction*` slots
(content lambdas — always provided), and emits an `All` constant.

### 2. Declarative form — when the Kt method is stripped

The dotnet/android-libraries binder strips Compose overloads with
mangled JVM names (`Text--4IGK_g`, `Surface-T9BRK9s`, `AlertDialog-Oix01E0`,
`FloatingActionButton-X-z6DiA`). These come from Kotlin `@JvmInline value
class` parameters (`Color`, `Dp`, `TextUnit`, `FontWeight`, …). Once
[dotnet/java-interop#1440] lands the generic form will work for them
too — until then, hand the generator the Kotlin parameter names:

```csharp
[assembly: ComposeDefaults("ButtonDefault",
"!onClick", "modifier", "enabled", "shape", "colors",
"elevation", "border", "contentPadding", "interactionSource", "!content")]
```

- Each name occupies one bit at its positional index.
- Prefix with `!` to consume the bit but emit no enum member (params
the caller always provides — `onClick`, content lambdas, required
values like `text`, `value`).
- For optional slot lambdas the caller toggles per-call (e.g.
`AlertDialog`'s `dismissButton`/`icon`/`title`/`text`), keep them
*as enum members* — the call site clears the bit when the user
supplies the slot.

When the upstream binder fix lands, a declarative attribute can be
swapped to the generic form one-for-one and the comment in
`ComposeDefaults.cs` can be updated.

[dotnet/java-interop#1440]: https://github.com/dotnet/java-interop/pull/1440

### Generator diagnostics

| ID | Meaning |
|---------|--------------------------------------------------------|
| CN1001 | Generic form: named static method not found on `T`. |
| CN1002 | Generic form: method has no `IComposer` parameter. |
| CN1003 | Either form: attribute arguments couldn't be read. |

Tests live in `GeneratorTests.cs` and run against synthetic
compilations — no Android references. **Add a test for any new
generator behaviour.**

## `ComposeBridges.cs` — raw JNI bridges

When an overload is stripped from the binding we call it via
`JNIEnv.FindClass` + `GetStaticMethodID` + `CallStaticVoidMethod`.
Pattern, copied throughout the file:

1. Cache the JNI class + method handles in `static IntPtr` fields
(initialise lazily on first call — Android's class loader is slow).
2. Build the full JNI signature string as a `const string` next to the
bridge method, with a comment showing the Kotlin parameter list in
source order.
3. Allocate `JValue* args = stackalloc JValue[N]`; fill positionally.
4. The `$default` bitmask is the **last** `int` arg (after any
`$changed`/`$changed1`/… and `composer`). Compute it from the
matching `XxxDefault.All` constant.
5. For string params: `IntPtr ref = JNIEnv.NewString(s);` inside a
`try`/`finally` that calls `DeleteLocalRef`.
6. Pass managed objects as `((Java.Lang.Object)obj).Handle`. Use
`IntPtr.Zero` for `null`.

Keep these methods `internal` — user code never touches JNI directly.
**Do not add new JNI bridges if the binding already exposes the
method**; call the generated C# entry point instead (see
`Composables.cs::Column.Render` for the canonical example).

## Facade conventions (`Composables.cs`)

- All public types derive from `ComposableNode` (a single `internal
abstract void Render(IComposer)`).
- Container composables derive from `ComposableContainer`, which
implements `IEnumerable` + `Add(ComposableNode?)` so callers can use
C# collection-initializer syntax:
```csharp
new Column { new Text("Hi"), new Button(onClick: …) { new Text("Tap") } }
```
- Wrap Kotlin lambdas with `ComposableLambda0/1/2/3` (existing
helpers — don't hand-roll new lambda adapters).
- Multi-slot composables (e.g. `AlertDialog`) expose **named slot
properties** set via object-initializer syntax, not extra
collection-init Add overloads. Pattern: start `defaults =
XxxDefault.All`, clear the bit for each slot the caller actually
supplied. See `AlertDialog.Render` — this is the template the future
`ModalBottomSheet`, `DatePickerDialog`, etc. will follow.
- Required Kotlin parameters with no default (e.g. `AlertDialog.confirmButton`)
are validated in `Render` with a clear `InvalidOperationException`.
- File-scoped namespaces (`namespace ComposeNet;`). One blank line
separating `// ---- Section ----` banners. XML doc comments on every
public type and every non-trivial member.

## Bindings policy

The repo used to ship its own `*.Compose.*` binding projects. **Don't
bring those back.** Reference the official NuGets only:
`Xamarin.AndroidX.Compose.*` and `Xamarin.AndroidX.Compose.Material3`.
If a needed Compose API isn't bound, the workflow is:

1. File / link a tracking issue against `dotnet/android-libraries` (see
the existing #1415–#1418 references in `README.md`).
2. Add a JNI bridge in `ComposeBridges.cs` as a temporary measure.
3. Generate the matching `$default` enum via the declarative attribute
form above.
4. When the upstream binding fix ships, delete the bridge and switch
to the generic attribute form.

## Style

- Target framework: `net10.0-android` for the facade and sample;
`netstandard2.0` for the source generator (Roslyn requirement);
`net10.0` for the generator tests.
- C# 12+, nullable reference types enabled, file-scoped namespaces.
- Public API gets XML doc comments. Internal helpers get a one-line
`//` comment when they're non-obvious; otherwise leave them bare.
- Don't add markdown planning docs to the repo — use the session
artifact folder.
- Commit trailer: `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>`.
179 changes: 46 additions & 133 deletions src/ComposeNet.Compose/ComposeDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,24 @@
// to query them.
//
// `ColumnDefault` and `MaterialThemeDefault` are *generated* by
// ComposeNet.SourceGenerators from the two assembly-level attributes
// below. The generator reads the longest overload of the named static
// method, names each bit after its parameter, and emits an `All`
// constant that ORs every user-defaultable bit.
// ComposeNet.SourceGenerators from the generic `[ComposeDefaults<T>]`
// attribute below — the binder exposes those Kt classes, so the
// generator can read parameter names off the longest overload.
//
// `ButtonDefault` and `TextDefault` are still HAND-ROLLED because the
// dotnet/android-libraries binding strips `ButtonKt.Button` and
// `TextKt.Text--4IGK_g` (we call them via raw JNI in `ComposeBridges`),
// so there's no C# `IMethodSymbol` for the generator to introspect.
// The other seven (Button, Text, IconButton, FloatingActionButton,
// Surface, AlertDialog, TextField/OutlinedTextField) are *also*
// generated, but from the declarative `[ComposeDefaults]` overload.
// Their Kotlin overloads with the trailing $default param are stripped
// from the binding (mangled JVM names like `Text--4IGK_g` from inline
// classes such as `Color`/`TextUnit`/`Dp`), so there is no IMethodSymbol
// for the generator to introspect — we hand it the Kotlin parameter
// names instead. Names prefixed with `!` consume a bit position but
// don't emit an enum member (e.g. params the caller always provides).
//
// When dotnet/java-interop#1440 lands and exposes the inline-class
// overloads, the declarative attributes can be replaced with
// `[ComposeDefaults<ButtonKt>("Button", "ButtonDefault")]` etc. and
// this comment can be deleted.

using AndroidX.Compose.Foundation.Layout;
using AndroidX.Compose.Material3;
Expand All @@ -22,150 +31,54 @@
[assembly: ComposeDefaults<ColumnKt>("Column", "ColumnDefault")]
[assembly: ComposeDefaults<MaterialThemeKt>("MaterialTheme", "MaterialThemeDefault")]

namespace ComposeNet;

// androidx.compose.material3.ButtonKt.Button: 10 user params,
// bit 0 = onClick, bit 9 = content (both always provided).
[System.Flags]
internal enum ButtonDefault
{
None = 0,
Modifier = 1 << 1,
Enabled = 1 << 2,
Shape = 1 << 3,
Colors = 1 << 4,
Elevation = 1 << 5,
Border = 1 << 6,
ContentPadding = 1 << 7,
InteractionSource = 1 << 8,
All = Modifier | Enabled | Shape | Colors | Elevation | Border | ContentPadding | InteractionSource,
}
[assembly: ComposeDefaults("ButtonDefault",
"!onClick", "modifier", "enabled", "shape", "colors",
"elevation", "border", "contentPadding", "interactionSource", "!content")]

// androidx.compose.material3.TextKt.Text--4IGK_g: 17 user params,
// bit 0 = text (always provided).
[System.Flags]
internal enum TextDefault
{
None = 0,
Modifier = 1 << 1,
Color = 1 << 2,
FontSize = 1 << 3,
FontStyle = 1 << 4,
FontWeight = 1 << 5,
FontFamily = 1 << 6,
LetterSpacing = 1 << 7,
Decoration = 1 << 8,
Align = 1 << 9,
LineHeight = 1 << 10,
Overflow = 1 << 11,
SoftWrap = 1 << 12,
MaxLines = 1 << 13,
MinLines = 1 << 14,
OnTextLayout = 1 << 15,
Style = 1 << 16,
All = Modifier | Color | FontSize | FontStyle | FontWeight | FontFamily | LetterSpacing
| Decoration | Align | LineHeight | Overflow | SoftWrap | MaxLines | MinLines
| OnTextLayout | Style,
}
[assembly: ComposeDefaults("TextDefault",
"!text", "modifier", "color", "fontSize", "fontStyle",
"fontWeight", "fontFamily", "letterSpacing", "decoration", "align",
"lineHeight", "overflow", "softWrap", "maxLines", "minLines",
"onTextLayout", "style")]

// androidx.compose.material3.IconButtonKt.IconButton: 6 user params,
// bit 0 = onClick, bit 5 = content (both provided).
[System.Flags]
internal enum IconButtonDefault
{
None = 0,
Modifier = 1 << 1,
Enabled = 1 << 2,
Colors = 1 << 3,
InteractionSource = 1 << 4,
All = Modifier | Enabled | Colors | InteractionSource,
}
[assembly: ComposeDefaults("IconButtonDefault",
"!onClick", "modifier", "enabled", "colors", "interactionSource", "!content")]

// androidx.compose.material3.FloatingActionButtonKt.FloatingActionButton-X-z6DiA:
// 8 user params, bit 0 = onClick, bit 7 = content (both provided).
[System.Flags]
internal enum FloatingActionButtonDefault
{
None = 0,
Modifier = 1 << 1,
Shape = 1 << 2,
ContainerColor = 1 << 3,
ContentColor = 1 << 4,
Elevation = 1 << 5,
InteractionSource = 1 << 6,
All = Modifier | Shape | ContainerColor | ContentColor | Elevation | InteractionSource,
}
[assembly: ComposeDefaults("FloatingActionButtonDefault",
"!onClick", "modifier", "shape", "containerColor", "contentColor",
"elevation", "interactionSource", "!content")]

// androidx.compose.material3.SurfaceKt.Surface-T9BRK9s (non-interactive):
// 8 user params, only bit 7 = content provided.
[System.Flags]
internal enum SurfaceDefault
{
None = 0,
Modifier = 1 << 0,
Shape = 1 << 1,
Color = 1 << 2,
ContentColor = 1 << 3,
TonalElevation = 1 << 4,
ShadowElevation = 1 << 5,
Border = 1 << 6,
All = Modifier | Shape | Color | ContentColor | TonalElevation | ShadowElevation | Border,
}
[assembly: ComposeDefaults("SurfaceDefault",
"modifier", "shape", "color", "contentColor", "tonalElevation",
"shadowElevation", "border", "!content")]

// androidx.compose.material3.AndroidAlertDialog_androidKt.AlertDialog-Oix01E0:
// 14 user params, bit 0 = onDismissRequest, bit 1 = confirmButton
// (both always provided). The four slot Function2 params (dismissButton,
// icon, title, text) are optional and toggled per-call by AlertDialog.Render.
[System.Flags]
internal enum AlertDialogDefault
{
None = 0,
Modifier = 1 << 2,
DismissButton = 1 << 3,
Icon = 1 << 4,
Title = 1 << 5,
Text = 1 << 6,
Shape = 1 << 7,
ContainerColor = 1 << 8,
IconContentColor = 1 << 9,
TitleContentColor = 1 << 10,
TextContentColor = 1 << 11,
TonalElevation = 1 << 12,
Properties = 1 << 13,
All = Modifier | DismissButton | Icon | Title | Text | Shape | ContainerColor
| IconContentColor | TitleContentColor | TextContentColor | TonalElevation | Properties,
}
// icon, title, text) are toggled per-call by AlertDialog.Render — they
// stay as enum members so callers can OR them in conditionally.
[assembly: ComposeDefaults("AlertDialogDefault",
"!onDismissRequest", "!confirmButton", "modifier", "dismissButton",
"icon", "title", "text", "shape", "containerColor", "iconContentColor",
"titleContentColor", "textContentColor", "tonalElevation", "properties")]

// androidx.compose.material3.TextFieldKt.TextField (String overload) AND
// OutlinedTextFieldKt.OutlinedTextField (String overload): 23 user params,
// bit 0 = value, bit 1 = onValueChange (both provided).
[System.Flags]
internal enum TextFieldDefault
{
None = 0,
Modifier = 1 << 2,
Enabled = 1 << 3,
ReadOnly = 1 << 4,
TextStyle = 1 << 5,
Label = 1 << 6,
Placeholder = 1 << 7,
LeadingIcon = 1 << 8,
TrailingIcon = 1 << 9,
Prefix = 1 << 10,
Suffix = 1 << 11,
SupportingText = 1 << 12,
IsError = 1 << 13,
VisualTransformation = 1 << 14,
KeyboardOptions = 1 << 15,
KeyboardActions = 1 << 16,
SingleLine = 1 << 17,
MaxLines = 1 << 18,
MinLines = 1 << 19,
InteractionSource = 1 << 20,
Shape = 1 << 21,
Colors = 1 << 22,
All = Modifier | Enabled | ReadOnly | TextStyle | Label | Placeholder | LeadingIcon
| TrailingIcon | Prefix | Suffix | SupportingText | IsError | VisualTransformation
| KeyboardOptions | KeyboardActions | SingleLine | MaxLines | MinLines
| InteractionSource | Shape | Colors,
}
[assembly: ComposeDefaults("TextFieldDefault",
"!value", "!onValueChange", "modifier", "enabled", "readOnly",
"textStyle", "label", "placeholder", "leadingIcon", "trailingIcon",
"prefix", "suffix", "supportingText", "isError", "visualTransformation",
"keyboardOptions", "keyboardActions", "singleLine", "maxLines", "minLines",
"interactionSource", "shape", "colors")]

Loading