diff --git a/.github/skills/maui-devflow-debug/SKILL.md b/.github/skills/maui-devflow-debug/SKILL.md new file mode 100644 index 000000000..d79b9c1e2 --- /dev/null +++ b/.github/skills/maui-devflow-debug/SKILL.md @@ -0,0 +1,96 @@ +--- +name: maui-devflow-debug +description: >- + Run build, deploy, inspect, and fix loops for .NET MAUI apps that already have + MAUI DevFlow integrated. USE FOR: launching MAUI apps, selecting devices or + emulators, waiting for or recovering agent connections, broker/port/adb + connectivity issues, visual tree inspection, screenshots, UI interaction, + Blazor WebView CDP debugging, reading DevFlow logs, and iterative app + debugging. DO NOT USE FOR: first-time DevFlow package setup (use + maui-devflow-onboard), or generic desktop automation unrelated to MAUI. INVOKES: + maui devflow CLI, dotnet CLI, Android adb/android tools, and Apple simctl + tools. +--- + +# DevFlow Debug + +Use this skill for the active debugging loop after a MAUI app has DevFlow +packages and `builder.AddMauiDevFlowAgent()` registered. + +## When to Use + +- Build and run a MAUI app on Android, iOS, Mac Catalyst, macOS, Windows, or GTK. +- Choose or create a simulator/emulator for a project. +- Wait for a DevFlow agent, inspect the visual tree, tap/fill UI, or capture screenshots. +- Recover from DevFlow connection failures after the app is integrated, including broker, port, and Android adb forwarding issues. +- Debug Blazor Hybrid content through DevFlow WebView/CDP commands. +- Read app logs, network captures, preferences, device info, or recordings through DevFlow. +- Iterate on an app bug with a build -> deploy -> inspect -> fix loop. + +## Route Elsewhere + +- If DevFlow packages or `MauiProgram.cs` registration are missing, use `maui-devflow-onboard`. +- If the failure is a generic build or SDK issue with no DevFlow angle, use normal .NET/MAUI diagnostics. + +## Core Loop + +1. Confirm the app is already integrated: + + ```bash + grep -rl "Microsoft.Maui.DevFlow" --include="*.csproj" . + ``` + + If no project has DevFlow package references, stop and switch to `maui-devflow-onboard`. + +2. Pick the target framework and launch target. Do not assume `net10.0`; inspect the project first. + + ```bash + grep -i "TargetFramework" *.csproj Directory.Build.props 2>/dev/null + ``` + +3. Start or select the device/emulator. For Android and iOS, avoid reusing a simulator/emulator that is already running another app under investigation. + +4. Launch the app and keep the launch process alive when required. + + - iOS, Android, and Mac Catalyst `dotnet build -t:Run` usually block for the app lifetime. + - GTK `dotnet run` blocks for the app lifetime. + - macOS AppKit builds can exit after compiling; launch the `.app` separately. + +5. Wait for the DevFlow agent before inspecting UI: + + ```bash + maui devflow wait + maui devflow ui tree --depth 3 --fields "id,type,text,automationId" + ``` + + If `wait`, `list`, or `ui tree` cannot connect after the app is running, load `references/connectivity.md` and recover the broker/agent connection before continuing. + +6. Inspect, interact, capture evidence, then edit the app and repeat from launch. + +## Critical Anti-patterns + +- Do not treat an empty `maui devflow list` as proof the project is not integrated. `list` is runtime state; project files are source of truth. +- Do not use arbitrary sleeps after launch. Use `maui devflow wait` to gate on the actual agent connection. +- Do not kill an async `dotnet build -t:Run` or `dotnet run` shell while you still need the app; that often kills the app. +- Do not reuse a busy simulator/emulator when multiple MAUI apps or agents may be running. +- Do not debug Blazor WebView DOM issues through the native visual tree alone; use the WebView/CDP commands. + +## Stop Signals + +- Stop and switch to `maui-devflow-onboard` when package references or `AddMauiDevFlowAgent()` are absent. +- Stop and ask which project, device, or agent to target when multiple candidates match. +- Stop rebuilding after two identical failures until you inspect the first meaningful build/runtime error. +- Stop using screenshots for exact property values; query the visual tree or properties instead. + +## Reference Map + +Load these only when needed: + +- `references/setup.md` - detailed integration, package, entitlement, and update notes. +- `references/connectivity.md` - broker, agent, port, Android forwarding, and "no agents connected" recovery. +- `references/android.md` - Android SDK, emulator, adb, build, deploy, and port forwarding details. +- `references/ios-and-mac.md` - iOS simulator, Mac Catalyst, permissions, entitlements, and Apple tooling. +- `references/macos.md` - macOS AppKit project shape, launch model, and troubleshooting. +- `references/linux.md` - GTK/Linux launch, packages, and WebKitGTK notes. +- `references/batch.md` - batching multiple DevFlow UI/WebView operations. +- `references/troubleshooting.md` - build, connection, CDP, and platform-specific failure recovery. diff --git a/.github/skills/maui-devflow-debug/references/android.md b/.github/skills/maui-devflow-debug/references/android.md new file mode 100644 index 000000000..ea92d9d09 --- /dev/null +++ b/.github/skills/maui-devflow-debug/references/android.md @@ -0,0 +1,213 @@ +# Android Reference + +## Table of Contents +- [Emulator Management](#emulator-management) +- [Building and Deploying](#building-and-deploying) +- [Android CLI Tool](#android-cli-tool) +- [ADB Reference](#adb-reference) +- [SDK Management](#sdk-management) +- [Troubleshooting](#troubleshooting) + +## Emulator Management + +### Avoiding multi-project conflicts + +When multiple projects (or AI agents) may deploy to Android emulators simultaneously, +each project should use its own dedicated AVD. Two apps deployed to the same emulator +will coexist (unlike iOS), but `adb reverse`/`adb forward` port forwarding is per-device +and can cause confusion when multiple emulators are running. + +**Before creating or starting an emulator, check what's already in use:** +```bash +maui devflow list # shows agents with platform + port +adb devices # shows connected emulators +``` + +If an emulator is already running another project's agent, create a new AVD: +```bash +android avd create --name "ProjectName-Pixel8" \ + --sdk "system-images;android-35;google_apis;arm64-v8a" --device pixel_8 +android avd start --name "ProjectName-Pixel8" +``` + +**When multiple emulators are running**, use `-s ` to target a specific one: +```bash +adb -s emulator-5554 reverse tcp:19223 tcp:19223 # first emulator +adb -s emulator-5556 reverse tcp:19223 tcp:19223 # second emulator +``` + +**Naming convention:** Use `-` (e.g. `TodoApp-Pixel8`) so it's +clear which AVD belongs to which project. + +### List and start AVDs +```bash +android avd list # list available AVDs +android avd start --name # start emulator +``` + +### Create AVD +```bash +# List available targets and device profiles +android avd targets # system images +android avd devices # device profiles (pixel, etc.) + +android avd create --name "Pixel8API35" \ + --sdk "system-images;android-35;google_apis;arm64-v8a" \ + --device pixel_8 +``` + +### Delete AVD +```bash +android avd delete --name +``` + +### Verify emulator is running +```bash +adb devices # should show "emulator-5554 device" +android device list # formatted list +``` + +## Building and Deploying + +```bash +# Build and deploy to running emulator +dotnet build -f net10.0-android -t:Run + +# Build only (no deploy) +dotnet build -f net10.0-android +``` + +**Critical: Port forwarding after deploy** — the Android emulator runs in its own network. +Forward the broker port and the agent port: +```bash +adb reverse tcp:19223 tcp:19223 # Broker (lets agent register) +adb forward tcp: tcp: # Agent (lets CLI reach agent) +``` + +The broker reverse is needed so the agent inside the emulator can connect to the host's +broker daemon. The agent forward uses the port shown in `maui devflow list` after the agent +registers (range 10223–10899). + +If the broker isn't available (fallback mode), forward the port from `.mauidevflow` instead: +```bash +adb forward tcp:9223 tcp:9223 # Fallback: direct agent port +``` + +Then verify: `maui devflow ui status` and `maui devflow webview status`. + +### Install APK manually +```bash +adb install -r path/to/app.apk # install/reinstall +android device install --package path/to/app.apk +``` + +## Android CLI Tool + +The `android` command (from `androidsdk.tool` NuGet) wraps SDK tools. + +### SDK management +``` +android sdk list # all packages +android sdk list --installed # installed only +android sdk list --available # available for install +android sdk install --package "platforms;android-35" +android sdk install --package "system-images;android-35;google_apis;arm64-v8a" +android sdk install --package "emulator" +android sdk uninstall --package +android sdk info # SDK location, tools versions +android sdk accept-licenses # accept all SDK licenses +android sdk download # download cmdline-tools +``` + +### AVD management +``` +android avd list # available AVDs +android avd targets # available system images +android avd devices # available device profiles +android avd create --name --sdk --device +android avd delete --name +android avd start --name +``` + +### Device/emulator operations +``` +android device list # connected devices/emulators +android device info [--device ] # device properties +android device install --package # install APK +android device uninstall --package # uninstall by package name +``` + +### JDK management +``` +android jdk list # available JDKs +android jdk info # current JDK info +``` + +## ADB Reference + +### Device/emulator basics +```bash +adb devices # list connected devices +adb -s shell # shell into specific device +adb shell pm list packages | grep # find installed packages +adb shell am start -n / # launch activity +adb shell am force-stop # kill app +``` + +### Port forwarding (critical for MAUI DevFlow) +```bash +adb reverse tcp:19223 tcp:19223 # Broker (agent → host) +adb forward tcp: tcp: # Agent (CLI → emulator, get port from `maui devflow list`) +adb reverse --list # verify forwarding +adb forward --list # verify forwarding +adb reverse --remove-all # clean up reverse +adb forward --remove-all # clean up forward +``` + +### File operations +```bash +adb push local/file /sdcard/path # push file to device +adb pull /sdcard/path local/file # pull file from device +``` + +### Logs +```bash +adb logcat -s "DOTNET" --format brief # .NET runtime logs +adb logcat -s "MauiDevFlow" # agent logs +adb logcat --pid=$(adb shell pidof ) # app-specific logs +adb logcat -c # clear log buffer +``` + +### Screenshots and screen recording +```bash +adb shell screencap /sdcard/screen.png && adb pull /sdcard/screen.png +adb shell screenrecord /sdcard/video.mp4 # Ctrl+C to stop +``` + +## SDK Management + +### Typical setup for MAUI Android development +```bash +android sdk accept-licenses +android sdk install --package "platforms;android-35" +android sdk install --package "build-tools;35.0.0" +android sdk install --package "system-images;android-35;google_apis;arm64-v8a" +android sdk install --package "emulator" +android sdk install --package "platform-tools" +``` + +### Environment variables +```bash +export ANDROID_HOME=$HOME/Library/Android/sdk +export ANDROID_SDK_ROOT=$ANDROID_HOME +export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator +``` + +## Troubleshooting + +- **`adb devices` shows "unauthorized"**: Accept the USB debugging prompt on the device/emulator. +- **Agent not connecting on emulator**: Forgot `adb reverse tcp:19223 tcp:19223` for the broker. Run port forwarding, then check `maui devflow list`. +- **Emulator won't start**: Check available system images with `android avd targets`. May need + to install with `android sdk install --package "system-images;..."`. +- **Build error "No Android devices found"**: Ensure emulator is booted (`adb devices`). +- **Slow emulator**: Use hardware acceleration. Prefer `arm64-v8a` images on Apple Silicon Macs. diff --git a/.github/skills/maui-devflow-debug/references/batch.md b/.github/skills/maui-devflow-debug/references/batch.md new file mode 100644 index 000000000..80d3725af --- /dev/null +++ b/.github/skills/maui-devflow-debug/references/batch.md @@ -0,0 +1,50 @@ +# Batch Command Reference + +Execute multiple MAUI/cdp commands in a single CLI invocation via stdin. Outputs JSONL +responses (one JSON object per line) to stdout — ideal for AI agents and scripting. + +## Usage + +```bash +# Pipe multiple commands (semicolons or newlines as separators) +echo "MAUI fill textUsername user; MAUI fill textPassword pwd123; MAUI tap buttonLogin" | maui devflow batch + +# Multi-line input +printf "MAUI status\nMAUI tree\nMAUI screenshot --output screen.png" | maui devflow batch + +# With options +echo "MAUI status; MAUI tree" | maui devflow batch --delay 500 --continue-on-error --agent-port 10224 + +# Human-readable output instead of JSONL +echo "MAUI status; MAUI tree" | maui devflow batch --human +``` + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--delay ` | 250 | Delay between commands (lets UI settle) | +| `--continue-on-error` | false | Continue after a command fails (default: stop) | +| `--human` | false | Human-readable output instead of JSONL | + +## JSONL Response Format + +One JSON object per command, streamed as each completes: +```json +{"command":"MAUI fill textUsername user","exit_code":0,"output":"Filled: textUsername"} +{"command":"MAUI tap buttonLogin","exit_code":1,"output":"Error: Element not found: buttonLogin"} +``` + +## Interactive Streaming + +The batch command processes stdin line-by-line, so a caller can read each JSONL response +before sending the next command. This enables reactive workflows where the AI agent inspects +results and decides the next action. + +## Input Rules + +- Lines starting with `#` are comments (skipped) +- Empty lines are skipped +- Semicolons separate multiple commands on one line +- Quoted strings are preserved: `MAUI fill myEntry "hello world"` +- Only `MAUI` and `cdp` commands are allowed (broker/list/etc. are rejected) diff --git a/.github/skills/maui-devflow-debug/references/connectivity.md b/.github/skills/maui-devflow-debug/references/connectivity.md new file mode 100644 index 000000000..e6f398822 --- /dev/null +++ b/.github/skills/maui-devflow-debug/references/connectivity.md @@ -0,0 +1,114 @@ +# Connectivity Recovery + +Use this when a project already has DevFlow package references and `builder.AddMauiDevFlowAgent()` registered, but the `maui` CLI cannot reach a running app. + +## Quick workflow + +1. Run the built-in diagnostic: + + ```bash + maui devflow diagnose + ``` + + Use the result to separate broker startup, missing project integration, no running app, and target-device networking issues. + +2. Verify integration before chasing ports: + + ```bash + grep -R --include="*.csproj" "Microsoft.Maui.DevFlow.Agent" . + grep -R "AddMauiDevFlowAgent" . + ``` + + If package references or `AddMauiDevFlowAgent()` are missing, stop and switch to `maui-devflow-onboard`. + +3. Check broker health: + + ```bash + maui devflow broker status + maui devflow broker start + ``` + +4. List and wait for agents: + + ```bash + maui devflow list + maui devflow wait + maui devflow ui tree --depth 1 + ``` + + A successful `ui tree` means connectivity is fixed; continue the main debug loop. + +## Platform notes + +### Android emulator + +Android emulators run in a separate network namespace. Broker registration and CLI-to-agent traffic need opposite forwarding directions: + +```bash +maui devflow list # note the assigned agent port +adb reverse tcp:19223 tcp:19223 # app in emulator -> host broker +adb forward tcp: tcp: # host CLI -> app agent +adb reverse --list +adb forward --list +``` + +If no broker is available and the app uses a direct `.mauidevflow` port, forward that port instead: + +```bash +adb forward tcp:9223 tcp:9223 +``` + +### iOS simulator + +No forwarding is normally needed because simulators share host networking: + +```bash +xcrun simctl list devices booted +``` + +### Mac Catalyst and macOS + +Mac Catalyst needs Debug entitlements that allow the agent and CDP servers to bind ports: + +```xml +com.apple.security.network.server + +``` + +macOS AppKit and Mac Catalyst otherwise use direct localhost access. Check for explicit port conflicts only after confirming broker discovery: + +```bash +lsof -i :9223 +``` + +### Windows and Linux/GTK + +These use direct localhost access. On Linux, verify the app process started: + +```bash +pgrep -f "YourApp" +``` + +## Common symptoms + +| Symptom | Fix | +|---------|-----| +| `maui devflow list` shows no agents | Verify app is running in Debug, package references exist, and `AddMauiDevFlowAgent()` executes | +| Android agent never registers | Run `adb reverse tcp:19223 tcp:19223` for broker registration | +| Android connection refused after registration | Run `adb forward tcp: tcp:` using the port from `maui devflow list` | +| Broker unavailable | `maui devflow broker start`; retry the command | +| Port already in use | Identify the owner with `lsof -i :` before killing a PID | +| Multiple agents connected | Ask which app/device/agent port to target; use `--agent-port` | + +## Stop signals + +- Stop and switch to `maui-devflow-onboard` if package references or `AddMauiDevFlowAgent()` are missing. +- Stop connectivity recovery once `maui devflow wait` succeeds and `maui devflow ui tree --depth 1` returns a tree. +- Stop and ask which target to use if multiple connected agents or devices match the app. +- Stop after confirming a generic build/deploy failure; the app must launch before broker connectivity can work. + +## Anti-patterns + +- Do not treat an empty `maui devflow list` as proof the project is not integrated. It only means no runtime agent is connected. +- Do not use `adb reverse` for the agent HTTP port when the host CLI must connect into the emulator. Use `adb forward tcp: tcp:` for that direction. +- Do not kill random processes by name. Identify the owning PID first. diff --git a/.github/skills/maui-devflow-debug/references/ios-and-mac.md b/.github/skills/maui-devflow-debug/references/ios-and-mac.md new file mode 100644 index 000000000..ca7231e29 --- /dev/null +++ b/.github/skills/maui-devflow-debug/references/ios-and-mac.md @@ -0,0 +1,324 @@ +# iOS & Mac Catalyst Reference + +## Table of Contents +- [Simulator Management](#simulator-management) +- [Building and Deploying](#building-and-deploying) +- [Apple CLI Tool](#apple-cli-tool) +- [xcrun simctl Reference](#xcrun-simctl-reference) +- [Troubleshooting](#troubleshooting) + +## Simulator Management + +### Avoiding multi-project conflicts + +When multiple projects (or AI agents) may deploy to iOS simulators simultaneously, each +project should use its own dedicated simulator. Two apps deployed to the same simulator +will replace each other — only the last-deployed app survives. + +**Before creating or booting a simulator, check what's already in use:** +```bash +maui devflow list # shows agents with platform + port +xcrun simctl list devices booted # shows all booted simulators +``` + +If a booted simulator is already running another project's agent, create a new one: +```bash +xcrun simctl create "ProjectName-iPhone17Pro" "iPhone 17 Pro" "iOS 26.2" +# Use the returned UDID in your build command +``` + +**Naming convention:** Use `-` (e.g. `TodoApp-iPhone17Pro`) so +it's clear which simulator belongs to which project. + +### List simulators +```bash +xcrun simctl list devices # all devices by runtime +xcrun simctl list devices booted # only booted +xcrun simctl list devices available # only available +apple simulator list # formatted table +apple simulator list --booted # booted only +``` + +### Create simulator +```bash +# List available device types and runtimes first +xcrun simctl list devicetypes # e.g. "iPhone 16 Pro" +xcrun simctl list runtimes # e.g. "iOS 18.2" + +xcrun simctl create "My iPhone" "iPhone 16 Pro" "iOS 18.2" +apple simulator create "My iPhone" --device-type "iPhone 16 Pro" --runtime "iOS 18.2" +``` + +### Boot / shutdown +```bash +xcrun simctl boot +xcrun simctl shutdown +apple simulator boot +apple simulator shutdown +``` + +### Install and launch app +```bash +xcrun simctl install booted /path/to/App.app +xcrun simctl launch booted com.company.appid +``` + +### Screenshots (iOS Simulator) +```bash +xcrun simctl io booted screenshot output.png +apple simulator screenshot --output output.png +``` + +### Screenshots (Mac Catalyst) + +**Use `maui devflow ui screenshot`** for Mac Catalyst apps — it captures the UI in-process +and does NOT require the app to be in the foreground. Never use `osascript` to bring the window +to the front or `screencapture` for Mac Catalyst screenshots; they are unnecessary and unreliable. + +```bash +maui devflow ui screenshot --output screen.png +``` + +### Delete / erase +```bash +xcrun simctl erase # factory reset +xcrun simctl delete # permanently remove +xcrun simctl delete unavailable # clean up old sims +``` + +## Building and Deploying + +### Mac Catalyst + +**⚠️ Entitlements required:** Mac Catalyst apps are sandboxed by default and need the +`com.apple.security.network.server` entitlement for MAUI DevFlow's in-app HTTP server. +Without it, the agent fails to bind its port and the app may crash silently. + +**Quick fix (disable sandbox for Debug):** Create `Platforms/MacCatalyst/Entitlements.Debug.plist`: +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + +``` + +Then reference it in `.csproj` for Debug only: +```xml + + Platforms/MacCatalyst/Entitlements.Debug.plist + +``` + +If sandbox must stay enabled (e.g. testing App Store builds), add `network.server` explicitly. +See [setup.md Step 5](setup.md#5-mac-catalyst-entitlements) for the full sandbox-enabled plist. + +```bash +dotnet build -f net10.0-maccatalyst # build only +dotnet build -f net10.0-maccatalyst -t:Run # build + run +open path/to/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/AppName.app # run existing +``` + +### iOS Simulator +```bash +# Find UDID of booted simulator +UDID=$(xcrun simctl list devices booted -j | python3 -c " +import json,sys +d=json.load(sys.stdin) +for r in d['devices'].values(): + for dev in r: + if dev['state']=='Booted': print(dev['udid']); break +" 2>/dev/null | head -1) + +# Build and deploy +dotnet build -f net10.0-ios -t:Run -p:_DeviceName=:v2:udid=$UDID +``` + +The `-t:Run` target keeps the process alive while the app runs — it **blocks until the app exits**. +Always run in an async/background shell, then poll `maui devflow ui status` to detect when the +app is ready. Do NOT wait for the process to finish. + +### Determining the correct TFM +Check the project file for ``: +```bash +grep -i TargetFramework *.csproj +``` +Common values: `net9.0-ios`, `net9.0-maccatalyst`, `net10.0-ios`, `net10.0-maccatalyst`. + +## Apple CLI Tool + +The `apple` command (from `appledev.tools` NuGet) provides higher-level wrappers. + +### Simulator commands +``` +apple simulator list [--booted|--available|--unavailable|--name "..."] +apple simulator create --device-type "..." [--runtime "..."] +apple simulator boot +apple simulator shutdown +apple simulator erase +apple simulator delete +apple simulator screenshot [--output path.png] +apple simulator app install +apple simulator app launch +apple simulator app uninstall +apple simulator open [] +apple simulator open-url +apple simulator logs [--filter "..."] +apple simulator push [--payload "..."] +apple simulator location set --lat --lon +apple simulator privacy grant +``` + +### Device commands +``` +apple device list +apple xcode list # installed Xcode versions +``` + +## xcrun simctl Reference + +Key subcommands beyond the basics: + +| Command | Use | +|---------|-----| +| `simctl addmedia file.jpg` | Add photos/videos to sim | +| `simctl openurl "url"` | Open URL / deep link | +| `simctl push bundle payload.json` | Simulate push notification | +| `simctl privacy grant location bundle` | Grant permissions | +| `simctl location set 37.33,-122.03` | Set GPS location | +| `simctl pbcopy ` | Copy stdin to clipboard | +| `simctl pbpaste ` | Read clipboard | +| `simctl get_app_container bundle` | App container path | +| `simctl listapps ` | Installed apps | + +## Troubleshooting + +- **Mac Catalyst blank/white screen after crash**: macOS shows a "reopen windows" dialog after + a crash, blocking the app from rendering. All MAUI elements appear as `[hidden] [disabled]` + with `-1x-1` sizes. Fix: clear saved state before launch: + ```bash + rm -rf ~/Library/Saved\ Application\ State/.savedState + ``` + If the "Reopen windows?" dialog is already on screen, ask the user to dismiss it manually, + then relaunch. Do not use AppleScript here by default — it steals focus from the user's + desktop session. +- **"Unable to lookup in current state: Shutdown"**: Simulator not booted. Run `xcrun simctl boot `. +- **Build error NETSDK1005 "Assets file doesn't have a target"**: Wrong TFM. Check + `` in .csproj and use matching version (e.g. `net10.0-ios` not `net9.0-ios`). +- **Agent not connecting after deploy**: The app may still be launching. Poll + `maui devflow ui status` every few seconds. If it hasn't connected after ~60-90s, read the + async shell output from `dotnet build -t:Run` for build/launch errors. +- **Mac Catalyst app name vs binary name**: The `.app` bundle name may differ from the project + name (e.g. `MauiTodo.app` vs `SampleMauiApp`). Check the `ApplicationTitle` in .csproj. + Find the bundle: `find bin/Debug/net10.0-maccatalyst -name "*.app" -maxdepth 3` + +## Permission & Dialog Handling + +### Pre-grant permissions (prevents dialogs from appearing) +```bash +# Grant specific permission before the app requests it +xcrun simctl privacy grant location com.company.appid +xcrun simctl privacy grant camera com.company.appid +xcrun simctl privacy grant photos com.company.appid +xcrun simctl privacy grant contacts com.company.appid +xcrun simctl privacy grant microphone com.company.appid + +# Grant all permissions at once +xcrun simctl privacy grant all com.company.appid + +# Revoke (deny) a permission +xcrun simctl privacy revoke location com.company.appid + +# Reset (next request will show dialog again) +xcrun simctl privacy reset all com.company.appid + +# Via apple CLI +apple simulator privacy grant location com.company.appid +``` + +Available services: `all`, `calendar`, `contacts`, `contacts-limited`, `location`, `location-always`, `photos`, `photos-add`, `media-library`, `microphone`, `motion`, `reminders`, `siri`. + +### Using MAUI DevFlow Driver for permissions +```csharp +var driver = new iOSSimulatorAppDriver(); +driver.DeviceUdid = ""; +driver.BundleId = "com.company.appid"; + +// Pre-grant before running the app +await driver.GrantPermissionAsync(PermissionService.Location); +await driver.GrantPermissionAsync(PermissionService.Camera); + +// Reset to test the dialog flow +await driver.ResetPermissionAsync(PermissionService.Location); +``` + +### Detecting and dismissing alerts (accessibility tree + HID tap) +When a dialog appears unexpectedly (permission prompt, app alert, action sheet), the driver can +detect it via the iOS accessibility tree and tap a button to dismiss it: + +```csharp +// Check if an alert is currently showing +var alert = await driver.DetectAlertAsync(); +if (alert is not null) +{ + Console.WriteLine($"Alert: {alert.Title}"); + foreach (var btn in alert.Buttons) + Console.WriteLine($" Button: {btn.Label} at ({btn.CenterX},{btn.CenterY})"); +} + +// Dismiss by tapping the first "accept" button (Allow, OK, etc.) +await driver.DismissAlertAsync(); + +// Dismiss by tapping a specific button +await driver.DismissAlertAsync("Don't Allow"); + +// Convenience: detect + dismiss if present, no-op if not +await driver.HandleAlertIfPresentAsync(); +``` + +### Example workflow: permission dialog handling +``` +1. App requests location → system shows "Allow location?" dialog +2. Agent detects dialog via DetectAlertAsync() +3. Agent sees buttons: ["Allow While Using App", "Allow Once", "Don't Allow"] +4. Agent taps "Allow While Using App" via DismissAlertAsync("Allow While Using App") +5. App receives permission grant, continues normal flow +``` + +### Dialog test page in SampleMauiApp +The SampleMauiApp includes a **Dialogs** tab with buttons that trigger: +- **Permission dialogs**: Location, Camera, Photos, Contacts, Microphone, Notifications +- **App alerts**: OK-only, OK/Cancel, custom buttons (Delete/Keep) +- **Action sheets**: Multiple options with cancel/destructive +- **Prompt dialogs**: Text input with OK/Cancel + +Use these to test and validate dialog detection and dismissal workflows. + +## Dark Mode Testing + +### Toggle dark mode +```bash +# Preferred when available: use an in-app theme toggle so the host desktop is unaffected. +# +# iOS Simulator (safe: affects the simulator only) +xcrun simctl ui appearance dark +xcrun simctl ui appearance light +``` + +For **Mac Catalyst**, changing system appearance affects the user's entire macOS desktop. Only do +that with explicit user approval; otherwise prefer app-level theme controls and verify via +`maui devflow ui property` / WebView inspection. + +### Verify dark mode via inspection +Use `maui devflow` to verify colors without relying on screenshots: +```bash +maui devflow ui property BackgroundColor # check MAUI element colors +maui devflow webview Runtime evaluate "window.matchMedia('(prefers-color-scheme: dark)').matches" # Blazor +``` diff --git a/.github/skills/maui-devflow-debug/references/linux.md b/.github/skills/maui-devflow-debug/references/linux.md new file mode 100644 index 000000000..397ceb6ca --- /dev/null +++ b/.github/skills/maui-devflow-debug/references/linux.md @@ -0,0 +1,172 @@ +# Linux / GTK Platform Guide + +Platform-specific setup and usage for .NET MAUI apps running on Linux via Maui.Gtk (GTK4). + +## Overview + +Maui.Gtk apps target `net10.0` (not a platform-specific TFM like `net10.0-ios`) and use +GTK4 via GirCore bindings. MAUI DevFlow provides dedicated Linux packages that work with +this architecture. + +## NuGet Packages + +| Package | Purpose | +|---------|---------| +| `Microsoft.Maui.DevFlow.Agent.Gtk` | In-app agent (visual tree, screenshots, tapping, logging) | +| `Microsoft.Maui.DevFlow.Blazor.Gtk` | CDP bridge for WebKitGTK BlazorWebView | + +These replace `Microsoft.Maui.DevFlow.Agent` and `Microsoft.Maui.DevFlow.Blazor` which target +standard MAUI platforms (iOS, Android, macCatalyst, Windows). + +```xml + + + + + +``` + +## Registration + +### MauiProgram.cs + +```csharp +using Microsoft.Maui.DevFlow.Agent.Gtk; +using Microsoft.Maui.DevFlow.Blazor.Gtk; // Blazor Hybrid only + +var builder = MauiApp.CreateBuilder(); +// ... your existing setup ... + +#if DEBUG +builder.AddMauiDevFlowAgent(); +builder.AddMauiBlazorDevFlowTools(); // Blazor Hybrid only +#endif +``` + +### Application Startup + +The agent must be started after the MAUI Application is available. In your GTK app +startup (e.g., `GtkMauiApplication.OnActivate` or equivalent): + +```csharp +#if DEBUG +app.StartDevFlowAgent(); + +// For Blazor Hybrid, wire CDP to the agent: +var blazorService = app.Handler?.MauiContext?.Services + .GetService(); +blazorService?.WireBlazorCdpToAgent(); +#endif +``` + +## Building and Running + +Linux/GTK apps use `dotnet run` (not `dotnet build -t:Run` which is MAUI-specific): + +```bash +# Build and run (in background/async shell) +dotnet run --project + +# Build only +dotnet build +``` + +Build times are typically fast (~5-10s) since there's no device deployment step. + +## Network Setup + +**No special setup needed.** Linux apps run directly on localhost — the CLI connects +directly to `http://localhost:`. No port forwarding (unlike Android) or entitlements +(unlike Mac Catalyst) required. + +## Key Simulation + +The `LinuxAppDriver` automatically detects the display server and uses the appropriate +**driver-mediated** key simulation backend. This is an implementation detail for Linux +automation support, **not** a recommendation for an AI agent to invoke these tools directly +from the shell. Prefer `maui devflow ui fill`, `maui devflow ui tap`, and `maui devflow batch` +for normal interaction. + +- **X11 sessions**: Uses `xdotool` (human-readable key names, no daemon needed) +- **Wayland sessions**: Uses `ydotool` (Linux input event keycodes, requires `ydotoold` daemon) + +Detection uses `XDG_SESSION_TYPE` and `WAYLAND_DISPLAY` environment variables. If the +preferred tool isn't installed, it falls back to whichever is available. + +### Installing xdotool (X11) + +```bash +sudo apt install xdotool # Debian/Ubuntu +sudo dnf install xdotool # Fedora +``` + +### Installing ydotool (Wayland) + +```bash +sudo apt install ydotool # Debian/Ubuntu +sudo dnf install ydotool # Fedora +``` + +**Required setup for ydotool** (to run without root): + +```bash +# Add your user to the input group for /dev/uinput access +sudo usermod -aG input $USER + +# Create udev rule for persistent permissions +echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' | sudo tee /etc/udev/rules.d/99-uinput.rules +sudo udevadm control --reload-rules && sudo udevadm trigger + +# Log out and back in, then start the daemon +ydotoold & +``` + +Key simulation is used by the driver for alert dismissal and keyboard input. +Only the driver backend should reach for `xdotool` or `ydotool`. + +## Platform Differences + +| Feature | Standard MAUI | Linux/GTK | +|---------|--------------|-----------| +| NuGet packages | `Agent`, `Blazor` | `Agent.Gtk`, `Blazor.Gtk` | +| TFM | `net10.0-` | `net10.0` | +| Build command | `dotnet build -f $TFM -t:Run` | `dotnet run --project ` | +| Agent startup | Automatic (lifecycle hook) | Manual (`app.StartDevFlowAgent()`) | +| Network | Varies by platform | Direct localhost | +| Screenshots | `VisualDiagnostics` | GTK `WidgetPaintable` → `Texture.SaveToPng()` | +| Native tap | Platform gesture system | `Gtk.Widget.Activate()` | +| Key simulation | Driver-mediated backend | `xdotool` (X11) / `ydotool` (Wayland) | +| Screen recording | Platform-specific | `ffmpeg` with `x11grab` (X11) / `pipewire` (Wayland) | +| Blazor WebView | WKWebView / WebView2 / Chrome | WebKitGTK 6.0 | + +## Troubleshooting + +### Agent Not Starting + +1. Ensure `app.StartDevFlowAgent()` is called after the app is activated +2. Check that `Application.Current` is available when `StartDevFlowAgent()` runs +3. Verify the port isn't in use: `lsof -i :` or `ss -tlnp | grep ` + +### xdotool / ydotool Not Working + +- On Wayland, `xdotool` does not work — install `ydotool` and start `ydotoold` +- On X11, `xdotool` is preferred over `ydotool` (simpler, no daemon) +- Prefer DevFlow commands over direct key injection wherever possible +- If `ydotool` fails silently, check that `ydotoold` daemon is running: `pgrep -x ydotoold` +- If `ydotool` gives permission errors, ensure your user is in the `input` group and `/dev/uinput` is group-writable +- Ensure the app window has focus for key events +- Check `XDG_SESSION_TYPE` to verify which display server is active: `echo $XDG_SESSION_TYPE` + +### Screen Recording on Wayland + +- Wayland screen recording uses `ffmpeg -f pipewire` instead of `x11grab` +- Requires PipeWire to be installed and running (standard on modern Wayland desktops) +- Check ffmpeg PipeWire support: `ffmpeg -devices | grep pipewire` +- If PipeWire is not available, you can force X11 with `GDK_BACKEND=x11` and use `x11grab` +- The first recording on Wayland may trigger a system permission dialog for screen sharing + +### WebKitGTK CDP Issues + +- WebKitGTK uses `EvaluateJavascriptAsync` for JS evaluation +- The same two-eval CDP pattern (send + poll) applies as other platforms +- Check that `chobitsu.js` is properly loaded in the WebView diff --git a/.github/skills/maui-devflow-debug/references/macos.md b/.github/skills/maui-devflow-debug/references/macos.md new file mode 100644 index 000000000..062b3aed8 --- /dev/null +++ b/.github/skills/maui-devflow-debug/references/macos.md @@ -0,0 +1,205 @@ +# macOS (AppKit) Platform Guide + +Platform-specific setup and usage for .NET MAUI apps running on macOS via Platform.Maui.MacOS (AppKit). + +## Table of Contents +- [Overview](#overview) +- [Project Structure](#project-structure) +- [NuGet Packages](#nuget-packages) +- [Registration](#registration) +- [Building and Running](#building-and-running) +- [Blazor Hybrid](#blazor-hybrid) +- [Platform Differences](#platform-differences) +- [Troubleshooting](#troubleshooting) + +## Overview + +macOS (AppKit) apps use the community `Platform.Maui.MacOS` packages to run MAUI on native +AppKit (not Mac Catalyst). The TFM is `net10.0-macos`. Like Linux/GTK, macOS apps typically +use a **separate app head project** rather than adding `-macos` to the standard MAUI project's +TargetFrameworks. + +Detect macOS projects: +```bash +grep -i 'Platform\.Maui\.MacOS\|net.*-macos' *.csproj Directory.Build.props 2>/dev/null +``` + +## Project Structure + +macOS apps use a separate app head project (similar to Linux/GTK): + +``` +src/ +├── MyApp/ # Standard MAUI project (iOS, Android, Mac Catalyst, Windows) +├── MyApp.MacOS/ # macOS AppKit app head +│ ├── Program.cs # Entry point: MacOSMauiApplication +│ ├── MauiProgram.cs # macOS-specific builder (UseMauiAppMacOS, AddMacOSEssentials) +│ └── MyApp.MacOS.csproj # References Platform.Maui.MacOS packages +``` + +Shared source files (pages, view models, services) are typically linked from the main project. + +## NuGet Packages + +The app project needs the Platform.Maui.MacOS packages plus the standard MAUI DevFlow packages: + +```xml + + + + + + + + + + +``` + +The standard `Microsoft.Maui.DevFlow.Agent` and `Microsoft.Maui.DevFlow.Blazor` packages include +`net10.0-macos` targets — no separate macOS-specific MAUI DevFlow packages needed. + +## Registration + +### Program.cs (Entry Point) + +```csharp +using AppKit; +using Microsoft.Maui.Platform.MacOS; +using ObjCRuntime; + +namespace MyApp.MacOS; + +[Register("Program")] +public class Program : MacOSMauiApplication +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + + static void Main(string[] args) + { + NSApplication.Init(); + NSApplication.Main(args); + } +} +``` + +The `[Register]` attribute is required. + +### MauiProgram.cs + +```csharp +using Microsoft.Maui.Platform.MacOS; +using Microsoft.Maui.Platform.MacOS.Controls; + +#if DEBUG +using Microsoft.Maui.DevFlow.Agent; +using Microsoft.Maui.DevFlow.Blazor; +#endif + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiAppMacOS() // NOT UseMauiApp — macOS-specific + .AddMacOSEssentials() // REQUIRED — without this, no window appears + .AddMacOSBlazorWebView() // Blazor only + .ConfigureFonts(fonts => { /* ... */ }); + +#if DEBUG + builder.AddMauiDevFlowAgent(); + builder.AddMauiBlazorDevFlowTools(); // Blazor only +#endif + + return builder.Build(); + } +} +``` + +**Critical:** `AddMacOSEssentials()` is required — without it the app runs but no window appears. + +## Building and Running + +macOS apps do NOT use `-t:Run`. Build first, then launch with `open`: + +```bash +# Build +dotnet build -f net10.0-macos path/to/MyApp.MacOS + +# Find and launch the .app bundle +open path/to/MyApp.MacOS/bin/Debug/net10.0-macos/osx-arm64/AppName.app +``` + +**Code signing:** A clean `dotnet build` produces a valid ad-hoc signature. Do NOT manually +re-sign the app — it breaks the signature (SIGKILL on launch). If the app fails to launch, +clean rebuild: `rm -rf bin obj && dotnet build`. + +**Finding the .app bundle:** +```bash +find bin/Debug/net10.0-macos -name "*.app" -maxdepth 3 +``` + +## Network Setup + +**No special setup needed.** macOS apps run directly on localhost — the CLI connects +directly to `http://localhost:`. No port forwarding or entitlements required. + +## Blazor Hybrid + +Use `MacOSBlazorWebView` instead of the standard `BlazorWebView`: + +```csharp +using Microsoft.Maui.Platform.MacOS.Controls; + +// In page code-behind, replace BlazorWebView with MacOSBlazorWebView +var blazorWebView = new MacOSBlazorWebView(); +blazorWebView.HostPage = "wwwroot/index.html"; +blazorWebView.RootComponents.Add(new RootComponent { ... }); +``` + +Chobitsu.js is auto-injected via the Blazor JS module initializer — no manual ` +``` + +The library detects both approaches — manual script tags take priority over auto-injection. + +### What if it's not working? + +The library checks at runtime and logs a message: +``` +[BlazorDevFlow] ⚠️ No chobitsu script tag found. Auto-injection via JS initializer may not have run. +``` + +### How the file gets there + +The `chobitsu.js` file is included in the NuGet package as a static web asset. It is +automatically available at the root of your app's `wwwroot/` — no `.targets` file copying, +no manual downloads. It works in both Debug and Release builds (though MAUI DevFlow itself +should only be referenced in Debug configurations). + +## 5. Mac Catalyst: Entitlements + +Mac Catalyst apps need the `com.apple.security.network.server` entitlement to allow the +agent and CDP servers to bind ports. Without this, the app will crash or fail silently. + +### Option A: Sandbox disabled (simpler for development) + +Create or update `Platforms/MacCatalyst/Entitlements.plist` for Debug builds: + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + +``` + +### Option B: Sandbox enabled (required for App Store) + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + + +``` + +Reference in your `.csproj` (Debug only, so Release uses the default entitlements): + +```xml + + Platforms/MacCatalyst/Entitlements.Debug.plist + +``` + +**Avoiding TCC permission dialogs:** Even with sandbox disabled, macOS prompts for access to +`~/Documents`, `~/Downloads`, `~/Desktop`, and dotfiles in `~/` on every rebuild (ad-hoc signing +changes the code signature each build). To avoid this, store app data in +`Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)` (`~/Library/Application Support/`) +instead of the home directory root. This path is not TCC-protected. + +## 6. Android: Port Forwarding + +After deploying to an Android emulator, set up port forwarding for the broker and agent: + +```bash +adb reverse tcp:19223 tcp:19223 # Broker (lets agent register with host broker) +adb forward tcp: tcp: # Agent (lets CLI reach agent — get port from `maui devflow list`) +``` + +The broker reverse (`tcp:19223`) is needed so the agent inside the emulator can connect to +the host's broker daemon. Set this up once per emulator session. + +The agent forward uses the port shown in `maui devflow list` after the agent registers +(range 10223–10899). + +**Fallback (no broker):** If using direct mode with a `.mauidevflow` config file: +```bash +adb forward tcp:9223 tcp:9223 # Direct agent port (single port for Agent + CDP) +``` + +## 7. Verify Setup + +After building and running the app: + +```bash +maui devflow list # Should show registered agents (via broker) +maui devflow ui status # Should show agent info, platform, app name +maui devflow webview status # Should show "Connected" (Blazor Hybrid only) +``` + +If status commands fail: +- **Broker not running?** `maui devflow broker status` — CLI auto-starts the broker, but check if it's healthy +- **Agent not registered?** `maui devflow list` — wait a few seconds for the agent to register +- **Mac Catalyst:** Check entitlements (Step 5) +- **macOS (AppKit):** Ensure `AddMacOSEssentials()` is called — see [references/macos.md](macos.md) +- **Android:** Check port forwarding (Step 6) — need both `adb reverse tcp:19223` and `adb forward tcp:` +- **iOS Simulator:** Should work without extra config +- **Linux/GTK:** Should work without extra config — runs directly on localhost +- **All platforms:** Ensure the app is running and the `#if DEBUG` block is active +- **Port conflict:** Check if another process holds the port: `lsof -i :9223` (or your configured port) +- **Wrong port:** Use `maui devflow list` to find the assigned port, or ensure CLI is run from the project directory + +## Quick Checklist + +For an AI agent setting up MAUI DevFlow in a new project: + +1. [ ] `Microsoft.Maui.DevFlow.Agent` NuGet package added (or `Microsoft.Maui.DevFlow.Agent.Gtk` for Linux) +2. [ ] `Microsoft.Maui.DevFlow.Blazor` NuGet package added (Blazor Hybrid only; or `Microsoft.Maui.DevFlow.Blazor.Gtk` for Linux) +3. [ ] `builder.AddMauiDevFlowAgent(...)` in MauiProgram.cs inside `#if DEBUG` +4. [ ] `builder.AddMauiBlazorDevFlowTools(...)` in MauiProgram.cs (Blazor Hybrid only) +5. [ ] Chobitsu auto-injected via JS initializer (Blazor Hybrid — no manual step needed) +6. [ ] Mac Catalyst entitlements include `network.server` (Mac Catalyst only) +7. [ ] `adb reverse tcp:19223` for broker + `adb forward tcp:` for agent (Android only) +8. [ ] Linux/GTK: `app.StartDevFlowAgent()` called after app activation +9. [ ] macOS (AppKit): `UseMauiAppMacOS()`, `AddMacOSEssentials()`, `MacOSBlazorWebView` — see [macos.md](macos.md) +10. [ ] Verify with `maui devflow list` and `maui devflow ui status` + +## Checking for Updates + +At the start of each session (or periodically), check whether the CLI, skill, and NuGet +packages are up to date. Outdated components can cause confusing failures or missing features. + +### Check CLI version +```bash +maui devflow version +dotnet tool search Microsoft.Maui.Cli | head -5 +``` + +If a newer version is available: +```bash +dotnet tool update --global Microsoft.Maui.Cli +``` + +### Update the skill +```bash +maui devflow skills check +maui devflow skills update +``` + +The skills manager compares installed files against the skills bundled in the running +`Microsoft.Maui.Cli` and records local install state under `~/.maui/devflow`. + +**AI agents should check at session start:** run `maui devflow skills check`. If it reports +`update-available-from-current-cli`, ask the user if they want to run +`maui devflow skills update` before proceeding. + +### Check NuGet packages in the project +```bash +grep -i 'Microsoft.Maui.DevFlow' *.csproj Directory.Build.props Directory.Packages.props 2>/dev/null +``` + +If packages are outdated: +```bash +dotnet add package Microsoft.Maui.DevFlow.Agent +dotnet add package Microsoft.Maui.DevFlow.Blazor # only if Blazor Hybrid +# For Linux/GTK: use .Gtk variants instead +``` + +### Re-run setup verification +After any updates, walk through the checklist above to ensure everything is still properly +configured. A CLI update may introduce new setup requirements. diff --git a/.github/skills/maui-devflow-debug/references/troubleshooting.md b/.github/skills/maui-devflow-debug/references/troubleshooting.md new file mode 100644 index 000000000..458027685 --- /dev/null +++ b/.github/skills/maui-devflow-debug/references/troubleshooting.md @@ -0,0 +1,93 @@ +# Troubleshooting + +## Table of Contents +- [Connection Refused](#connection-refused--cannot-connect) +- [Build Failures](#build-failures) +- [CDP Not Connecting](#cdp-not-connecting-blazor-hybrid) +- [Mac Catalyst Permission Dialogs](#mac-catalyst-repeated-permission-dialogs-on-rebuild) + +## Connection Refused / Cannot Connect + +If `maui devflow ui status` fails with connection refused: + +1. **App not running?** Verify the app launched: check the build output for errors. +2. **Check the broker:** Run `maui devflow list` to see if the agent registered. If the list + is empty, the app may not have connected to the broker yet (wait a few seconds and retry). +3. **Wrong port?** If using `.mauidevflow`, ensure the port matches between build and CLI. + Run CLI from the project directory so it auto-detects the config file. +4. **Port already in use?** Another process may hold the port. Check with: + ```bash + lsof -i : # macOS/Linux + ``` + With the broker, this is less common since ports are auto-assigned. +5. **Android?** Did you run `adb reverse tcp:19223 tcp:19223` (for broker) and + `adb forward tcp: tcp:` (for agent)? Re-run after each deploy. +6. **Mac Catalyst?** Check entitlements include `network.server` (see setup.md step 5). +7. **macOS (AppKit)?** Ensure `AddMacOSEssentials()` is called and the app window appeared. + See [references/macos.md](macos.md) for troubleshooting. +8. **Linux/GTK?** No special network setup needed — runs directly on localhost. Check if the app started successfully. +9. **Broker issues?** `maui devflow broker status` to check. `maui devflow broker stop` then + retry (CLI will auto-restart it). + +## Build Failures + +**Missing workloads:** +``` +error NETSDK1147: To build this project, the following workloads must be installed: maui-ios +``` +Fix: `dotnet workload install maui` (installs all MAUI workloads). + +**SDK version mismatch:** +``` +error : The current .NET SDK does not support targeting .NET 10.0 +``` +Fix: Install the required .NET SDK version, or check `global.json` for version pins. + +**Android SDK not found:** +``` +error XA0000: Could not find Android SDK +``` +Fix: Install Android SDK via `android sdk install` or set `$ANDROID_HOME`. + +**iOS provisioning / signing errors:** +Fix: For simulators, ensure no signing is configured (default). For devices, set up provisioning +profiles via `apple appstoreconnect profiles list`. + +**General build failure recovery:** +1. `dotnet clean` then retry the build +2. Delete `bin/` and `obj/` directories: `rm -rf bin obj` then rebuild +3. Check the full build output (not just the last error) — earlier warnings often reveal the root cause + +## CDP Not Connecting (Blazor Hybrid) + +If `maui devflow webview status` fails but `ui status` works: + +1. **Chobitsu not loading?** Check logs for `[BlazorDevFlow]` messages. If auto-injection failed, add `` manually to `wwwroot/index.html` +2. **Blazor not initialized?** Navigate to a Blazor page first, then retry +3. Check app logs: `maui devflow ui logs --limit 20` — look for `[BlazorDevFlow]` errors + +## Mac Catalyst: Repeated Permission Dialogs on Rebuild + +If macOS prompts "App would like to access your Documents folder" on every rebuild: + +**Cause:** TCC permissions are tied to the app's code signature. Ad-hoc Debug builds produce a +different signature each rebuild → macOS forgets the grant and re-prompts. This happens even +with App Sandbox disabled. + +**Fix:** Don't access TCC-protected directories (`~/Documents`, `~/Downloads`, `~/Desktop`, +or dotfiles like `~/.myapp/` in the home root) programmatically. Instead use: +- `Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)` → `~/Library/Application Support/` (not TCC-protected) +- `NSOpenPanel`/`NSSavePanel` for user-initiated file access (grants automatic TCC exemption) + +If you can't avoid TCC paths, sign Debug builds with a stable Apple Development certificate +so the code signature stays consistent across rebuilds. + +## macOS (AppKit) Issues + +For detailed macOS (AppKit) troubleshooting, see [references/macos.md](macos.md#troubleshooting). + +Common issues: +- **No window appears** → Missing `AddMacOSEssentials()` in builder +- **SIGKILL on launch** → Don't re-sign manually; clean rebuild instead +- **Blazor stuck on "Loading..."** → Use `MacOSBlazorWebView`, not standard `BlazorWebView` +- **No sidebar content** → Add `MacOSShell.SetUseNativeSidebar(shell, true)` + `FlyoutBehavior.Locked` diff --git a/.github/skills/maui-devflow-onboard/SKILL.md b/.github/skills/maui-devflow-onboard/SKILL.md new file mode 100644 index 000000000..d4ddc7c14 --- /dev/null +++ b/.github/skills/maui-devflow-onboard/SKILL.md @@ -0,0 +1,140 @@ +--- +name: maui-devflow-onboard +description: >- + Add MAUI DevFlow to a .NET MAUI project with agent package references, + MauiProgram.cs registration, Blazor WebView support, GTK variants, Central + Package Management guidance, and verification commands. USE FOR: first-time + DevFlow setup, reviewing what files to edit, choosing DevFlow packages, or + continuing after `maui devflow init` installs skills. DO NOT USE FOR: + troubleshooting an already-integrated app that cannot connect, iterative app + debugging, UI inspection, or generic MAUI build failures (use + maui-devflow-debug). INVOKES: maui devflow CLI and dotnet CLI. +--- + +# DevFlow Onboard + +Use this skill to add MAUI DevFlow to a project after `maui devflow init` has installed the DevFlow skills. + +## When to Use + +- The project has no `Microsoft.Maui.DevFlow.*` package references yet. +- The user asks how to onboard, initialize, install, integrate, or set up DevFlow. +- The agent needs to choose between standard, Blazor, GTK, or GTK + Blazor DevFlow packages. +- The repo uses Central Package Management and package edits need to be split correctly. +- `maui devflow init` has installed skills and the user asks for the next project edits. + +## Route Elsewhere + +- If package references and `AddMauiDevFlowAgent()` are already present but the CLI cannot connect, use `maui-devflow-debug`. +- If an agent is reachable and the user wants to inspect, tap, screenshot, or debug UI, use `maui-devflow-debug`. + +## Workflow + +1. Optionally run `maui devflow skills check` and update bundled skills before editing if it reports `update-available-from-current-cli`. +2. Find MAUI app projects in the workspace. Prefer app projects with `UseMaui`, platform TFMs such as `net*-android`/`net*-ios`/`net*-maccatalyst`/`net*-windows`, or GTK MAUI package references. +3. Determine whether each target project is standard MAUI, MAUI + Blazor WebView, GTK, or GTK + Blazor. +4. Add the correct DevFlow package references. Respect Central Package Management if `Directory.Packages.props` is present. +5. Register DevFlow in `MauiProgram.cs` inside `#if DEBUG`. +6. Build and run the app. +7. Verify with: + + ```bash + maui devflow diagnose + maui devflow wait + maui devflow ui tree --depth 1 + ``` + +If verification fails after integration, switch to `maui-devflow-debug` for connectivity recovery. + +## Stop Signals + +- Stop before editing if multiple MAUI app projects exist and the user has not indicated which one to onboard. +- Stop and ask before enabling DevFlow outside Debug builds. +- Stop after package/registration edits and verify with a build before doing runtime debugging. + +## Critical Anti-patterns + +- Do not add versions to project `PackageReference` items when `Directory.Packages.props` is managing package versions. +- Do not add Blazor DevFlow packages unless the app uses Blazor WebView. +- Do not register DevFlow outside `#if DEBUG` unless the user explicitly asks. +- Do not use the old `builder.Services.AddMauiDevFlowAgent()` pattern; use `builder.AddMauiDevFlowAgent()`. + +## Package Selection + +| Project flavor | Required packages | +| --- | --- | +| Standard MAUI | `Microsoft.Maui.DevFlow.Agent` | +| MAUI + Blazor WebView | `Microsoft.Maui.DevFlow.Agent`, `Microsoft.Maui.DevFlow.Blazor` | +| GTK MAUI | `Microsoft.Maui.DevFlow.Agent.Gtk` | +| GTK MAUI + Blazor WebView | `Microsoft.Maui.DevFlow.Agent.Gtk`, `Microsoft.Maui.DevFlow.Blazor.Gtk` | + +Blazor WebView indicators include a `Microsoft.AspNetCore.Components.WebView.Maui` package reference or `AddMauiBlazorWebView()` in `MauiProgram.cs`. + +GTK indicators include package references such as `Maui.Gtk`, `Platform.Maui.Linux.Gtk4`, `GirCore.Gtk-4.0`, or `Platform.Maui.Linux.Gtk4.BlazorWebView`. + +## Central Package Management + +If the repo uses `Directory.Packages.props`, put versions there and leave project `PackageReference` entries versionless. + +```xml + + +``` + +```xml + + +``` + +If the repo does not use Central Package Management, put the version on the `PackageReference`. + +## MauiProgram.cs Registration + +For standard MAUI: + +```csharp +using Microsoft.Maui.DevFlow.Agent; + +// inside CreateMauiApp(), before return builder.Build(); +#if DEBUG +builder.AddMauiDevFlowAgent(); +#endif +``` + +For MAUI + Blazor WebView: + +```csharp +using Microsoft.Maui.DevFlow.Agent; +using Microsoft.Maui.DevFlow.Blazor; + +// inside CreateMauiApp(), before return builder.Build(); +#if DEBUG +builder.AddMauiDevFlowAgent(); +builder.AddMauiBlazorDevFlowTools(); +#endif +``` + +For GTK, use the `.Gtk` namespaces and packages. GTK apps also need to start the agent after app activation, for example `app.StartDevFlowAgent()` in the platform app activation flow. + +For Mac Catalyst, ensure the Debug entitlements allow the in-app HTTP server: + +```xml +com.apple.security.network.server + +``` + +## Validation Checklist + +- `MauiProgram.cs` registers DevFlow only in Debug builds. +- The app project references the package flavor that matches the target platform. +- Blazor DevFlow tools are added only when the app uses Blazor WebView. +- GTK apps start the DevFlow agent after app activation. +- Mac Catalyst Debug entitlements include `com.apple.security.network.server`. +- `dotnet build` succeeds. +- A running app appears in `maui devflow list`. +- `maui devflow ui tree --depth 1` returns a visual tree. + +## References + +- See `references/package-selection.md` for package/flavor details. +- See `references/mauiprogram-registration.md` for registration patterns. diff --git a/.github/skills/maui-devflow-onboard/references/mauiprogram-registration.md b/.github/skills/maui-devflow-onboard/references/mauiprogram-registration.md new file mode 100644 index 000000000..d884f0632 --- /dev/null +++ b/.github/skills/maui-devflow-onboard/references/mauiprogram-registration.md @@ -0,0 +1,34 @@ +# MauiProgram.cs Registration + +Add DevFlow registration in `CreateMauiApp()` before `return builder.Build();`. + +Standard MAUI: + +```csharp +using Microsoft.Maui.DevFlow.Agent; + +#if DEBUG +builder.AddMauiDevFlowAgent(); +#endif +``` + +MAUI + Blazor WebView: + +```csharp +using Microsoft.Maui.DevFlow.Agent; +using Microsoft.Maui.DevFlow.Blazor; + +#if DEBUG +builder.AddMauiDevFlowAgent(); +builder.AddMauiBlazorDevFlowTools(); +#endif +``` + +GTK uses the `.Gtk` namespaces: + +```csharp +using Microsoft.Maui.DevFlow.Agent.Gtk; +using Microsoft.Maui.DevFlow.Blazor.Gtk; +``` + +Keep the calls inside `#if DEBUG` unless the user explicitly wants DevFlow available in non-Debug builds. diff --git a/.github/skills/maui-devflow-onboard/references/package-selection.md b/.github/skills/maui-devflow-onboard/references/package-selection.md new file mode 100644 index 000000000..caef657fe --- /dev/null +++ b/.github/skills/maui-devflow-onboard/references/package-selection.md @@ -0,0 +1,23 @@ +# Package Selection + +Choose packages based on the app flavor: + +| Project flavor | Packages | +| --- | --- | +| Standard MAUI | `Microsoft.Maui.DevFlow.Agent` | +| MAUI + Blazor WebView | `Microsoft.Maui.DevFlow.Agent`, `Microsoft.Maui.DevFlow.Blazor` | +| GTK MAUI | `Microsoft.Maui.DevFlow.Agent.Gtk` | +| GTK MAUI + Blazor WebView | `Microsoft.Maui.DevFlow.Agent.Gtk`, `Microsoft.Maui.DevFlow.Blazor.Gtk` | + +MAUI indicators: + +- `true`; +- platform TFMs such as `net10.0-android`, `net10.0-ios`, `net10.0-maccatalyst`, or `net10.0-windows10.0.19041.0`; +- GTK package references such as `Maui.Gtk`, `Platform.Maui.Linux.Gtk4`, or `GirCore.Gtk-4.0`. + +Blazor WebView indicators: + +- package reference to `Microsoft.AspNetCore.Components.WebView.Maui`; +- call to `AddMauiBlazorWebView()` in `MauiProgram.cs`. + +Use Central Package Management if `Directory.Packages.props` exists. Otherwise place versions directly on project package references. diff --git a/.github/workflows/ci-comet.yml b/.github/workflows/ci-comet.yml index b06000ca4..b30ce60ae 100644 --- a/.github/workflows/ci-comet.yml +++ b/.github/workflows/ci-comet.yml @@ -1,5 +1,8 @@ name: CI - Comet +env: + COMET_WORKLOAD_VERSION: 11.0.100-preview.7.26410.2 + on: push: branches: [main] @@ -27,11 +30,10 @@ jobs: - name: Setup .NET 11 Preview uses: actions/setup-dotnet@v5 with: - dotnet-version: '11.0.x' - dotnet-quality: 'preview' + global-json-file: src/Comet/global.json - name: Install MAUI workload - run: dotnet workload install maui + run: dotnet workload install maui --version ${{ env.COMET_WORKLOAD_VERSION }} - name: Build Comet run: dotnet build src/Comet/Comet.csproj -c Release @@ -63,11 +65,10 @@ jobs: - name: Setup .NET 11 Preview uses: actions/setup-dotnet@v5 with: - dotnet-version: '11.0.x' - dotnet-quality: 'preview' + global-json-file: src/Comet/global.json - name: Install MAUI workload - run: dotnet workload install maui + run: dotnet workload install maui --version ${{ env.COMET_WORKLOAD_VERSION }} - name: Pack Comet run: dotnet pack src/Comet/Comet.csproj -c Release -o ../../artifacts/packages diff --git a/AGENTS.md b/AGENTS.md index 05e9ea8ab..c3b0d784d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,133 @@ dotnet test src/DevFlow/Microsoft.Maui.DevFlow.Tests/ - Test results: `artifacts/TestResults/**/*.xml` - No quarantine or outerloop test attributes are used in this repo +## Comet node-backend (Compose/SwiftUI) development + +Comet is being refactored off MAUI handlers onto a retained **node backend** +(Jetpack Compose on Android, SwiftUI on iOS). This path uses **.NET 11 preview**, +pinned by `src/Comet/global.json` — NOT the repo-root .NET 10. + +- **Run Comet builds from inside `src/Comet`.** Bare `dotnet` at the repo root + resolves the .NET 10 SDK (root `global.json`) and fails net11 targets with + NETSDK1045. +- **Host tests link Comet via a HintPath**, so rebuild it first: + `dotnet build src/Comet/src/Comet/Comet.csproj -f net11.0-maccatalyst` + before `dotnet test tests/Comet.Tests` — otherwise tests run a stale DLL. +- **Facade edits no longer need clean rebuilds** (fixed 2026-07-03). The old + `ComposableLambda2: no Java peer` crash after editing `src/Comet/src/vendor/` + was a .NET Android SDK incremental bug: `_BuildApkFastDev` didn't list + `libxamarin-app.so` (MVID-keyed debug typemaps) in its Inputs, so the APK kept + stale typemaps while fast deploy pushed the new assembly. Worked around in + `src/Comet/Directory.Build.targets` (`_CometFixFastDevApkInputs`); upstream + issue draft: `src/Comet/docs/research/upstream-issue-fastdev-typemap-staleness.md`. + If the crash ever reappears (SDK update changing target names), fall back to + `src/Comet/tools/clean-android.sh`. iOS: after a `Comet.SwiftUI.Shim` change, + re-run `build-xcframework.sh` AND rm the probe `obj`/`bin` (incremental build + won't relink the NativeReference xcframework). +- **Probe apps**: `sample/CometComposeProbe` (Android), + `sample/CometSwiftUIProbe` (iOS). Android deploy: + `dotnet build -t:Run -p:AndroidPackageFormat=apk`. +- **Standalone per-sample apps**: add `-p:CometSample=reply` (or `jetchat`) to + either probe build — unique app id `com.comet.sample.`, launcher name + "Comet Reply"/"Comet Jetchat", isolated `obj|bin/sample-/`, and the + screen defaults from the app id — so samples install SIDE BY SIDE instead of + replacing each other. The plain probe ids (`com.comet.composeprobe`, + `com.comet.swiftuiprobe`, Jetchat default + `--es screen` / `COMET_SCREEN` + switch) are what the smoke scripts drive — leave them installed. +- **Two devices are usually attached** — always target explicitly with + `adb -s 13041FDD4007MT` (the physical Pixel 5). Don't use left-edge swipes to + open drawers (triggers the system back/home gesture). +- **A black `adb screencap` usually means the display is off or the lock screen + is up, NOT a render bug** — check `dumpsys display | grep mScreenState` and + `keyguardShowing` before touching code. (Same lesson on the iOS simulator: its + GPU state degrades after a couple of launches — verify on a physical device.) +- **Screenshots come scaled** ("multiply by N"); compute tap targets in the FULL + resolution (1080-wide), not the displayed size. +- **Gold standard is local**: `~/work/compose-samples` (android/compose-samples — + Jetchat/JetNews/Reply). Read the Kotlin directly; don't WebFetch GitHub. + +### Matching the gold standard (fidelity rules) — TOP PRIORITY +The point of the Comet sample work is proving Comet drives the **EXACT native +control** the sample uses — the real Jetpack Compose widget on Android AND the +real native SwiftUI control on iOS. The sample is the proof, not the goal. +**A styled look-alike is a DEFECT even when it looks and behaves identically.** +- **Before building/claiming any control, open the gold `.kt` and read which + composable it actually uses** (`grep -n "" .kt`), then drive + that exact widget — a `Button` is a `Button` (not a styled `Text`/`HStack`), a + FAB is a `FloatingActionButton`, a confirm is a `TextButton`. Don't assume which + control; VERIFY in source (the gold may hand-roll a row rather than use a Material + item — only the source tells you). +- If the facade lacks the control or a needed param (e.g. FAB `containerColor`), + **EXTEND the facade** — that's the "fill out Comet" work, not a license to ship a + look-alike. Reproduce real modifiers too (`baselineHeight`, `clip(CircleShape)`). +- **Same standard on iOS/SwiftUI** — drive the native SwiftUI control, never leave a + stub/placeholder where a native control exists. +- Don't report "faithful / pixel-exact / done" without a side-by-side against the + gold image; state what you verified and on which device. +- Never assert an environment fact (device locked, network up, the cause of a + black/blank shot) you haven't checked — verify first, then claim. + +### DevFlow integration in CometComposeProbe + +CometComposeProbe has a built-in DevFlow agent (`sample/CometComposeProbe/DevFlowHelper.cs`) +that enables `maui devflow screenshot`, `maui devflow list`, and tap/inspect from the CLI. +**Use DevFlow by default when doing Android Comet work** — it gives direct screenshot +feedback without needing `adb screencap` + Read each time. + +#### Architecture (no UseMaui needed) + +- `DevFlowAgentService.StartServerOnly(IAgentDispatcher)` — designed for Comet apps where + `Application.Current` is unavailable; starts HTTP server + broker registration, no MAUI runtime. +- `ComposeProbeAgentService` overrides `HandleScreenshot` and uses Android + `PixelCopy.Request` to capture GPU-rendered Compose content faithfully. +- `PixelCopyListener` **must live outside `#if DEBUG`** — Xamarin.Android's JNI typemap + generator only registers `Java.Lang.Object` subclasses it sees in every build; a type + hidden behind `#if DEBUG` is skipped in the Release codegen pass → "no Java peer type found" + at runtime even on Debug builds after a clean deploy. +- All `PixelCopy.Request` calls must be dispatched via `activity.RunOnUiThread(...)` — the + HTTP handler thread pool is not JNI-registered so `new PixelCopyListener(...)` would throw. + +#### ADB setup (required after every app restart — port changes each launch) + +The broker runs on the Mac; the agent runs on the device. Two tunnels are needed: + +```bash +# 1. Device → Mac broker (reverse — device connects outward to Mac port 19223) +adb -s 13041FDD4007MT reverse tcp:19223 tcp:19223 + +# 2. Mac CLI → device agent (forward — Mac connects inward to device's assigned port) +# Get the port first: +adb -s 13041FDD4007MT logcat -d | grep "DevFlow agent started" +# Then forward that port (e.g. 10227): +adb -s 13041FDD4007MT forward tcp:10227 tcp:10227 +``` + +Or read the port from the broker automatically: +```bash +PORT=$(maui devflow list | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['port']) if d else None") +adb -s 13041FDD4007MT forward tcp:$PORT tcp:$PORT +``` + +#### Typical workflow + +```bash +# After deploy, set up tunnels (see above), then: +maui devflow list # confirm agent registered +maui devflow ui screenshot --output /tmp/s.png # grab screenshot +``` + +#### Incremental deploy vs clean deploy + +After editing only `DevFlowHelper.cs` (not the facade), incremental deploy is fine. +After adding or moving a `Java.Lang.Object` subclass (including `PixelCopyListener`), +**do a clean rebuild** — incremental deploy replaces the managed DLL but not the APK's +embedded Java proxy classes: + +```bash +dotnet build sample/CometComposeProbe/CometComposeProbe.csproj -f net11.0-android -c Debug -t:Clean +dotnet build sample/CometComposeProbe/CometComposeProbe.csproj -f net11.0-android -c Debug -t:Run "-p:AdbTarget=-s 13041FDD4007MT" +``` + ## Code Conventions - **ImplicitUsings**: enabled repo-wide diff --git a/src/Comet/Directory.Build.props b/src/Comet/Directory.Build.props index 987aeb0a2..241c6ec8f 100644 --- a/src/Comet/Directory.Build.props +++ b/src/Comet/Directory.Build.props @@ -12,7 +12,13 @@ - $(MicrosoftMauiControlsVersion) + + 11.0.0-preview.7.26406.9 diff --git a/src/Comet/Directory.Build.targets b/src/Comet/Directory.Build.targets index 0e0f5c0ed..7e137be17 100644 --- a/src/Comet/Directory.Build.targets +++ b/src/Comet/Directory.Build.targets @@ -62,6 +62,10 @@ - + + + diff --git a/src/Comet/NuGet.config b/src/Comet/NuGet.config new file mode 100644 index 000000000..c52a66991 --- /dev/null +++ b/src/Comet/NuGet.config @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Comet/RESULTS.md b/src/Comet/RESULTS.md new file mode 100644 index 000000000..c418b38a1 --- /dev/null +++ b/src/Comet/RESULTS.md @@ -0,0 +1,535 @@ +# Comet-Next — Performance Baselines (Phase 0) + +These are the **pre-refactor** baselines captured on the existing MAUI-handler +render path, on branch `davidortinau/comet-next` at the start of the Comet-Next +refactoring (typed property storage + own backend abstraction + Jetpack Compose / +SwiftUI backends). Every later phase A/B's against these numbers. + +See the plan at `~/.claude/plans/focus-on-comet-in-clever-ladybug.md` and the +harness scripts in `tools/bench/`. + +## Environment + +| | | +|---|---| +| Date captured | 2026-06-12 | +| Host | Apple M4 Max, 16 cores, macOS 26.5 (Darwin 25.5.0) | +| .NET SDK | 11.0.100-preview.5.26302.115 (runtime 11.0.0, Arm64 RyuJIT) | +| MAUI | .NET 11 preview 5 (`Microsoft.Maui.Controls $(MauiVersion)`) | +| BenchmarkDotNet | 0.14.0 | +| Android device | Pixel 5, Android 14 (physical, `13041FDD4007MT`) | +| Android minSdk | 24 (bumped from 21 for .NET 11 preview 5) | + +## 1. Android app size — `CometStressTest`, Release default publish + +`dotnet publish -f net11.0-android -c Release` (no RID filter → fat APK, both +ABIs; managed assemblies packed into `libassembly-store.so`). This is the +"default Release" shape an app author gets today; the trimming/AOT gate in +Phase 4 will compare against a single-RID, fully-trimmed build. + +| Metric | Value | +|---|---| +| APK raw size | **30.59 MiB** (32,077,084 bytes) | +| APK download size (estimated) | 30.76 MB | + +Internal breakdown (largest entries, uncompressed): + +| Entry | Size | +|---|---| +| `lib/x86_64/libassembly-store.so` (managed code) | 8.66 MiB | +| `lib/arm64-v8a/libassembly-store.so` (managed code) | 8.47 MiB | +| `classes.dex` + `classes2.dex` | 15.58 MiB total | +| `lib/*/libcoreclr.so` | 5.9 / 5.8 MiB | +| `lib/*/libclrjit.so` | 3.4 / 3.2 MiB | + +> Note: dual-ABI (arm64-v8a + x86_64). A single-RID arm64 build roughly halves +> the native `.so` + assembly-store contribution. The trimming win we are +> targeting is on the managed `libassembly-store.so` (unused control handlers). + +## 2. Android cold start — `CometStressTest`, Release on Pixel 5 + +`adb shell am start -W` TotalTime, app force-stopped between runs +(`tools/bench/startup.sh`, 10 runs, median reported). + +| Metric | Value | +|---|---| +| Cold start (median of 10) | **644 ms** | +| Cold start (range) | 629–711 ms | + +Raw runs (ms): 696, 629, 685, 635, 631, 655, 647, 639, 642, 711. + +> Methodology aligned with `jonathanpeppers/maui-profiling` (Pixel 5, adb, +> launcher-activity resolution, `am start -W`). For Phase 4 we will additionally +> capture the JIT / interpreter / profiled-AOT time split that repo tracks. + +## 3. Reactive / MVU microbenchmarks (BenchmarkDotNet) + +In-process, host-side (not on-device). These exercise the parts that **stay** +through the refactor (Signal/Computed/scheduler, diff, body rebuilds) — they let +us prove the typed-storage change doesn't regress the reactive core, and give the +MVU-vs-XAML reference the framework has historically reported. Mean time and +allocation per op. + +### Reactive primitives + +| Benchmark | Mean | Allocated | +|---|---:|---:| +| Create + dispose 1000 Signals | 19,878.9 ns | 192,000 B | +| SignalList: 100 Adds + ConsumePendingChanges | 2,647.7 ns | 7,136 B | +| 1 Signal write → 1 effect update | 3,166.0 ns | – | +| 100 Signal writes → single flush | 631,694.3 ns | 747,136 B | +| 50 Computeds, 1 Signal change → all re-evaluate | 60,180.8 ns | 50,680 B | +| 1000-item SignalList + 1 Add → targeted insert | 4,007.2 ns | 17,192 B | +| Computed cache hit (100 reads, no dep change) | 638.7 ns | – | + +### State updates (MVU vs XAML) + +| Benchmark | Mean | Allocated | +|---|---:|---:| +| XAML: Single property update | 1,360.8 ns | – | +| **MVU: Single state update** | **174.6 ns** | – | +| XAML: N independent property changes | 1,713.1 ns | – | +| MVU: N independent state changes | 1,221.9 ns | – | +| XAML: No-op property change (same value) | 1,467.7 ns | – | +| MVU: No-op state change (same value) | 808.7 ns | – | +| XAML: Change 1 of 100 properties | 1,764.7 ns | – | +| MVU: Change 1 of 100 states | 1,042.2 ns | – | + +### View construction + +| Benchmark | Mean | Allocated | +|---|---:|---:| +| XAML: Flat StackLayout + Labels | 59,874.8 ns | 79,904 B | +| MVU: Flat VStack + Text | 796.4 ns | 1,128 B | +| XAML: Deep nested layouts | 311,710.9 ns | 269,775 B | +| MVU: Deep nested views | 784.5 ns | 1,128 B | +| XAML: Mixed form controls | 29,282.2 ns | 46,045 B | +| MVU: Mixed form controls | 788.9 ns | 1,128 B | + +### Real-world scenarios + +| Benchmark | Mean | Allocated | +|---|---:|---:| +| XAML: Todo list build + mutations | 94,164.0 ns | 139,620 B | +| MVU: Todo list build + mutations | 1,134.1 ns | 4,041 B | +| XAML: Form build + validation | 57,653.5 ns | 93,218 B | +| MVU: Form build + validation | 1,437.1 ns | 5,865 B | +| XAML: Dashboard build | 175,332.3 ns | 244,125 B | +| MVU: Dashboard build | 785.8 ns | 1,128 B | + +### Memory + +| Benchmark | Mean | Allocated | +|---|---:|---:| +| XAML: Alloc per property change | 2,152.2 ns | 3,664 B | +| MVU: Alloc per state change (body rebuild) | 1,217.3 ns | 2,201 B | +| MVU: Alloc for 100-node tree rebuild | 1,108.5 ns | 1,560 B | +| XAML: Startup alloc (50-control page) | 1,243,158.0 ns | 2,181,102 B | +| MVU: Startup alloc (50-control view) | 7,881.0 ns | 11,282 B | +| XAML: Cascading computed properties | 6,868.5 ns | 12,352 B | +| MVU: Cascading derived state | 1,626.9 ns | 3,201 B | + +### Diff algorithm + +| Benchmark | Mean | Allocated | +|---|---:|---:| +| Diff: Identical trees (no change) | 78,549.2 ns | 39,898 B | +| Diff: Single node changed in N | 79,797.4 ns | 40,079 B | +| Diff: All nodes changed | 84,354.7 ns | 40,217 B | +| Diff: Append node to list | 916.7 ns | 1,344 B | +| Diff: Remove node from middle | 1,000.8 ns | 1,368 B | +| Diff: Toggle subtree (show/hide) | 983.0 ns | 1,392 B | + +### Rapid updates / animation + +| Benchmark | Mean | Allocated | +|---|---:|---:| +| XAML: Rapid counter | 30,796.2 ns | – | +| MVU: Rapid counter | 9,208.1 ns | – | +| XAML: Multi-prop animation (4 props) | 49,631.9 ns | 12,944 B | +| MVU: Multi-prop animation (unbatched) | 30,781.0 ns | 9,928 B | +| MVU: Multi-prop animation (batched) | 37,116.2 ns | 9,928 B | +| XAML: String-heavy updates | 37,231.1 ns | 30,272 B | +| MVU: String-heavy updates | 21,003.8 ns | 32,400 B | + +### Hot reload (must be preserved through the refactor) + +| Benchmark | Mean | Allocated | +|---|---:|---:| +| Hot reload: 10-Signal view state transfer | 32,236.0 ns | 10,928 B | +| Hot reload: 50-Signal view state transfer | 124,847.5 ns | 57,272 B | + +## How to reproduce + +```bash +# Microbenchmarks (host) +dotnet run -c Release --project tests/Comet.Benchmarks -- --filter '*' --join + +# Android app size +tools/bench/size.sh sample/CometStressTest/CometStressTest.csproj + +# Android cold start (set ANDROID_SERIAL if multiple devices attached) +ANDROID_SERIAL= tools/bench/startup.sh \ + sample/CometStressTest/bin/Release/net11.0-android/publish/com.comet.stresstest-Signed.apk \ + com.comet.stresstest 10 +``` + +--- + +## Phase 4 — Trimming proof (managed code, new backend) + +The core thesis of the refactor: the legacy MAUI-handler path roots **every** +control, so none can be trimmed, whereas the new node backend reaches a control's +renderer only through the virtual `View.CreateBackendNode`, so unused controls +trim away with their backend nodes. + +**Why the legacy path can't trim** — `AppHostBuilderExtensions.UseCometHandlers` +(required by every legacy Comet app) registers a `Dictionary` literal +with `typeof(Picker)`, `typeof(CollectionView)`, `typeof(GraphicsView)`, … for all +~60 controls. A `typeof`/`ldtoken` in reachable code roots the type; the ILLinker +cannot remove it. So a legacy "hello world" still ships every control + handler. + +**New backend, measured** — `CometComposeProbe` (pure Android, no `UseMaui`, no +`UseCometHandlers`) uses only Text, Button, VStack, ListView, NavigationView. +Published Release with `-p:TrimMode=full` (full ILLink; `RunAOTCompilation=false` +to isolate managed trimming), then the linked `Comet.dll` was inspected with +`ikdasm`/`monodis`: + +| Metric | Unlinked | Linked (TrimMode=full) | Δ | +|---|---:|---:|---:| +| `Comet.dll` size | 805,888 B (787 KiB) | 108,544 B (106 KiB) | **−87%** | +| Type definitions (classes) | 773 | 113 | **−85%** | +| `libassembly-store.so` (arm64, all managed) | 8.47 MiB¹ | 6.10 MiB | −2.37 MiB | + +¹ Phase 0 baseline (CometStressTest, legacy path). + +**Unused controls — typedefs present (unlinked → linked):** + +| Control | → | Control | → | Control | → | +|---|---|---|---|---|---| +| CollectionView | 3 → **0** | CarouselView | 2 → **0** | Picker | 2 → **0** | +| GraphicsView | 1 → **0** | WebView | 1 → **0** | RefreshView | 2 → **0** | +| FlyoutPage | 1 → **0** | Stepper | 3 → **0** | SwipeView | 1 → **0** | +| TabView | 2 → **0** | SearchBar | 2 → **0** | DatePicker | 3 → **0** | +| ProgressBar | 3 → **0** | ActivityIndicator | 2 → **0** | ScrollView/Frame/Border/Grid | → **0** | + +**Used controls survive** (linked): Text, Button, VStack, ListView (+`ListView`), +NavigationView — all present. + +> Conclusion: the new backend makes Comet's managed surface pay-for-what-you-use — +> a 5-control app ships 106 KiB of Comet instead of 787 KiB, and every unused +> control (and its `CreateBackendNode`/`Compose*Node`) is gone. This is the +> "handlers aren't trimmable → app-size bloat" problem, retired. +> +> Caveats: total APK is still dominated by the Compose AndroidX deps (dex) + +> CoreCLR runtime, not Comet's managed code, so the win shows in `Comet.dll` / +> assembly-store, not the package total. Cold-start A/B and a minimal **legacy** +> trimmed build (to show controls *survive* empirically) are still pending — the +> latter needs a few-control legacy app since CometStressTest instantiates most +> controls. + +### Reproduce + +```bash +dotnet publish sample/CometComposeProbe/CometComposeProbe.csproj \ + -f net11.0-android -c Release -p:TrimMode=full -p:RunAOTCompilation=false +LINKED=sample/CometComposeProbe/obj/Release/net11.0-android/android-arm64/linked/Comet.dll +ikdasm "$LINKED" | grep -c '\.class ' # ~113 +ikdasm "$LINKED" | grep -E '\.class .*\bPicker\b' # (empty — trimmed) +``` + +### iOS / SwiftUI backend (same proof) + +`CometSwiftUIProbe` (Text/Button/TextField/Toggle/Nav) built +`-c Release -r iossimulator-arm64 -p:TrimMode=full`, linked `Comet.dll` inspected: + +| Metric | Value | +|---|---:| +| `Comet.dll` (linked) | 243,200 B / 183 classes | +| Compose backend (`ComposeNode`, `Compose*Node`) | **trimmed to 0** (Android-only code gone from the iOS build) | +| SwiftUI nodes in use (`SwiftUINode`, `SwiftUINavigationNode`) | survive | +| `SwiftUIListNode` (probe drops ListView) | **0** — trimmed | +| Picker, GraphicsView, WebView, Stepper, TabView, DatePicker, ScrollView, Frame, Grid | **0** | + +So each platform's build trims the *other* backend plus unused controls. **Caveat +(Phase 5 item):** on iOS a few legacy handlers still survive — +`Comet.Handlers.CollectionViewHandler` (+ `CollectionView`/`CarouselView`) are +rooted by the iOS static registrar's `[Register]` scan, even though the probe uses +the new backend. On the pure new-backend Android probe these trim to 0. Fully +decoupling the legacy iOS handlers from the new render path is Phase 5 work. + +## Phase 4 — Cold start A/B (Pixel 5, same session) + +`tools/bench/startup.sh` (`am start -W` TotalTime, force-stop + 2 s settle +between runs, 10 runs, median). Both Release single-RID `android-arm64`, on the +physical Pixel 5 (`13041FDD4007MT`), measured back-to-back on 2026-06-13. + +| App | Render path | Cold start (median) | Range | +|---|---|---:|---:| +| `CometStressTest` | **legacy** MAUI handlers (`UseCometHandlers`) | **721 ms** | 695–820 | +| `CometComposeProbe` | **new** node backend, no `UseMaui` | **534 ms** | 515–570 | + +**~26% faster (−187 ms) and a much tighter distribution.** The new path is a pure +`ComponentActivity` that never runs the MAUI app-host (`MauiApp.CreateBuilder` → +handler registration → Shell), so it skips that init entirely; the legacy app pays +for the full MAUI startup before the first frame. + +> Caveat: not a perfectly controlled A/B — the two are different apps +> (CometStressTest exercises many pages; the probe is minimal), so part of the gap +> is app complexity, not solely the backend. But the dominant cold-start cost is +> runtime + framework init, and the new path provably avoids the MAUI host. (The +> 2026-06-12 baseline measured CometStressTest at 644 ms; it read 721 ms today — +> ~12% device/thermal drift — hence the same-session re-measure for a fair delta.) + +## Phase 5 — Legacy render path decoupled (iOS handler rooting fixed) + +The Phase 4 iOS caveat (legacy `CollectionViewHandler` + CollectionView/CarouselView +survived trimming, rooted by the static registrar's scan of every NSObject type in +the assembly) is resolved. The legacy MAUI ViewHandler render path is now gated +behind `CometLegacyRenderPath` (MSBuild property, default `true`). Building with +`-p:CometLegacyRenderPath=false` excludes the cluster (`Handlers/**`, `Maui/**`, +`AppHostBuilderExtensions`, and the iOS `CometView*`/`CUI*` native views) so the +registrar never sees them and they trim. + +`CometSwiftUIProbe` Release sim, `TrimMode=full`, linked `Comet.dll`: + +| | legacy ON | legacy OFF | +|---|---:|---:| +| `Comet.dll` | 243,200 B / 183 classes | **202,752 B / 136 classes** | +| `CollectionViewHandler`, `CometViewHandler`, `CometView`, `CUITableView`, `ScrollViewHandler`, `NavigationViewHandler` | present | **all 0** | +| SwiftUI backend (`SwiftUINode`, …) + used controls | present | present | + +Non-breaking: the default (`true`) build is unchanged — maccatalyst compiles and +the host suite stays 875 pass / 1 known-fail. Windows/MacCatalyst keep the legacy +path; iOS/Android node-backend apps opt out for the smaller, fully-trimmed binary. + +## Hot reload on the node backend (gate: "hot reload preserved") — 2026-07-01 + +The plan's Phase 1/2 exit criterion, verified for the first time on the node +backend — and it required real fixes: the reload path still assumed the legacy +handler contract (handlers lazily re-render, so a rebuilt tree "eventually" +appears). The node backend pushes patches at diff time, so the reload path had +to transfer the retained node tree explicitly: + +- `UpdateFromOldView` now transfers `Node` (adopt + event-sink rebind to the new + view + re-emit set properties) — the node twin of the ViewHandler transfer. +- Views with a live `Node` register as hot-reload targets (`AddActiveView`), and + a Component/[Body] view that collapses to its rendered subtree registers at + materialize time (`TriggerReload` only reloads `Parent == null` roots). +- A fresh hot-reload replacement has never rendered; the diff now force-builds it + when retained nodes are in play, and `GetRenderView`'s replacement branch diffs + the outgoing built tree into the replacement so the nodes carry over. +- `TryMergeComponents` reuses an already-installed replacement instead of + constructing a second instance (which stranded the transferred node). +- `ComposeBackendRoot` re-resolves its layout root per pass (read-only + `BuiltView`; a captured tree goes stale after reload — and building views on a + background flush thread crashes). +- New protocol hook `ICometBackendNode.OnOwnerViewChanged(View)`: own-content + nodes (Drawer, NavigationView, ListView) re-point at the reloaded view and + re-materialize the content they built from the old tree. + +Verified: 4 host tests (`Backend/BackendHotReloadTests.cs` — prop patch onto the +SAME retained node, event rebind, root-type replacement via TriggerReload, +component replacement preserving `Component` state) + on-device demo +(emulator, `CometComposeProbe`): `adb shell am broadcast -n +com.comet.composeprobe/.HotReloadDemoReceiver` swaps `JetchatRoot→JetchatRootV2` +(the exact two calls `CometMetadataUpdateHandler` makes when the runtime applies +an EnC delta); the running Jetchat re-renders with the new inset, the typed +composer text survives, and the emoji selector still opens (events + reactive +re-layout intact through the re-materialized drawer content). + +KNOWN LIMITATIONS: (1) structural child insert/remove in a reloaded tree does +not re-materialize nodes yet (a brand-new view with no same-type pair renders +nothing — legacy recreated handlers lazily, the node path needs explicit +Insert/RemoveChild materialization; follow-up); (2) the SwiftUI nodes inherit +the no-op `OnOwnerViewChanged` — iOS needs the same own-content pass. + +## Backend patch-stream benchmarks (host, BenchmarkDotNet) — 2026-07-01 + +`tests/Comet.Benchmarks/BackendMutationBenchmarks.cs` (the plan's verification +item): pure Comet-side overhead of the diff→backend contract against a no-op +recording node (no Compose/SwiftUI interop in the loop). net11.0, ShortRunJob, +MemoryDiagnoser (allocated = per single op): + +| Scenario | Mean | Allocated | +|---|---:|---:| +| `SignalChange_PatchesNode` (steady-state reactive patch: signal → flush → one property to the retained node) | **657 ns** | 1.52 KB | +| `Materialize_SmallTree` (5-view tree → nodes, set-only property emission) | 358 µs | 151 KB | +| `Reload_DiffsOntoRetainedNodes` ([Body] rebuild + diff + node transfer — the hot-reload path) | 279 µs | 119 KB | + +The steady-state loop (what runs per user interaction) is sub-microsecond with +~1.5 KB/op (dominated by the bound string interpolation). Reload of a small +Body sits ~350× under the plan's 100 ms hot-reload budget. Materialization is +one-time-per-view; its 151 KB (env/context dictionaries) is the number to +revisit when the typed-storage conversion of the remaining string-keyed +environment happens. + +## FPS/GC on-device A/B — DEFERRED to hardware + +The dual-flavor scroll FPS + GC A/B (legacy handlers vs node backend, gfxinfo +first-pass methodology) needs the physical Pixel 5; emulator frame numbers are +not meaningful and are deliberately not recorded. Run it from tag +`comet-pre-phase5-delete` (the last commit with the legacy render path present) +when the device is available. The Jetchat scroll numbers already on record +(100% jank / 350ms 90th → 3% / 12ms after the LazyColumn row cache, Pixel 5, +Release) stand as the node-backend-only measurement. + +## NativeAOT (iOS) — gate: "NativeAOT where supported" — 2026-07-01 + +`CometSwiftUIProbe` (full shared Jetchat sample) publishes under NativeAOT: +`dotnet publish -f net11.0-ios -c Release -p:RuntimeIdentifier=ios-arm64 +-p:EnableProbeAot=true` (the probe csproj maps that to `PublishAot=true` +app-side only — a global `-p:PublishAot` leaks into plain-classlib +dependencies and fails with NETSDK1203, the PublishTrimmed trap again). + +ILC compiles end-to-end and produces the package: **7.2 MB .ipa** (17 MB .app, +15.9 MB single native executable — no separate IL assemblies, no JIT). +Remaining trim warnings come from MAUI's `HybridWebViewHandler` (legacy +Microsoft.Maui.Controls dependency — goes away with the Phase 5 delete) and +Comet's debug-only hot-reload reflection (IL2104, suppressed by design). +Simulator RIDs don't support AOT (NETSDK1203) — device RID only. + +## Phase 5 COMPLETE: legacy MAUI render path DELETED — 2026-07-01 + +The gate became a delete (approved): `Handlers/**`, `Maui/**`, +`AppHostBuilderExtensions.cs`, and ALL native platform views +(`Platform/{iOS,Android,MacOS,Windows,Standard}` handler bridges) are gone; +the `CometLegacyRenderPath` MSBuild property and `COMET_LEGACY_RENDER_PATH` +guards with them. The Compose/SwiftUI node backend is the only render path. +Windows and the AppKit macOS heads are parked with it (TFMs removed); +maccatalyst stays for the host suite. The 20 legacy samples +(`UseCometApp`/`UseCometHandlers`) are parked as reference source — +`sample/LEGACY-SAMPLES.md`; the last commit with the legacy path is tagged +`comet-pre-phase5-delete`. + +Post-delete verification: all three TFM heads compile; host suite 886 pass / +26 skip / 1 pre-existing known fail (4 handler-only NativeHost tests deleted +with the interface they tested); CometComposeProbe renders on the Android +emulator and CometSwiftUIProbe on the iPhone 16 Pro sim; Release +TrimMode=full links Comet.dll at 265,728 B (larger than June's legacy-off +202,752 B because P1–P7 + hot reload/IME/animations landed since — the point +is the handlers can no longer be compiled in, let alone rooted). + +--- + +# compose-samples build-out (Reply → JetNews → Jetsnack → Jetcaster → JetLagged) + +Per-sample sections use the template below (see +`docs/sample-workflow-checklist.md`, step 8). Emulator numbers land with the +sample; Pixel 5 numbers land in batches — **B1** after JetNews (Reply+JetNews), +**B2** after Jetcaster (Jetsnack+Jetcaster), **B3** after JetLagged (final sweep ++ Jetchat re-run to catch framework-growth regressions). + + + +## Jetsnack — 2026-07-10 + +Emulator = Pixel 9 Pro AVD (API 36, arm64, Apple M4 Max host). Gold = the Kotlin +Jetsnack app from ~/work/compose-samples. NOTE: like JetNews, the gold's +minified `app-release.apk` (10.6 MiB) fails to launch on this emulator +(sample-side issue), so runtime comparisons use the gold DEBUG build. + +| Metric | Comet (emulator) | Comet (Pixel 5, B1) | Gold Kotlin app | +|---|---|---|---| +| Release APK, trimmed single-RID (`tools/bench/size.sh -p:CometSingleRid=true`) | 33.5 MiB (35,159,321 B) | — | 10.6 MiB (release artifact) | +| Cold start median, 10 runs (`am start -W --es screen jetsnack`) | 678 ms | — | 876 ms (DEBUG build — release fails to launch) | +| gfxinfo scroll jank, home feed (6 fling swipes) | 7.4% janky, p50 17ms / p90 20ms / p95 26ms | — | 5.3% janky, p50 17ms / p90 21ms / p95 25ms (DEBUG) | +| iOS .ipa trimmed size / launch feel (manual note) | not measured (sim-only gate; Debug launch feels instant after splash) | n/a | n/a | +| Comet.dll (linked, android-arm64) | 834 KiB (854,016 B) | — | n/a | + +Notes: the probe now bundles FOUR samples; the +7.3 MiB over the JetNews row +is almost entirely the 36 snack photo jpgs (7.5 MiB of assets), not code. +Cold start at 678 ms is the best Comet number yet (Reply 3705 → JetNews 2347 → +Jetsnack 678 on the same runtime — the Jetsnack feed composes far less at +first frame than JetNews' suite+list-detail chrome) and beats even the DEBUG +gold. Scroll jank is at parity on the gradient-heavy feed. Pixel 5 column +lands with batch B1. + +## JetNews — 2026-07-09 + +Emulator = Pixel 9 Pro AVD (API 36, arm64, Apple M4 Max host). Gold = the Kotlin +JetNews app from ~/work/compose-samples. NOTE: the gold's minified +`app-release.apk` (3.8 MiB) CRASHES at launch on this emulator (sample-side +proguard/deeplink issue), so runtime comparisons use the gold DEBUG build — +gold cold start and jank read worse than a healthy release would. + +| Metric | Comet (emulator) | Comet (Pixel 5, B1) | Gold Kotlin app | +|---|---|---|---| +| Release APK, trimmed single-RID (`tools/bench/size.sh -p:CometSingleRid=true`) | 26.2 MiB (27,475,468 B) | — | 3.8 MiB (release artifact) | +| Cold start median, 10 runs (`am start -W --es screen jetnews`) | 2347 ms | — | 2914 ms (DEBUG build — release crashes) | +| gfxinfo scroll jank, home feed (6 fling swipes) | 13.8% janky, p50 17ms / p90 46ms / p95 81ms | — | 11.3% janky, p50 26ms / p90 42ms / p95 65ms (DEBUG) | +| iOS .ipa trimmed size / launch feel (manual note) | not measured (sim-only gate; Debug launch feels instant after splash) | n/a | n/a | +| Comet.dll (linked, android-arm64) | 829 KiB (849,408 B) | — | n/a | + +Notes: the probe APK now bundles THREE samples (Jetchat + Reply + JetNews, +incl. all post images + fonts) — the total is still dominated by the AndroidX +Compose dex + CoreCLR preview runtime, so the size delta vs the Reply row is ++0.1 MiB for a whole extra sample. Comet cold start improved vs the Reply row +(3705 → 2347 ms median) with no tuning; against the DEBUG gold it now reads +faster, but the honest release-gold comparison is blocked on the gold's own +launch crash. Jank is near parity (worse p95, better p50) on the paragraph- +heavy home feed. Pixel 5 column lands with batch B1. + +## Reply — 2026-07-08 + +Emulator = Pixel 9 Pro AVD (API 36, arm64, Apple M4 Max host). Gold = the Kotlin +Reply `app-release.apk` (minified) from ~/work/compose-samples, same device. + +| Metric | Comet (emulator) | Comet (Pixel 5, B1) | Gold Kotlin app | +|---|---|---|---| +| Release APK, trimmed single-RID (`tools/bench/size.sh -p:CometSingleRid=true`) | 26.1 MiB (27,409,987 B) | — | 3.4 MiB | +| Cold start median, 10 runs (`am start -W --es screen reply`) | 3705 ms | — | 773 ms | +| gfxinfo scroll jank, inbox list (6 fling swipes) | 13.0% janky, p50 26ms / p90 40ms / p95 57ms | — | 9.3% janky, p50 28ms / p90 40ms / p95 48ms | +| iOS .ipa trimmed size / launch feel (manual note) | not measured (sim-only gate; Debug launch feels instant after splash) | n/a | n/a | +| Comet.dll (linked, android-arm64) | 819 KiB (839,168 B) | — | n/a | + +Notes: the probe APK bundles BOTH samples (Jetchat + Reply, incl. avatar +drawables + icon font) — a per-sample split would shave little since the total +is dominated by the AndroidX Compose dex (~32 MB uncompressed across 3 +classes.dex) and the CoreCLR preview runtime (libcoreclr+libclrjit ~9 MB); +managed code is 8.4 MiB (assembly store). No AOT/startup profile tuning yet — +cold start carries full JIT + Compose init (the 4.8× gap vs gold is the +headline framework-maturity number to attack, not the scroll path). Scroll +jank is at parity with the gold app on the same device. Single-RID publish +needs the `CometSingleRid` csproj switch — a raw `-p:RuntimeIdentifiers` flows +into the netstandard2.0 source generator and breaks its copy step. Pixel 5 +column lands with batch B1 (after JetNews). + + +## Pixel 5 batch B1 — 2026-07-15 (Reply + JetNews + Jetsnack) + +Physical Pixel 5 (`13041FDD4007MT`, Android 14). Comet = today's Release +trimmed single-RID probe (bundles FOUR samples, 33.5 MiB — same APK as the +Jetsnack row) launched with `am start -W --es screen `; 10 runs, +0 FATALs across all 30 launches. Gold = Reply release APK; JetNews/Jetsnack +DEBUG builds (their release APKs fail to launch — sample-side minify issue, +same caveat as the emulator rows). Jank = 6 fling swipes on the same screens +as the emulator rows, `dumpsys gfxinfo` after a post-launch reset. + +| Metric | Reply | JetNews | Jetsnack | +|---|---|---|---| +| Comet cold start median (range) | 1373 ms (1342–1439) | 1395 ms (1379–1423) | 1795 ms (1764–2127) | +| Gold cold start median | 365 ms (release) | 1237 ms (DEBUG) | 1919 ms (DEBUG) | +| Comet scroll jank | 26.7% janky, p50 13 / p90 150 ms | 21.9%, p50 11 / p90 105 ms | 20.0%, p50 13 / p90 250 ms | +| Gold scroll jank | 1.6%, p50 10 / p90 20 ms (release) | 10.8%, p50 10 / p90 29 ms (DEBUG) | 25.0%, p50 14 / p90 97 ms (DEBUG) | + +Notes: on real hardware the full-JIT cost shows up in both cold start and the +fling p90s (first-fling JIT warmup lands inside the 6-swipe window) — p50s are +at parity everywhere, and Jetsnack Comet BEATS the debug gold on cold start +(1795 vs 1919) and janky-frame rate (20.0 vs 25.0%). Reply remains the honest +headline gap (1373 vs 365 release gold; 3.8× — emulator was 4.8×): no AOT / +startup profile tuning yet. The per-sample emulator tables' "Pixel 5 (B1)" +columns are superseded by this batch table. diff --git a/src/Comet/docs/adaptive-primitives-design.md b/src/Comet/docs/adaptive-primitives-design.md new file mode 100644 index 000000000..1c9b8e4ea --- /dev/null +++ b/src/Comet/docs/adaptive-primitives-design.md @@ -0,0 +1,72 @@ +# Adaptive primitives design note (Reply → JetNews, Jetcaster) + +Written before coding, per plan. Two framework primitives + the chrome controls they +compose. Everything keys off **`CometWindowMetrics`** (M0, `Backend/CometWindowMetrics.cs`): +reading `WidthClass`/`HeightClass`/`SizeDp` in a body/binding re-renders exactly the +views that read it — no new reactive machinery needed. + +## 1. `NavigationSuite` (the chrome switcher) + +Gold behavior (Reply): bottom bar <600dp, rail 600–1199dp, permanent drawer ≥1200dp +(AND width-class Expanded); modal drawer overlays everything, openable only in rail +mode. `NavigationSuiteScaffoldLayout` is NOT facade-bound and we deliberately don't +bind it (plan fallback): **hand-compose the three variants in one Comet container view**. + +```csharp +new NavigationSuite() + .Destinations(dests) // icon + label + route key, selected index Binding + .Header(...) // FAB/compose affordance per variant slot + .DrawerContent(...) // shared item list for modal+permanent sheets + .Content(() => currentScreen) +``` + +- Body: `switch (this.GetWindowMetrics().WidthClass, SizeDp.Width)` → HStack(rail, content) + | HStack(permanentSheet, content) | VStack(content, bottomBar); modal drawer wraps + the whole thing (existing Drawer control, gestures gated like the gold). +- The suite type is **derived state, not stored** — resize re-runs the body; the + content view instance is shared across variants so screen state (scroll, text) + survives chrome swaps. Host test: resize across a breakpoint → chrome node type + changes, content node identity stable. +- Per-variant chrome = real M3 widgets via new leaf controls (NavigationBar/Item, + NavigationRail/Item, NavigationDrawerItem, sheets) — thin nodes over the already-bound + facade composables, one commit each with FakeBackendNode tests. +- Rail/drawer header-top/content-centered placement (gold `navigationMeasurePolicy`): + Yoga column with center-justified content block and `coerceAtLeast(header)` behavior — + express as spacer-weighted layout, NOT a custom measure policy. + +## 2. `ListDetail` (two-pane) + +Gold: accompanist TwoPane 50/50, 16dp gap, ≥840dp only; detail falls back to first item; +compact pushes a full-screen detail with BackHandler. + +```csharp +new ListDetail() + .List(() => inbox) + .Detail(() => opened ?? first) + .IsDetailOpen(binding) // compact: drives push/back; expanded: highlight only +``` + +- Body on WidthClass: Expanded → HStack(list.Frame(0.5f), gap 16, detail.Frame(0.5f)); + else → single pane where `IsDetailOpen` swaps list↔detail (NavigationView push on + Android compact for real back handling). +- Fold postures explicitly out of scope (plan); API leaves room (`SplitFraction`, + `Gap` modifiers) without modeling DisplayFeatures. +- Host tests: breakpoint crossing swaps pane structure; open-detail state preserved + across the swap (the gold's `closeDetailScreen` LaunchedEffect on contentType is the + behavior to match: entering single-pane with detail-not-explicitly-open shows list). + +## 3. Plumbing prerequisite (M0 deferral now due) + +`ComposeNode.AvailableSize` is still a process-wide static fed by the root; the +primitives read per-root `GetWindowMetrics()`. Re-plumb: ComposeBackendRoot owns a +`CometWindowMetrics` instance, installs it via `view.WindowMetrics(m)`, updates it in +LayoutChange (Shared stays as fallback + keeps updating for single-window). SwiftUI +root same in RunLayout; REAL iOS resize observation (viewWillTransition/scene geometry) +lands at the Reply iOS gate. + +## Test/verify hooks + +- Smoke: `android_resize 2100 2856` → assert rail element present, bottom bar absent; + `3780x2856` → drawer items with labels present; reset → bottom bar back. (Verified in + M0 that wm-size config change re-creates the activity and the app survives.) +- The 3 gold chrome screenshots are the pixel contract (`sample/Shared/Reply/gold/`). diff --git a/src/Comet/docs/baseline-alignment.md b/src/Comet/docs/baseline-alignment.md new file mode 100644 index 000000000..92e24493f --- /dev/null +++ b/src/Comet/docs/baseline-alignment.md @@ -0,0 +1,313 @@ +# Spec: Baseline alignment in the Comet layout engine + +Status: **Implemented on Android, pixel-exact; iOS pending.** Created 2026-06-14. + +> **Update — exact baseline landed.** The cross-engine seam (below) was closed by measuring +> through **Compose's own `TextMeasurer`** (`ComposeTextMeasure.FirstBaselineDp` → +> `TextLayoutResult.firstBaseline`) with a `TextStyle` mirroring the rendered `Text` — one engine for +> measure + render, the RN model. **No JNI bridge was needed**: `createFontFamilyResolver`, `Density`, +> `TextMeasurer`, `Measure`, `TextLayoutResult.FirstBaseline`, and `ConstraintsKt.Constraints` are all +> directly callable from the Xamarin binding. Exact by construction (logged Jetchat header baselines: +> `John Glenn` 16sp = 15.636dp, `8:12 PM` 12sp = 10.909dp → engine drops the timestamp 4.727dp so both +> land at 15.636dp). Gotchas: `Constraints` can't pack a large *finite* maxHeight next to a width — use +> Compose's `Int.MaxValue` Infinity sentinel; and a screenshot ruler has a ~1px font-size bias, so +> trust the logged layout values, not pixel-counting. + +## Implementation notes (what shipped) +- Public API: **`view.AlignBaseline()`** (env `Layout.BaselineAlign`), mirroring Compose's + `Modifier.alignByBaseline()`. Per-child opt-in on a row. +- Engine: `YogaMeasureBridge.ApplyStyle` sets `AlignSelf = FlexAlign.Baseline` for opted-in + children of a row; `CometBackendLayoutEngine.Build` installs `node.BaselineFunction → + leaf.Node.MeasureBaseline(...)` (falling back to node height when null). +- Node contract: `ICometBackendNode.MeasureBaseline(width, height)` (default `null`); + `ComposeTextNode` overrides it via `TextMeasure.FirstBaselineDp` — the analytical first-baseline + inside the pinned `LineHeightSp`, using the **proportional** leading split (Compose's default). +- **Visibility gap (§4.1) was a non-issue:** `Comet.Layout.Yoga` already has + `[InternalsVisibleTo("Comet")]`, so `BaselineFunction`/`YogaBaselineFunc` are reachable — no port + change needed. +- Jetchat `AuthorNameTimestamp` now uses `.AlignBaseline()`; the empirical `0.5dp` nudge is gone. +- Verified: host unit test `BaselineAlignedRow_LinesUpChildBaselines` (engine math, generalizes to + any baseline values, 45/45 backend tests pass) + on-device Pixel 5 measurement (author/timestamp + baselines within 1px — the screenshot measurement floor). + +### Pixel-exact: the cross-engine seam (the ~1px residual) + +The engine baseline wiring is **the same design React Native uses** — Yoga `align-items: baseline` ++ a per-node baseline function the text node supplies (RN: ["base-line metric exposure for +``"](https://github.com/facebook/react-native/commit/51b3529f6c2ca354800c0cf6ecb8eb3115eaa36e)). +RN is pixel-exact because its `` **measures and renders with the same text engine** (Android +`StaticLayout`/`Layout` → `ReactTextView`); the baseline handed to Yoga comes from the same layout +object that paints the glyphs. + +Comet has **two text engines in series**: it measures with Android `StaticLayout`/`Paint` +(`TextMeasure`) but **renders with Jetpack Compose** (`androidx.compose.ui.text`), which has its own +leading distribution, `includeFontPadding` default, and integer-metric rounding. That seam is the +entire ~1px error. Measured on a Pixel 5 for the 16sp/12sp pair, every *prediction* model floors at +~1px regardless: proportional leading −1.0px, centered −0.93px, natural line-height (no leading) +−2 to −3px. The leading model is not the lever; the cross-engine seam is. + +**The correct (RN-equivalent) fix: measure through Compose, so one engine does measure + render.** +Two routes, both confirmed to be heavy facade work (investigated 2026-06-14): +- **(1) Bind `TextMeasurer`** (measure-before-layout, RN-shadow-node style — cleanest, no frame + caveat). Needs: a shared `TextMeasurer` (built from `createFontFamilyResolver(context)` + `Density` + + `LayoutDirection`), its `measure()` (heavy — the `Constraints` inline value-class param packs to + a `long`; many params, a `$default` synthetic), and `TextLayoutResult.firstBaseline` + `.size` + (`IntSize` inline-class unpack). `ComposeTextNode.Measure`/`MeasureBaseline` then call it; this also + retires the `LineHeightSp`/letter-spacing fudges and makes *all* text sizing render-accurate. +- **(2) Read `TextLayoutResult.firstBaseline` from the live render via `onTextLayout`**, cache it per + type-style (baseline depends only on family/weight/size, not content), and reflow once on a cache + miss (1-frame warm-up using the `StaticLayout` estimate, then exact; on a scrolling list, styles are + cached after their first row). Lighter conceptually BUT the facade's `[ComposeFacade]` generator + can't pass an object-typed callback (`[Callback]` supports only bool/string/float — error CN3005), + so the **core `Text` bridge must be hand-rewritten** (drop `[ComposeFacade]`), which is risky + because `Text` is used everywhere. Also needs a reflow trigger (`ComposeBackendRoot.RunLayout` + posted to the next frame). + +Recommendation: **(1) `TextMeasurer`** is the principled fix (single engine, measure-before-render, +broad payoff) and should be its own task. Until then the proportional model (~1px, committed) is the +floor for prediction. A purely *visual* exact result for a specific name+timestamp is also available +by merging the two into one `AnnotatedText` run (Compose owns the line's baseline) — exact and +fastest for long lists, but it's one `Text`, not the gold standard's `Row`+two `Text`s, and the gap +becomes space-approximated. + +### Still open +- **iOS**: the SwiftUI text node still returns `null` from `MeasureBaseline` (falls back to height + ≈ bottom). Implement via `UIFont` metrics — see §5. Not validated yet (Android-first per current + direction). +- **`FormattedText`** (rich-text bubbles) doesn't override `MeasureBaseline` yet — only plain `Text`. +- The proportional leading model matches Compose to ≤1px here; if a future font exposes a mismatch, + pin an explicit `LineHeightStyle` (§5) to make it closed-form. + +--- + +## Original spec follows. + +## 1. Goal + +Let a row of text-bearing views align its children on a shared **text baseline**, the way +Jetpack Compose's `Row(verticalAlignment = …)` + `Modifier.alignByBaseline()` and SwiftUI's +`HStack(alignment: .firstTextBaseline)` do. + +The motivating case is the Jetchat conversation header — an author name and a timestamp at +different font sizes that must sit on the same line: + +``` +John Glenn 8:12 PM +‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ ← one shared baseline +``` + +The gold standard (`android/compose-samples` Jetchat `AuthorNameTimestamp`) uses: + +```kotlin +Row { + Text(author, style = titleMedium, modifier = Modifier.alignBy(LastBaseline)) + Spacer(Modifier.width(8.dp)) + Text(timestamp, style = bodySmall, modifier = Modifier.alignBy(LastBaseline)) +} +``` + +### Why the current result is wrong + +The Comet layout engine has no baseline alignment, so today the sample bottom-aligns the two +texts (`VerticalLayoutAlignment.End`) plus an **empirical `0.5dp` bottom-margin nudge** on the +timestamp (`JetchatConversation.AuthorNameTimestamp`). Measured on a Pixel 5 this lands the two +baselines within ~1px — but it is a hand-tuned constant calibrated to the specific 16sp/12sp pair. +It does not generalize to other font-size pairs and is exactly the class of "≈2px off" error we +want to eliminate structurally. + +**Definition of done:** a developer can opt two (or more) text views in a row into baseline +alignment with no magic numbers, and the rendered baselines coincide on both the Compose (Android) +and SwiftUI (iOS) backends, at any density and for any font-size pair. + +## 2. Background — how baseline alignment works + +- The **baseline** of a line of text is the line the glyphs "sit" on (descenders hang below). +- Flexbox baseline alignment: in a row, each participating child reports its **first-baseline + offset** (distance from the child's top edge to its baseline). The engine finds the maximum such + offset across the row and shifts every participating child down so all baselines land on that + common line. Bigger text naturally has a larger ascent, so it pushes the shared baseline down and + the smaller text rides up to meet it. +- This is **not** the same as bottom/top/center alignment of the boxes — those align box edges or + box centers, which only coincides with the baseline by accident. + +## 3. Prior art + +- **Yoga** (Meta's flexbox engine, the basis of React Native and of our C# port) fully supports + `align-items: baseline` and per-child `align-self: baseline`, plus a per-node **baseline + function** registered via `YGNodeSetBaselineFunc`. Docs: + https://www.yogalayout.dev/docs/styling/align-items-self +- **React Native** exposes `alignItems: 'baseline'`; it works because RN's native **Text** shadow + node installs a baseline function that returns the text's first baseline from its measurement. + This is the canonical pattern we should mirror. See the Yoga 3.0 layout-conformance notes: + https://blog.logrocket.com/react-native-layout-management-yoga-3/ +- **Jetpack Compose**: `Modifier.alignByBaseline()` / `alignBy(FirstBaseline|LastBaseline)` on + `Row` children; the measured `Placeable` exposes `FirstBaseline`/`LastBaseline` alignment lines. +- **SwiftUI**: `HStack(alignment: .firstTextBaseline)` and `.alignmentGuide(.firstTextBaseline)`. + +Takeaway: every comparable system implements this as **"the leaf reports its baseline; the +container aligns to it."** Our engine is already built for exactly this. + +## 4. Current state of *this* codebase (what already exists) + +Good news: the vendored C# Yoga port (`src/Comet/src/Comet.Layout.Yoga/`) **already contains the +full baseline machinery** — it was ported from Yoga's `Baseline.cpp`/`Align.cpp`, not stripped: + +- `FlexEnums.cs` → `FlexAlign.Baseline = 5`. +- `YogaNode.cs` → `YogaBaselineFunc` delegate, `BaselineFunction` property, `HasBaselineFunc`, + `IsReferenceBaseline`. +- `AlgorithmUtils.cs` → `BaselineHelper.CalculateBaseline(node)` (recursive: uses the node's + baseline func, else recurses into the first baseline child, else **falls back to the node's full + height**) and `BaselineHelper.IsBaselineLayout(node)`. +- `YogaAlgorithm.cs` → the cross-axis positioning honors baseline layout (≈ lines 628, 700–780, + 1733–1795): it computes each child's ascent via `CalculateBaseline` and offsets children so + baselines line up. + +So the **engine needs no algorithm work.** The reason bottom-align is ~2px low today is precisely +the fallback above: a text leaf with **no baseline function** reports its baseline as its full +height (its bottom edge), so "baseline" degenerates to "bottom of the line box," which sits a +descent-plus-leading below the real baseline. + +### The gaps (all at the Comet ↔ Yoga integration seam) + +1. **Visibility.** `YogaBaselineFunc` is `internal` and `IsReferenceBaseline` is `internal`, while + `BaselineFunction` is a `public` property *of an internal type* — so `Comet.csproj` (a different + assembly) cannot construct/set a baseline function today. Need to make the delegate public (or + expose a `public` setter taking `Func<…>`/a px value), or add `[InternalsVisibleTo]`. +2. **No baseline value flows from the backend nodes.** `ICometBackendNode` exposes only + `Size Measure(double, double)` (`src/Comet/Backend/ICometBackendNode.cs:46`). There is no way for + a text node to report its first-baseline offset. +3. **The layout engine never sets a baseline function.** `CometBackendLayoutEngine.Build` + (`src/Comet/Backend/CometBackendLayoutEngine.cs`, leaf branch ~line 103) installs a + `MeasureFunction` but no `BaselineFunction`. +4. **No public API to request baseline alignment.** `LayoutAlignment` is + `Microsoft.Maui.Primitives.LayoutAlignment` (Start/Center/End/Fill) — a MAUI enum we cannot + extend with `Baseline`. We need a separate opt-in (see §6). +5. **`YogaMeasureBridge.ApplyStyle`** (`src/Comet/Layout/YogaMeasureBridge.cs`) maps + `LayoutAlignment → AlignSelf`; it has no path to emit `FlexAlign.Baseline`. + +## 5. The core challenge: measured baseline must equal *rendered* baseline + +The baseline function returns a number used purely for layout. If that number differs from where the +backend actually *draws* the baseline, the visual result is wrong by the difference — reintroducing +the very ~2px error. So the hard part is **not** the flexbox math; it is computing a baseline value +that exactly matches each backend's text rendering. + +### Compose (Android) +`ComposeTextNode` pins `text.LineHeight = LineHeightSp(sp)` where `LineHeightSp = round(sp * 1.3)` +(`ComposeLeafNodes.cs`). Within that line box, the baseline position depends on how Compose +distributes the extra leading (`LineHeightStyle.Alignment`/`Trim`), which is **not** simply +"`-ascent` from the top." Measurement already uses `android.text.StaticLayout` / `Paint.FontMetrics` +(`TextMeasure`), so the metrics are in hand, but the leading distribution must be matched. + +Recommended approach: **make the distribution deterministic.** Set an explicit +`LineHeightStyle(alignment = …, trim = LineHeightStyle.Trim.None)` on the rendered `Text`, then the +first-baseline offset is a closed-form function of `FontMetrics` + `LineHeightSp` that the baseline +function can reproduce exactly. Add `TextMeasure.MeasureBaseline(text, sp, width, typeface)` +returning the first-baseline offset in **dp** (divide px by `ComposeNode.Density`). +Cross-check with `StaticLayout.GetLineBaseline(0)` and, ideally, assert against the composed +`Text`'s reported `FirstBaseline` alignment line in an instrumented test. + +### SwiftUI (iOS) +Two options: +- **(A) Compute from `UIFont` metrics** (mirrors the Compose path): first baseline ≈ `font.ascender` + plus the top-leading the shim applies; return it from `measureNode`. Keeps the absolute-positioning + model the engine already uses. +- **(B) Defer to SwiftUI.** Because SwiftUI has true first-baseline support, a row that opts into + baseline alignment could be rendered as a real `HStack(alignment: .firstTextBaseline)` instead of + the engine's absolute placement. Cleaner typographically but it carves a hole in the + "engine owns all layout" model and only works when the whole row is SwiftUI-native. + +Option (A) is recommended for consistency with the Android path and the engine's design; (B) is a +possible future optimization. + +## 6. Proposed design + +### 6.1 Public API (developer-facing) +`LayoutAlignment` can't carry `Baseline`, so add a dedicated opt-in. Recommended: a per-child +modifier mirroring Compose's `Modifier.alignByBaseline()`: + +```csharp +new HStack { + new Text(author).TitleMedium().AlignBaseline(), + new Text(time).BodySmall().AlignBaseline(), +} +``` + +`AlignBaseline()` sets an env flag (e.g. `EnvironmentKeys.Layout.BaselineAlign`) consumed by the +engine, exactly like the existing `.AsSurface()`/`.Borderless()` opt-ins. (Alternative: an +`HStack`-level `.BaselineAligned()` that sets `AlignItems=Baseline` for all children — less precise +but matches the `Row(verticalAlignment=…)` shape. The per-child modifier is preferred; it is what the +gold standard uses and avoids forcing baseline on non-text children like icons.) + +### 6.2 Backend-node contract +Extend the node protocol so a leaf can report its baseline: + +```csharp +// ICometBackendNode +double? MeasureBaseline(double width, double height); // first-baseline offset from the top, in Dp; + // null = no text baseline (fall back to height) +``` + +Default implementation returns `null`. `ComposeTextNode` and the SwiftUI text node override it +(§5). `FormattedText` returns its first line's baseline. + +### 6.3 Engine wiring +- `CometBackendLayoutEngine.Build` leaf branch: if the view opted into baseline align, set + `node.BaselineFunction = (n, w, h) => (float)(leaf.Node?.MeasureBaseline(w, h) ?? h);`. +- `YogaMeasureBridge.ApplyStyle` (and/or the engine's per-child setup): when the view has the + baseline-align flag, set `node.AlignSelf = FlexAlign.Baseline` (overriding the + cross-axis alignment), and ensure the parent isn't a column (baseline only applies to rows). +- Make `YogaBaselineFunc` public (or expose a `Func<>`-typed setter) in `Comet.Layout.Yoga`. + +### 6.4 Retire the empirical nudge +Once landed, delete the `0.5dp` margin + `VerticalLayoutAlignment.End` workaround in +`JetchatConversation.AuthorNameTimestamp` and replace with `.AlignBaseline()` on both texts. + +## 7. Touch points (file checklist) +- `src/Comet/src/Comet.Layout.Yoga/YogaNode.cs` — make `YogaBaselineFunc` public / expose setter. +- `src/Comet/src/Comet/Backend/ICometBackendNode.cs` — add `MeasureBaseline` (default `null`). +- `src/Comet/src/Comet/Backend/CometBackendLayoutEngine.cs` — set `BaselineFunction` for baseline leaves. +- `src/Comet/src/Comet/Layout/YogaMeasureBridge.cs` — emit `FlexAlign.Baseline` for opted-in children. +- `src/Comet/src/Comet/Helpers/…` — `AlignBaseline()` extension + `EnvironmentKeys.Layout.BaselineAlign`. +- `src/Comet/src/Comet/Platform/Compose/ComposeLeafNodes.cs` — `TextMeasure.MeasureBaseline` + a + deterministic `LineHeightStyle`; `ComposeTextNode`/`ComposeFormattedTextNode` override `MeasureBaseline`. +- `src/Comet/src/Comet/Platform/SwiftUI/SwiftUINode.cs` + `Comet.SwiftUI.Shim` — report baseline from + `measureNode` (UIFont metrics). +- `JetchatConversation.AuthorNameTimestamp` — swap the nudge for `.AlignBaseline()`. + +## 8. Risks & open questions +- **Leading distribution match (highest risk).** Getting `MeasureBaseline` to equal the rendered + baseline within ≤0.5px on Compose hinges on pinning `LineHeightStyle`. Must be verified by + instrumented measurement, not by eye. +- **Multi-line text.** First vs last baseline semantics (`alignBy(FirstBaseline)` vs `LastBaseline`). + Jetchat uses `LastBaseline`; for single-line text first==last. Decide whether to expose both. +- **Mixed children.** A row with an icon + text + text: only the texts opt in; the icon keeps its + own (top/center) alignment. The per-child API handles this; an `AlignItems=Baseline` container + would not. +- **`LayoutContent` rows.** The LazyColumn/list row pass (`LayoutContent`) must preserve baseline + funcs — verify baseline survives the width-pinned/height-wrapped pass. +- **Performance.** Baseline layout adds a second measurement pass for baseline rows in Yoga; rows + are tiny so this is negligible, but note it. + +## 9. Test plan +- **Host/unit (`tests/Comet.Tests/Backend/…`):** a fake backend node returning a known + `MeasureBaseline`; assert that two children of different heights are positioned so + `top_i + baseline_i` is equal across the row (pure engine math, no device). +- **Instrumented (Android):** render the 16sp/12sp pair, screenshot, measure the two baselines + (the harness already does this — see the pixel-baseline measurement used to find the original 2px + error); assert ≤0.5px. Repeat for a 22sp/14sp pair to prove it generalizes. +- **iOS:** same screenshot-measure on the simulator. + +## 10. Rollout +1. Engine plumbing + host unit tests (no rendering) — make `FlexAlign.Baseline` reachable end-to-end + with a fake baseline func. +2. Compose `MeasureBaseline` + deterministic line height; instrumented test; convert Jetchat; delete + the nudge. +3. SwiftUI `MeasureBaseline`; iOS verification. +4. Doc the `AlignBaseline()` API in `controls.md`/`styling.md`. + +## Appendix: the original failing measurement (for regression context) +Pixel 5, Jetchat header, bottom-aligned (pre-nudge): author baseline y=519, timestamp y=521 → +timestamp **2px low** (it has less descender+leading below its baseline than the larger name). The +`0.5dp` nudge brought it to within 1px but is font-size-pair-specific. This spec removes the need +for any nudge. diff --git a/src/Comet/docs/jetcaster-parity-backlog.md b/src/Comet/docs/jetcaster-parity-backlog.md new file mode 100644 index 000000000..66f18b339 --- /dev/null +++ b/src/Comet/docs/jetcaster-parity-backlog.md @@ -0,0 +1,153 @@ +# Jetcaster (M4) — parity backlog + +Gold: ~/work/compose-samples/Jetcaster — **mobile/ + core/ only** (tv/wear/ +glancewidget out of scope per plan). Survey 2026-07-15 (file:line citations +below are into that tree). Gold mobile-debug.apk built and ready for captures. + +## App shape (values-from-source pointers) + +- **Flat 2-destination NavHost** (JetcasterApp.kt:51-80): `home` and + `player/{episodeUri}` — NOT a bottom-nav app. Podcast details is a + **SupportingPaneScaffold supporting pane inside Home** (Home.kt:249), keyed + by podcast URI, with a custom `calculateScaffoldDirective` (Home.kt:132-172: + single pane when width OR height compact; 2 panes at expanded; tabletop → + vertical partitions). All wrapped in `SharedTransitionLayout`. +- **Home** (Home.kt:362-441): radial-gradient background scrim → transparent + Scaffold; top bar = M3 **SearchBar** (decorative, never expands — + Home.kt:313) + LinearProgressIndicator while refreshing; content = ONE + **LazyVerticalGrid(Adaptive(362dp))** switching Library/Discover on a + bottom-center **HorizontalFloatingToolbar** pill (Home.kt:445-527). + - Library: **HorizontalMultiBrowseCarousel** of followed podcasts + (Home.kt:659) + "Latest Episodes" list. + - Discover: LazyRow of **FilterChip** category tabs (Discover.kt:96-118) + + per-category **HorizontalUncontainedCarousel** (PodcastCategory.kt:127) + + episode list. +- **Episode rows** (EpisodeListItem.kt): Surface card + ripple, image shared + element, **SwipeToDismissBox** swipe-to-remove (:79), HTML summary via + HtmlTextContainer (:248). +- **PodcastDetails** (supporting pane; PodcastDetailsScreen.kt): LazyVerticalGrid + header (280dp PodcastImage, expandable description, subscribe/notify + **ButtonGroup of ToggleButtons** :291) + episode items. +- **Player** (PlayerScreen.kt): image-backed background with color scrim + (:193 — surface@0.9 over the Coil image), vertical gradient scrims + (:298/:358/:436), shared-element PlayerImage, **basicMarquee** title (:621), + **Slider** (:696), play/pause **ToggleButton with morphing shapes** (:722) + + ButtonGroup of prev/-10/+10/next IconButtons; fold-aware tabletop/book + layouts via Accompanist TwoPane (:254-334). +- **Theme**: `MaterialExpressiveTheme`, and mobile ALWAYS uses the static + **darkScheme** (Theme.kt:478; dynamicColor param never true). Fonts: + Montserrat (already bundled for Jetsnack) + **RobotoFlex variable** (display + styles, e.g. displayLarge 64sp weight 738 — nearest static weight is the + documented approximation). + +## Data / player (app-layer plan) + +- Gold = **live RSS**: 18 hard-coded feeds (Feeds.kt:27-47), OkHttp + ROME + parse incl. iTunes DTD module (PodcastFetcher.kt:54-157), Room persistence + (5 entities), Coil artwork. **No artwork→theme extraction** (scrims only). +- Gold player is a **MOCK TICKER** (MockEpisodePlayer.kt:88-112 — coroutine + delay loop advancing timeElapsed; media3 is in the version catalog but + unreferenced). Comet's player will be the same mock — that IS parity. +- **Comet plan (per approved M4 shape)**: C# feed pipeline with TWO modes — + - **fixture mode** (default for smokes/pixel work): bundled snapshot of a + few real feed XMLs + bundled artwork, mirroring core/domain-testing + PreviewData (PreviewData.kt:27-72) so renders are deterministic offline; + - **live mode**: HttpClient fetch of the same 18 URLs, System.Xml parse + (RSS 2.0 + iTunes namespace: image, summary, duration, categories). + - Persistence: in-memory stores first (the gold's Room is an offline cache, + not observable behavior); revisit only if a screen needs it. +- **Image v2 decision point** (plan): remote artwork = C#-side download cache + + placeholder/crossfade on the existing Image node — bias C# cache over + binding Coil, keeps iOS symmetric. Fixture mode sidesteps it for pixels; + live mode exercises it. + +## Capability classification + +**Bound in the facade already (unwired → needs Comet nodes/controls):** +LazyVerticalGrid, HorizontalMultiBrowseCarousel, HorizontalUncontainedCarousel, +SearchBar (+Docked/Top variants), FilterChip, Slider, Linear/Circular +ProgressIndicator, TopAppBar, Snackbar/SnackbarHost, AlertDialog (Comet +control exists), IconButton (backlogged promotion from M3). + +**Facade-missing (JNI binding work):** HorizontalFloatingToolbar (expressive), +ButtonGroup, plain ToggleButton + ToggleButtonShapes (morphing), +SwipeToDismissBox, basicMarquee modifier. + +**Comet-side capabilities:** +- SupportingPaneScaffold → map onto the existing ListDetail/NavigationSuite + adaptive primitives (custom directive ≈ variantFor policy); assess whether + the real M3-adaptive widget is warranted or the gold's custom directive + makes hand-mapping faithful (it computes its own directive — same bar as + Jetsnack's hand-composed chrome). +- **GradientSpec increment: Radial** — Home background radialGradientScrim + (GradientScrim.kt:44) and ImageBackgroundRadialGradientScrim + (ImageBackground.kt:43, + BlendMode.Multiply). Spec v2 (b4a0fb30) covers + vertical scrims (per-stop alpha precomputes the exponential decay of + GradientScrim.kt:72-138); radial is the next Direction/Kind value. +- Image v2 (above). HTML→FormattedText mapping for episode summaries. +- Shared-element transitions: same M3 decision — bounds-matched fallback, + documented deviation (plan's 3-day timebox already spent in M3 decision). +- iOS twins: carousels → shim horizontal list w/ item sizing; floating + toolbar → hand-composed pill (SwiftUI has no twin); marquee → static + truncation deviation or manual animation. + +## Deviations declared up front + +- Mock player = gold behavior (not a deviation — the gold has no audio). +- Marquee, morphing ToggleButtonShapes, shared elements: approximate/static + first pass, polish second pass (pre-agreed descope pattern). +- Fold postures (tabletop/book TwoPane): WindowSizeClass adaptivity IS in + scope; hinge-specific postures are OUT per plan (foldable-hinge specifics + excluded). +- displayLarge RobotoFlex weight 738 → nearest bundled static weight. + +## Milestone checklist state + +- [x] Source survey (this doc) +- [x] Gold mobile-debug.apk built +- [x] Gold captures, first set 2026-07-15 (emulator, live feeds): + compact-01-discover / 02-podcast-details / 03-player in + sample/Shared/Jetcaster/gold/. Capture notes: the floating + Library|Discover toolbar renders TOP-docked overlapping the status bar + on this build (survey said bottom-center — verify against source when + building it) and its Library half sits under the status clock, so the + library view + expanded/two-pane + offline-dialog captures are PENDING + (grab during build phase; details pane already proves the pane nav). + Player screen: episode CARD opens the player; the row's play button + only toggles mock playback in place. +- [x] Fixture feed snapshot (`30230406`) + C# pipeline (`03f3ca5c`): models, + RSS+iTunes XDocument parser (host-verified on all 6 fixtures), in-memory + PodcastStore, MockEpisodePlayer ticker; fixtures wired into both probes + (Android assets + iOS bundle root). Live mode = later increment. +- [x] Framework (`a92229d9`): radial GradientSpec; ListView grid + (LazyVerticalGrid Adaptive) + M3 carousel flavors; FilterChip control + (real widget + iOS twin); SearchBar control already existed (Reply). +- [x] Home screen (`0bdaef8e`): Discover + Library on the floating pill, + verified interactive on Pixel 5. Known follow-ups: full-span grid + headers (span API unbound), HorizontalFloatingToolbar binding, Library + empty state, chip-label color nuance, reload granularity (whole-grid + ReloadData per chip tap ANRs the 3GB AVD; fine on hardware — the M3 + reload-storm backlog item now has a concrete heavy consumer). +- [ ] Screens remaining: PodcastDetails pane → Player (Home DONE) +- [ ] Smokes both platforms; standalone app id; RESULTS row (+ Pixel 5 B2 + with Jetsnack per plan); /code-review +- [ ] Canvas feasibility spike (1 day, for M5 JetLagged) DURING this sample + +## /code-review round 1 (417bdcc7..HEAD) — outcome 2026-07-16 + +11 verified findings, ALL FIXED (`022829a2` framework, `f9919ac8` sample): +iOS FilterChip measured slots before Arrange materialized them (EnsureContent +on the hosted base now guarantees it) and never Refresh()ed on state patches; +ComposeListNode flavor precedence unified + carousel 0-width fallback + the +scroll-state bridge no longer observes a state the grid can't attach +(rememberLazyGridState binding BACKLOGGED); iOS border gradients honor +spec.Direction; GradientSpec extent works on Vertical (Diagonal/Radial +documented); RobotoFlex loads lazily; MockEpisodePlayer duration/threading/CTS +hygiene; NEW IconButton control (real M3 widget both platforms) replacing the +episode-card look-alikes. Verified: host 936, jetsnack 24+24 both platforms. + +Deferred cleanup themes (from the review's cleanup angles): one-row-ListView +reload idiom → framework reactive-chrome primitive (3+ copies); per-sample +ComposeScheme()/icon-map/typography boilerplate consolidation; gradient-over- +solid on ONE node (the two-layer ZStack idiom will spread to player/details); +grid full-span headers; sample registry for MainActivity's 3-site growth. diff --git a/src/Comet/docs/jetchat-parity-backlog.md b/src/Comet/docs/jetchat-parity-backlog.md new file mode 100644 index 000000000..cbd910ced --- /dev/null +++ b/src/Comet/docs/jetchat-parity-backlog.md @@ -0,0 +1,240 @@ +# Jetchat → Comet parity backlog + +Purpose: use Google's **Jetchat** (android/compose-samples, the gold standard) to find and fill +gaps in the **Comet node backend** (Jetpack Compose on Android). This is not about imitating the +sample — each item below is a **Comet capability** the sample happens to exercise. + +**Method (important):** screenshots identify *what* differs (missing features, layout structure); +every **color / size / spacing / lineHeight / weight** value is taken from **source**, never +eyeballed or color-picked. Source root: `~/work/compose-samples/Jetchat/app/src/main/java/com/example/compose/jetchat` +(checkout `d3ff757b`). Citations below are `file:line`. + +**Status (2026-06-14):** ✅ §0 value corrections (`d2743458`), ✅ **T1 Material You dynamic color** +(`2a7e805b`), ✅ **T3 explicit line height** (`` — `View.LineHeight(sp)` → bubbles get +bodyLarge 16/24; letterSpacing deferred, facade `Sp` integer-only + negligible). All verified Pixel 5. +**Next: C1.** Facade is ready (`LazyColumn.State`/`.ReverseLayout`, `LazyListState.CanScrollForward`/ +`AnimateScrollToItemAsync`, `composer.RememberLazyListState`) — no facade change. C1 design: +(1) `ListView` exposes a `Signal` "scrolled-away" + a `ScrollToBottom()`; (2) `ComposeListNode` +remembers a `LazyListState`, reads `CanScrollForward` (boundary-triggered, so no per-frame recompose → +no scroll-jank regression) and marshals it to the signal, and runs `AnimateScrollToItemAsync` on the +remembered coroutine scope; (3) the JumpToBottom `ExtendedFloatingActionButton` (surface/primary, +h36, `ic_arrow_downward` 18dp) overlays via ZStack. **Sub-dependency discovered: node-level REACTIVE +VISIBILITY** (show/hide the button from the signal without re-running the whole tree) — currently the +conversation tree is static; needs a `.Visible(signal)`/opacity node property or AnimatedVisibility. +That's the real capability C1 unblocks (and it generalizes). +✅ **D1 drawer structure** (``): "Chats" + leading logo pills + "(you)" + Settings, verified. +✅ **C1 scroll-state + reactive visibility** (``): `ComposeNode` now honors `Opacity`/ +`IsVisible` → `Modifier.Alpha` + skip-clickable-when-faded (the **reactive-visibility capability**); +`View.Backend` emits `Opacity` whenever explicitly set (even =1) so a toggle back to default reaches +the node. `IListView` gained a scroll bridge (`ScrolledAway` signal + `RegisterScroller`/`ScrollToBottom`); +`ComposeListNode` remembers a `LazyListState`, reads `CanScrollForward` in composition (boundary- +triggered) → marshals to `ScrolledAway`, and registers an `AnimateScrollToItemAsync(last)` scroller. +The Jetchat **JumpToBottom** FAB binds its opacity to `ScrolledAway` and taps → `ScrollToBottom()`. +Verified Pixel 5: fades in at top / on scroll-up, animates to bottom on tap, fades out at the bottom. +**Follow-up C1a:** the FAB is a styled Comet view (like the profile FAB); a real +`ExtendedFloatingActionButton` *control* (Comet view → facade node) is deferred (same lift as the +I-cluster "real widget" work). Box child `align`/`fillMaxSize` for the overlay handled by the existing +ZStack engine path (no new unknowns). ✅ Follow-up (D1 color) DONE: the drawer chat-icon is now the +jetchat logo TINTED monochrome (`onSurfaceVariant`, or `primary` when selected). `ComposeIconNode` +routes a multicolor asset through the tinted Icon path WHEN an explicit `.Color()` is set (else the +untinted Image path keeps its colors), so the channel-bar logo stays multicolor (dropped its no-op +`.Color`) while the drawer rows tint. Verified Pixel 5. + +**Reframing finding:** the captured screenshots are rose/maroon because Jetchat runs +`JetchatTheme(isDynamicColor = true)` → `dynamicLightColorScheme(context)` (`theme/Themes.kt:91`), +i.e. **Material You from the wallpaper**. The blue scheme in our code is only the static fallback +(`JetchatLightColorScheme`, `theme/Themes.kt`). So do not "match" the rose pixels — implement the +capability and use the static tokens as the canonical values. + +--- + +## 0. Value corrections to existing code (bugs from earlier screenshot-sampling) + +These are wrong *values* in `sample/Shared/Jetchat/JetchatTheme.cs`; the widgets are already correct. + +| Our current value | Correct (source) | Source | +|---|---|---| +| `TertiaryContainer = #F8D8F0` (FAB bg, pink) | `tertiaryContainer = Yellow90 = #FFDE9C` | `theme/Themes.kt` `JetchatLightColorScheme`; `theme/Color.kt` | +| `OnTertiaryContainer = #3A2A33` | `onTertiaryContainer = Yellow10 = #261900` | same | +| `SurfaceTinted #E7E9F8` used for the **header** | header is `CenterAlignedTopAppBar` → default container = **`surface` #FBFDFD** (no tint) | `components/JetchatAppBar.kt:46` | +| same hex used for the **footer** | footer = `Surface(tonalElevation = 2.dp)` → **computed** tonal overlay; selector panel = `8.dp` | `conversation/UserInput.kt:151,224` | +| footer action-icon tint ≈ primary | footer `Surface(contentColor = secondary)` → `DarkBlue40 #3648EA` | `conversation/UserInput.kt:151` | +| lineHeight = `round(sp × 1.3)` heuristic | **explicit** per style (see T3) | `theme/Typography.kt` | +| `LetterSpacing.Zero` everywhere | per style (see T3) | `theme/Typography.kt` | + +--- + +## Theming capabilities + +### T1 — Material 3 dynamic color (Material You) + dark mode ⭐ foundational +- **Source:** `theme/Themes.kt:91–110` — `JetchatTheme(isDarkTheme = isSystemInDarkTheme(), isDynamicColor = true)`; `dynamicLightColorScheme(context)` / `dynamicDarkColorScheme(context)` on API ≥ 31, else `JetchatLightColorScheme` / `JetchatDarkColorScheme`. +- **Comet gap:** consume a real Material `ColorScheme` provider (incl. the platform dynamic scheme) and respond to system dark mode; feed it through the existing `ComposeBackendRoot.WrapContent` theme hook. Today the app hardcodes a light scheme. +- **Canonical static tokens** (`theme/Themes.kt`, raw in `theme/Color.kt`): primary `Blue40 #1546F6`, onPrimary white, primaryContainer `Blue90 #DDE1FF`, secondary `DarkBlue40 #3648EA`, surface/background `Grey99 #FBFDFD`, onSurface `Grey10 #191C1D`, surfaceVariant `BlueGrey90 #E2E1EC`, onSurfaceVariant `BlueGrey30 #45464F`, tertiary `Yellow40 #7A5900`, tertiaryContainer `Yellow90 #FFDE9C`, onTertiaryContainer `Yellow10 #261900`, outline `BlueGrey50 #767680`. + +### T2 — Tonal elevation (`surfaceColorAtElevation`) +- **Source:** footer `Surface(tonalElevation = 2.dp)` (`conversation/UserInput.kt:151`); selector panel `Surface(tonalElevation = 8.dp)` (`:224`). +- **Comet gap:** compute the M3 tonal overlay (primary over surface at an elevation-derived alpha) instead of a flat sampled hex. Pairs with T1. + +### T3 — Type scale: explicit lineHeight + letterSpacing +- **Source:** `theme/Typography.kt` — replace our `1.3×` heuristic with exact values: + - headlineSmall: Montserrat SemiBold **24 / 32** + - titleMedium: Montserrat SemiBold **16 / 24**, ls 0.1 + - titleSmall: Karla Bold **14 / 20** + - bodyLarge: Karla Normal **16 / 24**, ls **0.15** + - bodyMedium: Montserrat Medium **14 / 20**, ls 0.25 + - bodySmall: Karla Bold **12 / 16**, ls **0.4** + - labelLarge: Montserrat SemiBold **14 / 20**, ls 0.1 + - labelMedium: Montserrat SemiBold **12 / 16**, ls 0.5 + - labelSmall: Montserrat SemiBold **11 / 16** +- **Comet gap:** carry explicit `lineHeight` + `letterSpacing` through the type system and the text measure/baseline path (bodyLarge is **24**, not 20.8 — this shifts bubble + profile line spacing and the baseline math). Families/weights/sizes already match. + +--- + +## Conversation screen (`conversation/Conversation.kt`, `JumpToBottom.kt`) + +### C1 — Jump-to-bottom button ✅ (unlocked the scroll-state + reactive-visibility capabilities) +- **Source:** `JumpToBottom.kt` — `ExtendedFloatingActionButton`, icon `ic_arrow_downward` (height 18dp), text `R.string.jumpBottom` ("Jump to bottom"), `containerColor = surface`, `contentColor = primary`, height **36dp**, visibility via `updateTransition` + `animateDp` offset between **-32dp** (gone) and **32dp** (visible). Trigger: `firstVisibleItemIndex != 0 || firstVisibleItemScrollOffset > 56.dp` (`Conversation.kt:329–336`, `JumpToBottomThreshold = 56.dp:563`), `align(BottomCenter)`. +- **Comet gap:** expose `LazyListState` (first-visible index/offset) + `scrollToItem(0)` from `ListView`/`ComposeListNode`, plus animated show/hide. Today our list has no scroll awareness. + +### C2 — reverseLayout + auto-scroll on send ◑ (auto-scroll-on-send done; reverseLayout pending) +- **Source:** `Messages(...)` uses a `LazyColumn(reverseLayout = true)` (newest at bottom); `UserInput(resetScroll = { scope.launch { scrollState.scrollToItem(0) } })`. +- **Done (with I5, ``):** Send appends a "me" `MsgRow`, `ListView.ReloadData()` (now also drives the node backend via `UpdateBackendNode` → re-emit `List_Version` → recompose) rebuilds the LazyColumn, and `ScrollToBottom()` (C1 scroller) animates to the new message; the field clears via the two-way `InputText` signal. +- **Pending:** `reverseLayout` (open at the newest message instead of the top) — needs the list to expose `ReverseLayout` + open scrolled-to-bottom. + +### C3 — Date day-headers by real date +- **Source:** `Conversation.kt:300–306` emit `DayHeader("20 Aug")` and `DayHeader("Today")`; `DayHeader` (`:445`): row of `DayHeaderLine` + Text `labelSmall`, `onSurfaceVariant`, `padding(vertical=8, horizontal=16).height(16)`; divider color `onSurface.copy(alpha=0.12f)` (`:463`). We hardcode a single "Today". + +### C4 — Author avatar double-ring (verify ours matches) +- **Source:** `Image(...).padding(horizontal=16).size(42).border(1.5.dp, borderColor, CircleShape).border(3.dp, surface, CircleShape)`; `borderColor = primary` (me) / `tertiary` (others) (`Conversation.kt` Message ~357–397). Non-first-in-group rows use a `Spacer(width = 74.dp)` gutter (16+42+16). Group spacing: `padding(top = 8.dp)` between authors; within a group `Spacer(height = 8.dp)` then `4.dp`. +- **Already matches** (commit f3633806 FlexShrink + rings); re-confirm the **two** borders (1.5dp color + 3dp surface gap) and the `tertiary` ring for others (now `Yellow40`, was sampled). + +### Already faithful (for reference, do not redo) +- ChatItemBubble: me = `primary`, others = `surfaceVariant`; shape `RoundedCornerShape(4,20,20,20)`; text `padding(16)`; image attachment `size(160)`, `Spacer(4)` (`:473–508`). +- AuthorNameTimestamp: name `titleMedium` `paddingFrom(LastBaseline, after=8.dp)`, `Spacer(8)`, timestamp `bodySmall` `alignBy(LastBaseline)` `onSurfaceVariant` (`:422–443`) — our `.AlignBaseline()` covers this. +- ChannelNameBar: title `titleMedium`, "N members" `bodySmall`/`onSurfaceVariant`; search/info `Icon` tint `onSurfaceVariant` `padding(horizontal=12, vertical=16)` (`:222–278`). Nav icon = `JetchatIcon` `size(64).padding(16)` (`JetchatAppBar.kt`). + +--- + +## Input bar (`conversation/UserInput.kt`) — the largest cluster + +### I1 — InputSelector state machine + expandable panel ⭐ +- **Source:** `InputSelector` enum `{ NONE, MAP, DM, EMOJI, PHONE, PICTURE }` (`:113`); `UserInput` (`:135`) tracks `currentInputSelector`; `SelectorExpanded` (`:212`) shows a `Surface(tonalElevation = 8.dp)` (`:224`) panel below the input that swaps content by selector; selector row `UserInputSelector` `height(72).padding(start=16,end=16,bottom=16)` (`:265`). +- **Comet gap:** stateful composite input with an expandable selector region. + +### I2 — Selected action-icon state +- **Source:** `InputSelectorButton` (`:345`): when selected, `Modifier.background(color = LocalContentColor.current, shape = RoundedCornerShape(14.dp))`, icon tint `contentColorFor(LocalContentColor)`; `IconButton`, icon `padding(8).size(56)`. Icons: `ic_mood` (EMOJI), `ic_alternate_email` (DM), `ic_insert_photo` (PICTURE), `ic_place` (MAP), `ic_duo` (PHONE). +- **Note:** the maroon "teardrop" above a selected icon in the screenshots is the **OS text-cursor drag handle** (the field was focused), **not** a Jetchat element — do not build it. + +### I3 — Emoji selector (EMOJI/STICKER tabs + emoji table) +- **Source:** `EmojiSelector` (`:570`) with `ExtendedSelectorInnerButton` tabs (`:609`); `EmojiTable` (`:633`) = `Column(fillMaxWidth) { repeat(4) { Row(fillMaxWidth) { repeat(EMOJI_COLUMNS) { Text(emoji, clickable, textAlign=Center, Modifier.weight(1f)) } } } }`. +- **Comet gap:** *minor* — this is **weighted Rows, not a Grid** (Yoga `FlexGrow` already supports it). Real needs: a tab row + insert-emoji-at-cursor (see I7). (No `LazyVerticalGrid` required — earlier assumption corrected by reading source.) + +### I4 — Dialog / AlertDialog on the node backend ✅ (``) +- **Source:** `NotAvailablePopup` (`:381`) = Material `AlertDialog` "Functionality not available 🙊" + CLOSE (`UiExtras.kt` `FunctionalityNotAvailablePopup`); `FunctionalityNotAvailablePanel` (`:237`) = "Functionality currently not available / Grab a beverage and check back later!" (panel, not dialog). +- **Done:** new Comet `AlertDialog` control (`Controls/AlertDialog.cs`: `Signal IsOpen` + Text/ConfirmButton/Title?/DismissButton? slot Views) → `ComposeAlertDialogNode` renders the real Material 3 `AlertDialog` (own window + scrim) only while open, materializing the slot Views and dropping them into the facade `ConfirmButton`/`Text`/`Title`/`DismissButton` slots (laid out by Material, no Yoga). `Dialog_IsOpen` prop (224) + `DialogDismissed` event (12), wired exactly like Drawer (signal→node, onDismissRequest→event→IsOpen=false). `IBackendManagesOwnContent` so it's a zero-size leaf in its parent layout — can sit anywhere. iOS = empty placeholder node (SwiftUI `.alert` deferred, Android-first). Triggered faithfully from the DM "@" selector (`InputSelector.DM -> NotAvailablePopup`). Verified Pixel 5: opens on "@", dismisses via CLOSE button AND scrim tap. **Follow-up I4a:** `FunctionalityNotAvailablePanel` (the inline panel, not the dialog) + a `TextButton` variant for CLOSE (currently a plain styled Button). + +### I5 — Send button reactive enabled/disabled ✅ (``) +- **Source:** `Button` `height(36)`, `enabled = sendMessageEnabled`, `border = if(!enabled) BorderStroke(1.dp, onSurface.copy(0.3f)) else null`, `contentPadding = PaddingValues(0)`, `colors = ButtonDefaults.buttonColors(disabledContentColor = onSurface.copy(0.3f))`, Text `padding(horizontal=16)` (`:265` block). Empty → outlined/transparent; text present → filled `primary`. +- **Done:** the composer text is a two-way `Signal InputText` (bound via `SignalExtensions.TextField`); the Send button restyles reactively from it (subscribe `PropertyChanged` → `send.Outlined(false).Color(OnPrimary)` filled when text present, `Outlined(true).Color(Disabled)` bordered-grey when empty). Added `Button.Outlined(bool)` + made `Button_Outlined` emit both ways (the set-only patch would otherwise leave it stuck outlined — same fix as Opacity). Verified Pixel 5: empty=outlined/grey, type→filled primary, send→clears→back to outlined. + +### I6 — Voice record (mic): long-press + drag + animated overlay +- **Source:** `RecordButton.kt`; `RecordingIndicator` (`UserInput.kt:505`) — press-and-hold record, swipe-to-cancel, pulsing dot. +- **Comet gap:** long-press + drag gestures + animated overlay. Larger; later. + +### I7 — TextField: cursor/selection + focus/IME coordination +- **Source:** `UserInputText`/`UserInputTextField` (`:391–503`), `height(64)`, text `padding(start=32)`, `imeAction = Send`; emoji insert uses `TextFieldValue.addText` at the selection (`:197`); opening the emoji panel hides the keyboard and vice-versa. +- **Comet gap:** `TextFieldValue` selection/caret + focus/IME control. Feeds I3 (insert at cursor) and I1 (keyboard ⇄ selector). + +--- + +## Navigation drawer (`components/JetchatDrawer.kt`) + +### D1 — Drawer structure parity +- **Source:** `JetchatDrawerContent` (`:65`): `DrawerItemHeader` "Chats" / "Recent Profiles" / "Settings" (`heightIn(min=52).padding(horizontal=28)`, `bodySmall`/`onSurfaceVariant`, `:117`). + - `ChatItem` (`:133`): `height(56).padding(horizontal=12).clip(CircleShape)`; selected → `background(primaryContainer)`; leading `Icon` (jetchat logo) tint `primary` (selected) / `onSurfaceVariant`, `padding(start=16,top=16,bottom=16)`; label `bodyMedium`, `primary`/`onSurface`, `padding(start=12)`. Two chats: "composers" (selected), "droidcon-nyc". + - `ProfileItem` (`:174`): same 56dp pill; avatar `Image size(24).clip(CircleShape)` (or `Spacer` if none), `padding(start=16,top=16,bottom=16)`; label `bodyMedium`/`onSurface`. "Ali Conors **(you)**", "Taylor Brooks". + - `WidgetDiscoverability` (`:246`) under "Settings": "Add Widget to Home Page". +- **Comet gap:** mostly content/structure (section header "Chats" not "Channels", leading chat icons, `(you)` suffix, dynamic `primaryContainer` selected pill). Our drawer is close but differs in section name + per-chat icons. + +--- + +## Profile (`profile/Profile.kt`) — mostly done; deltas + +### P1 — FAB float position + dynamic color +- **Source:** `FloatingActionButton` `padding(16).align(BottomEnd).offset(y = -100.dp).height(48).widthIn(min=48)`, `containerColor = tertiaryContainer`, icon `ic_create` (me, "Edit Profile") / `ic_chat` (other, "Message") (`:113,233–245`). +- **Delta:** ours uses `Margin(bottom: 24 + inset)`; source is `padding(16) + offset(y = -100.dp)` (≈116dp lift). FAB color falls out of T1 (`tertiaryContainer`). + +### P2 — Parallax photo header +- **Source:** `ProfileHeader` (`:178`): `Image.heightIn(max = containerHeight/2).fillMaxWidth().padding(start=16, top=offsetDp, end=16).clip(CircleShape)`, `ContentScale.Crop`; `offset = scrollState.value / 2` (`:179`) → the photo scrolls up at half speed. +- **Comet gap:** scroll-offset-driven layout (shares C1 scroll-state capability). + +### Already faithful (commit 5ae5833b) +- `UserInfoFields`: `Spacer(8)`, `NameAndPosition`, 4×`ProfileProperty` (`:120`). `NameAndPosition` `Column(padding horizontal 16)`, Name `baselineHeight(32)`, Position `padding(bottom=20).baselineHeight(24)` (`:144`). `ProfileProperty` `Column(padding start=16,end=16,bottom=16)`, Label + value `baselineHeight(24)`, link = `primary` (`:203`). + +--- + +## App / navigation +- **Source:** single-Activity `NavHost`; drawer hosts conversation + profile; `AnimatedContent` transitions. +- **Comet gap (minor):** animated nav transitions (we have push/pop; commit ca096220). + +--- + +## Parked (need framework infrastructure that isn't built yet) +- **I1/I2/I3 — InputSelector state machine + expandable selector panel + emoji table.** The panel + appears below the input and swaps content by selector (EMOJI table / "not available" panel), with + the active icon highlighted. The icon highlight is reactive-property (doable), but the **panel's + appear/collapse + content-swap is a structural reactive change** — clean only via root-Component + structural re-render (**task #28**, still open) or a new conditional-content "expandable panel" + node (the AlertDialog/Drawer pattern: always in the tree, renders the active panel from a + MutableState). Parked to avoid a half-built unverifiable state unattended. The DM selector already + shows the real `NotAvailablePopup` (I4). +- **P2 — parallax profile photo.** Needs a **ScrollView scroll-offset bridge** (the C1 pattern, but + for `ComposeScrollNode`'s `ScrollState.Value` → drive the photo's translation at half speed). + Buildable; parked as a lower-value flourish. +- **I7 (advanced) — TextField caret/selection + IME action + insert-at-cursor.** Basic input + send + + clear works (I5); the advanced `TextFieldValue` selection/IME coordination is facade-deep and feeds + I1/I3 (insert emoji at cursor). Parked with I1. +- **I6 — voice record (mic).** Long-press + drag + animated overlay. Largest input item; later. + +## Out of scope (not a Comet in-app gap) +- **"Add Widget to Home Page"** launches a **Glance home-screen App Widget** (`widget/`), an OS/launcher feature, not in-app UI. N/A for Comet UI parity. + +--- + +## Suggested sequencing +`T1 (dynamic color)` + `0 (value fixes)` → `T2 / T3 (tonal + type values)` → `C1 (scroll state)` → +`I4 (dialog)` + `I5/I7 (textfield/send)` → `I1/I2 (selector)` → `I3 (emoji)` → +animation → `I6 (voice)`. `C3 / D1 / P1` are quick wins anytime. The framework-capability builds are +**T1, T2, C1, I4, I7**; most other items compose from those. + +--- + +## Addendum 2026-07-01 (autonomous run) + +- **I7 / P4 caret+IME: DONE** (`210ede7c`) — `BasicTextField(TextFieldValue)` bound; + the composer tracks the caret, the emoji table inserts AT the caret (gold + `addText`), typing echo never resets the caret. Deferred: `reverseLayout` (C2 + remainder), TextFieldValue composition/IME-region styling. +- **P7 IME insets: DONE** (`7c592914`) — Android-15 edge-to-edge makes AdjustResize + a no-op; ComposeBackendRoot now observes the decor-level IME inset + (ViewCompat) + AdjustNothing and reflows to `AvailableSize`. Composer + + selector row sit flush above the keyboard; restore on dismiss. +- **Record-dot pulse: DONE** (`24f6ddcf`) — Comet's own animation engine now runs on + the node backends (ChoreographerTicker / DisplayLinkTicker); the gold's + infiniteRepeatable alpha pulse is `recordDot.Animate(repeats, autoReverses)`. +- **iOS F3 seed-scroll: DONE** (`01192c99`) — conversation opens at the newest + message on iOS. +- Remaining engine-level deviations: `AnimatedContent`/`updateTransition` + facade bindings (nav transitions, panel crossfades), reverseLayout, + structural-insert re-materialization on reload, `_rowCache` LRU. + + +## Known visual bug (found 2026-07-09, PRE-EXISTING — verified present at 56f5deae) +- **Android: long message bubbles' TEXT overflows the bubble box** (right edge — + the FormattedText/Text ink wraps at a wider width than the Yoga box it gets, + clipping at the screen edge; the bubble BACKGROUND is correctly at + screen−16dp). iOS wraps correctly. Suspect: the Compose text node's + measure-vs-render width mismatch (MeasureRuns/MeasureWrapped constraint vs + the rendered composable's width). Repro: Jetchat conversation, John Glenn's + long messages. Surfaced during the Reply close-out visual inspection — + smokes never caught it (existence-based asserts). diff --git a/src/Comet/docs/jetnews-parity-backlog.md b/src/Comet/docs/jetnews-parity-backlog.md new file mode 100644 index 000000000..a970a92bb --- /dev/null +++ b/src/Comet/docs/jetnews-parity-backlog.md @@ -0,0 +1,197 @@ +# JetNews parity backlog (M2) + +Gold standard: `~/work/compose-samples/JetNews` (Kotlin). Gold screenshots: +`sample/Shared/JetNews/gold/` (self-captured 2026-07-09, Pixel 9 Pro API 36 — +compact home/scrolled/drawer/interests×3/article×2; 700dp rail; 1260dp +rail+two-pane "Select a post"). Method per `docs/sample-workflow-checklist.md`. +Glance widget + deeplinks OUT of scope per plan. + +App shape: 2 top-level routes (Home, Interests) behind a **ModalNavigationDrawer** +(`JetnewsApp.kt:62`, gesturesEnabled only when NOT expanded :74); expanded adds +**AppNavRail** (`:77-81`) and Home becomes **list-detail** (`navigation/ +ListDetailScene.kt` — "Select a post" placeholder pane). Article opens +full-screen compact / detail-pane expanded. All data static in-memory +(`data/posts` fakes); post images are bundled drawables. + +## 1. Screens + +### Home (`home/HomeScreens.kt`, `home/PostCards.kt`, `home/PostCardTop.kt`) +- App bar: drawer icon (terminal-prompt brand icon), centered lowercase + "jetnews" wordmark image, search icon. Compact only; expanded home shows a + **Search posts** OutlinedTextField at the top of the list pane instead. +- Sections: "Top stories for you" (PostCardTop: full-width image 16:9ish, + headline, author, date+read-time) · simple rows (PostCardSimple: 40dp + thumbnail, title, author-readtime, bookmark toggle icon) · "Popular on + Jetnews" **horizontal card carousel** (PostCardPopular: elevated cards w/ + image top) · history rows with "BASED ON YOUR HISTORY" overline + overflow + menu. Dividers between rows. +- Interactions: row tap → article; bookmark toggle (state in viewmodel); + pull-to-refresh (fake refresh); snackbar on refresh error (JetnewsSnackbarHost). + +### Article (`post/PostScreen.kt`, `post/PostContent.kt` — THE typography lift) +- Top bar: back arrow + "Published in: " w/ 36dp logo image. +- Content LazyColumn (`PostContent.kt:99-110`): header image, title h4, + subtitle, author+avatar row, then **rich paragraphs** (`:159-197`): + ParagraphType Title/Subhead/Text/Header/CodeBlock/Quote/Bullet — styled via + `getTextAndParagraphStyle()`; **AnnotatedString runs** (bold/italic/code/link + markups from `model/Paragraph`); CodeBlock = surface-tinted background block; + Quote = styled italic; Bullet = custom ParagraphStyle w/ bullet glyph. + → Comet: FormattedText runs exist; needs per-paragraph style mapping, + code-block background, bullet rendering. Line-height/serif type (Domine + headers + Montserrat body — `theme/Type.kt`, verify). +- Bottom action bar (compact): thumbs-up, bookmark, share, text-settings icons. +- Expanded: article renders in the detail pane, top bar hidden (list-detail). + +### Interests (`interests/InterestsScreen.kt`) +- Tabs: Topics / People / Publications (M3 TabRow, primary indicator). +- Topics = sectioned list (section header + rows: 56dp image placeholder, + name, **SelectTopicButton** = round add/check toggle w/ border). People / + Publications = flat lists, same row shape. +- Selection state per row (checked ⇒ filled check icon), persists in VM. + +### Drawer (`ui/AppDrawer.kt`) +- Brand row (icon + wordmark), NavigationDrawerItems Home/Interests with + selected pill — same shape as Reply's modal drawer (reuse NavigationSuite + drawer variant OR standalone Drawer; JetNews has NO bottom bar — chrome = + drawer (compact) / rail (expanded), so NavigationSuite needs a + **drawer-primary compact variant** or this app drives Drawer directly). + +## 2. Adaptive (`JetnewsApp.kt:55-81`, `navigation/ListDetailScene.kt`) +- `isExpandedScreen = widthSizeClass == Expanded` (≥840dp): rail + list-detail + home ("Select a post" empty pane), drawer gestures disabled. +- Compact/medium: modal drawer, single pane. (No bottom bar anywhere.) +- Comet: ListDetail primitive ✅ (Reply), rail ✅ (suite) — but chrome + composition differs from Reply (drawer-first, no bar): likely drive + Drawer + rail directly rather than NavigationSuite. DECIDE at build time. + +## 3. Capability classification (initial — verify each at build) +| Need | Class | +|---|---| +| ModalNavigationDrawer + items | WORKING (Reply suite drawer / Drawer control) | +| NavRail | WORKING (suite rail) — needs standalone-rail composition w/ labels | +| ListDetail two-pane + placeholder pane | WORKING (Reply) + "Select a post" empty state | +| LazyColumn lists + dividers | WORKING | +| Horizontal card carousel (LazyRow) | **verify** — ListView is vertical; needs LazyRow node or HStack-in-Scroll | +| Elevated Card w/ image | WORKING (Reply cards) + Image top slot | +| TabRow (3 tabs, indicator) | **bound-unwired?** — facade TabRow? verify; Jetchat had none | +| Rich text: styled paragraph runs | FormattedText runs (WORKING base) + paragraph-type styles | +| CodeBlock background / Quote / Bullet | sample-level composition over FormattedText | +| Bookmark/select toggle icon buttons | WORKING (icon + tap) | +| Pull-to-refresh | **verify** — facade PullRefresh? else defer w/ note | +| Snackbar | **bound-unwired?** — facade SnackbarHost? verify | +| OutlinedTextField (expanded search) | WORKING (TextField borderless variant? verify outlined) | +| Serif display font (Domine) + Montserrat | bundle fonts, register (Jetchat pattern) | +| Post images (drawables) | copy to both probes (Reply avatar pattern) | + +## 4. Known carryovers relevant here +- Android long-text bubble overflow (jetchat backlog) — the ARTICLE body is + paragraph-heavy; fix the Compose text measure/render width mismatch EARLY if + it reproduces on article paragraphs. +- Registry/agent: existence-based asserts — smoke asserts should tap through + real visible flows (system back on Android for article close if no + NavigationView). +- iOS gate: hand-composed chrome checklist (same-scale side-by-side pixel + measurement) applies to the drawer/rail/tabs this sample hand-composes. + +## 5. Next steps (in checklist order) +1. Deep source survey → fill file:line cites + values (this doc is the skeleton). +2. Framework gaps first, host-test-first: TabRow? LazyRow? Snackbar? + pull-to-refresh? (verify facade bindings before building controls). +3. Screens: Home → Article (typography) → Interests → drawer/rail chrome. +4. jetnews.android.sh as screens land; then iOS gate; then RESULTS row. + +## Status 2026-07-09 — compact-width core LANDED on Android (near-gold) + +Commits `bd797be8`…`31e64d6a`: data/theme/icons port, Home, Article, +Interests, drawer-first chrome, jetnews.android.sh (16/16). + +Framework capabilities added en route: +- `ListView.Horizontal` → real Compose `LazyRow` (popular carousel). +- `Text.LineBreak(Heading|Paragraph)` → real Compose `LineBreak` presets via + the Text/AnnotatedText `style` slot + matching StaticLayout measurement + (balanced+phrase / high-quality). iOS: no SwiftUI wrap-strategy API — + documented greedy deviation. +- `TabBar` → real M3 `PrimaryTabRow`+`Tab` (Interests switcher). +- `DrawerOpened` event — gesture-open now reflects into the open signal + (desync previously swallowed the next programmatic close). +- Surface-container tones derived from CorePalette.Neutral (mapper predates + the M3 container roles). + +Known deviations / follow-ups (compact): +- Wordmark + jetnews-logo + publication-badge VECTORS are stand-ins (styled + Text / menu glyph / android glyph on #073042). Bundle the gold drawables + (tinted-Icon resId path exists — the Jetchat footer approach). +- App bar does NOT collapse on scroll (gold: enterAlwaysScrollBehavior). +- Drawer sheet pills are hand-composed to NavigationDrawerItem metrics + (Drawer takes a free-form sheet); selected highlight is Peek-baked, not + reactive. +- Interests tab lists are ListViews (LazyColumn) vs gold Column+verticalScroll; + bullet dot is line-height-centered vs gold's baseline alignBy; history-row + 3-line phrase break differs on one title. +- Article favorites/bookmark bottom-bar actions are inert (gold shows an + unimplemented-dialog for most; bookmark toggle should wire to favorites). +- Scroll position resets on Article re-open (gold restores per-post state). + +Remaining for M2: expanded chrome (AppNavRail + list-detail + search field, +gold medium-700dp/expanded-1260dp), per-sample app id + icons +(`-p:CometSample=jetnews`), iOS gate (SwiftUI twins: horizontal list = +ScrollView(.horizontal)+LazyHStack design noted; TabBar twin; article), +RESULTS.md row, /code-review. + +## Status 2026-07-09 (later) — expanded chrome + standalone app + gold vectors LANDED + +Commits `ca09ce4b`…`bf775607`: +- Expanded chrome (gold expanded-1260dp): root rebuilt on NavigationSuite + (JetNews policy: None <840dp + modal drawer w/ REAL NavigationDrawerItems; + REAL NavigationRail ≥840 with selected-only labels) + ListDetail at ~1/3 + split (compact pushes article full-screen w/ system back; expanded shows + "Select a post" placeholder). Home list pane variant-aware via the suite's + new variant signal (app bar ↔ "Search posts" field). Smoke now 20/20 incl. + the 1260dp resize walk. +- Framework: NavigationSuiteVariant.None + variantFor policy + variantSignal; + railShowsSelectedLabel (alwaysShowLabel slot exposed on the bridge); + ListDetail.ListFraction; suite per-flush metrics recheck (resize could + strand the variant); Icon.IconFillFrame (non-square vectors). +- Standalone com.comet.sample.jetnews (-p:CometSample=jetnews, gold launcher). +- Gold vectors landed: logo/wordmark/badge everywhere (stand-ins gone). +- Drawer-item selected highlight now reactive (real NavigationDrawerItems). + +Still open for M2: app-bar collapse-on-scroll; expanded article chrome +(floating toolbar, no top bar — currently shows compact chrome in the pane); +rail items top-aligned vs gold centered; article bookmark action inert; +scroll-position restore; iOS gate (TabBar twin + horizontal list + +None-variant suite twin + article/interests screens); RESULTS.md row; +/code-review. + +## /code-review (2026-07-09, 8-angle fan-out on bd797be8~1..HEAD) — outcome + +FIXED (commits `cd69df98`, `ee35663b`): stale-article-on-destination-change; +FeedLists / article-shell subscription leaks; SwiftUIListDetailNode ignoring +ListFraction (iOS rendered 50/50); hosted-twin OnOwnerViewChanged never +dispatching (virtual on the base now); suite per-flush env-chain walk; JNI +TextStyle/LineBreakConfig rebuilt per render/measure (cached); compact app-bar +wordmark still the Text stand-in (replacement had silently missed); wrong +"inert in the gold too" search comment; no-op TrimIndent; dead topInset param. + +REPORTED, BACKLOGGED (not fixed): +- ~~REAL-widget promotions~~ **DONE 2026-07-10 (`4412942c`/`25700132`)**: + popular card → clickable M3 Card (View.AsCard()), bookmark → + IconToggleButton control, expanded search → OutlinedTextField + (TextField.Outlined().LeadingIcon()). All verified interactive on device. +- LineBreakValues reflection hardening (R8/mangling → Release crash risk; + current Release publish survives). Consider deriving packed values from + public Strategy/Strictness/WordBreak constants. +- MulticolorAssets hardcoded set → promote to a control-level .Multicolor() + property (both backends). +- TabBar styling via ctor tokens → migrate to the env styling system. +- iOS suite drawer only wraps the None variant (documented); DrawerOpen set + during Rail is swallowed/stale (also: reset the signal on variant switch). +- Text_LineBreak has no reset-to-Default path on a reused node (hot reload). +- Android horizontal ListView branch skips header/footer/scroll-state wiring. +- ListFraction unvalidated (0/1/out-of-range). +- TabBar backend hook + ContentSwitcher/NavigationSuite share the + subscribe-without-unsubscribe idiom on long-lived signals (framework-wide). +- Dedup nits: Tx() ×4, ToArgbUint/C() color conversions ×3, 48dp icon-button + composition ×6, JetNewsRoot derived signals (ContentSwitcher bool overload). +- Style base swap: LineBreak text uses TextStyle.Default (not LocalTextStyle) + as its base — benign while all slots are explicit; semantic trap noted. diff --git a/src/Comet/docs/jetsnack-parity-backlog.md b/src/Comet/docs/jetsnack-parity-backlog.md new file mode 100644 index 000000000..cd6231a52 --- /dev/null +++ b/src/Comet/docs/jetsnack-parity-backlog.md @@ -0,0 +1,159 @@ +# Jetsnack (M3) — parity backlog + +Gold: ~/work/compose-samples/Jetsnack (Kotlin). Golds self-captured 2026-07-10 +(app-debug on the Pixel 9 Pro AVD): sample/Shared/Jetsnack/gold/ — 8 compact +(home / scrolled / filters sheet / detail / detail-scrolled / search / cart / +profile) + medium-700dp + expanded-1260dp. + +## App shape (values-from-source pointers) + +- **Custom design system, NOT stock M3**: JetsnackTheme (ui/theme) with its own + color set (brand purple, GRADIENTS everywhere — card headers, section + titles use tinted text, bottom bar brand background), custom JetsnackSurface/ + JetsnackButton/JetsnackBottomBar components (ui/components). The gold itself + hand-composes its chrome — so Comet hand-composing the SAME structure from + the same primitives IS the faithful reproduction here (unlike Reply/JetNews + where stock M3 widgets were the target). +- Screens: Home (destination bar + filter chip row + snack collections as + horizontal card rows), Snack detail (gradient header, circular image, price, + Details/Ingredients, qty stepper + ADD TO CART bottom bar), Search + (categories/lifestyles image grid), Cart (order rows + qty steppers + + summary + checkout bar), Profile (work-in-progress placeholder). +- Model: Snack.kt / SnackCollection.kt / Filter.kt / Search.kt (~575 lines + total — small port). +- Images: LOCAL drawables in this gold revision (drawable-nodpi *.jpg — + copied to the Android probe; iOS bundle entries land with the screens). + (An older Jetsnack used Coil remote URLs — not this checkout.) + +## Capability classification (framework work expected) + +- **Gradient fills**: snack card headers + bottom-bar/status accents are + linear gradients. Comet Background() is solid-color today → needs a + gradient background capability (Compose Brush.linearGradient / SwiftUI + LinearGradient in the shim). +- Custom bottom bar: hand-composed (gold's own idiom) — NavigationSuite not + required; selected item = pill with icon+label, others icon-only. +- Qty stepper, filter chips, filter SHEET (custom dialog), search grid + (two-column image cards), cart swipe-to-remove (gold has it — classify). +- Detail screen: collapsing/parallax header behavior on scroll (gold + ui/snackdetail — classify how deep to go). +- Shared-element transitions (SnackSharedElementKey) — likely out of scope; + document as deviation. + +## Status + +- 2026-07-10: golds captured; backlog skeleton. +- 2026-07-10 (`a3e92c3a`): model/data + custom palette ported (28 snacks, + collections, cart, filters as Signals; JetsnackColors roles + 8 gradient + stop lists); snack photos in the Android probe; both probes compile. + Next: gradient-fill capability (Compose Brush.linearGradient / shim + LinearGradient), then the Home screen (destination bar + filter row + + Highlight/Normal collection rows + custom bottom bar). + +## Status 2026-07-10 (later) — Home feed LANDED on Android + +Commits `8aef7b35`…`a4e62294`: BackgroundGradient capability (Compose +Brush.horizontalGradient / shim LinearGradient; BrushBridges stale-jclass +crash fixed — first real exercise of the vendored gradient path), Home feed ++ chrome near-gold (destination bar, chips, highlight gradient cards, +circle rows, hand-composed bottom bar with routes). + +Known deviations / next: +- Gradient parallax: the gold's offsetGradientBackground shifts the band + with scroll (gradientWidth 6×card); ours is a static horizontal gradient. +- Chip border: gold uses a diagonal fade gradient border; ours solid + brand@40%. Filter icon metrics eyeballed. +- Section-header arrow: gold mirrors ic_arrow_back; ours arrow_forward glyph. +- Next increments: Snack detail (gradient header, qty stepper, ADD TO CART + bar), Filters sheet (FiltersOpen signal is wired, sheet unbuilt), Search, + Cart, iOS pass (bundle snack jpgs + verify gradients/rows), smokes, + standalone app id, RESULTS row, /code-review. + +## Status 2026-07-10 (night) — M3 COMPLETE on both platforms + reviewed + +Landed since the Home increment: Snack detail (gradient header, italic +title/tagline via NEW Text italic support, SEE MORE/LESS, qty stepper, cart +bar), Filters sheet (scrim overlay, sort/price/category — the walk exposed +and fixed the framework-level invisible-overlay input interception), Search +(uiFloated bar + gradient category grids), Cart (live totals, row remove), +iOS pass (36 jpgs — BundleResource Remove+re-Include flattening trap), +standalone app id, smokes (android 24, ios 24), RESULTS row (678ms cold +start — best Comet number yet; jank at parity). + +## /code-review round 2 (4412942c~1..HEAD) — outcome + +FIXED (`06750c5f`/`8a8f1616` + earlier `3335eeda`): frozen bottom-bar pill +(Peek-built, never re-styled); GradientBackground missing invalidation + the +per-recomposition JNI brush rebuild (now cached per node); filters-overlay +per-rebuild subscription leak; icon-toggle double materialization +(IBackendManagesOwnContent); AsCard suppressing long-press; italic +measure/render divergence; BrushBridges publication ordering; iOS card +elevation lost in the AsCard promotion; _seeMore leaking across snacks; dead +BodyVersion/DetailContent signals; sign-safe shared FormatPrice; captured +gradient-index counter; Take2/Skip2 chip slicing. + +REPORTED, BACKLOGGED: +- Stock-M3 promotions the gold demonstrably uses: DestinationBar → real + TopAppBar; IconButton control for the ~13 hand-rolled circular icon + buttons (section arrows, detail Up, cart X, filters close); filters + MaxCalories Slider (Comet has a real Slider node), Lifestyle chip section, + Reset button — currently silently omitted. +- Alpha-0 compose-nothing guard: stacks only (list/scroll/switcher/suite + containers keep the input hole); tension with ComposeFabNode's + keep-composed contract (hidden subtrees now lose remember{} state). + Needs a node-level hit-test story; consider a first-class Overlay + primitive instead of the reactive-Opacity idiom (3rd copy). +- Gradient payload is Color[]+horizontal only — the gold's parallax + offsetGradientBackground/diagonal gradients need a richer spec (define + the struct BEFORE more callers bake in the Color[] wire shape). +- IconToggleButton takes bool+callback (vs Toggle's Binding) — the + node's Toggle_IsOn path is undriveable; bind it. +- Outlined TextField branch drops color/fontSize/returnType; borderless + wins over outlined silently. +- FontSlant.Oblique silently dropped; iOS gradient stops re-marshal per + rebuild; reload-storm granularity (whole sheet/cart per toggle vs the + gold's single-node recomposition); shim bundledImage subdir search + (would remove the csproj flattening incantation); QuantitySelector/CTA + pill/placeholder-panel dedup. + +## Status 2026-07-13 — David's fidelity feedback round (all 8 items fixed) + +Commits `760d193f` (framework) + `3fb988ed` (sample): +1. Gradient (not solid) chip/circle borders — NEW BorderGradient capability; + the vendored border-brush bridge bound a nonexistent + `border-ziNgDLE$default` synthetic (Brush overload has no shape default → + no synthetic in the AAR) and crash-looped at first compose. The identical + "stale" screenshots during diagnosis were the RECENTS SNAPSHOT Android + paints while an activity crash-loops — pixel-compare across relaunches + before trusting a screenshot. +2. Typography — gold Type.kt helpers in JetsnackTheme, applied everywhere. +3. Horizontal-scroll spacing — wrapper Padding inside LazyRow items. +4. Section arrows point LEFT (ic_arrow_back). +5. Bottom-bar selected state — was the pre-8a8f1616 stale standalone install; + rebuilt both standalone apps (first launch on a loaded emulator can ANR + twice, then settles — CPU 0% after warm-up). +6. Cart tagline sits above the price row. +7. Stepper circles: 28dp gradient-border circles over uiBackground. +8. Summary label+amount both default dark; Total label right-aligned beside + its titleMedium amount (gold wrapContentWidth(End) + pad end 16). + +Also fixed: home FilterBar chips froze on toggle (Peek-built; subscribe-once ++ ReloadData); custom-font italic never rendered on EITHER backend +(Typeface-backed FontFamily bypasses Compose font matching; SwiftUI .italic() +no-op on custom UIFont) — slant now baked into the resolved font on both. + +Verification: jetsnack smokes 24/24 android + 24/24 iOS; jetchat 13/13 + +24/24; reply 14/14 + 22/22; jetnews iOS 22/22. KNOWN FLAKE: jetnews.android +expanded-resize segment (2 fails: activity recreation after `wm size` +restores Interests+open drawer instead of Home) — pre-existing, not in this +round's paths. DIAGNOSED 2026-07-15: the resize-restore signature was the +EMULATOR, not the app — the 3GB AVD throws system/launcher ANR dialogs under +load that EAT smoke taps (the "home via drawer" tap never landed, so the +resize happened on Interests with the drawer open). Two real harness fixes +landed: curl 127.0.0.1 not localhost (a Mac-side MAUI DevFlow process squats +the same port on IPv6 [::1] and answers instead of the adb forward), and +agent_wait 20s -> 60s (first launch after emulator boot exceeds 20s of JIT). +Recommend recreating the AVD with 6-8GB RAM. (A "Comet.Tests doesn't compile" scare was self-inflicted: +clean-android.sh deletes src/Comet/bin, and the test project links Comet via +a HintPath to the net11.0-maccatalyst DLL — rebuild that target first. Suite +is green: 932 pass / 27 skip / 1 known scheduler failure.) diff --git a/src/Comet/docs/reply-parity-backlog.md b/src/Comet/docs/reply-parity-backlog.md new file mode 100644 index 000000000..9752d643c --- /dev/null +++ b/src/Comet/docs/reply-parity-backlog.md @@ -0,0 +1,261 @@ +# Reply parity backlog (M1) + +Gold standard: `~/work/compose-samples/Reply` (Kotlin). Gold screenshots + capture +matrix + breakpoint table: `sample/Shared/Reply/gold/` (self-captured 2026-07-05, +Pixel 9 Pro API 36). Method per `docs/sample-workflow-checklist.md`: this document is +the build contract — every widget/value cited `file:line` into the Kotlin source; +capability classified **WORKING / bound-unwired / facade-missing / no-control**. + +App shape: 4 top-level routes (`ReplyApp.kt:128-153`) — Inbox (real), Articles / +DirectMessages / Groups (all `EmptyComingSoon`). One activity, no per-route back +stack beyond inbox→detail. All data static in-memory (`data/local/LocalEmailsDataProvider`); +no network, no images beyond bundled avatar drawables. + +## 1. Adaptive chrome (the framework lift — see docs/adaptive-primitives-design.md) + +Source of truth `ReplyNavigationComponents.kt:77-107` + `ReplyApp.kt:76-88`: + +| Window | Nav chrome | Content | +|---|---|---| +| width <600dp OR height <480dp (`isCompact()` :77) or tabletop | NavigationBar (bottom) | single-pane | +| 600–839dp | NavigationRail | single-pane (dual only in folding posture :79-83) | +| 840–1199dp | NavigationRail | **two-pane** (`WindowWidthSizeClass.Expanded` :85) | +| ≥840dp class AND window ≥1200dp (:98-99) | PermanentNavigationDrawer | two-pane | + +- Whole app wrapped in `ModalNavigationDrawer` (:122-137); gestures enabled only when + open or rail mode (:113-114); opened ONLY by the rail's menu item (:151-155); + `BackHandler` closes (:116-120). → Comet: **Drawer control exists** (ComposeDrawerNode) + — needs modal-sheet content variant + gesture-enable flag; verify against gold + `medium-700dp-02`. +- `NavigationSuiteScaffoldLayout` (:138) is **facade-missing** — per plan, DON'T bind it; + hand-compose the 3 chrome variants switched on `CometWindowMetrics` (the pre-agreed + fallback). Height ≥480dp centers rail/drawer content, else top (:103-107, + `navigationMeasurePolicy` :435-471 — custom Layout; Yoga equivalent: header top, + content centered in remaining space, `coerceAtLeast(header)`). +- Two-pane: accompanist `TwoPane` 50/50 split, 16dp gap (`ReplyListContent.kt:81-100`). + Fold `displayFeatures` OUT of scope (plan: foldable-hinge specifics excluded) → + plain HStack-based ListDetail primitive keyed off width class. +- Detail pane content when nothing opened: `openedEmail ?: emails.first()` (:94). +- Compact FAB only in BOTTOM_NAVIGATION mode (:112), aligned BottomEnd padding 16 + (:119-121). + +Comet infra: `CometWindowMetrics` (WidthClass/HeightClass, Signal-reactive) landed in +M0 (`bf879a75`) — the chrome switch programs against it. REMAINING M0 deferral now due: +ComposeNode.AvailableSize re-plumb so per-root metrics (not the static) drive layout. + +## 2. Nav chrome widgets + +| Widget | Gold cite | Facade | Comet control | Class | +|---|---|---|---|---| +| NavigationBar + NavigationBarItem (icon-only, 4 items) | ReplyNavigationComponents.kt:237-252 | bound (NavigationBarItem.cs area — verify) | none | **bound-unwired** | +| NavigationRail + NavigationRailItem (menu + FAB header, centered items) | :172-234 | bound (NavigationRailItem.cs) | none | **bound-unwired** | +| PermanentDrawerSheet (min 200 / max 300dp :261, surfaceContainerHigh) | :255-337 | bound (ComposeBridges.cs) | none | **bound-unwired** | +| ModalDrawerSheet + scrim | :340-433 | bound (DrawerStateHolder.cs) | Drawer (verify variant) | **bound-unwired** | +| NavigationDrawerItem (selected pill, transparent unselected :326-328, label padding h16 :315) | :309-331 | bound | none | **bound-unwired** | +| FloatingActionButton +(rail: tertiaryContainer, 18dp icon, pad top8/bottom32 :197-208) | :197-208 | bound | Fab (Jetchat) | **WORKING** (variant check) | +| ExtendedFloatingActionButton (drawers: fillMaxWidth, pad top8/bottom40 :282-300; compact list: text+icon, `expanded` collapse :113-126 ReplyListContent) | both files | bound (ExtendedFloatingActionButton.cs) | Fab is plain circle | **bound-unwired** — real ExtendedFAB control w/ `expanded` state (gold compact-05 = collapsed) | + +Drawer/rail headers: "REPLY" `titleMedium` primary uppercase (:278-281, :365-369); +modal header adds `ic_menu_open` close IconButton (:370-375). + +## 3. Inbox list screen (`ReplyListContent.kt`) + +- Root Box `windowInsetsPadding(statusBars)` (:171) — **safe-area automation** is the + planned kill-the-manual-topInset item (no-control today; App.Build(topInset) hack). +- `ReplyDockedSearchBar` pinned on top, h16/v16 padding (:172-180); LazyColumn under it + `padding(top = 80.dp)` (:182-186). +- **DockedSearchBar + SearchBarDefaults.InputField** (`ReplyAppBars.kt:84-168`): + facade-bound (SearchBarInputField.cs), no Comet control → **bound-unwired**. + States (gold compact-04): collapsed = search icon + "Search emails" + 32dp profile + trailing (pad 12 :117-124); expanded = back arrow (pad start16, click collapses+clears + :97-107), content = "No search history" / "No item found" / results LazyColumn + (contentPadding 16, spacedBy 4 :131-156) of **ListItem** (headline=subject, + supporting=sender, leading 32dp avatar) → ListItem facade-bound, no control → + **bound-unwired**. Search filter: subject OR fullName startsWith ignoreCase (:65-82). + Item click → navigateToDetail + collapse (:149-153). +- **ReplyEmailListItem** (`ReplyEmailListItem.kt:52-142`): Card, pad h16/v4 (:64), + inner Column pad 20 (:81); container = primaryContainer if selected / + secondaryContainer if opened / surfaceVariant (:72-76) — gold compact-01 shows the + opened first item darker. Comet Card exists? → verify; Jetchat used custom rows. + Row: 40dp circular avatar (`ReplyProfileImage.kt`: Image clip CircleShape size 40); + sender firstName + createdAt both `labelMedium` (:106-113); star IconButton in + CircleShape surfaceContainerHigh bg, outline tint (:115-126). Subject `bodyLarge` + pad top12/bottom8 (:129-133); body `bodyMedium` maxLines 2 ellipsis (:134-139). +- **Interactions**: `combinedClickable` onClick=detail, onLongClick=toggleSelection + (:67-70) → needs long-press gesture (NOTE: comet-next HandleGesture still falls back + longpress→tap; the longpress CLI work lives only on devflow/longpress-gesture branch). + Avatar click (no ripple `indication = null` :84-87) also toggles selection; + `AnimatedContent` swaps avatar ↔ `SelectedProfileImage` (primary circle + 24dp check + :144-161) → AnimatedContent facade-bound (Transitions.cs) → **bound-unwired**; + fallback = plain swap, animation second pass. +- ExtendedFAB collapse driver: `emailLazyListState.lastScrolledBackward || + !canScrollBackward` (:124-125) → map to ListView scroll-bridge signals + (ScrollOffset/AtTop from Jetchat; may need lastScrolledBackward direction signal). +- List end spacer: `windowInsetsBottomHeight(systemBars)` (:200-202). + +## 4. Email detail (`ReplyListContent.kt:207-225` + `ReplyAppBars.kt:173-228`) + +- LazyColumn on inverseOnSurface bg (:209-211). +- **EmailDetailAppBar = M3 TopAppBar** (facade-bound, no Comet control → + **bound-unwired**): container inverseOnSurface (:176-178); title Column — subject + `titleMedium` onSurfaceVariant + "N Messages" `labelMedium` outline pad top4 + (:179-196), centered when fullscreen else start-aligned (:182-183); nav icon only + fullscreen: **FilledIconButton** surface container, 14dp back icon, pad 8 (:199-214) + → facade-bound; actions: more-vert IconButton onSurfaceVariant (:216-226). No + scrollBehavior (that's JetNews). +- **ReplyEmailThreadItem** (`ReplyEmailThreadItem.kt:44-136`): Card surfaceContainerHigh + (:47-49), pad h16/v4, inner pad 20; header row like list item but time hardcoded + "20 mins ago" outline (:71-75), star bg surfaceContainer (:79-82); subject + `bodyMedium` outline (:91-96); body `bodyLarge` onSurfaceVariant (:98-102); + buttons row pad top20/bottom8 spacedBy 12 (:103-133): two **Button**s weight 1f, + containerColor **surfaceBright**, text onSurface — plain M3 filled Button = WORKING + (Jetchat) but surfaceBright color token must exist in theme bridge. +- Back: compact detail is `isDetailOnlyOpen` state w/ BackHandler (:141-147) — Comet + NavigationView push or state-swap; two-pane never navigates. + +## 5. EmptyComingSoon (`EmptyComingSoon.kt`) + +Column centered: title `titleLarge` primary + subtitle `bodySmall` outline, pad 8 — +all WORKING (Text/VStack). Strings: "Screen under construction" / R.string subtitle. + +## 6. Theme (values-from-source — DETERMINISTIC, no dynamic color) + +- `ContrastAwareReplyTheme` defaults **dynamicColor = false** (`Theme.kt:294-317`); + default contrast → static `lightScheme`/`darkScheme` (:37-111). **Every color is a + literal in `ui/theme/Color.kt`** — port the light+dark tables verbatim (primary + `#805610`, tertiaryContainer `#D4EABB`, background/surface `#FFF8F4`, etc). + Contrast variants (UiModeManager) OUT of scope — note in RESULTS. +- Typography `Type.kt`: default Roboto, explicit sizes/weights (headlineLarge + SemiBold 32/40 … ) — port the overridden styles; no custom fonts (unlike Jetchat). +- Shapes: `Shapes.kt` (verify — likely default M3). +- Jetchat theme generator NOT needed here; a static scheme ctor path suffices + (`JetchatTheme.cs` pattern minus the seed generation). + +## 7. Data layer + +`data/`: Email(id, sender/recipients Accounts, subject, body, attachments, mailbox, +createdAt string, threads), MailboxType, LocalEmailsDataProvider static list, avatar +drawables. → straight C# port under `sample/Shared/Reply/Data/`; ViewModel +(`ReplyHomeViewModel.kt`) = replyHomeUIState { emails, openedEmail, selectedEmails, +isDetailOnlyOpen } + navigateToDetail/toggleSelectedEmail/closeDetailScreen → +Comet State/Signal. + +## 8. Capability rollup (build order) + +1. **Framework, host-test-first** (separate commits): ExtendedFAB control (+expanded + signal); NavigationBar/Rail/DrawerItem controls; modal/permanent drawer variants; + TopAppBar control; DockedSearchBar control; ListItem control; adaptive + NavSuite + ListDetail primitives on CometWindowMetrics (design note first); + safe-area statusBars inset automation; long-press gesture (port/redo — see branch + note); AnimatedContent wiring (or defer, plain swap first pass). +2. **Sample screens**: theme port → data port → inbox list → detail → coming-soon → + search → adaptive chrome → selection mode. +3. **Smoke**: `tools/smoke/reply.android.sh` as screens land (use `android_resize` + for the 3 chrome asserts; elements: search placeholder, first-email subject, + "Screen under construction", drawer items; drag for FAB collapse). +4. iOS gate after Android fidelity (SwiftUI twins: rail→sidebar-ish hand-comp, + real resize observation lands here per M0 deferral). + +Known iOS debt Reply will hit: real resize observation (SwiftUIBackendRoot reads +UIScreen bounds per pass only), drawer-row semantic-tap-doesn't-navigate (backlog), +no drag injector (scroll_by fallback). + +## 9. Progress log (2026-07-06 — Android structural build-out COMPLETE) + +DONE (commits 2be19e2b..9f40b105): NavigationSuite (bar/rail/permanent-drawer on real +M3 widgets, own-content swap) · ListDetail (two-pane ≥840 / compact push+BackHandler +round-trip) · ContentSwitcher route host · safe-area contract (SafeAreaDp; suite insets +content) · leaf-padding Yoga fix · full static ReplyTheme + per-screen Compose scheme · +ReplyData verbatim port + gold avatars · inbox/detail/coming-soon screens · +ExtendedFAB expand-at-top/collapse-on-scroll (ScrolledFromTop) · reply.android.sh +14/14 + jetchat 13/13. + +REMAINING for M1 (updated 2026-07-09): items 1 and 2 SHIPPED (commit 4bc7557b — +long-press end-to-end w/ agent `longpress` action + ListView.LastScrolledBackward +on both backends; selection = check avatar + primaryContainer, gold precedence; +FAB re-expands mid-list). M1 close-out = only the small polish items below. +Agent gap (tracked): the `back` action = PopNavigation, which no-ops on apps +without a NavigationView (Reply) — it should fall back to the system back +(Android OnBackPressedDispatcher / the iOS detail's own affordance) so smokes +drive the gold close path; reply.android.sh uses KEYCODE_BACK meanwhile. +Registry asserts are EXISTENCE-based — elements of hidden panes still pass; +a visibility-honest query (or per-pane pruning) would catch stale-screen runs. + +Small gold divergences (visual polish, non-blocking): detail status-bar + strip is Background, gold paints it inverseOnSurface (needs per-route strip + color or screen-owned top inset); Android search popup expands on focus + only (Expanded ↔ SearchBarState facade sync); rail container inverseOnSurface + (containerColor now exists control-side — pass it for the rail variant too). + +### Review skips (2026-07-07, tracked) +- Leaf padding on NON-text Compose leaves (Image/Button/TextField-material) grows the + box without insetting content; SwiftUI engine path has no PadsOwnContent twin — align + both backends (generalize inset to all plain leaves) before any sample pads such leaves. +- Suite BottomBar variant: bar offset ignores the widget's internal bottom-inset growth + (visible only with 3-button nav); use safeDp.Bottom in the bar offset + content math. +- Refactor debt: shared ComposeOwnContentNode base (5 copies of lifecycle scaffolding); + EmitSignalProperty helper for the 7 backend partials; suite reusing bar/rail item + rendering + drawer sync from their standalone nodes. +- Reply Release size snapshot ✅ (2026-07-08): single-RID (arm64) 26.1 MiB via the new + `-p:CometSingleRid=true` probe-csproj switch (a raw `-p:RuntimeIdentifiers` breaks the + netstandard2.0 source generator's copy step). Full RESULTS.md row filled: cold start + 3705ms vs gold 773ms (JIT + Compose init — the maturity gap to attack); inbox scroll + jank AT PARITY with gold (13.0% vs 9.3%, near-identical percentiles). + +## 10. iOS gate status (2026-07-07) + +DONE (01da1d53, 44ee7093): shim maxLines + scroll-top handler (xcframework rebuilt); +SwiftUI hosted-composition twins (ContentSwitcher/ListDetail/NavigationSuite/SearchBar); +Text_MaxLines + ScrolledFromTop on iOS nodes; agent semantic-tap BUBBLING (fixes the +old drawer-row bug; Icons queryable by symbol); probe COMET_SCREEN=reply switch + +avatars. VERIFIED on iPhone 16 Pro sim: inbox renders near-gold (cards/avatars/ +opened-highlight/2-line ellipsis/FAB/suite bar + pill, safe-area clean); +jetchat.ios.sh 14/14 (no regressions); reply.ios.sh inbox asserts 4/4. + +✅ BLOCKER RESOLVED (26e3858a, 2026-07-08): the crash was NOT a context-graph +cycle — the env walk was innocent (GetValue frames just topped a much deeper +stack; the depth cap in 56f5deae was aimed at the wrong layer). The real loop, +recovered from the managed dump (`--console-pty` stderr — CoreCLR prints the +full managed stack on fatal overflow; DiagnosticReports .ips only shows +interpreter frames): hosted Refresh → Materialize → SwiftUIListNode.Rebuild → +ThreadItem's .FontSize → SetEnvironment → ReactiveEnv.SetValue → +EnsureFlushScheduled → FlushEntry runs INLINE (main thread, _flushing already +false during AfterFlush) → AfterFlush → ancestor twin Relayout → Layout → +Arrange(mid-build LD node, _shown==null) → Refresh again. Fixed in +ReactiveScheduler: FlushEntry re-entrancy guard + pass loop (see commit). +Host repro: BackendHostedCompositionChurnTests (nests 67 before / 5 after). +Also fixed: font-mapped Icons (Icon_Glyph path) lost their queryable symbol — +CometDevRegistry now falls back to Icon.Symbol for element text. +reply.ios.sh 13/13 → the iOS gate smoke is GREEN (inbox, detail round-trip via +app-bar back, route switch); jetchat 14/14 iOS + 13/13 Android, reply.android +14/14 — no regressions. +✅ POST-GATE POLISH DONE (2026-07-08, commits 199d95ad..b12a7abb): +- iOS search end-to-end (pill → expanded pane → typed query → gold-filtered + result renders → close restores pill) — smoke grew a search section (18/18). + Surfaced + fixed 4 framework gaps: HoldFlushes (atomic hosted swaps), + node-generation disposal + registry pruning (stale-gen ghosts), SearchBar + Expanded state on the CONTROL, ListView.ReloadData re-pulls items AND + invalidates the row-view cache (opened-highlight now moves on iOS). +- iPad Pro 13 two-pane: rail + list + detail render side-by-side (gold + 840–1199 band); row tap swaps the detail in place; highlight follows. +- Android search: SearchBar itself now accepts the agent fill (TextChanged → + expand + Query) since the M3 input field isn't a registered element — + query pipeline verified (results rebuild). FOLLOW-UP: sync + SearchBar.Expanded ↔ M3 SearchBarState so the popup opens/closes + programmatically on Android (today expansion is focus-driven only); + adb-IME text into the popup remains broken (upstream/IME quirk, agent + fill is the supported path). +✅ iOS PARITY PASS 2 (2026-07-08, commit 77922ba0 — David flagged the FAB and +missing icons): ExtendedFAB now CONTRACTS on iOS (the node re-frames to a +Height-dp square with the right edge pinned — the shim only hides the label, +the frame was staying extended-wide) and uses the real M3 CornerLarge 16dp +shape (was a capsule). SearchBar.containerColor + NavigationSuite +containerColor/indicatorColor let the hand-composed iOS chrome wear the gold +M3 tokens (surfaceContainerHigh pill, surfaceContainer bar, secondaryContainer +indicator — Android's real widgets take these from the scheme); nav item +icons tinted onSurfaceVariant. Real app icons on BOTH platforms: per-variant +asset catalogs (iOS property — icon-set NAME, so one Info.plist +serves all variants) / @mipmap + per-variant app_name strings (Android manifest +label was hardcoded); gold launcher art for reply/jetchat, the Comet mark +(art/comet_icon.svg) for the probe. diff --git a/src/Comet/docs/research/upstream-issue-fastdev-typemap-staleness.md b/src/Comet/docs/research/upstream-issue-fastdev-typemap-staleness.md new file mode 100644 index 000000000..ebb3ebf31 --- /dev/null +++ b/src/Comet/docs/research/upstream-issue-fastdev-typemap-staleness.md @@ -0,0 +1,103 @@ +# DRAFT upstream issue for dotnet/android — fast-dev APK skips libxamarin-app.so changes → "no Java peer type found" + +Status: draft, not yet filed. Verified against Microsoft.Android.Sdk.Darwin +36.99.0-preview.5.308 (.NET 11.0.100-preview.5). Repo workaround lives in +`src/Comet/Directory.Build.targets` (`_CometFixFastDevApkInputs`) — remove it when +the SDK fix ships. + +## Title + +Incremental Debug build: `_BuildApkFastDevStaticInputs` omits `@(_ApplicationSharedLibrary)`, deploying stale MVID-keyed typemaps → `NotSupportedException: no Java peer type found` + +## Description + +**Repro.** App project → library project containing `internal` `Java.Lang.Object` +subclasses (e.g. a Compose binding facade full of internal bridge lambdas). +Debug build with fast deployment (`EmbedAssembliesIntoApk=false`, +`AndroidPackageFormat=apk`): + +1. Clean build, `-t:Run` — app runs. +2. Make an **implementation-only** edit in the library (add a private field to an + internal JLO subclass; anything that does not change the ref assembly). +3. Incremental `dotnet build -t:Run`. +4. App crashes at startup: + +``` +W monodroid-assembly: typemap: unable to look up assembly name for type 'AndroidX.Compose.ComposableLambda2', trying without it. +E AndroidRuntime: FATAL EXCEPTION: main +E AndroidRuntime: android.runtime.JavaProxyThrowable: [System.NotSupportedException]: Cannot create instance of type 'AndroidX.Compose.ComposableLambda2': no Java peer type found. +``` + +**Cause (from the binlog).** On step 3 the build does the right work almost +everywhere: + +- `_GenerateJavaStubs` re-runs (JCWs fine), +- `_CompileNativeAssemblySources` re-runs and `_CreateApplicationSharedLibraries` + relinks `app_shared_libraries//libxamarin-app.so` with the new MVID-keyed + debug typemaps, + +but `_BuildApkFastDev` is **skipped** ("all output files are up-to-date"), because +`_BuildApkFastDevStaticInputs` does not list the application shared library: + +```xml +<_BuildApkFastDevStaticInputs> + @(_AndroidMSBuildAllProjects) + ;@(_ShrunkFrameworkAssemblies) + ;@(_AndroidNativeLibraryForFastDev) + ;@(_DexFileForFastDevInput) + ;$(_AndroidBuildPropertiesCache) + ;$(_AdbPropertiesCache) + ;$(_PackagedResources) + +``` + +The embed-path property right above it **does** include it: + +```xml +<_BuildApkEmbedInputs> + ... + ;@(_ApplicationSharedLibrary) + +``` + +`_Upload` then fast-deploys only assemblies (`_FastDevFiles` contains .dlls only), +so the device ends up with the **new-MVID** library assembly plus the **old-MVID** +typemaps inside the installed APK's `libxamarin-app.so`. The managed→Java typemap +lookup is MVID-keyed in Debug, so constructing any JLO subclass from that assembly +throws. + +Why it bites hardest with internal types: any library rebuild changes the MVID, but +teams using ref assemblies (`ProduceReferenceAssembly`, SDK default) typically only +see downstream rebuild activity on impl-only edits — everything looks incremental +and healthy, and the failure appears only at runtime. Users learn to "always clean +rebuild after editing that library" (which is what we did for months). + +**Suggested fix.** Add `;@(_ApplicationSharedLibrary)` to +`_BuildApkFastDevStaticInputs` (populated via `_PrepareApplicationSharedLibraryItems`, +as the target already does for the task call at `ApplicationSharedLibraries=`). + +**Workaround** (project/repo level): + +```xml + + + <_BuildApkFastDevStaticInputs>$(_BuildApkFastDevStaticInputs);@(_ApplicationSharedLibrary) + + +``` + +Two traps hit while landing the workaround (both cost a re-verification): + +1. `_PrepareApplicationSharedLibraryItems` expands `%(_BuildTargetAbis.Identity)`, + populated by `_DefineBuildTargetAbis`. Without that dependency the item — and + thus the appended input — is silently EMPTY. Our copy warns when it detects this. +2. MSBuild stops at the NEAREST `Directory.Build.targets`; a sample-level file + shadows the repo-level one, so the fix must live in a file imported by every + Android app project (`src/Comet/eng/FastDevApkFix.targets`, imported from both). + +Verified end-to-end: impl-only library edit → incremental `-t:Run` → `.so` relinks +→ APK rebuilds + reinstalls → app survives (three consecutive edit cycles, both a +facade edit and Comet.dll edits). Without the workaround the same edit crashes as +above. diff --git a/src/Comet/docs/sample-workflow-checklist.md b/src/Comet/docs/sample-workflow-checklist.md new file mode 100644 index 000000000..3abe55466 --- /dev/null +++ b/src/Comet/docs/sample-workflow-checklist.md @@ -0,0 +1,95 @@ +# Per-sample workflow checklist (compose-samples build-out) + +The repeatable delivery process for reproducing an android/compose-samples app on +Comet's node backends (Compose/Android + SwiftUI/iOS) at Jetchat-grade fidelity. +Derived from the Jetchat effort's retro; the named verification techniques at the +bottom are the ones that actually worked there. Gold-standard Kotlin source is +local at `~/work/compose-samples/` — read it directly. + +Order of samples (reuse-ordered): **Reply → JetNews → Jetsnack → Jetcaster → JetLagged.** + +## The checklist + +1. **Gold capture.** Enumerate the sample's screens (including compact/medium/expanded + adaptive states and interaction states — open drawers, sheets, filters) and request + user-captured gold screenshots for each. These are the fidelity contract; store them + with the sample. + +2. **Source survey → parity backlog.** Write `docs/-parity-backlog.md` in the + `jetchat-parity-backlog.md` format: every widget, color, dp value, and type style + cited `file:line` from the Kotlin source. Classify each needed capability: + **WORKING / bound-unwired / facade-missing / no-control.** This document is the + sample's living plan; mark items with commit hashes as they land. + +3. **Framework work, host-test-first.** For each new node/control: + FakeBackendNode contract tests (`tests/Comet.Tests/Backend/`) → Compose node in + `src/Comet/Platform/Compose/` → device verify → SwiftUI shim kind **designed now**, + implemented by the iOS gate. Framework node + its host tests = one commit, + separate from sample commits. Keep the host-test baseline green at every commit. + +4. **Screen build.** `sample/Shared//` mirroring the Jetchat layout + (Theme / Icons / Screens files). Theme = the `JetchatTheme.cs` M3 generator seeded + with this sample's color/type tokens from source (swap font families per sample). + +5. **Fidelity passes.** Values-from-source sweep, then pixel-scan vs gold + (`tools/snapshot_compare.py`), forcing end-state signals for render-only states. + Loop until the backlog's fidelity items are closed. + +6. **DevFlow smoke script — written AS YOU GO, not after.** + `tools/smoke/..sh`: launch → walk every screen → exercise each + interaction → assert via tree/element queries → screenshot each screen. + Missing DevFlow verb? 30-minute timebox to add it upstream (agent or src/DevFlow); + otherwise adb fallback + a backlog entry. + +7. **iOS gate.** All screens on CometSwiftUIProbe; same smoke script + (`.ios.sh`); same values-from-source bar. Parity bar is **structure, + values, and interaction** — not cross-OS pixel identity. Budget for known iOS debt + the sample exercises (SelectorPanel, FormattedText runs, hot-reload own-content). + **Hand-composed chrome checklist** (anything an iOS twin builds from Comet + primitives where Android uses a real M3 widget): capture SAME-SCALE + side-by-side screenshots and pixel-measure each chrome piece — Yoga's + cross-axis default is flex-start while M3 widgets CENTER their slots, so + every slot needs explicit centering, every strip an explicit background, + every shape the real M3 token (16dp CornerLarge, 28dp capsule…). The Reply + gate shipped four such misses (search-pill slots, bar/rail icons, safe-area + strip, FAB shape) that only a measured side-by-side caught. + The sample is not done — and the next does not start — until this gate passes. + +8. **Perf/size snapshot (emulator).** `tools/bench/size.sh` trimmed Release APK, + `tools/bench/startup.sh` cold start, gfxinfo first-pass on the heaviest scroll + screen → the sample's RESULTS.md section, alongside the gold Kotlin app's own + Release APK size. (Pixel 5 numbers are batched: B1 after JetNews, B2 after + Jetcaster, B3 after JetLagged + Jetchat re-run.) + +9. **/code-review** on the full sample diff before calling it done; fix findings. + +10. **Docs/memory.** Backlog marked with commit hashes; new platform gotchas → + AGENTS.md; DevFlow gaps → upstream backlog; newly discovered framework debt → + the infra ledger in the plan. + +11. **Retro (15 min).** What was mis-estimated, which lazy-infra call was wrong, + what should change for the next sample. + +## Named verification techniques (use these; they worked) + +- **values-from-source** — never eyeball a dimension/color; cite the Kotlin + `file:line` and use its exact value. +- **pixel-scan** — locate/verify small UI elements by scanning gold screenshot + pixels (`tools/snapshot_compare.py`), not by judgment. +- **gfxinfo first-pass** — `adb shell dumpsys gfxinfo ` after ONE scroll pass, + reset stats first; later passes hide first-composition jank. +- **forced end-state signals** — for states only reachable through timing + (animations, refreshes), drive the terminal signal directly to verify the render. +- **device-tap method** — Comet OnTap needs `input swipe X Y X Y 120`, NOT + `input tap`; compute coordinates at full resolution (1080-wide), not screenshot scale. +- **black screencap ≠ render bug** — check `mScreenState`/keyguard first. +- **host-tests-first** — patch-stream/signal/reactive behavior proven in + FakeBackendNode tests before any device build. + +## Timeboxes + +- Hard problem with a pre-written fallback: use the timebox from the plan + (shared-element 3d, Canvas 4d, scrollBehavior 1d), then take the fallback and + document the decision in the sample's backlog doc. +- Platform rabbit hole (build system, emulator, tooling): **90 minutes**, then write + the AGENTS.md gotcha and route around it. diff --git a/src/Comet/eng/FastDevApkFix.targets b/src/Comet/eng/FastDevApkFix.targets new file mode 100644 index 000000000..180a8ad18 --- /dev/null +++ b/src/Comet/eng/FastDevApkFix.targets @@ -0,0 +1,32 @@ + + + + + <_BuildApkFastDevStaticInputs>$(_BuildApkFastDevStaticInputs);@(_ApplicationSharedLibrary) + + + + diff --git a/src/Comet/global.json b/src/Comet/global.json index f34a41ab2..4bdd8162c 100644 --- a/src/Comet/global.json +++ b/src/Comet/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-preview.3.26207.106", + "version": "11.0.100-preview.7.26381.103", "rollForward": "latestPatch" } } diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_bold.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_bold.ttf new file mode 100644 index 000000000..052231c16 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_bold.ttf differ diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_regular.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_regular.ttf new file mode 100644 index 000000000..4269aa069 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_regular.ttf differ diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/material_icons.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/material_icons.ttf new file mode 100644 index 000000000..9d09b0feb Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/material_icons.ttf differ diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_medium.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_medium.ttf new file mode 100755 index 000000000..6e079f698 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_medium.ttf differ diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_regular.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_regular.ttf new file mode 100755 index 000000000..8d443d5d5 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_regular.ttf differ diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_semibold.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_semibold.ttf new file mode 100755 index 000000000..f8a43f2b2 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_semibold.ttf differ diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/roboto_flex.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/roboto_flex.ttf new file mode 100644 index 000000000..2e5c2a26a Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/roboto_flex.ttf differ diff --git a/src/Comet/sample/CometComposeProbe/CometComposeProbe.csproj b/src/Comet/sample/CometComposeProbe/CometComposeProbe.csproj new file mode 100644 index 000000000..0a9331456 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/CometComposeProbe.csproj @@ -0,0 +1,75 @@ + + + + + net11.0-android + 24 + Exe + enable + disable + CometComposeProbe + com.comet.composeprobe + 1 + 1.0 + + android-arm64 + + + + + com.comet.sample.$(CometSample) + Comet $(CometSample.Substring(0,1).ToUpperInvariant())$(CometSample.Substring(1)) + obj/sample-$(CometSample)/ + bin/sample-$(CometSample)/ + + + + + probe + $(CometSample) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Comet/sample/CometComposeProbe/DevFlowHelper.cs b/src/Comet/sample/CometComposeProbe/DevFlowHelper.cs new file mode 100644 index 000000000..338a33d2d --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/DevFlowHelper.cs @@ -0,0 +1,187 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Android.App; +using Android.Graphics; +using Android.OS; +using Android.Views; +#if DEBUG +using Microsoft.Maui.DevFlow.Agent.Core; +#endif +#pragma warning disable CA1416 + +namespace CometComposeProbe +{ + // PixelCopyListener lives outside #if DEBUG so Xamarin.Android generates its JNI typemap + // entry unconditionally. The build tool only registers Java.Lang.Object subclasses it sees + // in every build; a type hidden behind #if DEBUG is skipped in the Release codegen pass. + sealed class PixelCopyListener : Java.Lang.Object, PixelCopy.IOnPixelCopyFinishedListener + { + readonly Action _callback; + public PixelCopyListener(Action callback) => _callback = callback; + public void OnPixelCopyFinished(int copyResult) => _callback(copyResult); + } + +#if DEBUG + /// + /// Bootstraps the DevFlow in-app agent for CometComposeProbe without requiring UseMaui. + /// Uses DevFlowAgentService.StartServerOnly (designed for Comet apps where + /// Application.Current is unavailable) with an Android-native PixelCopy screenshot. + /// + static class DevFlowHelper + { + static ComposeProbeAgentService? _agent; + + public static void Start(Activity activity, View rootView) + { + var dispatcher = new ActivityDispatcher(); + + _ = Task.Run(async () => + { + try + { + var broker = new BrokerRegistration( + project: "CometComposeProbe", + tfm: "net11.0-android", + platform: "Android", + appName: "CometComposeProbe"); + + var assignedPort = await broker.TryRegisterAsync(TimeSpan.FromSeconds(5)); + var options = new AgentOptions { Port = assignedPort ?? AgentOptions.DefaultPort }; + + var agent = new ComposeProbeAgentService(activity, options); + agent.SetBrokerRegistration(broker); + broker.CurrentPort = agent.Port; + _agent = agent; + + dispatcher.Dispatch(() => agent.StartServerOnly(dispatcher)); + Console.WriteLine($"[CometComposeProbe] DevFlow agent started on port {agent.Port}"); + } + catch (Exception ex) + { + Console.WriteLine($"[CometComposeProbe] DevFlow startup failed: {ex.Message}"); + } + }); + } + } + + /// + /// DevFlowAgentService subclass for CometComposeProbe. + /// Provides Android PixelCopy screenshot (captures GPU-rendered Compose content). + /// Visual tree walking returns empty results (no MAUI app) — screenshot and + /// coordinate-based tap are fully functional. + /// + sealed class ComposeProbeAgentService : DevFlowAgentService + { + readonly Activity _activity; + + public ComposeProbeAgentService(Activity activity, AgentOptions? options = null) + : base(options) + { + _activity = activity; + } + + protected override async Task HandleScreenshot(HttpRequest request) + { + try + { + var pngData = await CaptureFullScreenAsync(); + if (pngData != null) + return HttpResponse.Png(pngData); + return HttpResponse.Error("PixelCopy returned null"); + } + catch (Exception ex) + { + return HttpResponse.Error($"Screenshot failed: {ex.Message}"); + } + } + + Task CaptureFullScreenAsync() + { + var tcs = new TaskCompletionSource(); + + // RunOnUiThread: PixelCopyListener (Java.Lang.Object) must be instantiated on a + // JNI-attached thread. The HTTP handler thread pool is not registered with the JVM. + _activity.RunOnUiThread(() => + { + try + { + var win = _activity.Window; + var decorView = win?.DecorView; + if (win == null || decorView == null || decorView.Width <= 0 || decorView.Height <= 0) + { + tcs.SetResult(null); + return; + } + + var bmp = Bitmap.CreateBitmap(decorView.Width, decorView.Height, Bitmap.Config.Argb8888!)!; + + if (Build.VERSION.SdkInt >= BuildVersionCodes.O) + { + PixelCopy.Request( + win, + bmp, + new PixelCopyListener(result => + { + try + { + if (result == 0) + { + using var ms = new MemoryStream(); + bmp.Compress(Bitmap.CompressFormat.Png!, 90, ms); + tcs.SetResult(ms.ToArray()); + } + else + { + Android.Util.Log.Warn("CometProbe", $"PixelCopy failed: {result}"); + tcs.SetResult(null); + } + } + finally { bmp.Recycle(); } + }), + new Handler(Looper.MainLooper!)); + } + else + { + try + { + decorView.Draw(new Canvas(bmp)); + using var ms = new MemoryStream(); + bmp.Compress(Bitmap.CompressFormat.Png!, 90, ms); + tcs.SetResult(ms.ToArray()); + } + catch (Exception ex) + { + Android.Util.Log.Warn("CometProbe", $"Canvas fallback failed: {ex.Message}"); + tcs.SetResult(null); + } + finally { bmp.Recycle(); } + } + } + catch (Exception ex) + { + tcs.TrySetResult(null); + Android.Util.Log.Warn("CometProbe", $"CaptureFullScreen failed: {ex.Message}"); + } + }); + + return tcs.Task; + } + } + + sealed class ActivityDispatcher : IAgentDispatcher + { + readonly Handler _handler = new(Looper.MainLooper!); + + public bool IsDispatchRequired => Looper.MyLooper() != Looper.MainLooper; + public bool Dispatch(Action action) { _handler.Post(action); return true; } + public bool DispatchDelayed(TimeSpan delay, Action action) + { + _handler.PostDelayed(action, (long)delay.TotalMilliseconds); + return true; + } + } +#endif +} + +#pragma warning restore CA1416 diff --git a/src/Comet/sample/CometComposeProbe/HotReloadDemo.cs b/src/Comet/sample/CometComposeProbe/HotReloadDemo.cs new file mode 100644 index 000000000..523b8a447 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/HotReloadDemo.cs @@ -0,0 +1,56 @@ +#if DEBUG +using Android.Content; +using Comet; + +namespace CometComposeProbe +{ + /// + /// The app root as a [Body] view — the shape a real Comet app has, and what + /// MauiHotReloadHelper.TriggerReload targets (it reloads root views, so a + /// bare static-built tree would never re-render on hot reload). + /// + public class JetchatRoot : View + { + [Body] + View body() => CometSamples.Jetchat.JetchatConversation.Build(topInset: 24); + } + + /// + /// Stand-in for the EnC-updated JetchatRoot: property-level deltas (inset + root + /// background) make a successful reload unmistakable on screen while every static + /// signal (composer text, drawer state, selector) carries over untouched. + /// + public class JetchatRootV2 : View + { + [Body] + View body() => CometSamples.Jetchat.JetchatConversation.Build(topInset: 140) + .Background(Microsoft.Maui.Graphics.Color.FromArgb("#B3261E")); + } + + /// + /// Demo trigger standing in for the .NET MetadataUpdateHandler: registers the + /// replacement type and triggers the reload — exactly the two calls + /// CometMetadataUpdateHandler.UpdateType/UpdateApplication make when the + /// runtime applies an EnC delta. Fire with: + /// adb shell am broadcast -n com.comet.composeprobe/.HotReloadDemoReceiver + /// + [BroadcastReceiver(Enabled = true, Exported = true, Name = "com.comet.composeprobe.HotReloadDemoReceiver")] + public class HotReloadDemoReceiver : BroadcastReceiver + { + public override void OnReceive(Context? context, Intent? intent) + { + Android.Util.Log.Info("CometProbe", "HotReload demo: registering JetchatRootV2 + TriggerReload"); + Comet.HotReload.CometHotReloadHelper.RegisterReplacedView( + typeof(JetchatRoot).FullName!, typeof(JetchatRootV2)); + ThreadHelper.RunOnMainThread(() => + { + var before = MainActivity.RootView?.BuiltView; + Microsoft.Maui.HotReload.MauiHotReloadHelper.TriggerReload(); + var after = MainActivity.RootView?.BuiltView; + Android.Util.Log.Info("CometProbe", + $"HotReload demo: built {before?.GetType().Name}#{before?.GetHashCode()} -> {after?.GetType().Name}#{after?.GetHashCode()} (changed={!ReferenceEquals(before, after)})"); + }); + } + } +} +#endif diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetchat/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/jetchat/appicon.png new file mode 100644 index 000000000..61f78a9e1 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/jetchat/appicon.png differ diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetchat/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/jetchat/strings.xml new file mode 100644 index 000000000..217221956 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Icons/jetchat/strings.xml @@ -0,0 +1,4 @@ + + + Comet Jetchat + diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetnews/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/jetnews/appicon.png new file mode 100644 index 000000000..645a6e37b Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/jetnews/appicon.png differ diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetnews/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/jetnews/strings.xml new file mode 100644 index 000000000..f57968f5d --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Icons/jetnews/strings.xml @@ -0,0 +1,4 @@ + + + Comet Jetnews + diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetsnack/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/jetsnack/appicon.png new file mode 100644 index 000000000..68ba15329 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/jetsnack/appicon.png differ diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetsnack/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/jetsnack/strings.xml new file mode 100644 index 000000000..d9c959842 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Icons/jetsnack/strings.xml @@ -0,0 +1,4 @@ + + + Comet Jetsnack + diff --git a/src/Comet/sample/CometComposeProbe/Icons/probe/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/probe/appicon.png new file mode 100644 index 000000000..be26d381f Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/probe/appicon.png differ diff --git a/src/Comet/sample/CometComposeProbe/Icons/probe/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/probe/strings.xml new file mode 100644 index 000000000..dfd86163e --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Icons/probe/strings.xml @@ -0,0 +1,4 @@ + + + Comet+Compose + diff --git a/src/Comet/sample/CometComposeProbe/Icons/reply/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/reply/appicon.png new file mode 100644 index 000000000..93c31df60 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/reply/appicon.png differ diff --git a/src/Comet/sample/CometComposeProbe/Icons/reply/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/reply/strings.xml new file mode 100644 index 000000000..86b7bbf75 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Icons/reply/strings.xml @@ -0,0 +1,4 @@ + + + Comet Reply + diff --git a/src/Comet/sample/CometComposeProbe/MainActivity.cs b/src/Comet/sample/CometComposeProbe/MainActivity.cs new file mode 100644 index 000000000..c98a960be --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/MainActivity.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Android.App; +using Android.OS; +using Comet; +using Comet.Platform.Compose; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; + +namespace CometComposeProbe +{ + /// + /// Reproduces a Jetchat-style conversation screen (a C# port of Google's Compose + /// "Jetchat" sample) on the Comet node backend: a fixed top bar over a virtualized, + /// Yoga-laid-out message list. Every row is laid out by the shared C# Yoga engine + /// (avatar + author + wrapping body), so it renders identically to the iOS/SwiftUI + /// backend — no MAUI handlers in the path. + /// + // No Label on the attribute: the launcher shows the application label, which comes from + // — so `-p:CometSample=…` names each standalone sample app. The icon + // resource is likewise per-variant (Icons//appicon.png linked into mipmap). + [Activity(MainLauncher = true, Icon = "@mipmap/appicon")] + public class MainActivity : AndroidX.Activity.ComponentActivity + { + /// Which sample to show: the `--es screen` intent extra (the smoke scripts / + /// dev loop), else derived from the package id (`com.comet.sample.<name>` — the + /// standalone per-sample installs), else Jetchat. + string Screen => + Intent?.GetStringExtra("screen") + ?? (PackageName is { } pkg && pkg.StartsWith("com.comet.sample.") + ? pkg.Substring("com.comet.sample.".Length) + : "jetchat"); +#if DEBUG + /// The logical root, exposed so the hot-reload demo receiver can log reload state. + internal static View? RootView; +#endif + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + ActionBar?.Hide(); + + // P7 IME handling lives in ComposeBackendRoot (decor-level insets listener): + // no SoftInput mode needed — Android 15's forced edge-to-edge makes + // AdjustResize a no-op anyway, and the insets path works on every version. + + ThreadHelper.SetFireOnMainThread(a => RunOnUiThread(a)); + +#if DEBUG + // Must be on BEFORE any View is constructed: view registration and the + // active-view list (what TriggerReload targets) are gated on IsEnabled. + Microsoft.Maui.HotReload.MauiHotReloadHelper.IsEnabled = true; + + // The Comet-tree dev agent (DevFlow wire-compatible tree/elements/tap/fill + + // real MotionEvent drags) must also start BEFORE the UI is built — it enables + // CometDevRegistry tracking, and views materialized earlier never enter the + // tree. Fixed port 9223: reach it with `adb forward tcp:9223 tcp:9223`. + Comet.Platform.Compose.ComposeDevAgentHost.Start(this); +#endif + + // Load Jetchat's real fonts (Montserrat titles/labels, Karla body) from bundled assets + // and register them per weight so the Compose backend renders the true outlines. Jetchat + // ships distinct weight files; synthesizing weights from a single base TTF makes the + // intermediate weights (e.g. Montserrat Medium) render thin/pale, so register each. + try + { + Android.Graphics.Typeface Asset(string name) => + Android.Graphics.Typeface.CreateFromAsset(Assets, "fonts/" + name); + + ComposeFontRegistry.Register("Montserrat", 400, Asset("montserrat_regular.ttf")); + ComposeFontRegistry.Register("Montserrat", 500, Asset("montserrat_medium.ttf")); + ComposeFontRegistry.Register("Montserrat", 600, Asset("montserrat_semibold.ttf")); + ComposeFontRegistry.Register("Karla", 400, Asset("karla_regular.ttf")); + ComposeFontRegistry.Register("Karla", 700, Asset("karla_bold.ttf")); + + // Google's Material Icons font as the cross-platform icon set (same glyphs as iOS). + ComposeFontRegistry.Register(CometSamples.Jetchat.JetchatIcons.Font, 400, Asset("material_icons.ttf")); + CometSamples.Jetchat.JetchatIcons.Register(); + } + catch (Exception ex) + { + Android.Util.Log.Warn("CometProbe", "Font load failed: " + ex); + } + + // Material 3 color. By DEFAULT we seed the scheme from the Comet brand color via the + // shared material-color-utilities path (JetchatTheme.ApplyDynamicScheme) — the SAME algorithm + // and SAME seed as iOS — so the app is theme-driven and looks identical cross-platform. + // Flip UseWallpaperMaterialYou to instead adopt the device's Material You (gold-faithful to + // Themes.kt's isDynamicColor=true, but diverges from iOS, which can't read the wallpaper). + // Follow the system light/dark setting (the gold's isSystemInDarkTheme) so the generated + // scheme is the light OR dark M3 mapping. Read at startup (a live toggle would re-create + // the activity, which rebuilds anyway). + bool dark = (Resources!.Configuration!.UiMode & Android.Content.Res.UiMode.NightMask) + == Android.Content.Res.UiMode.NightYes; + + const bool UseWallpaperMaterialYou = false; + AndroidX.Compose.Material3.ColorScheme scheme; + if (UseWallpaperMaterialYou && Build.VERSION.SdkInt >= BuildVersionCodes.S) + { + scheme = dark ? AndroidX.Compose.MaterialTheme.DynamicDarkColorScheme(this) + : AndroidX.Compose.MaterialTheme.DynamicLightColorScheme(this); + try { ApplySchemeToTheme(scheme); } + catch (Exception ex) { Android.Util.Log.Warn("CometProbe", "scheme read-back failed, using static theme: " + ex); } + } + else + { + // Generate the M3 scheme in shared C# (applies it to the C# theme tokens the Comet views + // read), then build the matching Compose ColorScheme so the real Material controls + // (Button, ripples) are seeded identically. Brand seed by default; flip SeedFromContent to + // derive the scheme from the profile photo (content-based Material You — same generator, + // image seed — identical to iOS's SeedFromContent path). + const bool SeedFromContent = false; + MaterialColorUtilities.Schemes.Scheme s = + SeedFromContent && PixelsFromDrawable(Resource.Drawable.ali) is { } px + ? CometSamples.Jetchat.JetchatTheme.ApplyDynamicSchemeFromPixels(px, dark) + : CometSamples.Jetchat.JetchatTheme.ApplyDynamicScheme(CometSamples.Jetchat.JetchatTheme.SeedColor, dark); + scheme = ComposeSchemeFromSeed(s, dark); + } + + // Per-sample theming: the Reply screen uses Reply's OWN static scheme + // (gold: ContrastAwareReplyTheme, dynamicColor=false) — the real M3 widgets + // (NavigationBar/Rail, drawer sheets) derive their container colors from it. + bool replyScreen = Screen == "reply"; + if (replyScreen) + scheme = CometSamples.Reply.ReplyTheme.ComposeScheme(); + else if (Screen == "jetnews") + scheme = CometSamples.JetNews.JetNewsTheme.ComposeScheme(); + else if (Screen == "jetcaster") + scheme = CometSamples.Jetcaster.JetcasterTheme.ComposeScheme(); + + // Status bar: Surface (matches the header bar). + // Nav bar: SurfaceTinted (matches the footer/UserInput bar so the background is seamless). + var surf = replyScreen ? CometSamples.Reply.ReplyTheme.Background : CometSamples.Jetchat.JetchatTheme.Surface; + var statusTint = Android.Graphics.Color.Argb(255, (int)(surf.Red * 255), (int)(surf.Green * 255), (int)(surf.Blue * 255)); + var footerSurf = replyScreen ? CometSamples.Reply.ReplyTheme.SurfaceContainer : CometSamples.Jetchat.JetchatTheme.SurfaceTinted; + var navTint = Android.Graphics.Color.Argb(255, (int)(footerSurf.Red * 255), (int)(footerSurf.Green * 255), (int)(footerSurf.Blue * 255)); + Window!.SetStatusBarColor(statusTint); + Window.SetNavigationBarColor(navTint); + // Light bars (dark icons) on a light surface; in dark mode clear the flags for light icons. + const int lightBars = (int)Android.Views.WindowInsetsControllerAppearance.LightStatusBars + | (int)Android.Views.WindowInsetsControllerAppearance.LightNavigationBars; + Window.InsetsController?.SetSystemBarsAppearance(dark ? 0 : lightBars, lightBars); + + var root = BuildUi(); +#if DEBUG + RootView = root; +#endif + + // Link taps in chat bubbles open the system browser (the gold's uriHandler.openUri). + CometSamples.Jetchat.JetchatConversation.OpenUrl = url => + { + try { StartActivity(new Android.Content.Intent(Android.Content.Intent.ActionView, Android.Net.Uri.Parse(url))); } + catch (Exception ex) { Android.Util.Log.Warn("CometProbe", "open url failed: " + ex.Message); } + }; + + var backend = new ComposeBackendRoot(new EmptyServiceProvider()) + { + UseYogaLayout = true, + // Same scheme drives the real Material controls (Button, ripples) so they match. + WrapContent = content => + { + var theme = new AndroidX.Compose.MaterialTheme { ColorScheme = scheme }; + theme.Add(content); + return theme; + }, + }; + var composeView = backend.CreateView(this, root); + SetContentView(composeView); +#if DEBUG + DevFlowHelper.Start(this, composeView); +#endif + } + + // Decode a drawable to a small AARRGGBB pixel buffer for content-based theming (the + // material-color-utilities Quantize+Score wants raw pixels). Downscaled for a fast seed extract. + uint[]? PixelsFromDrawable(int resId, int max = 64) + { + using var bmp = Android.Graphics.BitmapFactory.DecodeResource(Resources, resId); + if (bmp is null) return null; + int w = System.Math.Min(max, bmp.Width), h = System.Math.Min(max, bmp.Height); + using var scaled = Android.Graphics.Bitmap.CreateScaledBitmap(bmp, w, h, true); + var ints = new int[w * h]; + scaled!.GetPixels(ints, 0, w, 0, 0, w, h); // ARGB ints + var px = new uint[ints.Length]; + for (int i = 0; i < ints.Length; i++) px[i] = (uint)ints[i]; + return px; + } + + // Build the Compose MaterialTheme ColorScheme from the seed-generated M3 roles so the real + // Material controls (Button, ripples) match the Comet views (which read the same roles from the + // C# theme tokens). Mirrors the role subset the static scheme used. + static AndroidX.Compose.Material3.ColorScheme ComposeSchemeFromSeed(MaterialColorUtilities.Schemes.Scheme s, bool dark) + { + static AndroidX.Compose.Color C(uint argb) => + AndroidX.Compose.Color.FromArgb(0xFF, (byte)((argb >> 16) & 0xFF), (byte)((argb >> 8) & 0xFF), (byte)(argb & 0xFF)); + return dark + ? AndroidX.Compose.MaterialTheme.DarkColorScheme( + primary: C(s.Primary), onPrimary: C(s.OnPrimary), + surface: C(s.Surface), onSurface: C(s.OnSurface), + surfaceVariant: C(s.SurfaceVariant), onSurfaceVariant: C(s.OnSurfaceVariant), + background: C(s.Background), onBackground: C(s.OnBackground), + tertiary: C(s.Tertiary)) + : AndroidX.Compose.MaterialTheme.LightColorScheme( + primary: C(s.Primary), onPrimary: C(s.OnPrimary), + surface: C(s.Surface), onSurface: C(s.OnSurface), + surfaceVariant: C(s.SurfaceVariant), onSurfaceVariant: C(s.OnSurfaceVariant), + background: C(s.Background), onBackground: C(s.OnBackground), + tertiary: C(s.Tertiary)); + } + + // Read the scheme's role colors back into the C# theme tokens (facade Color exposes A/R/G/B). + static void ApplySchemeToTheme(AndroidX.Compose.Material3.ColorScheme s) + { + static Microsoft.Maui.Graphics.Color M(AndroidX.Compose.Color c) => + new Microsoft.Maui.Graphics.Color(c.R / 255f, c.G / 255f, c.B / 255f, c.A / 255f); + + CometSamples.Jetchat.JetchatTheme.ApplyScheme( + primary: M(s.Primary), onPrimary: M(s.OnPrimary), primaryContainer: M(s.PrimaryContainer), + secondary: M(s.Secondary), surface: M(s.Surface), onSurface: M(s.OnSurface), + surfaceVariant: M(s.SurfaceVariant), onSurfaceVariant: M(s.OnSurfaceVariant), + background: M(s.Background), tertiary: M(s.Tertiary), tertiaryContainer: M(s.TertiaryContainer), + onTertiaryContainer: M(s.OnTertiaryContainer), outline: M(s.Outline)); + } + + // The faithful Jetchat conversation screen (shared tree, identical on iOS). A ~24dp top + // inset clears the status bar. The screen comes from the `--es screen` extra (smokes) + // or the standalone per-sample package id — see . +#if DEBUG + // A [Body] root is what hot reload targets (see HotReloadDemo.cs). + View BuildUi() => Screen switch + { + "reply" => new CometSamples.Reply.ReplyProbeRoot(), + "jetnews" => new CometSamples.JetNews.JetNewsRoot(), + "jetsnack" => new CometSamples.Jetsnack.JetsnackRoot(topInset: 52), + "jetcaster" => BuildJetcaster(), + _ => new JetchatRoot(), + }; +#else + View BuildUi() => Screen switch + { + "reply" => new CometSamples.Reply.ReplyProbeRoot(), + "jetnews" => new CometSamples.JetNews.JetNewsRoot(), + "jetsnack" => new CometSamples.Jetsnack.JetsnackRoot(topInset: 52), + "jetcaster" => BuildJetcaster(), + _ => CometSamples.Jetchat.JetchatConversation.Build(topInset: 24), + }; +#endif + + static bool _robotoFlexRegistered; + + // Fixture mode: parse the six bundled feed snapshots — and load Jetcaster's + // 1.6MB RobotoFlex variable font — once, on first entry, so the other samples' + // cold-start numbers stay untouched. + View BuildJetcaster() + { + if (!_robotoFlexRegistered) + { + _robotoFlexRegistered = true; + // Instantiate the gold's display weights from the single variable file + // (Typeface.Create(tf, w, italic) resolves weight axes on API 28+). + var robotoFlex = Android.Graphics.Typeface.CreateFromAsset(Assets, "fonts/roboto_flex.ttf"); + ComposeFontRegistry.Register("RobotoFlex", 400, robotoFlex); + if (OperatingSystem.IsAndroidVersionAtLeast(28)) + ComposeFontRegistry.Register("RobotoFlex", 738, + Android.Graphics.Typeface.Create(robotoFlex, 738, false)); + } + CometSamples.Jetcaster.JetcasterFixtures.Load( + name => Assets!.Open("jetcaster/" + name)); + return new CometSamples.Jetcaster.JetcasterRoot(topInset: 52); + } + + sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + } +} diff --git a/src/Comet/sample/CometComposeProbe/Properties/AndroidManifest.xml b/src/Comet/sample/CometComposeProbe/Properties/AndroidManifest.xml new file mode 100644 index 000000000..22884cbd5 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Properties/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ali.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/ali.png new file mode 100644 index 000000000..eab3c658a Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/ali.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/almonds.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/almonds.jpg new file mode 100644 index 000000000..0f1eef57e Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/almonds.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_chips.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_chips.jpg new file mode 100644 index 000000000..37c805d16 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_chips.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_juice.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_juice.jpg new file mode 100644 index 000000000..d519dbcd4 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_juice.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_pie.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_pie.jpg new file mode 100644 index 000000000..41096b221 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_pie.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_sauce.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_sauce.jpg new file mode 100644 index 000000000..7f5331ee2 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_sauce.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apples.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apples.jpg new file mode 100644 index 000000000..cc729f164 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apples.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_0.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_0.jpg new file mode 100644 index 000000000..dcf2608a8 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_0.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_1.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_1.jpg new file mode 100644 index 000000000..23f171d48 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_1.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_10.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_10.jpg new file mode 100644 index 000000000..27b8dc615 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_10.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_2.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_2.jpg new file mode 100644 index 000000000..54c74a888 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_2.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_3.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_3.jpg new file mode 100644 index 000000000..a63f8ce57 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_3.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_4.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_4.jpg new file mode 100644 index 000000000..279b70def Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_4.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_5.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_5.jpg new file mode 100644 index 000000000..e4266c738 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_5.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_6.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_6.jpg new file mode 100644 index 000000000..0b3226775 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_6.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_7.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_7.jpg new file mode 100644 index 000000000..01e9a775b Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_7.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_8.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_8.jpg new file mode 100644 index 000000000..5b387afa2 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_8.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_9.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_9.jpg new file mode 100644 index 000000000..087bf93af Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_9.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_express.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_express.png new file mode 100644 index 000000000..f05790fb9 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_express.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/cheese.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/cheese.jpg new file mode 100644 index 000000000..c5c6dce61 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/cheese.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/chips.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/chips.jpg new file mode 100644 index 000000000..04d5fe2cd Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/chips.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/cupcake.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/cupcake.jpg new file mode 100644 index 000000000..42e766d84 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/cupcake.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/desserts.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/desserts.jpg new file mode 100644 index 000000000..6d4499076 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/desserts.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/donut.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/donut.jpg new file mode 100644 index 000000000..076896a81 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/donut.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/eclair.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/eclair.jpg new file mode 100644 index 000000000..560178034 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/eclair.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/froyo.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/froyo.jpg new file mode 100644 index 000000000..e1bb068cc Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/froyo.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/fruit.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/fruit.jpg new file mode 100644 index 000000000..412247318 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/fruit.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/gingerbread.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/gingerbread.jpg new file mode 100644 index 000000000..ac069de10 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/gingerbread.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/gluten_free.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/gluten_free.jpg new file mode 100644 index 000000000..0745457a3 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/gluten_free.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/grapes.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/grapes.jpg new file mode 100644 index 000000000..3b573787c Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/grapes.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/honeycomb.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/honeycomb.jpg new file mode 100644 index 000000000..ea632bd25 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/honeycomb.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_at.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_at.xml new file mode 100644 index 000000000..37d1268e9 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_at.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_chat.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_chat.xml new file mode 100644 index 000000000..e53bb3379 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_chat.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_create.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_create.xml new file mode 100644 index 000000000..db9b31f52 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_create.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat.xml new file mode 100644 index 000000000..e87573ebb --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat.xml @@ -0,0 +1,42 @@ + + + + + + + + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_back.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_back.xml new file mode 100644 index 000000000..2332fa8a1 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_back.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_front.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_front.xml new file mode 100644 index 000000000..b2200fa4f --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_front.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_badge.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_badge.xml new file mode 100644 index 000000000..5f54f0f04 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_badge.xml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_logo.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_logo.xml new file mode 100644 index 000000000..16a95957a --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_logo.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_wordmark.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_wordmark.xml new file mode 100644 index 000000000..932f18a3a --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_wordmark.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_mood.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_mood.xml new file mode 100644 index 000000000..b5f400251 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_mood.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_photo.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_photo.xml new file mode 100644 index 000000000..083151a12 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_photo.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_place.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_place.xml new file mode 100644 index 000000000..ddb5a94e2 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_place.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_video.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_video.xml new file mode 100644 index 000000000..7e0cdcdcd --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_video.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ice_cream_sandwich.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/ice_cream_sandwich.jpg new file mode 100644 index 000000000..fd77631e9 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/ice_cream_sandwich.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/jelly_bean.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/jelly_bean.jpg new file mode 100644 index 000000000..84a10208c Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/jelly_bean.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/jetchat_logo.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/jetchat_logo.xml new file mode 100644 index 000000000..142212298 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/jetchat_logo.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/kitkat.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/kitkat.jpg new file mode 100644 index 000000000..75b2e44ab Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/kitkat.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/kiwi.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/kiwi.jpg new file mode 100644 index 000000000..2197fbd48 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/kiwi.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/lollipop.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/lollipop.jpg new file mode 100644 index 000000000..98d1db7d2 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/lollipop.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/mango.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/mango.jpg new file mode 100644 index 000000000..717773d7b Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/mango.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/marshmallow.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/marshmallow.jpg new file mode 100644 index 000000000..cdc115922 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/marshmallow.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/nougat.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/nougat.jpg new file mode 100644 index 000000000..1a844d9b9 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/nougat.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/nuts.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/nuts.jpg new file mode 100644 index 000000000..03556767e Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/nuts.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/oreo.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/oreo.jpg new file mode 100644 index 000000000..cf2c3e534 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/oreo.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/organic.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/organic.jpg new file mode 100644 index 000000000..2847abf4f Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/organic.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/paleo.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/paleo.jpg new file mode 100644 index 000000000..750fcd58e Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/paleo.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/pie.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/pie.jpg new file mode 100644 index 000000000..439c18cfb Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/pie.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder.jpg new file mode 100644 index 000000000..31e05faac Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder_1_1.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder_1_1.png new file mode 100644 index 000000000..55fb3c2ac Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder_1_1.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/popcorn.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/popcorn.jpg new file mode 100644 index 000000000..02713ffdb Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/popcorn.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1.png new file mode 100644 index 000000000..f2efcdc27 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1_thumb.png new file mode 100644 index 000000000..6af4fcc71 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1_thumb.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2.png new file mode 100644 index 000000000..acc31574c Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2_thumb.png new file mode 100644 index 000000000..e5c598ec4 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2_thumb.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3.png new file mode 100644 index 000000000..f8922fbe9 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3_thumb.png new file mode 100644 index 000000000..6e6a82d8b Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3_thumb.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4.png new file mode 100644 index 000000000..14b14d977 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4_thumb.png new file mode 100644 index 000000000..d772e0019 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4_thumb.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5.png new file mode 100644 index 000000000..31eb8183b Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5_thumb.png new file mode 100644 index 000000000..5c42ce2a8 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5_thumb.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6.png new file mode 100644 index 000000000..3ad0c2952 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6_thumb.png new file mode 100644 index 000000000..092cc3f13 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6_thumb.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/pretzels.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/pretzels.jpg new file mode 100644 index 000000000..d31d4aa4e Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/pretzels.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/smoothies.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/smoothies.jpg new file mode 100644 index 000000000..f2eaa31bf Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/smoothies.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/someone_else.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/someone_else.jpg new file mode 100644 index 000000000..95bc4a8bb Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/someone_else.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/sticker.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/sticker.png new file mode 100644 index 000000000..54bbcfb96 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/sticker.png differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/vegan.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/vegan.jpg new file mode 100644 index 000000000..29276a68b Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/vegan.jpg differ diff --git a/src/Comet/sample/CometComposeProbe/Resources/values/colors.xml b/src/Comet/sample/CometComposeProbe/Resources/values/colors.xml new file mode 100644 index 000000000..5c0c1ac20 --- /dev/null +++ b/src/Comet/sample/CometComposeProbe/Resources/values/colors.xml @@ -0,0 +1,8 @@ + + + + #F3B711 + #0540F2 + diff --git a/src/Comet/sample/CometComposeProbe/phase1-gate-pixel5.png b/src/Comet/sample/CometComposeProbe/phase1-gate-pixel5.png new file mode 100644 index 000000000..c285114be Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase1-gate-pixel5.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase1-interactive-count3.png b/src/Comet/sample/CometComposeProbe/phase1-interactive-count3.png new file mode 100644 index 000000000..1702dab24 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase1-interactive-count3.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-app-detail.png b/src/Comet/sample/CometComposeProbe/phase2-app-detail.png new file mode 100644 index 000000000..05ed19121 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-app-detail.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-app-list.png b/src/Comet/sample/CometComposeProbe/phase2-app-list.png new file mode 100644 index 000000000..71723ae1c Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-app-list.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-form.png b/src/Comet/sample/CometComposeProbe/phase2-form.png new file mode 100644 index 000000000..5e2d35350 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-form.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-input-toggle-on.png b/src/Comet/sample/CometComposeProbe/phase2-input-toggle-on.png new file mode 100644 index 000000000..6bb71c7e6 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-input-toggle-on.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-lazycolumn.png b/src/Comet/sample/CometComposeProbe/phase2-lazycolumn.png new file mode 100644 index 000000000..c2a7aad35 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-lazycolumn.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-navigation-detail.png b/src/Comet/sample/CometComposeProbe/phase2-navigation-detail.png new file mode 100644 index 000000000..6cc840335 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-navigation-detail.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-navigation-home.png b/src/Comet/sample/CometComposeProbe/phase2-navigation-home.png new file mode 100644 index 000000000..31e159142 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-navigation-home.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-nested-derived.png b/src/Comet/sample/CometComposeProbe/phase2-nested-derived.png new file mode 100644 index 000000000..961e245b4 Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-nested-derived.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-slider.png b/src/Comet/sample/CometComposeProbe/phase2-slider.png new file mode 100644 index 000000000..b03198aea Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-slider.png differ diff --git a/src/Comet/sample/CometComposeProbe/phase2-styling.png b/src/Comet/sample/CometComposeProbe/phase2-styling.png new file mode 100644 index 000000000..edbd9ae6e Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-styling.png differ diff --git a/src/Comet/sample/CometControlsGallery/App.cs b/src/Comet/sample/CometControlsGallery/App.cs index b75cf4b1e..3bc221532 100644 --- a/src/Comet/sample/CometControlsGallery/App.cs +++ b/src/Comet/sample/CometControlsGallery/App.cs @@ -16,7 +16,7 @@ using Microsoft.Maui.LifecycleEvents; using static Comet.CometControls; #if DEBUG -using MauiDevFlow.Agent; +using Microsoft.Maui.DevFlow.Agent; #endif #if __MACOS__ using Microsoft.Maui.Platform.MacOS.Hosting; diff --git a/src/Comet/sample/CometControlsGallery/CometControlsGallery.csproj b/src/Comet/sample/CometControlsGallery/CometControlsGallery.csproj index c981e1f23..61da299c7 100644 --- a/src/Comet/sample/CometControlsGallery/CometControlsGallery.csproj +++ b/src/Comet/sample/CometControlsGallery/CometControlsGallery.csproj @@ -7,7 +7,6 @@ Exe CometControlsGallery true - 10.0.31 true disable enable @@ -37,18 +36,16 @@ - - - - - - + diff --git a/src/Comet/sample/CometMacApp/CometMacApp.csproj b/src/Comet/sample/CometMacApp/CometMacApp.csproj index 6d237c9fe..671b9c892 100644 --- a/src/Comet/sample/CometMacApp/CometMacApp.csproj +++ b/src/Comet/sample/CometMacApp/CometMacApp.csproj @@ -14,7 +14,6 @@ 1.0 1 14.0 - 10.0.31 diff --git a/src/Comet/sample/CometStressTest/CometStressTest.csproj b/src/Comet/sample/CometStressTest/CometStressTest.csproj index 7620a85ec..d2ea128de 100644 --- a/src/Comet/sample/CometStressTest/CometStressTest.csproj +++ b/src/Comet/sample/CometStressTest/CometStressTest.csproj @@ -13,7 +13,7 @@ 1 XC0103 15.0 - 21.0 + 24.0 16.0 diff --git a/src/Comet/sample/CometSwiftUIProbe/AppDelegate.cs b/src/Comet/sample/CometSwiftUIProbe/AppDelegate.cs new file mode 100644 index 000000000..18f502085 --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/AppDelegate.cs @@ -0,0 +1,146 @@ +using Comet; +using Comet.DevTools; +using Comet.Platform.SwiftUI; +using Comet.Reactive; +using CoreFoundation; +using Foundation; +using Microsoft.Maui.Graphics; +using UIKit; + +namespace CometSwiftUIProbe +{ + [Register("AppDelegate")] + public class AppDelegate : UIApplicationDelegate + { + public override UIWindow? Window { get; set; } + + readonly Signal _count = new(0); + readonly Signal _name = new(string.Empty); + readonly Signal _fancy = new(false); + readonly Signal _taps = new(0); + readonly Signal _long = new(false); + readonly Signal _len = new(0); + + static readonly string[] Lengths = + { + "one line", + "a slightly longer line that may wrap once on a phone width here", + "three lines worth of text here that should wrap onto roughly three lines on a typical phone width so we can watch the stack below it move", + "a much longer paragraph designed to wrap onto five or six lines so that the rows beneath it are pushed substantially further down the screen, proving the whole vertical stack expands and contracts as the text length changes rather than the text just growing inside a fixed slot", + }; + + NavigationView? _nav; + CometDevAgent? _agent; + + public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions) + { + // Comet's fluent env writes post through ThreadHelper; we're on the main thread. + ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + Window = new UIWindow(UIScreen.MainScreen.Bounds); + + // Dev agent on the DevFlow CLI's default port: `maui devflow ui tree/tap` connects + // straight to localhost:9223 on the iOS sim, so the stock CLI drives this Comet app. + // Start BEFORE materializing so the registry tracks every node as the tree is built. + _agent = new CometDevAgent(CometDevAgent.DevFlowPort, a => DispatchQueue.MainQueue.DispatchAsync(a)); + _agent.Start(); + + // Use Google's Material Icons font (bundled, registered via Info.plist UIAppFonts) as the + // cross-platform icon set, so Icon("mic") etc. draw the SAME Material glyph as Android. + CometSamples.Jetchat.JetchatIcons.Register(); + + // Generate the Material 3 scheme from the Comet theme using Google's material-color-utilities + // (the SAME algorithm as Android's Material You) so iOS renders a real tonal scheme — identical + // to Android when both seed from the same input. Must run BEFORE BuildUi() (the tree reads the + // theme tokens at build time). Brand seed by default; flip SeedFromContent to derive the scheme + // from the user's profile photo (content-based Material You — same generator, image seed). + // Follow the system light/dark setting (the gold's isSystemInDarkTheme) so the generated + // scheme is the light OR dark M3 mapping. Read at startup; a live toggle would need the + // structural re-render (re-apply the scheme + rebuild the tree). + bool dark = UIScreen.MainScreen.TraitCollection.UserInterfaceStyle == UIUserInterfaceStyle.Dark; + const bool SeedFromContent = false; + if (SeedFromContent && PixelsFromBundle("ali") is { } pixels) + CometSamples.Jetchat.JetchatTheme.ApplyDynamicSchemeFromPixels(pixels, dark); + else + CometSamples.Jetchat.JetchatTheme.ApplyDynamicScheme(CometSamples.Jetchat.JetchatTheme.SeedColor, dark); + + // A real Comet view tree, rendered as SwiftUI through the node protocol — + // no MAUI handlers in the render path. + var backend = new SwiftUIBackendRoot(new EmptyServiceProvider()) { UseYogaLayout = true }; + Window.RootViewController = backend.CreateController(BuildUi()); + + Window.MakeKeyAndVisible(); + return true; + } + + // The faithful Jetchat conversation screen (shared tree, identical on Android). iPhone + // safe-area insets: status bar / Dynamic Island ~50dp top, home indicator ~28dp bottom. + // The screen comes from SIMCTL_CHILD_COMET_SCREEN (the smoke scripts — the iOS twin + // of the Android probe's intent extra) or, for the standalone per-sample installs + // (-p:CometSample=…), from the bundle id (com.comet.sample.). + static string Screen => + System.Environment.GetEnvironmentVariable("COMET_SCREEN") + ?? (NSBundle.MainBundle.BundleIdentifier is { } bid && bid.StartsWith("com.comet.sample.") + ? bid.Substring("com.comet.sample.".Length) + : "jetchat"); + + View BuildUi() => Screen switch + { + "reply" => new CometSamples.Reply.ReplyProbeRoot(), + "jetnews" => new CometSamples.JetNews.JetNewsRoot(), + "jetsnack" => new CometSamples.Jetsnack.JetsnackRoot(topInset: 59), + _ => CometSamples.Jetchat.JetchatConversation.Build(topInset: 50, bottomInset: 28), + }; + + // Decode a bundled image to a small AARRGGBB pixel buffer for content-based theming (the + // material-color-utilities Quantize+Score wants raw pixels). Downscaled for a fast seed extract. + static uint[]? PixelsFromBundle(string name, int max = 64) + { + using var img = UIImage.FromBundle(name); + if (img?.CGImage is not CoreGraphics.CGImage cg) return null; + int w = System.Math.Min(max, (int)cg.Width), h = System.Math.Min(max, (int)cg.Height); + if (w <= 0 || h <= 0) return null; + var bytes = new byte[w * h * 4]; + using (var cs = CoreGraphics.CGColorSpace.CreateDeviceRGB()) + using (var ctx = new CoreGraphics.CGBitmapContext(bytes, w, h, 8, w * 4, cs, CoreGraphics.CGImageAlphaInfo.PremultipliedLast)) + ctx.DrawImage(new CoreGraphics.CGRect(0, 0, w, h), cg); + var px = new uint[w * h]; + for (int i = 0; i < px.Length; i++) + px[i] = ((uint)bytes[i * 4 + 3] << 24) | ((uint)bytes[i * 4] << 16) | ((uint)bytes[i * 4 + 1] << 8) | bytes[i * 4 + 2]; + return px; + } + + // Interactive controls with AutomationIds (clean `--automationId` selector targets) plus + // a Navigate button, so the dev tree pruning across push/pop is observable. + View HomeScreen() => new VStack + { + new Text("Comet → SwiftUI").Color(Colors.White), + + new Text(() => $"Count: {_count.Value}").Color(Colors.White).AutomationId("countLabel"), + new Button("Increment", () => _count.Value++).AutomationId("incrementButton"), + + new TextField(_name, "Type a name").AutomationId("nameField"), + new Text(() => $"Hello, {(_name.Value.Length == 0 ? "stranger" : _name.Value)}").Color(Colors.White), + + new Toggle(_fancy).AutomationId("fancyToggle"), + new Text(() => _fancy.Value ? "Fancy: ON" : "Fancy: off").Color(Colors.White), + + new Text(() => $"Tapped: {_taps.Value}× (tap me)").Color(Colors.White) + .OnTap(_ => _taps.Value++).AutomationId("tapTarget"), + + new Button("Go to Page 2 →", () => _nav!.Navigate(SecondScreen())).AutomationId("gotoPage2"), + }.Background(Color.FromArgb("#6750A4")).Padding(24); + + View SecondScreen() => new VStack + { + new Text("📄 Page 2").Color(Colors.White), + new Text("Pushed via Navigate()").Color(Colors.White), + new Button("← Back", () => _nav!.Pop()).AutomationId("backButton"), + }.Background(Color.FromArgb("#7D5260")).Padding(28); + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/CometSwiftUIProbe.csproj b/src/Comet/sample/CometSwiftUIProbe/CometSwiftUIProbe.csproj new file mode 100644 index 000000000..4caa13e87 --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/CometSwiftUIProbe.csproj @@ -0,0 +1,122 @@ + + + + + net11.0-ios + Exe + enable + disable + 17.0 + CometSwiftUIProbe + com.comet.swiftuiprobe + CometSwiftUI + 1 + 1.0 + + true + + + + + com.comet.sample.$(CometSample) + Comet $(CometSample.Substring(0,1).ToUpperInvariant())$(CometSample.Substring(1)) + obj/sample-$(CometSample)/ + bin/sample-$(CometSample)/ + + + + + probe + $(CometSample) + AppIcon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ali.png + jetnews_logo.png + jetnews_wordmark.png + jetnews_badge.png + + + + someone_else.jpg + sticker.png + jetchat.png + avatar_0.jpg + avatar_1.jpg + avatar_10.jpg + avatar_2.jpg + avatar_3.jpg + avatar_4.jpg + avatar_5.jpg + avatar_6.jpg + avatar_7.jpg + avatar_8.jpg + avatar_9.jpg + avatar_express.png + + post_1.png + post_1_thumb.png + post_2.png + post_2_thumb.png + post_3.png + post_3_thumb.png + post_4.png + post_4_thumb.png + post_5.png + post_5_thumb.png + post_6.png + post_6_thumb.png + + + + + + Framework + False + + + + diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Bold.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Bold.ttf new file mode 100644 index 000000000..052231c16 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Bold.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Regular.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Regular.ttf new file mode 100644 index 000000000..4269aa069 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Regular.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla.ttf new file mode 100644 index 000000000..939d29ef1 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/MaterialIcons-Regular.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/MaterialIcons-Regular.ttf new file mode 100644 index 000000000..9d09b0feb Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/MaterialIcons-Regular.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Medium.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Medium.ttf new file mode 100755 index 000000000..6e079f698 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Medium.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Regular.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Regular.ttf new file mode 100755 index 000000000..8d443d5d5 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Regular.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-SemiBold.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-SemiBold.ttf new file mode 100755 index 000000000..f8a43f2b2 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-SemiBold.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat.ttf new file mode 100644 index 000000000..c97aca185 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/RobotoFlex-Regular.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/RobotoFlex-Regular.ttf new file mode 100644 index 000000000..2e5c2a26a Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/RobotoFlex-Regular.ttf differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..3193f63d6 --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "appicon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/appicon.png b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/appicon.png new file mode 100644 index 000000000..fc5af0e8f Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/appicon.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..3193f63d6 --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "appicon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/appicon.png b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/appicon.png new file mode 100644 index 000000000..b98372683 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/appicon.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..3193f63d6 --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "appicon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/appicon.png b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/appicon.png new file mode 100644 index 000000000..97f2f9ba2 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/appicon.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..3193f63d6 --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "appicon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/appicon.png b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/appicon.png new file mode 100644 index 000000000..1afc3b250 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/appicon.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Info.plist b/src/Comet/sample/CometSwiftUIProbe/Info.plist new file mode 100644 index 000000000..b41db398c --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Info.plist @@ -0,0 +1,48 @@ + + + + + + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + UILaunchStoryboardName + LaunchScreen + MinimumOSVersion + 17.0 + UIAppFonts + + Fonts/Montserrat-Regular.ttf + Fonts/Montserrat-Medium.ttf + Fonts/Montserrat-SemiBold.ttf + Fonts/Karla-Regular.ttf + Fonts/Karla-Bold.ttf + Fonts/MaterialIcons-Regular.ttf + Fonts/RobotoFlex-Regular.ttf + + + diff --git a/src/Comet/sample/CometSwiftUIProbe/LaunchScreen.storyboard b/src/Comet/sample/CometSwiftUIProbe/LaunchScreen.storyboard new file mode 100644 index 000000000..e27e4e8e8 --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/LaunchScreen.storyboard @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Comet/sample/CometSwiftUIProbe/Main.cs b/src/Comet/sample/CometSwiftUIProbe/Main.cs new file mode 100644 index 000000000..6f829f621 --- /dev/null +++ b/src/Comet/sample/CometSwiftUIProbe/Main.cs @@ -0,0 +1,9 @@ +using UIKit; + +namespace CometSwiftUIProbe +{ + public class Application + { + static void Main(string[] args) => UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/ali.png b/src/Comet/sample/CometSwiftUIProbe/Resources/ali.png new file mode 100644 index 000000000..eab3c658a Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/ali.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_0.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_0.jpg new file mode 100644 index 000000000..dcf2608a8 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_0.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_1.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_1.jpg new file mode 100644 index 000000000..23f171d48 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_1.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_10.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_10.jpg new file mode 100644 index 000000000..27b8dc615 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_10.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_2.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_2.jpg new file mode 100644 index 000000000..54c74a888 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_2.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_3.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_3.jpg new file mode 100644 index 000000000..a63f8ce57 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_3.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_4.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_4.jpg new file mode 100644 index 000000000..279b70def Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_4.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_5.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_5.jpg new file mode 100644 index 000000000..e4266c738 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_5.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_6.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_6.jpg new file mode 100644 index 000000000..0b3226775 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_6.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_7.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_7.jpg new file mode 100644 index 000000000..01e9a775b Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_7.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_8.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_8.jpg new file mode 100644 index 000000000..5b387afa2 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_8.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_9.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_9.jpg new file mode 100644 index 000000000..087bf93af Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_9.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_express.png b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_express.png new file mode 100644 index 000000000..f05790fb9 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_express.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/jetchat.png b/src/Comet/sample/CometSwiftUIProbe/Resources/jetchat.png new file mode 100644 index 000000000..f7b595c85 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/jetchat.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_badge.png b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_badge.png new file mode 100644 index 000000000..4ef8ad291 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_badge.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_logo.png b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_logo.png new file mode 100644 index 000000000..39927d235 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_logo.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_wordmark.png b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_wordmark.png new file mode 100644 index 000000000..93e2ff4d9 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_wordmark.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/placeholder_1_1.png b/src/Comet/sample/CometSwiftUIProbe/Resources/placeholder_1_1.png new file mode 100644 index 000000000..55fb3c2ac Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/placeholder_1_1.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_1.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_1.png new file mode 100644 index 000000000..f2efcdc27 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_1.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_1_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_1_thumb.png new file mode 100644 index 000000000..6af4fcc71 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_1_thumb.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_2.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_2.png new file mode 100644 index 000000000..acc31574c Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_2.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_2_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_2_thumb.png new file mode 100644 index 000000000..e5c598ec4 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_2_thumb.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_3.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_3.png new file mode 100644 index 000000000..f8922fbe9 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_3.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_3_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_3_thumb.png new file mode 100644 index 000000000..6e6a82d8b Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_3_thumb.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_4.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_4.png new file mode 100644 index 000000000..14b14d977 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_4.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_4_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_4_thumb.png new file mode 100644 index 000000000..d772e0019 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_4_thumb.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_5.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_5.png new file mode 100644 index 000000000..31eb8183b Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_5.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_5_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_5_thumb.png new file mode 100644 index 000000000..5c42ce2a8 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_5_thumb.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_6.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_6.png new file mode 100644 index 000000000..3ad0c2952 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_6.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_6_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_6_thumb.png new file mode 100644 index 000000000..092cc3f13 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_6_thumb.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/almonds.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/almonds.jpg new file mode 100644 index 000000000..0f1eef57e Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/almonds.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_chips.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_chips.jpg new file mode 100644 index 000000000..37c805d16 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_chips.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_juice.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_juice.jpg new file mode 100644 index 000000000..d519dbcd4 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_juice.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_pie.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_pie.jpg new file mode 100644 index 000000000..41096b221 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_pie.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_sauce.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_sauce.jpg new file mode 100644 index 000000000..7f5331ee2 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_sauce.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apples.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apples.jpg new file mode 100644 index 000000000..cc729f164 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apples.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cheese.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cheese.jpg new file mode 100644 index 000000000..c5c6dce61 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cheese.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/chips.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/chips.jpg new file mode 100644 index 000000000..04d5fe2cd Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/chips.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cupcake.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cupcake.jpg new file mode 100644 index 000000000..42e766d84 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cupcake.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/desserts.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/desserts.jpg new file mode 100644 index 000000000..6d4499076 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/desserts.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/donut.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/donut.jpg new file mode 100644 index 000000000..076896a81 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/donut.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/eclair.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/eclair.jpg new file mode 100644 index 000000000..560178034 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/eclair.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/froyo.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/froyo.jpg new file mode 100644 index 000000000..e1bb068cc Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/froyo.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/fruit.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/fruit.jpg new file mode 100644 index 000000000..412247318 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/fruit.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gingerbread.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gingerbread.jpg new file mode 100644 index 000000000..ac069de10 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gingerbread.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gluten_free.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gluten_free.jpg new file mode 100644 index 000000000..0745457a3 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gluten_free.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/grapes.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/grapes.jpg new file mode 100644 index 000000000..3b573787c Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/grapes.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/honeycomb.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/honeycomb.jpg new file mode 100644 index 000000000..ea632bd25 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/honeycomb.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/ice_cream_sandwich.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/ice_cream_sandwich.jpg new file mode 100644 index 000000000..fd77631e9 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/ice_cream_sandwich.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/jelly_bean.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/jelly_bean.jpg new file mode 100644 index 000000000..84a10208c Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/jelly_bean.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kitkat.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kitkat.jpg new file mode 100644 index 000000000..75b2e44ab Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kitkat.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kiwi.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kiwi.jpg new file mode 100644 index 000000000..2197fbd48 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kiwi.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/lollipop.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/lollipop.jpg new file mode 100644 index 000000000..98d1db7d2 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/lollipop.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/mango.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/mango.jpg new file mode 100644 index 000000000..717773d7b Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/mango.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/marshmallow.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/marshmallow.jpg new file mode 100644 index 000000000..cdc115922 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/marshmallow.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nougat.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nougat.jpg new file mode 100644 index 000000000..1a844d9b9 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nougat.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nuts.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nuts.jpg new file mode 100644 index 000000000..03556767e Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nuts.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/oreo.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/oreo.jpg new file mode 100644 index 000000000..cf2c3e534 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/oreo.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/organic.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/organic.jpg new file mode 100644 index 000000000..2847abf4f Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/organic.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/paleo.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/paleo.jpg new file mode 100644 index 000000000..750fcd58e Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/paleo.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pie.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pie.jpg new file mode 100644 index 000000000..439c18cfb Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pie.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/placeholder.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/placeholder.jpg new file mode 100644 index 000000000..31e05faac Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/placeholder.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/popcorn.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/popcorn.jpg new file mode 100644 index 000000000..02713ffdb Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/popcorn.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pretzels.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pretzels.jpg new file mode 100644 index 000000000..d31d4aa4e Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pretzels.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/smoothies.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/smoothies.jpg new file mode 100644 index 000000000..f2eaa31bf Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/smoothies.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/vegan.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/vegan.jpg new file mode 100644 index 000000000..29276a68b Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/vegan.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/someone_else.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/someone_else.jpg new file mode 100644 index 000000000..95bc4a8bb Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/someone_else.jpg differ diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/sticker.png b/src/Comet/sample/CometSwiftUIProbe/Resources/sticker.png new file mode 100644 index 000000000..54bbcfb96 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/sticker.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-a.png b/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-a.png new file mode 100644 index 000000000..8333ea0c4 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-a.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-b.png b/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-b.png new file mode 100644 index 000000000..0db7b8b37 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-b.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-list.png b/src/Comet/sample/CometSwiftUIProbe/phase3-list.png new file mode 100644 index 000000000..5dedf2716 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-list.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-nav-detail.png b/src/Comet/sample/CometSwiftUIProbe/phase3-nav-detail.png new file mode 100644 index 000000000..7ceab6bd6 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-nav-detail.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-nav-home.png b/src/Comet/sample/CometSwiftUIProbe/phase3-nav-home.png new file mode 100644 index 000000000..01a65139d Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-nav-home.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-a.png b/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-a.png new file mode 100644 index 000000000..f0356f214 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-a.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-b.png b/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-b.png new file mode 100644 index 000000000..afa1e9ec4 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-b.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-gate.png b/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-gate.png new file mode 100644 index 000000000..5a9d5c4c5 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-gate.png differ diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-tree.png b/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-tree.png new file mode 100644 index 000000000..85c6fcd03 Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-tree.png differ diff --git a/src/Comet/sample/Directory.Build.targets b/src/Comet/sample/Directory.Build.targets index 97fe8f806..03e6e1a24 100644 --- a/src/Comet/sample/Directory.Build.targets +++ b/src/Comet/sample/Directory.Build.targets @@ -1,8 +1,8 @@ + iOS/MacCatalyst SDK requires >= 17.0; current Android (.NET 11 preview 7) + requires minSdk >= 24. Override in Directory.Build.targets (after the csproj + evaluates) so all samples pick this up without per-project edits. --> 17.0 @@ -10,18 +10,31 @@ 17.0 - 23.0 + 24.0 - + + - - + + + + + + $(MSBuildThisFileDirectory)Shared/RuntimeDebug/Entitlements.Debug.plist + + + diff --git a/src/Comet/sample/LEGACY-SAMPLES.md b/src/Comet/sample/LEGACY-SAMPLES.md new file mode 100644 index 000000000..5e8fb7202 --- /dev/null +++ b/src/Comet/sample/LEGACY-SAMPLES.md @@ -0,0 +1,19 @@ +# Legacy samples (parked) + +Phase 5 (2026-07-01) **deleted the legacy MAUI ViewHandler render path** from +Comet (`Handlers/**`, `Maui/**`, `AppHostBuilderExtensions`, the native +`CUI*`/`Comet*` platform views, and the `Shared/RuntimeDebug` debug host). +The Compose (Android) / SwiftUI (iOS) node backend is the only render path. + +Every sample in this folder that calls `UseCometApp`/`UseCometHandlers` is +**parked**: it no longer compiles and is kept only as reference source. That is +all of them except the node-backend probes: + +- `CometComposeProbe` — Android, Jetpack Compose backend (active) +- `CometSwiftUIProbe` — iOS, SwiftUI backend (active) +- `Shared/Jetchat` — the shared gold-standard sample both probes build (active) + +To build or run a parked sample, check out the last commit with the legacy +path: tag **`comet-pre-phase5-delete`**. Reviving one for the node backend +means porting its app host to a backend root (see the probes' `MainActivity`/ +`AppDelegate`) — the view/tree code itself is largely portable. diff --git a/src/Comet/sample/MauiReference/MauiProgram.cs b/src/Comet/sample/MauiReference/MauiProgram.cs index 10f597069..1496ce0eb 100644 --- a/src/Comet/sample/MauiReference/MauiProgram.cs +++ b/src/Comet/sample/MauiReference/MauiProgram.cs @@ -37,9 +37,8 @@ public static MauiApp CreateMauiApp() fonts.AddFont("FluentSystemIcons-Regular.ttf", FluentUI.FontFamily); }); -#if DEBUG - builder.EnableSampleRuntimeDebugging(); -#endif + // NOTE: EnableSampleRuntimeDebugging() was removed with the legacy Comet render path + // (Phase 5) — the shared RuntimeDebug host is no longer compiled into samples. builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Comet/sample/Shared/JetNews/JetNewsArticle.cs b/src/Comet/sample/Shared/JetNews/JetNewsArticle.cs new file mode 100644 index 000000000..724160148 --- /dev/null +++ b/src/Comet/sample/Shared/JetNews/JetNewsArticle.cs @@ -0,0 +1,255 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Comet; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.JetNews.JetNewsTheme; + +namespace CometSamples.JetNews +{ + /// + /// The Article (post) screen, values-from-source: PostScreen.kt (chrome) + + /// PostContent.kt (body). Compact chrome = center top bar ("Published in: \n{name}" + /// next to the publication badge, back arrow tinted primary) + bottom action bar; + /// content = header image / headlineLarge title / subtitle / metadata / typed + /// paragraphs with markups (bold, italic, underline links, monospace code spans). + /// + public static class JetNewsArticle + { + const float SpacerSize = 16f; // PostContent.kt defaultSpacerSize + + // ── Rows: PostContent.kt postContentItems — header item, metadata item, one item + // per paragraph (a real lazy list, like the gold LazyColumn). ── + abstract record ArticleRow; + sealed record HeaderRow(Post Post) : ArticleRow; + sealed record MetadataRow(Post Post) : ArticleRow; + sealed record ParagraphRow(Paragraph Paragraph) : ArticleRow; + + public static View Screen(Comet.Reactive.Signal post, double topInset, System.Action onBack) + { + JetNewsIcons.Register(); + + static List Rows(Post p) + { + var rows = new List { new HeaderRow(p), new MetadataRow(p) }; + rows.AddRange(p.Paragraphs.Select(pg => (ArticleRow)new ParagraphRow(pg))); + return rows; + } + + var list = new ListView(() => Rows(post.Peek())) + { + ViewFor = r => r switch + { + HeaderRow h => Header(h.Post), + MetadataRow m => Metadata(m.Post.Metadata), + ParagraphRow p => ParagraphView(p.Paragraph), + _ => new HStack(), + }, + }; + // One retained shell serves every post: swapping CurrentPost re-pulls the rows. + // Subscribed ONCE to the (static) signal via the current-list slot — a per-Screen + // subscription would leak a handler + captured list per rebuild. + _currentList = list; + if (!_postHooked) + { + _postHooked = true; + post.PropertyChanged += (_, _) => _currentList?.ReloadData(); + } + + return new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)topInset).FlexShrink(0), + TopBar(post, onBack).FlexShrink(0), + list.FlexGrow(1).FlexBasis(0), + BottomBar().FlexShrink(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.Background); + } + + static ListView? _currentList; + static bool _postHooked; + + static Text Tx(string s) => new Text(s).FontFamily("Montserrat"); + + // CenterAlignedTopAppBar (PostScreen.kt): back arrow tinted primary; centered title = + // publication badge (36dp circle; the gold vector is an Android head on #073042 — + // approximated with the Material Icons android glyph on the same navy) + two-line + // "Published in: \n{name}" labelLarge, 8dp start padding, text left-aligned. + static View TopBar(Comet.Reactive.Signal post, System.Action onBack) => new HStack(spacing: 0f) + { + new Icon("arrow_back").IconSize(24).Color(T.Primary) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .FlexShrink(0) + .OnTap(_ => onBack()), + new HStack().FlexGrow(1), + // The gold icon_post_background vector (multicolor — renders untinted). + new Icon("jetnews_badge").IconSize(36) + .Frame(width: 36, height: 36).CornerRadius(18) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .FlexShrink(0), + new VStack(spacing: 0f) + { + Tx("Published in:").FontSize(14).FontWeight(FontWeight.Medium).Color(T.OnSurface), + new Text(() => post.Value.Publication?.Name ?? string.Empty).FontFamily("Montserrat") + .FontSize(14).FontWeight(FontWeight.Medium).Color(T.OnSurface), + }.Padding(new Thickness(8, 0, 0, 0)).VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + new HStack().Frame(width: 48).FlexShrink(0), // balances the nav slot + }.Frame(height: 64); + + // PostContent.kt item 1: header image (minH 180, full width, shapes.large = r8), + // spacer 16, headlineLarge title, spacer 8, bodyMedium subtitle + spacer 16. + static View Header(Post post) + { + var stack = new VStack(spacing: 0f) + { + new Image(post.ImageId).Frame(height: 180).CornerRadius(8) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + new HStack().Frame(height: SpacerSize), + Tx(post.Title).FontSize(32).LineHeight(40).Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading), + new HStack().Frame(height: 8), + }; + if (post.Subtitle is { } subtitle) + { + stack.Add(Tx(subtitle).FontSize(14).LineHeight(20).Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Paragraph)); + stack.Add(new HStack().Frame(height: SpacerSize)); + } + return stack.Padding(new Thickness(16, 0, 16, 0)); + } + + // PostContent.kt PostMetadata: account_circle 40 tinted content color, 8 gap, + // author labelLarge (padded 4 from top) over "{date} · {read} min read" bodySmall; + // 24dp bottom padding. + static View Metadata(Metadata metadata) => new HStack(spacing: 0f) + { + new Icon("account_circle").IconSize(40).Color(T.OnSurface) + .Frame(width: 40, height: 40).FlexShrink(0), + new VStack(spacing: 0f) + { + Tx(metadata.Author.Name).FontSize(14).FontWeight(FontWeight.Medium).Color(T.OnSurface) + .Padding(new Thickness(0, 4, 0, 0)), + Tx($"{metadata.Date} · {metadata.ReadTimeMinutes} min read") + .FontSize(12).Color(T.OnSurface), + }.Padding(new Thickness(8, 0, 0, 0)), + }.Padding(new Thickness(16, 0, 16, 24)); + + // ── Paragraph (PostContent.kt): style per ParagraphType; markups become styled runs. + // Trailing padding 24 (16 for Subhead/Header); non-block text carries the gold's 4dp pad. ── + static View ParagraphView(Paragraph paragraph) + { + var (fontSize, lineHeight, weight, trailing) = paragraph.Type switch + { + ParagraphType.Caption => (12f, 16f, FontWeight.Medium, 24f), + ParagraphType.Title => (32f, 40f, FontWeight.Regular, 24f), + ParagraphType.Subhead => (24f, 32f, FontWeight.Regular, 16f), + ParagraphType.Header => (28f, 36f, FontWeight.Regular, 16f), + _ => (16f, 28f, FontWeight.Regular, 24f), // Text/CodeBlock/Quote/Bullet = bodyLarge (Text pins lineHeight 28) + }; + + View content = paragraph.Type switch + { + ParagraphType.CodeBlock => CodeBlock(paragraph, fontSize), + ParagraphType.Bullet => Bullet(paragraph, fontSize, lineHeight), + _ => Runs(paragraph, fontSize, lineHeight, weight, + heading: paragraph.Type is ParagraphType.Title or ParagraphType.Subhead or ParagraphType.Header) + .Padding(new Thickness(4)), + }; + + return new VStack(spacing: 0f) { content } + .Padding(new Thickness(16, 0, 16, trailing)); + } + + /// The paragraph text with its markups as styled runs — bold / italic / + /// underlined link / monospace code span on onSurface@15% (PostContent.kt markup map). + static View Runs(Paragraph paragraph, float fontSize, float lineHeight, FontWeight weight, bool heading) + { + var view = paragraph.Markups is { Count: > 0 } + ? (View)new FormattedText(BuildRuns(paragraph)) + : Tx(paragraph.Text); + return view + .FontSize(fontSize).LineHeight(lineHeight).FontWeight(weight) + .FontFamily("Montserrat").Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap) + .LineBreak(heading ? TextLineBreak.Heading : TextLineBreak.Paragraph); + } + + static IReadOnlyList BuildRuns(Paragraph paragraph) + { + var text = paragraph.Text; + var runs = new List(); + int cursor = 0; + foreach (var m in paragraph.Markups!.OrderBy(m => m.Start)) + { + int start = System.Math.Clamp(m.Start, 0, text.Length); + int end = System.Math.Clamp(m.End, start, text.Length); + if (start < cursor) + continue; // overlapping markups: first one wins (gold data has none) + if (start > cursor) + runs.Add(new TextRun(text[cursor..start])); + runs.Add(m.Type switch + { + MarkupType.Bold => new TextRun(text[start..end], Bold: true), + MarkupType.Italic => new TextRun(text[start..end], Italic: true), + MarkupType.Link => new TextRun(text[start..end], Underline: true), + _ => new TextRun(text[start..end], Monospace: true, Background: T.CodeBlockBackground), + }); + cursor = end; + } + if (cursor < text.Length) + runs.Add(new TextRun(text[cursor..])); + return runs; + } + + // CodeBlockParagraph: full-width Surface, shapes.small = r4, onSurface@15%, pad 16, + // monospace bodyLarge. + static View CodeBlock(Paragraph paragraph, float fontSize) => new VStack(spacing: 0f) + { + new FormattedText(new[] { new TextRun(paragraph.Text, Monospace: true) }) + .FontSize(fontSize).LineHeight(24).Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap) + .Padding(new Thickness(16)), + } + .Background(T.CodeBlockBackground).CornerRadius(4) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + + // BulletParagraph: an 8sp dot aligned to the first line + indented text. + static View Bullet(Paragraph paragraph, float fontSize, float lineHeight) => new HStack(spacing: 0f) + { + new HStack().Frame(width: 8, height: 8) + .Background(T.OnSurface).CornerRadius(4) + .Margin(new Thickness(0, (lineHeight - 8) / 2, 8, 0)) + .VerticalLayoutAlignment(LayoutAlignment.Start).FlexShrink(0), + Runs(paragraph, fontSize, lineHeight, FontWeight.Regular, heading: false) + .FlexGrow(1).FlexBasis(0), + }; + + // BottomAppBar (PostScreen.kt bottomBarContent): M3 tokens — 80dp, surfaceContainer, + // leading 16; actions thumb_up / bookmark toggle / share / text_format. + static View BottomBar() + { + View Action(string icon, System.Action? onTap = null) => + new Icon(icon).IconSize(24).Color(T.OnSurfaceVariant) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .OnTap(_ => onTap?.Invoke()); + + return new HStack(spacing: 0f) + { + Action("thumb_up_offalt"), + Action("bookmark_border"), + Action("share"), + Action("text_format"), + } + .Padding(new Thickness(16, 0, 16, 0)) + .Frame(height: 80) + .Background(T.SurfaceContainer) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } + } +} diff --git a/src/Comet/sample/Shared/JetNews/JetNewsData.cs b/src/Comet/sample/Shared/JetNews/JetNewsData.cs new file mode 100644 index 000000000..d88a80868 --- /dev/null +++ b/src/Comet/sample/Shared/JetNews/JetNewsData.cs @@ -0,0 +1,1175 @@ +#nullable enable +using System.Collections.Generic; + +namespace CometSamples.JetNews +{ + /// Verbatim port of the gold data (data/posts/impl/PostsData.kt) — five posts, + /// full paragraph + markup content, same feed composition. Image ids are bundled + /// resource names (post_N / post_N_thumb). + public static class JetNewsData + { + static IReadOnlyList L(params T[] items) => items; + + + + + + + + + + + + + + + /** + * Define hardcoded posts to avoid handling any non-ui operations. + */ + + public static readonly PostAuthor pietro = new PostAuthor("Pietro Maggi", "https://medium.com/@pmaggi"); + public static readonly PostAuthor manuel = new PostAuthor("Manuel Vivo", "https://medium.com/@manuelvicnt"); + public static readonly PostAuthor florina = new PostAuthor( + "Florina Muntenescu", + "https://medium.com/@florina.muntenescu" + ); + public static readonly PostAuthor jose = new PostAuthor("Jose Alcérreca", "https://medium.com/@JoseAlcerreca"); + + public static readonly PostAuthor androidstudioteam = new PostAuthor("Android Studio Team", "https://twitter.com/androidstudio"); + + public static readonly Publication ThePublication = new Publication( + "Android Developers", + "https://cdn-images-1.medium.com/max/258/1*u7oZc2_5mrkcFaxkXEyfYA@2x.png" + ); + public static readonly IReadOnlyList paragraphsPost1 = L( + new Paragraph( + ParagraphType.Text, + "Working to make our Android application more modular, I ended up with a sample that included a set of on-demand features grouped inside a folder:" + ), + new Paragraph( + ParagraphType.Text, + "Pretty standard setup, all the on-demand modules, inside a “features” folder; clean." + ), + new Paragraph( + ParagraphType.Text, + "These modules are included in the settings.gradle file as:" + ), + new Paragraph( + ParagraphType.CodeBlock, + "include ':app'\ninclude ':features:module1'\ninclude ':features:module2'\ninclude ':features:module3'\ninclude ':features:module4'" + ), + new Paragraph( + ParagraphType.Text, + "These setup works nicely with a single “minor” issue: an empty module named features in the Android view in Android Studio:" + ), + new Paragraph( + ParagraphType.Text, + "I can live with that, but I would much prefer to remove that empty module from my project!" + ), + new Paragraph( + ParagraphType.Header, + "If you cannot remove it, just rename it!" + ), + + new Paragraph( + ParagraphType.Text, + "At I/O I was lucky enough to attend the “Android Studio: Tips and Tricks” talk where Ivan Gravilovic, from Google, shared some amazing tips. One of these was a possible solution for my problem: setting a custom path for my modules.", + L( + new Markup( + MarkupType.Italic, + 41, + 72 + ) + ) + ), + + new Paragraph( + ParagraphType.Text, + "In this particular case our settings.gradle becomes:", + L(new Markup(MarkupType.Code, 28, 43)) + ), + new Paragraph( + ParagraphType.CodeBlock, + """ + include ':app' + include ':module1' + include ':module1' + include ':module1' + include ':module1' + """ + ), + new Paragraph( + ParagraphType.CodeBlock, + """ + // Set a custom path for the four features modules. + // This avoid to have an empty "features" module in Android Studio. + project(":module1").projectDir=new File(rootDir, "features/module1") + project(":module2").projectDir=new File(rootDir, "features/module2") + project(":module3").projectDir=new File(rootDir, "features/module3") + project(":module4").projectDir=new File(rootDir, "features/module4") + """ + ), + new Paragraph( + ParagraphType.Text, + "And the layout in Android Studio is now:" + ), + new Paragraph( + ParagraphType.Header, + "Conclusion" + ), + new Paragraph( + ParagraphType.Text, + "As the title says, this is really a small thing, but it helps keep my project in order and it shows how a small Gradle configuration can help keep your project tidy." + ), + new Paragraph( + ParagraphType.Quote, + "You can find this update in the latest version of the on-demand modules codelab.", + L( + new Markup( + MarkupType.Link, + 54, + 79, + "https://codelabs.developers.google.com/codelabs/on-demand-dynamic-delivery/index.html" + ) + ) + ), + new Paragraph( + ParagraphType.Header, + "Resources" + ), + new Paragraph( + ParagraphType.Bullet, + "Android Studio: Tips and Tricks (Google I/O’19)", + L( + new Markup( + MarkupType.Link, + 0, + 47, + "https://www.youtube.com/watch?v=ihF-PwDfRZ4&list=PLWz5rJ2EKKc9FfSQIRXEWyWpHD6TtwxMM&index=32&t=0s" + ) + ) + ), + + new Paragraph( + ParagraphType.Bullet, + "On Demand module codelab", + L( + new Markup( + MarkupType.Link, + 0, + 24, + "https://codelabs.developers.google.com/codelabs/on-demand-dynamic-delivery/index.html" + ) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "Patchwork Plaid — A modularization story", + L( + new Markup( + MarkupType.Link, + 0, + 40, + "https://medium.com/androiddevelopers/a-patchwork-plaid-monolith-to-modularized-app-60235d9f212e" + ) + ) + ) + ); + + public static readonly IReadOnlyList paragraphsPost2 = L( + new Paragraph( + ParagraphType.Text, + "Dagger is a popular Dependency Injection framework commonly used in Android. It provides fully static and compile-time dependencies addressing many of the development and performance issues that have reflection-based solutions.", + L( + new Markup( + MarkupType.Link, + 0, + 6, + "https://dagger.dev/" + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "This month, a new tutorial was released to help you better understand how it works. This article focuses on using Dagger with Kotlin, including best practices to optimize your build time and gotchas you might encounter.", + L( + new Markup( + MarkupType.Link, + 14, + 26, + "https://dagger.dev/tutorial/" + ), + new Markup(MarkupType.Bold, 114, 132), + new Markup(MarkupType.Bold, 144, 159), + new Markup(MarkupType.Bold, 191, 198) + ) + ), + new Paragraph( + ParagraphType.Text, + "Dagger is implemented using Java’s annotations model and annotations in Kotlin are not always directly parallel with how equivalent Java code would be written. This post will highlight areas where they differ and how you can use Dagger with Kotlin without having a headache." + ), + new Paragraph( + ParagraphType.Text, + "This post was inspired by some of the suggestions in this Dagger issue that goes through best practices and pain points of Dagger in Kotlin. Thanks to all of the contributors that commented there!", + L( + new Markup( + MarkupType.Link, + 58, + 70, + "https://github.com/google/dagger/issues/900" + ) + ) + ), + new Paragraph( + ParagraphType.Header, + "kapt build improvements" + ), + new Paragraph( + ParagraphType.Text, + "To improve your build time, Dagger added support for gradle’s incremental annotation processing in v2.18! This is enabled by default in Dagger v2.24. In case you’re using a lower version, you need to add a few lines of code (as shown below) if you want to benefit from it.", + L( + new Markup( + MarkupType.Link, + 99, + 104, + "https://github.com/google/dagger/releases/tag/dagger-2.18" + ), + new Markup( + MarkupType.Link, + 143, + 148, + "https://github.com/google/dagger/releases/tag/dagger-2.24" + ), + new Markup(MarkupType.Bold, 53, 95) + ) + ), + new Paragraph( + ParagraphType.Text, + "Also, you can tell Dagger not to format the generated code. This option was added in Dagger v2.18 and it’s the default behavior (doesn’t generate formatted code) in v2.23. If you’re using a lower version, disable code formatting to improve your build time (see code below).", + L( + new Markup( + MarkupType.Link, + 92, + 97, + "https://github.com/google/dagger/releases/tag/dagger-2.18" + ), + new Markup( + MarkupType.Link, + 165, + 170, + "https://github.com/google/dagger/releases/tag/dagger-2.23" + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "Include these compiler arguments in your build.gradle file to make Dagger more performant at build time:", + L(new Markup(MarkupType.Code, 41, 53)) + ), + new Paragraph( + ParagraphType.Text, + "Alternatively, if you use Kotlin DSL script files, include them like this in the build.gradle.kts file of the modules that use Dagger:", + L(new Markup(MarkupType.Code, 81, 97)) + ), + new Paragraph( + ParagraphType.Text, + "Qualifiers for field attributes" + ), + new Paragraph( + ParagraphType.Text, + "", + L(new Markup(MarkupType.Link, 0, 0)) + ), + new Paragraph( + ParagraphType.Text, + "When an annotation is placed on a property in Kotlin, it’s not clear whether Java will see that annotation on the field of the property or the method for that property. Setting the field: prefix on the annotation ensures that the qualifier ends up in the right place (See documentation for more details).", + L( + new Markup(MarkupType.Code, 181, 187), + new Markup( + MarkupType.Link, + 268, + 285, + "http://frogermcs.github.io/dependency-injection-with-dagger-2-custom-scopes/" + ), + new Markup(MarkupType.Italic, 114, 119), + new Markup(MarkupType.Italic, 143, 149) + ) + ), + new Paragraph( + ParagraphType.Text, + "✅ The way to apply qualifiers on an injected field is:" + ), + new Paragraph( + ParagraphType.CodeBlock, + "@Inject @field:MinimumBalance lateinit var minimumBalance: BigDecimal", + L(new Markup(MarkupType.Bold, 8, 29)) + ), + new Paragraph( + ParagraphType.Text, + "❌ As opposed to:" + ), + new Paragraph( + ParagraphType.CodeBlock, + """ + @Inject @MinimumBalance lateinit var minimumBalance: BigDecimal + // @MinimumBalance is ignored! + """, + L(new Markup(MarkupType.Bold, 65, 95)) + ), + new Paragraph( + ParagraphType.Text, + "Forgetting to add field: could lead to injecting the wrong object if there’s an unqualified instance of that type available in the Dagger graph.", + L(new Markup(MarkupType.Code, 18, 24)) + ), + new Paragraph( + ParagraphType.Header, + "Static @Provides functions optimization" + ), + new Paragraph( + ParagraphType.Text, + "Dagger’s generated code will be more performant if @Provides methods are static. To achieve this in Kotlin, use a Kotlin object instead of a class and annotate your methods with @JvmStatic. This is a best practice that you should follow as much as possible.", + L( + new Markup(MarkupType.Code, 51, 60), + new Markup(MarkupType.Code, 73, 79), + new Markup(MarkupType.Code, 121, 127), + new Markup(MarkupType.Code, 141, 146), + new Markup(MarkupType.Code, 178, 188), + new Markup(MarkupType.Bold, 200, 213), + new Markup(MarkupType.Italic, 200, 213) + ) + ), + new Paragraph( + ParagraphType.Text, + "In case you need an abstract method, you’ll need to add the @JvmStatic method to a companion object and annotate it with @Module too.", + L( + new Markup(MarkupType.Code, 60, 70), + new Markup(MarkupType.Code, 121, 128) + ) + ), + new Paragraph( + ParagraphType.Text, + "Alternatively, you can extract the object module out and include it in the abstract one:" + ), + new Paragraph( + ParagraphType.Header, + "Injecting Generics" + ), + new Paragraph( + ParagraphType.Text, + "Kotlin compiles generics with wildcards to make Kotlin APIs work with Java. These are generated when a type appears as a parameter (more info here) or as fields. For example, a Kotlin List parameter shows up as List in Java.", + L( + new Markup(MarkupType.Code, 184, 193), + new Markup(MarkupType.Code, 216, 233), + new Markup( + MarkupType.Link, + 132, + 146, + "https://kotlinlang.org/docs/reference/java-to-kotlin-interop.html#variant-generics" + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "This causes problems with Dagger because it expects an exact (aka invariant) type match. Using @JvmSuppressWildcards will ensure that Dagger sees the type without wildcards.", + L( + new Markup(MarkupType.Code, 95, 116), + new Markup( + MarkupType.Link, + 66, + 75, + "https://en.wikipedia.org/wiki/Class_invariant" + ), + new Markup( + MarkupType.Link, + 96, + 116, + "https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.jvm/-jvm-suppress-wildcards/index.html" + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "This is a common issue when you inject collections using Dagger’s multibinding feature, for example:", + L( + new Markup( + MarkupType.Link, + 57, + 86, + "https://dagger.dev/multibindings.html" + ) + ) + ), + new Paragraph( + ParagraphType.CodeBlock, + """ + class MyVMFactory @Inject constructor( + private val vmMap: Map> + ) { + ... + } + """, + L(new Markup(MarkupType.Bold, 72, 93)) + ), + new Paragraph( + ParagraphType.Header, + "Inline method bodies" + ), + new Paragraph( + ParagraphType.Text, + "Dagger determines the types that are configured by @Provides methods by inspecting the return type. Specifying the return type in Kotlin functions is optional and even the IDE sometimes encourages you to refactor your code to have inline method bodies that hide the return type declaration.", + L(new Markup(MarkupType.Code, 51, 60)) + ), + new Paragraph( + ParagraphType.Text, + "This can lead to bugs if the inferred type is different from the one you meant. Let’s see some examples:" + ), + new Paragraph( + ParagraphType.Text, + "If you want to add a specific type to the graph, inlining works as expected. See the different ways to do the same in Kotlin:" + ), + new Paragraph( + ParagraphType.Text, + "If you want to provide an implementation of an interface, then you must explicitly specify the return type. Not doing it can lead to problems and bugs:" + ), + new Paragraph( + ParagraphType.Text, + "Dagger mostly works with Kotlin out of the box. However, you have to watch out for a few things just to make sure you’re doing what you really mean to do: @field: for qualifiers on field attributes, inline method bodies, and @JvmSuppressWildcards when injecting collections.", + L( + new Markup(MarkupType.Code, 155, 162), + new Markup(MarkupType.Code, 225, 246) + ) + ), + new Paragraph( + ParagraphType.Text, + "Dagger optimizations come with no cost, add them and follow best practices to improve your build time: enabling incremental annotation processing, disabling formatting and using static @Provides methods in your Dagger modules.", + L( + new Markup( + MarkupType.Code, + 185, + 194 + ) + ) + ) + ); + + public static readonly IReadOnlyList paragraphsPost3 = L( + new Paragraph( + ParagraphType.Text, + "Learn how to get started converting Java Programming Language code to Kotlin, making it more idiomatic and avoid common pitfalls, by following our new Refactoring to Kotlin codelab, available in English \uD83C\uDDEC\uD83C\uDDE7, Chinese \uD83C\uDDE8\uD83C\uDDF3 and Brazilian Portuguese \uD83C\uDDE7\uD83C\uDDF7.", + L( + new Markup( + MarkupType.Link, + 151, + 172, + "https://codelabs.developers.google.com/codelabs/java-to-kotlin/#0" + ), + new Markup( + MarkupType.Link, + 209, + 216, + "https://clmirror.storage.googleapis.com/codelabs/java-to-kotlin-zh/index.html#0" + ), + new Markup( + MarkupType.Link, + 226, + 246, + "https://codelabs.developers.google.com/codelabs/java-to-kotlin-pt-br/#0" + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "When you first get started writing Kotlin code, you tend to follow Java Programming Language idioms. The automatic converter, part of both Android Studio and Intellij IDEA, can do a pretty good job of automatically refactoring your code, but sometimes, it needs a little help. This is where our new Refactoring to Kotlin codelab comes in.", + L( + new Markup( + MarkupType.Link, + 105, + 124, + "https://www.jetbrains.com/help/idea/converting-a-java-file-to-kotlin-file.html" + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "We’ll take two classes (a User and a Repository) in Java Programming Language and convert them to Kotlin, check out what the automatic converter did and why. Then we go to the next level — make it idiomatic, teaching best practices and useful tips along the way.", + L( + new Markup(MarkupType.Code, 26, 30), + new Markup(MarkupType.Code, 37, 47) + ) + ), + new Paragraph( + ParagraphType.Text, + "The Refactoring to Kotlin codelab starts with basic topics — understand how nullability is declared in Kotlin, what types of equality are defined or how to best handle classes whose role is just to hold data. We then continue with how to handle static fields and functions in Kotlin and how to apply the Singleton pattern, with the help of one handy keyword: object. We’ll see how Kotlin helps us model our classes better, how it differentiates between a property of a class and an action the class can do. Finally, we’ll learn how to execute code only in the context of a specific object with the scope functions.", + L( + new Markup(MarkupType.Code, 245, 251), + new Markup(MarkupType.Code, 359, 365), + new Markup( + MarkupType.Link, + 4, + 25, + "https://codelabs.developers.google.com/codelabs/java-to-kotlin/#0" + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "Thanks to Walmyr Carvalho and Nelson Glauber for translating the codelab in Brazilian Portuguese!", + L( + new Markup( + MarkupType.Link, + 21, + 42, + "https://codelabs.developers.google.com/codelabs/java-to-kotlin/#0" + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "", + L( + new Markup( + MarkupType.Link, + 76, + 96, + "https://codelabs.developers.google.com/codelabs/java-to-kotlin-pt-br/#0" + ) + ) + ) + ); + + public static readonly IReadOnlyList paragraphsPost4 = L( + new Paragraph( + ParagraphType.Text, + "TL;DR: Expose resource IDs from ViewModels to avoid showing obsolete data." + ), + new Paragraph( + ParagraphType.Text, + "In a ViewModel, if you’re exposing data coming from resources (strings, drawables, colors…), you have to take into account that ViewModel objects ignore configuration changes such as locale changes. When the user changes their locale, activities are recreated but the ViewModel objects are not.", + L( + new Markup( + MarkupType.Bold, + 183, + 197 + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "AndroidViewModel is a subclass of ViewModel that is aware of the Application context. However, having access to a context can be dangerous if you’re not observing or reacting to the lifecycle of that context. The recommended practice is to avoid dealing with objects that have a lifecycle in ViewModels.", + L( + new Markup(MarkupType.Code, 0, 16), + new Markup(MarkupType.Code, 34, 43), + new Markup(MarkupType.Bold, 209, 303) + ) + ), + new Paragraph( + ParagraphType.Text, + "Let’s look at an example based on this issue in the tracker: Updating ViewModel on system locale change.", + L( + new Markup( + MarkupType.Link, + 61, + 103, + "https://issuetracker.google.com/issues/111961971" + ), + new Markup(MarkupType.Italic, 61, 104) + ) + ), + new Paragraph( + ParagraphType.Text, + "The problem is that the string is resolved in the constructor only once. If there’s a locale change, the ViewModel won’t be recreated. This will result in our app showing obsolete data and therefore being only partially localized.", + L(new Markup(MarkupType.Bold, 73, 133)) + ), + new Paragraph( + ParagraphType.Text, + "As Sergey points out in the comments to the issue, the recommended approach is to expose the ID of the resource you want to load and do so in the view. As the view (activity, fragment, etc.) is lifecycle-aware it will be recreated after a configuration change so the resource will be reloaded correctly.", + L( + new Markup( + MarkupType.Link, + 3, + 9, + "https://twitter.com/ZelenetS" + ), + new Markup( + MarkupType.Link, + 28, + 36, + "https://issuetracker.google.com/issues/111961971#comment2" + ), + new Markup(MarkupType.Bold, 82, 150) + ) + ), + new Paragraph( + ParagraphType.Text, + "Even if you don’t plan to localize your app, it makes testing much easier and cleans up your ViewModel objects so there’s no reason not to future-proof." + ), + new Paragraph( + ParagraphType.Text, + "We fixed this issue in the android-architecture repository in the Java and Kotlin branches and we offloaded resource loading to the Data Binding layout.", + L( + new Markup( + MarkupType.Link, + 66, + 70, + "https://github.com/googlesamples/android-architecture/pull/631" + ), + new Markup( + MarkupType.Link, + 75, + 81, + "https://github.com/googlesamples/android-architecture/pull/635" + ), + new Markup( + MarkupType.Link, + 128, + 151, + "https://github.com/googlesamples/android-architecture/pull/635/files#diff-7eb5d85ec3ea4e05ecddb7dc8ae20aa1R62" + ) + ) + ) + ); + + public static readonly IReadOnlyList paragraphsPost5 = L( + new Paragraph( + ParagraphType.Text, + "Working with collections is a common task and the Kotlin Standard Library offers many great utility functions. It also offers two ways of working with collections based on how they’re evaluated: eagerly — with Collections, and lazily — with Sequences. Continue reading to find out what’s the difference between the two, which one you should use and when, and what the performance implications of each are.", + L( + new Markup(MarkupType.Code, 210, 220), + new Markup(MarkupType.Code, 241, 249), + new Markup( + MarkupType.Link, + 210, + 221, + "https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/index.html" + ), + new Markup( + MarkupType.Link, + 241, + 250, + "https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.sequences/index.html" + ), + new Markup(MarkupType.Bold, 130, 134), + new Markup(MarkupType.Bold, 195, 202), + new Markup(MarkupType.Bold, 227, 233), + new Markup(MarkupType.Italic, 130, 134) + ) + ), + new Paragraph( + ParagraphType.Header, + "Collections vs sequences" + ), + new Paragraph( + ParagraphType.Text, + "The difference between eager and lazy evaluation lies in when each transformation on the collection is performed.", + L( + new Markup( + MarkupType.Italic, + 57, + 61 + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "Collections are eagerly evaluated — each operation is performed when it’s called and the result of the operation is stored in a new collection. The transformations on collections are inline functions. For example, looking at how map is implemented, we can see that it’s an inline function, that creates a new ArrayList:", + L( + new Markup(MarkupType.Code, 229, 232), + new Markup(MarkupType.Code, 273, 279), + new Markup(MarkupType.Code, 309, 318), + new Markup( + MarkupType.Link, + 183, + 199, + "https://kotlinlang.org/docs/reference/inline-functions.html" + ), + new Markup( + MarkupType.Link, + 229, + 232, + "https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/common/src/generated/_Collections.kt#L1312" + ), + new Markup(MarkupType.Bold, 0, 12), + new Markup(MarkupType.Italic, 16, 23) + ) + ), + new Paragraph( + ParagraphType.CodeBlock, + "public inline fun Iterable.map(transform: (T) -> R): List {\n return mapTo(ArrayList(collectionSizeOrDefault(10)), transform)\n}", + L( + new Markup(MarkupType.Bold, 7, 13), + new Markup(MarkupType.Bold, 88, 97) + ) + ), + new Paragraph( + ParagraphType.Text, + "Sequences are lazily evaluated. They have two types of operations: intermediate and terminal. Intermediate operations are not performed on the spot; they’re just stored. Only when a terminal operation is called, the intermediate operations are triggered on each element in a row and finally, the terminal operation is applied. Intermediate operations (like map, distinct, groupBy etc) return another sequence whereas terminal operations (like first, toList, count etc) don’t.", + L( + new Markup(MarkupType.Code, 357, 360), + new Markup(MarkupType.Code, 362, 370), + new Markup(MarkupType.Code, 372, 379), + new Markup(MarkupType.Code, 443, 448), + new Markup(MarkupType.Code, 450, 456), + new Markup(MarkupType.Code, 458, 463), + new Markup(MarkupType.Bold, 0, 9), + new Markup(MarkupType.Bold, 67, 79), + new Markup(MarkupType.Bold, 84, 92), + new Markup(MarkupType.Bold, 254, 269), + new Markup(MarkupType.Italic, 14, 20) + ) + ), + new Paragraph( + ParagraphType.Text, + "Sequences don’t hold a reference to the items of the collection. They’re created based on the iterator of the original collection and keep a reference to all the intermediate operations that need to be performed." + ), + new Paragraph( + ParagraphType.Text, + "Unlike transformations on collections, intermediate transformations on sequences are not inline functions — inline functions cannot be stored and sequences need to store them. Looking at how an intermediate operation like map is implemented, we can see that the transform function is kept in a new instance of a Sequence:", + L( + new Markup(MarkupType.Code, 222, 225), + new Markup(MarkupType.Code, 312, 320), + new Markup( + MarkupType.Link, + 222, + 225, + "https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/common/src/generated/_Sequences.kt#L860" + ) + ) + ), + new Paragraph( + ParagraphType.CodeBlock, + "public fun Sequence.map(transform: (T) -> R): Sequence{ \n return TransformingSequence(this, transform)\n}", + L(new Markup(MarkupType.Bold, 85, 105)) + ), + new Paragraph( + ParagraphType.Text, + "A terminal operation, like first, iterates through the elements of the sequence until the predicate condition is matched.", + L( + new Markup(MarkupType.Code, 27, 32), + new Markup( + MarkupType.Link, + 27, + 32, + "https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/common/src/generated/_Sequences.kt#L117" + ) + ) + ), + new Paragraph( + ParagraphType.CodeBlock, + "public inline fun Sequence.first(predicate: (T) -> Boolean): T {\n for (element in this) if (predicate(element)) return element\n throw NoSuchElementException(“Sequence contains no element matching the predicate.”)\n}" + ), + new Paragraph( + ParagraphType.Text, + "If we look at how a sequence like TransformingSequence (used in the map above) is implemented, we’ll see that when next is called on the sequence iterator, the transformation stored is also applied.", + L( + new Markup(MarkupType.Code, 34, 54), + new Markup(MarkupType.Code, 68, 71) + ) + ), + new Paragraph( + ParagraphType.CodeBlock, + "internal class TransformingIndexedSequence \nconstructor(private val sequence: Sequence, private val transformer: (Int, T) -> R) : Sequence {", + L( + new Markup( + MarkupType.Bold, + 109, + 120 + ) + ) + ), + new Paragraph( + ParagraphType.CodeBlock, + "override fun iterator(): Iterator = object : Iterator {\n …\n override fun next(): R {\n return transformer(checkIndexOverflow(index++), iterator.next())\n }\n …\n}", + L( + new Markup(MarkupType.Bold, 83, 89), + new Markup(MarkupType.Bold, 107, 118) + ) + ), + new Paragraph( + ParagraphType.Text, + "Independent on whether you’re using collections or sequences, the Kotlin Standard Library offers quite a wide range of operations for both, like find, filter, groupBy and others. Make sure you check them out, before implementing your own version of these.", + L( + new Markup(MarkupType.Code, 145, 149), + new Markup(MarkupType.Code, 151, 157), + new Markup(MarkupType.Code, 159, 166), + new Markup( + MarkupType.Link, + 193, + 207, + "https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/#functions" + ) + ) + ), + new Paragraph( + ParagraphType.Header, + "Collections and sequences" + ), + new Paragraph( + ParagraphType.Text, + "Let’s say that we have a list of objects of different shapes. We want to make the shapes yellow and then take the first square shape." + ), + new Paragraph( + ParagraphType.Text, + "Let’s see how and when each operation is applied for collections and when for sequences" + ), + new Paragraph( + ParagraphType.Subhead, + "Collections" + ), + new Paragraph( + ParagraphType.Text, + "map is called — a new ArrayList is created. We iterate through all items of the initial collection, transform it by copying the original object and changing the color, then add it to the new list.", + L(new Markup(MarkupType.Code, 0, 3)) + ), + new Paragraph( + ParagraphType.Text, + "first is called — we iterate through each item until the first square is found", + L(new Markup(MarkupType.Code, 0, 5)) + ), + new Paragraph( + ParagraphType.Subhead, + "Sequences" + ), + new Paragraph( + ParagraphType.Bullet, + "asSequence — a sequence is created based on the Iterator of the original collection", + L(new Markup(MarkupType.Code, 0, 10)) + ), + new Paragraph( + ParagraphType.Bullet, + "map is called — the transformation is added to the list of operations needed to be performed by the sequence but the operation is NOT performed", + L( + new Markup(MarkupType.Code, 0, 3), + new Markup(MarkupType.Bold, 130, 133) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "first is called — this is a terminal operation, so, all the intermediate operations are triggered, on each element of the collection. We iterate through the initial collection applying map and then first on each of them. Since the condition from first is satisfied by the 2nd element, then we no longer apply the map on the rest of the collection.", + L(new Markup(MarkupType.Code, 0, 5)) + ), + + new Paragraph( + ParagraphType.Text, + "When working with sequences no intermediate collection is created and since items are evaluated one by one, map is only performed on some of the inputs." + ), + new Paragraph( + ParagraphType.Header, + "Performance" + ), + new Paragraph( + ParagraphType.Subhead, + "Order of transformations" + ), + new Paragraph( + ParagraphType.Text, + "Independent of whether you’re using collections or sequences, the order of transformations matters. In the example above, first doesn’t need to happen after map since it’s not a consequence of the map transformation. If we reverse the order of our business logic and call first on the collection and then transform the result, then we only create one new object — the yellow square. When using sequences — we avoid creating 2 new objects, when using collections, we avoid creating an entire new list.", + L( + new Markup(MarkupType.Code, 122, 127), + new Markup(MarkupType.Code, 157, 160), + new Markup(MarkupType.Code, 197, 200) + ) + ), + new Paragraph( + ParagraphType.Text, + "Because terminal operations can finish processing early, and intermediate operations are evaluated lazily, sequences can, in some cases, help you avoid doing unnecessary work compared to collections. Make sure you always check the order of the transformations and the dependencies between them!" + ), + new Paragraph( + ParagraphType.Subhead, + "Inlining and large data sets consequences" + ), + new Paragraph( + ParagraphType.Text, + "Collection operations use inline functions, so the bytecode of the operation, together with the bytecode of the lambda passed to it will be inlined. Sequences don’t use inline functions, therefore, new Function objects are created for each operation.", + L( + new Markup( + MarkupType.Code, + 202, + 210 + ) + ) + ), + new Paragraph( + ParagraphType.Text, + "On the other hand, collections create a new list for every transformation while sequences just keep a reference to the transformation function." + ), + new Paragraph( + ParagraphType.Text, + "When working with small collections, with 1–2 operators, these differences don’t have big implications so working with collections should be ok. But, when working with large lists the intermediate collection creation can become expensive; in such cases, use sequences.", + L( + new Markup(MarkupType.Bold, 18, 35), + new Markup(MarkupType.Bold, 119, 130), + new Markup(MarkupType.Bold, 168, 179), + new Markup(MarkupType.Bold, 258, 267) + ) + ), + new Paragraph( + ParagraphType.Text, + "Unfortunately, I’m not aware of any benchmarking study done that would help us get a better understanding on how the performance of collections vs sequences is affected with different sizes of collections or operation chains." + ), + new Paragraph( + ParagraphType.Text, + "Collections eagerly evaluate your data while sequences do so lazily. Depending on the size of your data, pick the one that fits best: collections — for small lists or sequences — for larger ones, and pay attention to the order of the transformations." + ) + ); + + public static readonly IReadOnlyList paragraphsPost6 = L( + new Paragraph( + ParagraphType.Text, + "The Android Studio logo redesign caught the attention of the developer community since its sneak peek at the Android Developer Summit. We are thrilled to release the new Android Studio logo with the stable release of Flamingo. Now that the new logo is available to most Android Studio users, we can examine the design changes in greater detail and decode their meaning." + ), + new Paragraph( + ParagraphType.Text, + "This case study offers a comprehensive overview of the design journey, from identifying the initial problem to the final outcome. It explores the critical brand elements that the team needed to consider and the tools used throughout the redesign process. This case study also delves into the various stages of design exploration, highlighting the efforts to create a modern logo while honoring the Android Studio brand's legacy." + ), + new Paragraph( + ParagraphType.Header, + "Identifying the problem" + ), + new Paragraph( + ParagraphType.Text, + "You told us the Android Studio logo looked a little weird and complicated. It doesn't shrink down well and it's way too similar to the emulator. We heard you!" + ), + new Paragraph( + ParagraphType.Text, + "The Android Studio logo used between 2020 and 2022 was well-suited for print, but it posed challenges when used as an application icon. Its readability suffered when reduced to smaller sizes, and its similarity to the emulator caused confusion." + ), + new Paragraph( + ParagraphType.Text, + "Additionally, the use of color alone to differentiate between Canary and Stable versions made it difficult for users with color vision deficiencies." + ), + new Paragraph( + ParagraphType.Text, + "The redesign aimed to resolve these concerns by creating a logo that was easy to read, visually distinctive, and followed the OS guidelines when necessary, ensuring accessibility. The new design also maintained a connection with the Android logo family while honoring its legacy." + ), + new Paragraph( + ParagraphType.Text, + "In this case study, we will delve into the version history and evolution of the Android Studio logo and how it has changed over the years." + ), + new Paragraph( + ParagraphType.Header, + "A brief history of the Android Studio logo" + ), + new Paragraph( + ParagraphType.Bullet, + "2013: The original Android Studio logo was a 3D robot that highlighted the gears and interworking of the bugdroid. At this time, the Android Emulator was the bugdroid.", + L( + new Markup(MarkupType.Bold, 0, 5) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "2014: The Android Emulator merged to a flat mark but remained otherwise unchanged.", + L( + new Markup(MarkupType.Bold, 0, 5) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "2014-2019: An updated Android Studio logo was introduced featuring an \"A\" compass in front of a green circle.", + L( + new Markup(MarkupType.Bold, 0, 10) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "2019: In Canary 3.6, the color palette was updated to match Android 10.", + L( + new Markup(MarkupType.Bold, 0, 5) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "2020-2022: With the release of Android Studio 4.1 Canary, the \"A\" compass was reduced to an abstract form placed in front of a blueprint. The Android head was also added, peeking over the top.", + L( + new Markup(MarkupType.Bold, 0, 10) + ) + ), + new Paragraph( + ParagraphType.Header, + "Understanding the Android brand elements" + ), + new Paragraph( + ParagraphType.Text, + "When redesigning a logo, it's important to consider brand elements that unify products within an ecosystem. For the Android Developer ecosystem, the \"robot head\" is a key brand element, alongside the primaryAndroid green color. The secondary colors blue and navy, and tertiary colors like orange, can also be utilized for support." + ), + new Paragraph( + ParagraphType.Header, + "Key objectives" + ), + new Paragraph( + ParagraphType.Bullet, + "Iconography: use recognizable and appropriate symbols, such as compass \"A\" for Android Studio or a device for Android Emulator, to convey the purpose and functionality clearly and quickly.", + L( + new Markup(MarkupType.Bold, 0, 12) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "Enhance recognition and scalability: the Android Studio and Android Emulator should prioritize legibility and scalability, ensuring that they can be easily recognized and understood even at smaller sizes.", + L( + new Markup(MarkupType.Bold, 0, 36) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "Establish distinction: the Android Studio and Android Emulator need to be easily distinguishable, to avoid confusion.", + L( + new Markup(MarkupType.Bold, 0, 22) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "Maintain brand consistency: the Android Studio and Android Emulator designs should be consistent with the overall branding and visual identity of the Android family, while still being distinctive.", + L( + new Markup(MarkupType.Bold, 0, 27) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "Ensure accessibility: the logo should be accessible to all users, including those with visual impairments. This means using clear shapes, colors, and contrast.", + L( + new Markup(MarkupType.Bold, 0, 21) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "Follow OS guidelines: the updated application icon must align with the Android visual language and conform to the guidelines of macOS, Windows, and Linux operating systems, ensuring consistency and coherence across all platforms.", + L( + new Markup(MarkupType.Bold, 0, 21) + ) + ), + new Paragraph( + ParagraphType.Bullet, + "Ensure versatility: the Android Studio logo should be versatile enough to work in a variety of sizes and contexts, such as on different devices and platforms.", + L( + new Markup(MarkupType.Bold, 0, 20) + ) + ), + new Paragraph( + ParagraphType.Text, + "Read More", + L( + new Markup( + MarkupType.Link, + 0, + 9, + "https://android-developers.googleblog.com/2023/05/redesigning-android-studio-logo.html" + ) + ) + ) + ); + + public static readonly Post post1 = new Post( + Id: "dc523f0ed25c", + Title: "A Little Thing about Android Module Paths", + Subtitle: "How to configure your module paths, instead of using Gradle’s default.", + Url: "https://medium.com/androiddevelopers/gradle-path-configuration-dc523f0ed25c", + Publication: ThePublication, + Metadata: new Metadata( + Author: pietro, + Date: "August 02", + ReadTimeMinutes: 1 + ), + Paragraphs: paragraphsPost1, + ImageId: "post_1", + ImageThumbId: "post_1_thumb" + ); + + public static readonly Post post2 = new Post( + Id: "7446d8dfd7dc", + Title: "Dagger in Kotlin: Gotchas and Optimizations", + Subtitle: "Use Dagger in Kotlin! This article includes best practices to optimize your build time and gotchas you might encounter.", + Url: "https://medium.com/androiddevelopers/dagger-in-kotlin-gotchas-and-optimizations-7446d8dfd7dc", + Publication: ThePublication, + Metadata: new Metadata( + Author: manuel, + Date: "July 30", + ReadTimeMinutes: 3 + ), + Paragraphs: paragraphsPost2, + ImageId: "post_2", + ImageThumbId: "post_2_thumb" + ); + + public static readonly Post post3 = new Post( + Id: "ac552dcc1741", + Title: "From Java Programming Language to Kotlin — the idiomatic way", + Subtitle: "Learn how to get started converting Java Programming Language code to Kotlin, making it more idiomatic and avoid common pitfalls, by…", + Url: "https://medium.com/androiddevelopers/from-java-programming-language-to-kotlin-the-idiomatic-way-ac552dcc1741", + Publication: ThePublication, + Metadata: new Metadata( + Author: florina, + Date: "July 09", + ReadTimeMinutes: 1 + ), + Paragraphs: paragraphsPost3, + ImageId: "post_3", + ImageThumbId: "post_3_thumb" + ); + + public static readonly Post post4 = new Post( + Id: "84eb677660d9", + Title: "Locale changes and the AndroidViewModel antipattern", + Subtitle: "TL;DR: Expose resource IDs from ViewModels to avoid showing obsolete data.", + Url: "https://medium.com/androiddevelopers/locale-changes-and-the-androidviewmodel-antipattern-84eb677660d9", + Publication: ThePublication, + Metadata: new Metadata( + Author: jose, + Date: "April 02", + ReadTimeMinutes: 1 + ), + Paragraphs: paragraphsPost4, + ImageId: "post_4", + ImageThumbId: "post_4_thumb" + ); + + public static readonly Post post5 = new Post( + Id: "55db18283aca", + Title: "Collections and sequences in Kotlin", + Subtitle: "Working with collections is a common task and the Kotlin Standard Library offers many great utility functions. It also offers two ways of…", + Url: "https://medium.com/androiddevelopers/collections-and-sequences-in-kotlin-55db18283aca", + Publication: ThePublication, + Metadata: new Metadata( + Author: florina, + Date: "July 24", + ReadTimeMinutes: 4 + ), + Paragraphs: paragraphsPost5, + ImageId: "post_5", + ImageThumbId: "post_5_thumb" + ); + + public static readonly Post post6 = new Post( + Id: "55db18283ac0", + Title: "Redesigning the Android Studio Logo", + Subtitle: "A case study offering a comprehensive overview of the design journey of the Android Studio product logo.", + Url: "https://android-developers.googleblog.com/2023/05/redesigning-android-studio-logo.html", + Publication: ThePublication, + Metadata: new Metadata( + Author: androidstudioteam, + Date: "May 10", + ReadTimeMinutes: 5 + ), + Paragraphs: paragraphsPost6, + ImageId: "post_6", + ImageThumbId: "post_6_thumb" + ); + + public static readonly PostsFeed Posts = new PostsFeed( + HighlightedPost: post6, + RecommendedPosts: L(post1, post2, post3), + PopularPosts: L( + post5, + post1 with { Id = "post6" }, + post2 with { Id = "post7" } + ), + RecentPosts: L( + post6, + post3 with { Id = "post8" }, + post4 with { Id = "post9" }, + post5 with { Id = "post10" } + ) + ); + } + +} diff --git a/src/Comet/sample/Shared/JetNews/JetNewsIcons.cs b/src/Comet/sample/Shared/JetNews/JetNewsIcons.cs new file mode 100644 index 000000000..bfa8b3e07 --- /dev/null +++ b/src/Comet/sample/Shared/JetNews/JetNewsIcons.cs @@ -0,0 +1,46 @@ +#nullable enable +using System.Collections.Generic; +using Comet; + +namespace CometSamples.JetNews +{ + /// + /// JetNews' Material Icons glyph map (the one-font cross-platform approach shared by + /// Jetchat/Reply). Names follow the gold's icon usages (utils/JetnewsIcons.kt + the + /// material icons the screens reference directly). + /// + static class JetNewsIcons + { + public const string Font = "Material Icons"; + + static bool _registered; + + public static void Register() + { + if (_registered) + return; + _registered = true; + + var map = new Dictionary(); + void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint); + + Add("menu", 0xE5D2); // drawer affordance (gold uses the brand icon image; menu as fallback) + Add("search", 0xE8B6); // app bar search + Add("arrow_back", 0xE5C4); // article top bar + Add("home", 0xE88A); // drawer Home + Add("list_alt", 0xE0EE); // drawer Interests (ListAlt) + Add("bookmark", 0xE866); // saved post (filled) + Add("bookmark_border", 0xE867); // unsaved post + Add("thumb_up_offalt", 0xE8DC); // article action bar like (ThumbUpOffAlt) + Add("share", 0xE80D); // article action bar share + Add("text_format", 0xE165); // article action bar text settings (FormatSize glyph) + Add("more_vert", 0xE5D4); // history row overflow + Add("add", 0xE145); // interests unselected toggle + Add("check", 0xE5CA); // interests selected toggle + Add("account_circle", 0xE853); // article author avatar placeholder + Add("android", 0xE859); // publication badge stand-in (gold: icon_post_background vector) + + IconFont.Register(Font, map); + } + } +} diff --git a/src/Comet/sample/Shared/JetNews/JetNewsInterests.cs b/src/Comet/sample/Shared/JetNews/JetNewsInterests.cs new file mode 100644 index 000000000..d50e07598 --- /dev/null +++ b/src/Comet/sample/Shared/JetNews/JetNewsInterests.cs @@ -0,0 +1,174 @@ +#nullable enable +using System.Collections.Generic; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.JetNews.JetNewsTheme; + +namespace CometSamples.JetNews +{ + /// + /// The Interests screen (InterestsScreen.kt): center top bar, REAL PrimaryTabRow + /// (Topics / People / Publications), and per-tab selectable topic lists. + /// Gold renders the lists as Column+verticalScroll; here they're ListViews (LazyColumn) + /// so toggles re-pull rows — documented deviation, visually identical on compact. + /// + public static class JetNewsInterests + { + // FakeInterestsRepository verbatim. + static readonly IReadOnlyList<(string Section, IReadOnlyList Topics)> Topics = new (string, IReadOnlyList)[] + { + ("Android", new[] { "Jetpack Compose", "Kotlin", "Jetpack" }), + ("Programming", new[] { "Kotlin", "Declarative UIs", "Java", "Unidirectional Data Flow", "C++" }), + ("Technology", new[] { "Pixel", "Google" }), + }; + static readonly IReadOnlyList People = new[] + { + "Kobalt Toral", "K'Kola Uvarek", "Kris Vriloc", "Grala Valdyr", "Kruel Valaxar", + "L'Elij Venonn", "Kraag Solazarn", "Tava Targesh", "Kemarrin Muuda", + }; + static readonly IReadOnlyList Publications = new[] + { + "Kotlin Vibe", "Compose Mix", "Compose Breakdown", "Android Pursue", "Kotlin Watchman", + "Jetpack Ark", "Composeshack", "Jetpack Point", "Compose Tribune", + }; + + static readonly Signal TabIndex = new(0); + static readonly HashSet Selected = new(); // keyed "{section}|{topic}" / name + static readonly List> Lists = new(); + + static void Toggle(string key) + { + if (!Selected.Add(key)) + Selected.Remove(key); + foreach (var list in Lists) + list.ReloadData(); + } + + abstract record Row; + sealed record SectionRow(string Title) : Row; + sealed record TopicRow(string Key, string Title) : Row; + + static Text Tx(string s) => new Text(s).FontFamily("Montserrat"); + + public static View Screen(double topInset, System.Action openDrawer) + { + JetNewsIcons.Register(); + Lists.Clear(); + + var tabs = new TabBar(TabIndex, new[] { "Topics", "People", "Publications" }, + selectedColor: T.Primary, unselectedColor: T.OnSurface.WithAlpha(0.8f), + fontFamily: "Montserrat"); + + var content = new ContentSwitcher(TabIndex, new View[] + { + TabList(TopicsRows()), + TabList(NamesRows(People)), + TabList(NamesRows(Publications)), + }); + + return new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)topInset).FlexShrink(0), + TopBar(openDrawer).FlexShrink(0), + tabs.FlexShrink(0), + new HStack().Frame(height: 1).Background(T.OnSurface.WithAlpha(0.1f)) + .HorizontalLayoutAlignment(LayoutAlignment.Fill).FlexShrink(0), + content.FlexGrow(1).FlexBasis(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.Background); + } + + // CenterAlignedTopAppBar: nav = brand logo (menu stand-in, like Home), centered + // "Interests" titleLarge tinted primary, search action. + static View TopBar(System.Action openDrawer) => new HStack(spacing: 0f) + { + new Icon("jetnews_logo").IconSize(24).Color(T.Primary) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .FlexShrink(0) + .OnTap(_ => openDrawer()), + new HStack().FlexGrow(1), + Tx("Interests").FontSize(22).Color(T.Primary) + .VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + new Icon("search").IconSize(24).Color(T.OnSurfaceVariant) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .FlexShrink(0), + }.Frame(height: 64); + + static IReadOnlyList TopicsRows() + { + var rows = new List(); + foreach (var (section, topics) in Topics) + { + rows.Add(new SectionRow(section)); + foreach (var topic in topics) + rows.Add(new TopicRow($"{section}|{topic}", topic)); + } + return rows; + } + + static IReadOnlyList NamesRows(IReadOnlyList names) + { + var rows = new List { new SectionRow("") }; // gold pads the list top 16 + foreach (var name in names) + rows.Add(new TopicRow(name, name)); + return rows; + } + + static View TabList(IReadOnlyList rows) + { + var list = new ListView(() => rows) + { + ViewFor = r => r switch + { + SectionRow s when s.Title.Length > 0 => + Tx(s.Title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface) + .Padding(new Thickness(16)), + SectionRow => new HStack().Frame(height: 16), + TopicRow t => TopicItem(t), + _ => new HStack(), + }, + }; + Lists.Add(list); + return list; + } + + // TopicItem: placeholder image 56 r4 | title titleMedium pad 16 | select toggle 36; + // divider inset start 72, v-pad 8 (baked into the row column). + static View TopicItem(TopicRow t) => new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + new Image("placeholder_1_1").Frame(width: 56, height: 56).CornerRadius(4) + .FlexShrink(0), + Tx(t.Title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading) + .Padding(new Thickness(16)).FlexGrow(1).FlexBasis(0) + .VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().Frame(width: 16), + SelectTopicButton(Selected.Contains(t.Key)) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0), + }, + new HStack().Frame(height: 1).Background(T.OnSurface.WithAlpha(0.1f)) + .Margin(new Thickness(56, 8, 0, 8)) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + } + .Padding(new Thickness(16, 0, 16, 0)) + .OnTap(_ => Toggle(t.Key)); + + // SelectTopicButton.kt: 36dp circle Surface — unselected: onPrimary bg, onSurface@10% + // border, primary "+"; selected: primary bg, primary border, onPrimary "✓". + static View SelectTopicButton(bool selected) => + new Icon(selected ? "check" : "add").IconSize(20) + .Color(selected ? T.OnPrimary : T.Primary) + .Frame(width: 36, height: 36) + .Background(selected ? T.Primary : T.OnPrimary) + .CornerRadius(18) + .Border(1, selected ? T.Primary : T.OnSurface.WithAlpha(0.1f)); + } +} diff --git a/src/Comet/sample/Shared/JetNews/JetNewsModel.cs b/src/Comet/sample/Shared/JetNews/JetNewsModel.cs new file mode 100644 index 000000000..e294e2b97 --- /dev/null +++ b/src/Comet/sample/Shared/JetNews/JetNewsModel.cs @@ -0,0 +1,53 @@ +#nullable enable +using System.Collections.Generic; + +namespace CometSamples.JetNews +{ + // Verbatim port of the gold model (model/Post.kt) — same names, same shape. + // Image ids become bundled resource NAMES (drawables on Android / bundle images on iOS). + + public sealed record Post( + string Id, + string Title, + string? Subtitle, + string Url, + Publication? Publication, + Metadata Metadata, + IReadOnlyList Paragraphs, + string ImageId, + string ImageThumbId); + + public sealed record Metadata(PostAuthor Author, string Date, int ReadTimeMinutes); + + public sealed record PostAuthor(string Name, string? Url = null); + + public sealed record Publication(string Name, string LogoUrl); + + public sealed record Paragraph(ParagraphType Type, string Text, IReadOnlyList? Markups = null); + + public sealed record Markup(MarkupType Type, int Start, int End, string? Href = null); + + public enum MarkupType { Link, Code, Italic, Bold } + + public enum ParagraphType { Title, Caption, Header, Subhead, Text, CodeBlock, Quote, Bullet } + + // model/PostsFeed.kt + public sealed record PostsFeed( + Post HighlightedPost, + IReadOnlyList RecommendedPosts, + IReadOnlyList PopularPosts, + IReadOnlyList RecentPosts) + { + public IReadOnlyList AllPosts + { + get + { + var all = new List { HighlightedPost }; + all.AddRange(RecommendedPosts); + all.AddRange(PopularPosts); + all.AddRange(RecentPosts); + return all; + } + } + } +} diff --git a/src/Comet/sample/Shared/JetNews/JetNewsRoot.cs b/src/Comet/sample/Shared/JetNews/JetNewsRoot.cs new file mode 100644 index 000000000..16d3ec362 --- /dev/null +++ b/src/Comet/sample/Shared/JetNews/JetNewsRoot.cs @@ -0,0 +1,133 @@ +#nullable enable +using System.Linq; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Primitives; +using T = CometSamples.JetNews.JetNewsTheme; + +namespace CometSamples.JetNews +{ + /// + /// JetNews chrome + routing over the adaptive primitives (the gold JetnewsApp): + /// NavigationSuite with the JetNews breakpoint policy (chromeless + modal drawer + /// below 840dp, REAL NavigationRail with selected-only labels above — JetnewsApp.kt's + /// isExpandedScreen), destinations = Home / Interests (real NavigationDrawerItems in + /// the sheet), and Home = ListDetail (compact: full-screen article with system-back; + /// expanded: feed | detail at the gold's ~⅓ split with a "Select a post" placeholder). + /// + public class JetNewsRoot : View + { + /// 0 = Home, 1 = Interests (the drawer/rail destinations). + public static readonly Signal SelectedDest = new(0); + public static readonly Signal ArticleOpen = new(false); + public static readonly Signal CurrentPost = new(JetNewsData.Posts.HighlightedPost); + public static readonly Signal DrawerOpen = new(false); + + // Backend-written active chrome variant → derived indices for the variant-aware + // slots (compact app bar vs expanded search field; placeholder vs article). + static readonly Signal Variant = new((int)NavigationSuiteVariant.None); + static readonly Signal HomeChrome = new(0); + static readonly Signal DetailContent = new(0); + + static JetNewsRoot() + { + Variant.PropertyChanged += (_, _) => + HomeChrome.Value = Variant.Peek() == (int)NavigationSuiteVariant.Rail ? 1 : 0; + ArticleOpen.PropertyChanged += (_, _) => + DetailContent.Value = ArticleOpen.Peek() ? 1 : 0; + // Selecting a destination dismisses an open article (the gold's PopUpTo reset) — + // without this, drawer → Home while an article is open lands on the stale article. + SelectedDest.PropertyChanged += (_, _) => ArticleOpen.Value = false; + } + + public static void OpenPost(string id) + { + if (JetNewsData.Posts.AllPosts.FirstOrDefault(p => p.Id == id) is { } post) + { + CurrentPost.Value = post; + ArticleOpen.Value = true; + } + } + + public JetNewsRoot() { } + + static Text Tx(string s) => new Text(s).FontFamily("Montserrat"); + + [Body] + View body() + { + JetNewsIcons.Register(); + JetNewsScreens.ResetFeedLists(); // a rebuild replaces the live lists — drop the old generation + + // Home pane: compact = wordmark app bar; expanded = search field (gold + // HomeFeedWithArticleDetailsScreen swaps chrome the same way). + var homeList = new ContentSwitcher(HomeChrome, new View[] + { + JetNewsScreens.Home(topInset: 0, openDrawer: () => DrawerOpen.Value = true), + JetNewsScreens.ExpandedListPane(), + }); + + var detail = new ContentSwitcher(DetailContent, new View[] + { + SelectAPost(), + JetNewsArticle.Screen(CurrentPost, topInset: 0, onBack: () => ArticleOpen.Value = false), + }); + + var home = new ListDetail(ArticleOpen, homeList, detail, listFraction: 1 / 3.0); + + var routes = new ContentSwitcher(SelectedDest, new View[] + { + home, + JetNewsInterests.Screen(topInset: 0, openDrawer: () => DrawerOpen.Value = true), + }); + + var items = new[] + { + // onSelect fires even when the index is unchanged — re-tapping Home + // still dismisses an open article (the equality-gated signal wouldn't). + new NavigationItem(new Icon("home").IconSize(24), Tx("Home").FontSize(14), + onSelect: () => ArticleOpen.Value = false), + new NavigationItem(new Icon("list_alt").IconSize(24), Tx("Interests").FontSize(14)), + }; + + return new NavigationSuite(SelectedDest, items, routes, + railHeader: RailHeader(), drawerHeader: DrawerHeader(), + drawerOpen: DrawerOpen, + // M3 tokens for the hand-composed iOS twin (Android's real widgets self-theme). + containerColor: T.Background, indicatorColor: T.SecondaryContainer, + // JetnewsApp.kt: expanded (≥840dp) = rail; below = drawer-only chrome. + variantFor: (w, _) => w >= 840 ? NavigationSuiteVariant.Rail : NavigationSuiteVariant.None, + railShowsSelectedLabel: true, + variantSignal: Variant) + .Background(T.Background); + } + + // AppNavRail.kt header: the jetnews logo vector tinted primary, 12dp vertical padding. + static View RailHeader() => + new Icon("jetnews_logo").IconSize(24).Color(T.Primary) + .Frame(width: 80, height: 48).Padding(new Thickness(28, 12, 28, 12)); + + // AppDrawer.kt JetNewsLogo row: logo (primary) + wordmark (onSurfaceVariant), 8dp gap. + static View DrawerHeader() => new HStack(spacing: 0f) + { + new Icon("jetnews_logo").IconSize(24).Color(T.Primary).FlexShrink(0), + new HStack().Frame(width: 8), + new Icon("jetnews_wordmark").Color(T.OnSurfaceVariant).IconFillFrame() + .Frame(width: 80, height: 24) + .VerticalLayoutAlignment(LayoutAlignment.Center), + }.Padding(new Thickness(28, 24, 28, 24)); + + // HomeScreens.kt: expanded detail placeholder — centered "Select a post". + static View SelectAPost() => new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + Tx("Select a post").FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface) + .HorizontalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.Background); + } +} diff --git a/src/Comet/sample/Shared/JetNews/JetNewsScreens.cs b/src/Comet/sample/Shared/JetNews/JetNewsScreens.cs new file mode 100644 index 000000000..f31adeb8b --- /dev/null +++ b/src/Comet/sample/Shared/JetNews/JetNewsScreens.cs @@ -0,0 +1,267 @@ +#nullable enable +using System.Collections.Generic; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.JetNews.JetNewsTheme; + +namespace CometSamples.JetNews +{ + /// + /// JetNews screens, values-from-source (file:line cites into the gold Kotlin app). + /// Home = HomeScreens.kt PostList (:423-443) — top section, simple section, popular + /// carousel, history section, dividers; cards = PostCards.kt / PostCardTop.kt / + /// PostCardYourNetwork.kt. + /// + public static class JetNewsScreens + { + // ── Bookmarks (gold favorites: Set in HomeViewModel) ── + static readonly Comet.Reactive.Signal SearchQuery = new(string.Empty); + static readonly HashSet Favorites = new(); + static readonly List> FeedLists = new(); + + /// Root rebuild: drop the previous generation of feed lists so + /// ToggleFavorite stops reloading dead ones (and they can be collected). + internal static void ResetFeedLists() => FeedLists.Clear(); + static void ToggleFavorite(string id) + { + if (!Favorites.Add(id)) + Favorites.Remove(id); + foreach (var list in FeedLists) + list.ReloadData(); + } + + public static void OpenPost(string id) => JetNewsRoot.OpenPost(id); + + // Gold typography (Type.kt defaultTextStyle): Montserrat for EVERY style — already + // registered in both probes (Jetchat ships the same family). + static Text Tx(string s) => new Text(s).FontFamily("Montserrat"); + + // ── Home feed rows: the gold's LazyColumn sections flattened into one list + // (PostList :423-443 — top / simple×3+divider / popular / history). ── + abstract record HomeRow; + sealed record TopSectionRow(Post Post) : HomeRow; + sealed record SimpleRow(Post Post) : HomeRow; + sealed record PopularSectionRow(IReadOnlyList Posts) : HomeRow; + sealed record HistoryRow(Post Post) : HomeRow; + sealed record DividerRow : HomeRow; + + static IReadOnlyList HomeRows() + { + var feed = JetNewsData.Posts; + var rows = new List { new TopSectionRow(feed.HighlightedPost), new DividerRow() }; + foreach (var p in feed.RecommendedPosts) + { + rows.Add(new SimpleRow(p)); + rows.Add(new DividerRow()); + } + rows.Add(new PopularSectionRow(feed.PopularPosts)); + rows.Add(new DividerRow()); + foreach (var p in feed.RecentPosts) + { + rows.Add(new HistoryRow(p)); + rows.Add(new DividerRow()); + } + return rows; + } + + /// The compact Home screen: center-aligned top app bar + the feed. + /// clears the status bar (no NavigationSuite here — + /// JetNews chrome is drawer-first; the drawer lands in the next increment). + static ListView FeedList() + { + var list = new ListView(HomeRows) + { + ViewFor = r => r switch + { + TopSectionRow t => TopSection(t.Post), + SimpleRow s => PostCardSimple(s.Post), + PopularSectionRow p => PopularSection(p.Posts), + HistoryRow h => PostCardHistory(h.Post), + _ => Divider(), + }, + }; + FeedLists.Add(list); + return list; + } + + public static View Home(double topInset, System.Action? openDrawer = null) + { + JetNewsIcons.Register(); + + return new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)topInset).FlexShrink(0), + HomeTopAppBar(openDrawer).FlexShrink(0), + FeedList().FlexGrow(1).FlexBasis(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.Background); + } + + /// The expanded home LIST pane (HomeFeedWithArticleDetailsScreen): the REAL + /// OutlinedTextField search (submit toast-stubbed in the gold) above the same feed. + public static View ExpandedListPane() + { + JetNewsIcons.Register(); + + // The REAL Material OutlinedTextField (gold HomeSearch:578): accepts input; + // submit is toast-stubbed in the gold. Android drives the exact widget; the + // iOS twin renders Comet's standard field (documented deviation). + var field = SignalExtensions.TextField(SearchQuery, "Search posts") + .Outlined().LeadingIcon("search") + .Frame(height: 56) + .Margin(new Thickness(16, 8, 16, 8)) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + + return new VStack(spacing: 0f) + { + field.FlexShrink(0), + FeedList().FlexGrow(1).FlexBasis(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.Background); + } + + // Gold HomeTopAppBar: CenterAlignedTopAppBar — brand logo nav slot, centered + // wordmark VECTOR tinted primary, search action (HomeScreens.kt:630-660). + static View HomeTopAppBar(System.Action? openDrawer) => new HStack(spacing: 0f) + { + new Icon("jetnews_logo").IconSize(24).Color(T.Primary) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .FlexShrink(0) + .OnTap(_ => openDrawer?.Invoke()), + new HStack().FlexGrow(1), + new Icon("jetnews_wordmark").Color(T.Primary).IconFillFrame() + .Frame(width: 80, height: 24) + .VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + new Icon("search").IconSize(24).Color(T.OnSurfaceVariant) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .FlexShrink(0), + }.Frame(height: 64); + + // ── Top section (HomeScreens.kt:468-479 + PostCardTop.kt): section title + // titleMedium pad s16/t16/e16; card column pad 16 — image minH 180 rounded, + // spacer 16, title titleLarge padB 8, author labelLarge padB 4, + // date-readtime bodySmall onSurfaceVariant. ── + static View TopSection(Post post) => new VStack(spacing: 0f) + { + Tx("Top stories for you").FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface) + .Padding(new Thickness(16, 16, 16, 0)), + new VStack(spacing: 0f) + { + new Image(post.ImageId).Frame(height: 180).CornerRadius(8) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + new HStack().Frame(height: 16), + Tx(post.Title).FontSize(22).Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading) + .Padding(new Thickness(0, 0, 0, 8)), + Tx(post.Metadata.Author.Name).FontSize(14).FontWeight(FontWeight.Medium).Color(T.OnSurface) + .Padding(new Thickness(0, 0, 0, 4)), + Tx($"{post.Metadata.Date} - {post.Metadata.ReadTimeMinutes} min read") + .FontSize(12).Color(T.OnSurfaceVariant), + }.Padding(new Thickness(16)), + }.OnTap(_ => OpenPost(post.Id)); + + // ── Simple row (PostCards.kt:~85-128): image 40 pad 16 | column v10 (title + // titleMedium, author-readtime bodyMedium) | bookmark toggle pad v2/h6. ── + static View PostCardSimple(Post post) => new HStack(spacing: 0f) + { + new Image(post.ImageThumbId).Frame(width: 40, height: 40).CornerRadius(4) + .Margin(new Thickness(16)).FlexShrink(0), + new VStack(spacing: 2f) + { + Tx(post.Title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading), + Tx($"{post.Metadata.Author.Name} - {post.Metadata.ReadTimeMinutes} min read") + .FontSize(14).Color(T.OnSurfaceVariant), + }.Padding(new Thickness(0, 10, 0, 10)).FlexGrow(1).FlexBasis(0) + .VerticalLayoutAlignment(LayoutAlignment.Center), + BookmarkButton(post).FlexShrink(0), + }.OnTap(_ => OpenPost(post.Id)); + + // The REAL M3 IconToggleButton (gold utils/JetnewsIcons.kt BookmarkButton) — + // checked + onCheckedChange; rows rebuild with the new glyph via ReloadData. + static View BookmarkButton(Post post) => + new IconToggleButton(Favorites.Contains(post.Id), _ => ToggleFavorite(post.Id), + new Icon(Favorites.Contains(post.Id) ? "bookmark" : "bookmark_border") + .IconSize(24).Color(T.OnSurfaceVariant)) + .Margin(new Thickness(6, 2, 6, 2)) + .VerticalLayoutAlignment(LayoutAlignment.Center); + + // ── Popular section (HomeScreens.kt:516-541): title titleLarge pad 16; horizontal + // card row pad h16 spacing 8 (gold: Row+horizontalScroll; a horizontal ListView is + // the node-backend equivalent); spacer 16. Card (PostCardYourNetwork.kt:54-97): + // width 280 shape medium — image h100 fill, column pad 16 (title headlineSmall + // max2, author bodyMedium max1, date-readtime bodySmall). ── + static View PopularSection(IReadOnlyList posts) + { + var carousel = new ListView(() => posts) + { + Horizontal = true, + ViewFor = PostCardPopular, + }; + return new VStack(spacing: 0f) + { + Tx("Popular on Jetnews").FontSize(22).Color(T.OnSurface) + .Padding(new Thickness(16)), + carousel.Frame(height: 250).Margin(left: 16) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + new HStack().Frame(height: 16), + }; + } + + static View PostCardPopular(Post post) => new VStack(spacing: 0f) + { + new Image(post.ImageId).Frame(width: 280, height: 100), + new VStack(spacing: 0f) + { + Tx(post.Title).FontSize(24).Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading).MaxLines(2), + Tx(post.Metadata.Author.Name).FontSize(14).Color(T.OnSurface) + .MaxLines(1).Padding(new Thickness(0, 4, 0, 0)), + Tx($"{post.Metadata.Date} - {post.Metadata.ReadTimeMinutes} min read") + .FontSize(12).Color(T.OnSurfaceVariant), + }.Padding(new Thickness(16)), + } + .Frame(width: 280) + // The REAL M3 Card (clickable — gold PostCardYourNetwork.kt:55): self-themed + // containerColor (surfaceContainerLow) + elevation on Android; the iOS twin has no + // Card widget, so the tokens stay for the hand-composed side. + .AsCard() + .Background(T.SurfaceContainerLow).CornerRadius(4) + .Margin(right: 8) + .OnTap(_ => OpenPost(post.Id)); + + // ── History row (PostCards.kt:131-160): image 40 pad 16 | column v12 (overline + // labelMedium "BASED ON YOUR HISTORY", title, author-readtime pad t4) | more_vert. ── + static View PostCardHistory(Post post) => new HStack(spacing: 0f) + { + new Image(post.ImageThumbId).Frame(width: 40, height: 40).CornerRadius(4) + .Margin(new Thickness(16)).FlexShrink(0), + new VStack(spacing: 2f) + { + Tx("BASED ON YOUR HISTORY").FontSize(12).FontWeight(FontWeight.Medium) + .Color(T.OnSurfaceVariant), + Tx(post.Title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface) + .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading), + Tx($"{post.Metadata.Author.Name} - {post.Metadata.ReadTimeMinutes} min read") + .FontSize(14).Color(T.OnSurfaceVariant).Padding(new Thickness(0, 4, 0, 0)), + }.Padding(new Thickness(0, 12, 0, 12)).FlexGrow(1).FlexBasis(0), + new Icon("more_vert").IconSize(24).Color(T.OnSurfaceVariant) + .Frame(width: 48, height: 48).Margin(new Thickness(0, 12, 4, 0)).FlexShrink(0), + }.OnTap(_ => OpenPost(post.Id)); + + // Gold PostListDivider: 1dp, pad h14, onSurface 12% (HomeScreens.kt tail). + static View Divider() => new HStack() + .Frame(height: 1) + .Margin(left: 14, right: 14) + .Background(T.Divider) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } +} diff --git a/src/Comet/sample/Shared/JetNews/JetNewsTheme.cs b/src/Comet/sample/Shared/JetNews/JetNewsTheme.cs new file mode 100644 index 000000000..ead920518 --- /dev/null +++ b/src/Comet/sample/Shared/JetNews/JetNewsTheme.cs @@ -0,0 +1,89 @@ +#nullable enable +using Microsoft.Maui.Graphics; + +namespace CometSamples.JetNews +{ + /// + /// JetNews theme. The gold app uses DYNAMIC color unconditionally on S+ + /// (Theme.kt:90-97), so the gold screenshots carry the capture emulator's + /// wallpaper-derived Material You scheme. We regenerate that scheme with the SAME + /// algorithm (material-color-utilities, the Jetchat generator path) from the seed + /// sampled off the gold captures (tab indicator / primary ≈ #475D92), so both + /// backends render the identical palette the golds show. + /// + public static class JetNewsTheme + { + /// Sampled from the gold captures (Interests tab indicator = primary). + public static readonly Color Seed = Color.FromArgb("#475D92"); + + // Order matters: static fields initialize in source order, and S's builder reads Core. + static readonly MaterialColorUtilities.Palettes.CorePalette Core = + MaterialColorUtilities.Palettes.CorePalette.Of(ToArgbUint(Seed)); + + static readonly MaterialColorUtilities.Schemes.Scheme S = BuildScheme(); + + static MaterialColorUtilities.Schemes.Scheme BuildScheme() => + new MaterialColorUtilities.Schemes.LightSchemeMapper().Map(Core); + + public static readonly Color Primary = C(S.Primary); + public static readonly Color OnPrimary = C(S.OnPrimary); + public static readonly Color PrimaryContainer = C(S.PrimaryContainer); + public static readonly Color OnPrimaryContainer = C(S.OnPrimaryContainer); + public static readonly Color Secondary = C(S.Secondary); + public static readonly Color SecondaryContainer = C(S.SecondaryContainer); + public static readonly Color OnSecondaryContainer = C(S.OnSecondaryContainer); + public static readonly Color Background = C(S.Background); + public static readonly Color OnBackground = C(S.OnBackground); + public static readonly Color Surface = C(S.Surface); + public static readonly Color OnSurface = C(S.OnSurface); + public static readonly Color SurfaceVariant = C(S.SurfaceVariant); + public static readonly Color OnSurfaceVariant = C(S.OnSurfaceVariant); + public static readonly Color Outline = C(S.Outline); + public static readonly Color InverseOnSurface = C(S.InverseOnSurface); + + // M3 surface-container roles (neutral tones 100/96/94/92/90 in light schemes) — + // the 0.3.0 scheme mapper predates these tokens, so derive them from the palette. + // M3 Card's DEFAULT containerColor is SurfaceContainerLow (gold popular cards). + public static readonly Color SurfaceContainerLowest = C(Core.Neutral[100]); + public static readonly Color SurfaceContainerLow = C(Core.Neutral[96]); + public static readonly Color SurfaceContainer = C(Core.Neutral[94]); + public static readonly Color SurfaceContainerHigh = C(Core.Neutral[92]); + public static readonly Color SurfaceContainerHighest = C(Core.Neutral[90]); + + /// onSurface @ 12% — the gold's list dividers (DividerDefaults). + public static readonly Color Divider = C(S.OnSurface).WithAlpha(0.12f); + + /// onSurface @ 15% — code spans/blocks (PostContent.kt codeBlockBackground). + public static readonly Color CodeBlockBackground = C(S.OnSurface).WithAlpha(0.15f); + +#if ANDROID + /// The same scheme as a Compose ColorScheme, so the REAL M3 widgets + /// (TabRow, drawer sheets, snackbar) are seeded identically (the Reply pattern). + public static AndroidX.Compose.Material3.ColorScheme ComposeScheme() + { + static AndroidX.Compose.Color C(Color c) => AndroidX.Compose.Color.FromArgb( + (byte)(c.Alpha * 255), (byte)(c.Red * 255), (byte)(c.Green * 255), (byte)(c.Blue * 255)); + return AndroidX.Compose.MaterialTheme.LightColorScheme( + primary: C(Primary), onPrimary: C(OnPrimary), + primaryContainer: C(PrimaryContainer), onPrimaryContainer: C(OnPrimaryContainer), + secondary: C(Secondary), + secondaryContainer: C(SecondaryContainer), onSecondaryContainer: C(OnSecondaryContainer), + background: C(Background), onBackground: C(OnBackground), + surface: C(Surface), onSurface: C(OnSurface), + surfaceVariant: C(SurfaceVariant), onSurfaceVariant: C(OnSurfaceVariant), + inverseOnSurface: C(InverseOnSurface), + outline: C(Outline), + surfaceContainerLowest: C(SurfaceContainerLowest), + surfaceContainerLow: C(SurfaceContainerLow), + surfaceContainer: C(SurfaceContainer), + surfaceContainerHigh: C(SurfaceContainerHigh), + surfaceContainerHighest: C(SurfaceContainerHighest)); + } +#endif + + static Color C(uint argb) => Color.FromUint(argb | 0xFF000000); + static uint ToArgbUint(Color c) => + ((uint)(c.Alpha * 255) << 24) | ((uint)(c.Red * 255) << 16) | + ((uint)(c.Green * 255) << 8) | (uint)(c.Blue * 255); + } +} diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-01-home.png b/src/Comet/sample/Shared/JetNews/gold/compact-01-home.png new file mode 100644 index 000000000..7f2d71874 Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-01-home.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-02-home-scrolled.png b/src/Comet/sample/Shared/JetNews/gold/compact-02-home-scrolled.png new file mode 100644 index 000000000..8e46ac9b2 Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-02-home-scrolled.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-03-drawer.png b/src/Comet/sample/Shared/JetNews/gold/compact-03-drawer.png new file mode 100644 index 000000000..0edd92dab Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-03-drawer.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-04-interests.png b/src/Comet/sample/Shared/JetNews/gold/compact-04-interests.png new file mode 100644 index 000000000..72e84b529 Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-04-interests.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-05-interests-selected.png b/src/Comet/sample/Shared/JetNews/gold/compact-05-interests-selected.png new file mode 100644 index 000000000..72e84b529 Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-05-interests-selected.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-06-interests-people.png b/src/Comet/sample/Shared/JetNews/gold/compact-06-interests-people.png new file mode 100644 index 000000000..82b0b6e8b Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-06-interests-people.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-07-article.png b/src/Comet/sample/Shared/JetNews/gold/compact-07-article.png new file mode 100644 index 000000000..81637a842 Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-07-article.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-08-article-scrolled.png b/src/Comet/sample/Shared/JetNews/gold/compact-08-article-scrolled.png new file mode 100644 index 000000000..2baa36ebc Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-08-article-scrolled.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/expanded-1260dp-01.png b/src/Comet/sample/Shared/JetNews/gold/expanded-1260dp-01.png new file mode 100644 index 000000000..ceedb815d Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/expanded-1260dp-01.png differ diff --git a/src/Comet/sample/Shared/JetNews/gold/medium-700dp-01-rail.png b/src/Comet/sample/Shared/JetNews/gold/medium-700dp-01-rail.png new file mode 100644 index 000000000..81ee2508f Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/medium-700dp-01-rail.png differ diff --git a/src/Comet/sample/Shared/Jetcaster/JetcasterFeedParser.cs b/src/Comet/sample/Shared/Jetcaster/JetcasterFeedParser.cs new file mode 100644 index 000000000..7797417ed --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/JetcasterFeedParser.cs @@ -0,0 +1,100 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; + +namespace CometSamples.Jetcaster +{ + /// + /// RSS 2.0 + iTunes-namespace feed parser — the C# twin of the gold's ROME + /// SyndFeed mapping (PodcastFetcher.kt:120-157: title/description/author/image + /// from the iTunes module with RSS fallbacks; episodes keyed by guid). Pure + /// (stream in → records out) so fixture and live modes share it. + /// + public static class JetcasterFeedParser + { + static readonly XNamespace Itunes = "http://www.itunes.com/dtds/podcast-1.0.dtd"; + + public static PodcastWithEpisodes Parse(System.IO.Stream stream) + { + var doc = XDocument.Load(stream); + var channel = doc.Root?.Element("channel") + ?? throw new FormatException("not an RSS feed (no )"); + + string podcastUri = Value(channel.Element("link")) ?? Value(channel.Element("title")) + ?? throw new FormatException("feed has no link/title"); + + var podcast = new Podcast( + Uri: podcastUri, + Title: Value(channel.Element("title")) ?? "", + Description: Value(channel.Element(Itunes + "summary")) ?? Value(channel.Element("description")), + Author: Value(channel.Element(Itunes + "author")), + ImageUrl: channel.Element(Itunes + "image")?.Attribute("href")?.Value + ?? Value(channel.Element("image")?.Element("url")), + Copyright: Value(channel.Element("copyright"))); + + // The gold reads the iTunes category tree (nested ). + var categories = channel.Elements(Itunes + "category") + .SelectMany(c => new[] { c }.Concat(c.Elements(Itunes + "category"))) + .Select(c => c.Attribute("text")?.Value) + .Where(t => !string.IsNullOrEmpty(t)) + .Select(t => new Category(t!)) + .DistinctBy(c => c.Name) + .ToArray(); + + var episodes = channel.Elements("item").Select(item => new Episode( + Uri: Value(item.Element("guid")) ?? Value(item.Element("link")) ?? Guid.NewGuid().ToString(), + PodcastUri: podcastUri, + Title: Value(item.Element("title")) ?? "", + Subtitle: Value(item.Element(Itunes + "subtitle")), + Summary: Value(item.Element(Itunes + "summary")) ?? Value(item.Element("description")), + Author: Value(item.Element(Itunes + "author")), + Published: ParseDate(Value(item.Element("pubDate"))), + Duration: ParseDuration(Value(item.Element(Itunes + "duration"))))) + .OrderByDescending(e => e.Published) + .ToArray(); + + return new PodcastWithEpisodes(podcast, episodes, categories); + } + + static string? Value(XElement? e) + { + var v = e?.Value.Trim(); + return string.IsNullOrEmpty(v) ? null : v; + } + + // RFC 1123-ish pubDate ("Sun, 12 Jul 2026 06:00:00 -0400" / "… GMT" / "… EST"). + static DateTimeOffset ParseDate(string? s) + { + if (string.IsNullOrEmpty(s)) + return default; + if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto)) + return dto; + // Named zones DateTimeOffset can't parse (EST/PDT…) — strip and treat as UTC. + int lastSpace = s.LastIndexOf(' '); + if (lastSpace > 0 && DateTimeOffset.TryParse(s[..lastSpace], CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out dto)) + return dto; + return default; + } + + // itunes:duration: "HH:MM:SS", "MM:SS", or bare seconds. + static TimeSpan? ParseDuration(string? s) + { + if (string.IsNullOrEmpty(s)) + return null; + var parts = s.Split(':'); + return parts.Length switch + { + 1 when long.TryParse(parts[0], out var secs) => TimeSpan.FromSeconds(secs), + 2 when int.TryParse(parts[0], out var m) && int.TryParse(parts[1], out var sec) => + new TimeSpan(0, m, sec), + 3 when int.TryParse(parts[0], out var h) && int.TryParse(parts[1], out var m2) + && int.TryParse(parts[2], out var s2) => new TimeSpan(h, m2, s2), + _ => null, + }; + } + } +} diff --git a/src/Comet/sample/Shared/Jetcaster/JetcasterHome.cs b/src/Comet/sample/Shared/Jetcaster/JetcasterHome.cs new file mode 100644 index 000000000..43f4d556b --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/JetcasterHome.cs @@ -0,0 +1,354 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.Jetcaster.JetcasterTheme; + +namespace CometSamples.Jetcaster +{ + /// + /// Jetcaster Home (mobile ui/home/Home.kt), values-from-source: dark scheme over a + /// radial primary scrim, decorative M3 SearchBar top bar, ONE adaptive grid + /// (Adaptive(362dp)) switching Library/Discover on the floating Library|Discover + /// pill. Discover = category FilterChips + top-podcasts carousel + episode cards; + /// Library = followed carousel + Latest Episodes. The pill toolbar is hand-composed + /// until HorizontalFloatingToolbar binds (backlog); podcast-details pane and player + /// are the next increments. + /// + public class JetcasterRoot : View + { + /// false = Discover (the gold's default with no followed podcasts). + public static readonly Signal ShowLibrary = new(false); + public static readonly Signal SelectedCategory = new(string.Empty); + + static ListView? _grid; + static ListView? _pill; + static bool _hooked; + + readonly double _topInset; + + public JetcasterRoot(double topInset = 0) => _topInset = topInset; + + [Body] + View body() + { + JetcasterIcons.Register(); + + var grid = new ListView(Rows) + { + ViewFor = RowView, + GridAdaptiveMinWidth = 362, + }; + // One-row list so the pill re-styles when the selection changes (a Peek-built + // pill freezes — the Jetsnack bottom-bar lesson). + var pill = new ListView(() => new[] { 0 }) { ViewFor = _ => PillToolbar() }; + _pill = pill; + _grid = grid; + if (!_hooked) + { + // Subscribe ONCE via the current-instance slot (statics outlive rebuilds). + _hooked = true; + ShowLibrary.PropertyChanged += (_, _) => { _grid?.ReloadData(); _pill?.ReloadData(); }; + SelectedCategory.PropertyChanged += (_, _) => _grid?.ReloadData(); + PodcastStore.Version.PropertyChanged += (_, _) => _grid?.ReloadData(); + } + + return new ZStack + { + // HomeScreenBackground: colorScheme.background with a radial primary@15% + // scrim OVER it (two layers — a gradient replaces a solid on one node). + new VStack(spacing: 0f) + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.Background), + new VStack(spacing: 0f) + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .BackgroundGradient(new GradientSpec( + new[] { T.Primary.WithAlpha(0.15f), Colors.Transparent }, + GradientDirection.Radial)), + new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)_topInset).FlexShrink(0), + HomeAppBar().FlexShrink(0), + grid.FlexGrow(1).FlexBasis(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + // The floating Library|Discover pill (gold HorizontalFloatingToolbar, + // bottom-center over the grid). + new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + pill.Frame(width: 300, height: 72).FlexShrink(0), + new HStack().Frame(height: 24).FlexShrink(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill); + } + + // ── rows ──────────────────────────────────────────────────────────────────── + abstract record Row; + sealed record ChipsRow : Row; + sealed record CarouselRow(IReadOnlyList Feeds, bool Followed) : Row; + sealed record HeaderRow(string Title) : Row; + sealed record EpisodeRow(EpisodeToPodcast Item) : Row; + + static IReadOnlyList Rows() + { + var rows = new List(); + if (ShowLibrary.Peek()) + { + var followed = PodcastStore.FollowedFeeds; + rows.Add(new CarouselRow(followed, Followed: true)); + rows.Add(new HeaderRow("Latest Episodes")); + rows.AddRange(PodcastStore.LatestEpisodes(followed).Select(e => (object)new EpisodeRow(e))); + } + else + { + rows.Add(new ChipsRow()); + var category = CurrentCategory(); + if (category is not null) + { + var feeds = PodcastStore.InCategory(category); + rows.Add(new CarouselRow(feeds, Followed: false)); + rows.AddRange(PodcastStore.LatestEpisodes(feeds).Select(e => (object)new EpisodeRow(e))); + } + } + rows.Add("spacer"); + return rows; + } + + static Category? CurrentCategory() + { + var categories = PodcastStore.Categories; + if (categories.Count == 0) + return null; + var name = SelectedCategory.Peek(); + return categories.FirstOrDefault(c => c.Name == name) ?? categories[0]; + } + + static View RowView(object row) => row switch + { + ChipsRow => CategoryChips(), + CarouselRow c => PodcastCarousel(c.Feeds, c.Followed), + HeaderRow h => T.TitleLarge(h.Title).Color(T.OnSurface) + .Padding(new Thickness(16, 16, 16, 8)), + EpisodeRow e => EpisodeCard(e.Item), + _ => new HStack().Frame(height: 96), + }; + + // HomeAppBar: the REAL M3 SearchBar, decorative (the gold never expands it) — + // search icon leading, account icon trailing. + static View HomeAppBar() => new VStack(spacing: 0f) + { + new SearchBar(SearchQuery, + placeholder: T.BodyLarge("Search for a podcast").Color(T.OnSurfaceVariant), + content: new VStack(spacing: 0f) { T.BodyMedium("Search lands with a later increment").Color(T.OnSurfaceVariant).Padding(new Thickness(16)) }, + leading: new Icon("search").IconSize(24).Color(T.OnSurfaceVariant), + trailing: new Icon("account_circle").IconSize(24).Color(T.OnSurfaceVariant), + containerColor: T.SurfaceContainerHigh), + }.Padding(new Thickness(16, 8, 16, 8)); + + static readonly Signal SearchQuery = new(string.Empty); + + // PillToolbar (gold HorizontalFloatingToolbar + two toggle buttons). + static View PillToolbar() + { + View Half(string icon, string label, bool selected, Action onTap) + { + var row = new HStack(spacing: 8f) + { + new Icon(icon).IconSize(20) + .Color(selected ? T.OnSecondary : T.OnSurface) + .VerticalLayoutAlignment(LayoutAlignment.Center), + T.LabelLarge(label) + .Color(selected ? T.OnSecondary : T.OnSurface) + .VerticalLayoutAlignment(LayoutAlignment.Center), + } + .Frame(height: 40) + .Padding(new Thickness(16, 0, 16, 0)) + .CornerRadius(20) + .OnTap(_ => onTap()); + return selected ? row.Background(T.Secondary) : row; + } + + bool library = ShowLibrary.Peek(); + return new HStack(spacing: 4f) + { + Half("library_music", "Library", library, () => ShowLibrary.Value = true), + Half("music_note", "Discover", !library, () => ShowLibrary.Value = false), + } + .Frame(height: 56) + .Padding(new Thickness(8, 8, 8, 8)) + .Background(T.SurfaceContainer.WithAlpha(0.94f)) + .CornerRadius(28); + } + + // Discover's category tabs: a LazyRow of REAL M3 FilterChips (Discover.kt:96-118). + static View CategoryChips() + { + var row = new ListView(() => PodcastStore.Categories) + { + Horizontal = true, + ViewFor = category => new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + new FilterChip( + selected: CurrentCategory()?.Name == category.Name, + onClick: () => SelectedCategory.Value = category.Name, + label: T.LabelLarge(category.Name).Color( + CurrentCategory()?.Name == category.Name ? T.OnSecondaryContainer : T.OnSurfaceVariant)), + new HStack().FlexGrow(1), + }.Frame(height: 48).Padding(new Thickness(0, 0, 8, 0)), + }; + return row.Frame(height: 48).Margin(left: 16) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } + + // Top-podcasts row: the REAL M3 HorizontalUncontainedCarousel + // (PodcastCategory.kt:127); Library uses the MultiBrowse flavor (Home.kt:659). + static View PodcastCarousel(IReadOnlyList feeds, bool followedStyle) + { + var row = new ListView(() => feeds) + { + Horizontal = true, + Carousel = followedStyle ? ListCarousel.MultiBrowse : ListCarousel.Uncontained, + CarouselItemWidth = followedStyle ? 220 : 140, + ViewFor = feed => CarouselItem(feed, followedStyle), + }; + return row.Frame(height: followedStyle ? 220 : 200).Margin(left: 16) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } + + // TopPodcastRowItem / FollowedPodcastCarouselItem: artwork with a bottom + // Transparent→Black scrim, follow (+/✓) chip top-leading, title on the scrim. + static View CarouselItem(PodcastWithEpisodes feed, bool followedStyle) + { + double size = followedStyle ? 200 : 124; + bool followed = PodcastStore.IsFollowed(feed.Podcast.Uri); + return new VStack(spacing: 0f) + { + new ZStack + { + new Image(feed.Podcast.ImageUrl ?? "").Frame(width: (float)size, height: (float)size) + .CornerRadius(16), + new VStack(spacing: 0f) { new HStack().Frame(height: (float)(size / 2)) } + .Frame(width: (float)size, height: (float)size) + .BackgroundGradient(new GradientSpec( + new[] { Colors.Transparent, Colors.Black }, + GradientDirection.Vertical)) + .CornerRadius(16) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + new Icon(followed ? "check" : "add").IconSize(20) + .Color(T.OnSecondary) + .Frame(width: 36, height: 36).Padding(new Thickness(8)) + .Background(T.Secondary).CornerRadius(10) + .Margin(new Thickness(8, 8, 0, 0)) + .HorizontalLayoutAlignment(LayoutAlignment.Start) + .VerticalLayoutAlignment(LayoutAlignment.Start) + .OnTap(_ => PodcastStore.ToggleFollowed(feed.Podcast.Uri)), + T.TitleSmall(feed.Podcast.Title).Color(Colors.White).MaxLines(2) + .Padding(new Thickness(10, 0, 10, 10)) + .HorizontalLayoutAlignment(LayoutAlignment.Start) + .VerticalLayoutAlignment(LayoutAlignment.End), + }.Frame(width: (float)size, height: (float)size), + }.Padding(new Thickness(0, 0, 8, 0)); + } + + // EpisodeListItem: Surface card — texts + artwork, then the play row + // (SwipeToDismiss + shared elements are later increments). + static View EpisodeCard(EpisodeToPodcast item) => new VStack(spacing: 0f) + { + new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + new VStack(spacing: 4f) + { + T.TitleMedium(item.Episode.Title).Color(T.OnSurface).MaxLines(2), + T.BodyMedium(item.Podcast.Title).Color(T.OnSurfaceVariant).MaxLines(1), + }.FlexGrow(1).FlexBasis(0), + new Image(item.Podcast.ImageUrl ?? "").Frame(width: 56, height: 56) + .CornerRadius(8) + .Margin(new Thickness(16, 0, 0, 0)).FlexShrink(0), + }.HorizontalLayoutAlignment(LayoutAlignment.Fill), + new HStack().Frame(height: 12), + new HStack(spacing: 0f) + { + new Icon("play_arrow").IconSize(24).Color(T.OnPrimary) + .Frame(width: 40, height: 40).Padding(new Thickness(8)) + .Background(T.Primary).CornerRadius(20) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0) + .OnTap(_ => + { + MockEpisodePlayer.SetCurrent(item.Episode.Uri); + MockEpisodePlayer.Play(); + }), + T.BodySmall(EpisodeMeta(item.Episode)).Color(T.OnSurfaceVariant) + .Padding(new Thickness(12, 0, 0, 0)) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .FlexGrow(1).FlexBasis(0), + // REAL M3 IconButtons — the gold's EpisodeListItem uses IconButton for + // queue-add and overflow (the play button is the gold's own hand-roll). + new IconButton(() => MockEpisodePlayer.AddToQueue(item.Episode.Uri), + new Icon("playlist_add").IconSize(22).Color(T.OnSurfaceVariant)) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0), + new IconButton(() => { /* the gold's overflow is a TODO too */ }, + new Icon("more_vert").IconSize(22).Color(T.OnSurfaceVariant)) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0), + }.HorizontalLayoutAlignment(LayoutAlignment.Fill), + } + .Padding(new Thickness(16)) + .Background(T.SurfaceContainer.WithAlpha(0.62f)) + .CornerRadius(16) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + }.Padding(new Thickness(16, 6, 16, 6)); + + static string EpisodeMeta(Episode e) + { + string date = e.Published == default ? "" : e.Published.ToString("MMM d, yyyy"); + string dur = e.Duration is { } d ? $"{(int)Math.Round(d.TotalMinutes)} mins" : ""; + return dur.Length > 0 ? $"{date} • {dur}" : date; + } + } + + /// Jetcaster's Material Icons glyph map (the shared one-font approach — + /// same registration path as the other samples). + static class JetcasterIcons + { + public const string Font = "Material Icons"; + + static bool _registered; + + public static void Register() + { + if (_registered) + return; + _registered = true; + + var map = new Dictionary(); + void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint); + + Add("search", 0xE8B6); // app-bar search + Add("account_circle", 0xE853); // app-bar trailing + Add("library_music", 0xE030); // pill toolbar + Add("music_note", 0xE405); + Add("add", 0xE145); // follow + Add("check", 0xE5CA); // followed + Add("play_arrow", 0xE037); // episode play + Add("playlist_add", 0xE03B); // add to queue + Add("more_vert", 0xE5D4); // episode overflow + + IconFont.Register(Font, map); + } + } +} diff --git a/src/Comet/sample/Shared/Jetcaster/JetcasterModel.cs b/src/Comet/sample/Shared/Jetcaster/JetcasterModel.cs new file mode 100644 index 000000000..3ace7ebab --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/JetcasterModel.cs @@ -0,0 +1,144 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CometSamples.Jetcaster +{ + // Verbatim port of the gold entities (core/data/database/model) — same names, + // same shape. Room is the gold's offline cache, not observable behavior, so the + // stores here are in-memory (docs/jetcaster-parity-backlog.md §Data). + + public sealed record Podcast( + string Uri, + string Title, + string? Description = null, + string? Author = null, + string? ImageUrl = null, + string? Copyright = null); + + public sealed record Episode( + string Uri, + string PodcastUri, + string Title, + string? Subtitle = null, + string? Summary = null, + string? Author = null, + DateTimeOffset Published = default, + TimeSpan? Duration = null); + + public sealed record Category(string Name); + + /// The gold's EpisodeToPodcast join. + public sealed record EpisodeToPodcast(Episode Episode, Podcast Podcast); + + /// One parsed feed: the podcast + its episodes + its categories. + public sealed record PodcastWithEpisodes( + Podcast Podcast, + IReadOnlyList Episodes, + IReadOnlyList Categories); + + /// + /// In-memory twin of the gold's Podcast/Episode/Category stores, filled by + /// from the bundled fixture feeds (fixture mode) + /// or live fetches (later increment). Follow state is a signal-backed set like the + /// gold's PodcastFollowedEntry table. + /// + public static class PodcastStore + { + static readonly List Feeds = new(); + static readonly HashSet Followed = new(StringComparer.Ordinal); + + /// Bumped on any store change — screens subscribe once and reload. + public static readonly Comet.Reactive.Signal Version = new(0); + + public static void Add(PodcastWithEpisodes feed) + { + Feeds.RemoveAll(f => f.Podcast.Uri == feed.Podcast.Uri); + Feeds.Add(feed); + Version.Value = Version.Peek() + 1; + } + + public static IReadOnlyList All => Feeds; + + public static IReadOnlyList Categories => + Feeds.SelectMany(f => f.Categories).DistinctBy(c => c.Name) + .OrderBy(c => c.Name, StringComparer.Ordinal).ToArray(); + + public static IReadOnlyList InCategory(Category category) => + Feeds.Where(f => f.Categories.Any(c => c.Name == category.Name)).ToArray(); + + /// Newest-first episode rows for a set of feeds (the gold's + /// episodesInCategory / latest-library queries). + public static IReadOnlyList LatestEpisodes(IEnumerable feeds, int limit = 20) => + feeds.SelectMany(f => f.Episodes.Select(e => new EpisodeToPodcast(e, f.Podcast))) + .OrderByDescending(e => e.Episode.Published).Take(limit).ToArray(); + + public static Podcast? GetPodcast(string uri) => + Feeds.FirstOrDefault(f => f.Podcast.Uri == uri)?.Podcast; + + public static IReadOnlyList EpisodesFor(string podcastUri) => + Feeds.FirstOrDefault(f => f.Podcast.Uri == podcastUri)?.Episodes ?? Array.Empty(); + + public static Episode? GetEpisode(string uri) => + Feeds.SelectMany(f => f.Episodes).FirstOrDefault(e => e.Uri == uri); + + public static bool IsFollowed(string podcastUri) => Followed.Contains(podcastUri); + + public static IReadOnlyList FollowedFeeds => + Feeds.Where(f => Followed.Contains(f.Podcast.Uri)).ToArray(); + + public static void ToggleFollowed(string podcastUri) + { + if (!Followed.Remove(podcastUri)) + Followed.Add(podcastUri); + Version.Value = Version.Peek() + 1; + } + } + + /// + /// Fixture mode: the six bundled feed snapshots (sample/Shared/Jetcaster/fixtures, + /// trimmed to 10 episodes each) + their bundled 600px artwork. The probe wires + /// per platform (Android Assets.Open, iOS bundle path) + /// — the same pattern as font registration. + /// + public static class JetcasterFixtures + { + // Fixture file → bundled artwork image name (bare name resolves to the + // platform image resource, like the Jetsnack jpgs). + public static readonly IReadOnlyList<(string Feed, string Art)> Files = new[] + { + ("now_in_android.rss", "now_in_android_art"), + ("adb_backstage.rss", "adb_backstage_art"), + ("this_american_life.rss", "this_american_life_art"), + ("reply_all.rss", "reply_all_art"), + ("ninety_nine_pi.rss", "ninety_nine_pi_art"), + ("no_such_thing_as_a_fish.rss", "no_such_thing_as_a_fish_art"), + }; + + static bool _loaded; + + public static void Load(Func openAsset) + { + if (_loaded) + return; + _loaded = true; + foreach (var (feed, art) in Files) + { + try + { + using var stream = openAsset(feed); + var parsed = JetcasterFeedParser.Parse(stream); + // Point the artwork at the BUNDLED image (deterministic offline + // renders) instead of the snapshot's remote URL. + parsed = parsed with { Podcast = parsed.Podcast with { ImageUrl = art } }; + PodcastStore.Add(parsed); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Jetcaster] fixture {feed} failed: {ex.Message}"); + } + } + } + } +} diff --git a/src/Comet/sample/Shared/Jetcaster/JetcasterPlayer.cs b/src/Comet/sample/Shared/Jetcaster/JetcasterPlayer.cs new file mode 100644 index 000000000..227d169d3 --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/JetcasterPlayer.cs @@ -0,0 +1,118 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Comet.Reactive; + +namespace CometSamples.Jetcaster +{ + /// + /// The gold's MockEpisodePlayer (MockEpisodePlayer.kt:35-112) — playback IS a + /// timer: a 1s-tick loop advances TimeElapsed until the episode duration, then + /// auto-advances the queue. No audio stack in the gold either, so this mock is + /// parity, not a deviation. Signals mirror the gold's EpisodePlayerState flow. + /// + public static class MockEpisodePlayer + { + public static readonly Signal CurrentEpisodeUri = new(string.Empty); + public static readonly Signal IsPlaying = new(false); + public static readonly Signal TimeElapsedSeconds = new(0); + + static readonly List Queue = new(); + static CancellationTokenSource? _ticker; + + // One rule for "how long is this episode" everywhere (play limit and seek + // clamps): unknown duration = unbounded — ticks continue and the queue only + // auto-advances on a KNOWN completion. + static double LimitSeconds(Episode? episode) => + episode?.Duration?.TotalSeconds ?? double.MaxValue; + + public static void SetCurrent(string episodeUri) + { + Pause(); + CurrentEpisodeUri.Value = episodeUri; + TimeElapsedSeconds.Value = 0; + } + + public static void AddToQueue(string episodeUri) + { + if (!Queue.Contains(episodeUri)) + Queue.Add(episodeUri); + } + + public static void Play() + { + if (IsPlaying.Peek() || PodcastStore.GetEpisode(CurrentEpisodeUri.Peek()) is not { } episode) + return; + IsPlaying.Value = true; + var cts = new CancellationTokenSource(); + _ticker = cts; + double limit = LimitSeconds(episode); + _ = Task.Run(async () => + { + while (!cts.IsCancellationRequested) + { + try + { + await Task.Delay(1000, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; // paused/advanced — no exception per pause + } + // Signals must mutate on the UI thread (the repo-wide contract) — + // ThreadHelper is wired by every probe, unlike SynchronizationContext. + Comet.ThreadHelper.RunOnMainThread(() => + { + if (cts.IsCancellationRequested) + return; + var next = TimeElapsedSeconds.Peek() + 1; + if (next >= limit) + { + TimeElapsedSeconds.Value = limit; + Next(); // the gold auto-advances the queue at the end + } + else + TimeElapsedSeconds.Value = next; + }); + } + }, cts.Token); + } + + public static void Pause() + { + _ticker?.Cancel(); + _ticker?.Dispose(); + _ticker = null; + IsPlaying.Value = false; + } + + public static void SeekBy(double deltaSeconds) => + SeekTo(TimeElapsedSeconds.Peek() + deltaSeconds); + + public static void SeekTo(double seconds) + { + double limit = LimitSeconds(PodcastStore.GetEpisode(CurrentEpisodeUri.Peek())); + TimeElapsedSeconds.Value = Math.Clamp(seconds, 0, limit); + } + + public static void Next() + { + Pause(); + if (Queue.Count == 0) + return; + var next = Queue[0]; + Queue.RemoveAt(0); + SetCurrent(next); + Play(); + } + + public static void Previous() + { + // The gold restarts the current episode (no back-queue history). + TimeElapsedSeconds.Value = 0; + } + + } +} diff --git a/src/Comet/sample/Shared/Jetcaster/JetcasterTheme.cs b/src/Comet/sample/Shared/Jetcaster/JetcasterTheme.cs new file mode 100644 index 000000000..8947e8572 --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/JetcasterTheme.cs @@ -0,0 +1,112 @@ +#nullable enable +using Comet; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; + +namespace CometSamples.Jetcaster +{ + /// + /// The gold's theme, values-from-source. Mobile ALWAYS renders the static dark + /// scheme (Theme.kt:478 — dynamicColor defaults false and is never overridden), + /// so only darkScheme tokens are ported. Typography = Type.kt: RobotoFlex for + /// display styles (variable font, displayLarge weight 738), Montserrat W400/W500 + /// for everything else. + /// + public static class JetcasterTheme + { + // ── darkScheme tokens (mobile/ui/theme via core/designsystem Color.kt) ── + public static readonly Color Primary = Color.FromArgb("#F0FCB0"); + public static readonly Color OnPrimary = Color.FromArgb("#626004"); + public static readonly Color PrimaryContainer = Color.FromArgb("#313002"); + public static readonly Color OnPrimaryContainer = Color.FromArgb("#FDFCCE"); + public static readonly Color Secondary = Color.FromArgb("#FFE523"); + public static readonly Color OnSecondary = Color.FromArgb("#332D00"); + public static readonly Color SecondaryContainer = Color.FromArgb("#998700"); + public static readonly Color OnSecondaryContainer = Color.FromArgb("#FFF9CC"); + public static readonly Color Tertiary = Color.FromArgb("#FF9AD8"); + public static readonly Color OnTertiary = Color.FromArgb("#33000A"); + public static readonly Color TertiaryContainer = Color.FromArgb("#660014"); + public static readonly Color OnTertiaryContainer = Color.FromArgb("#FFE5EB"); + public static readonly Color Background = Color.FromArgb("#151218"); + public static readonly Color OnBackground = Color.FromArgb("#E7E0E8"); + public static readonly Color Surface = Color.FromArgb("#261604"); + public static readonly Color OnSurface = Color.FromArgb("#FBEDE4"); + public static readonly Color SurfaceVariant = Color.FromArgb("#49454E"); + public static readonly Color OnSurfaceVariant = Color.FromArgb("#CBC4CF"); + public static readonly Color Outline = Color.FromArgb("#948F99"); + public static readonly Color OutlineVariant = Color.FromArgb("#49454E"); + public static readonly Color SurfaceDim = Color.FromArgb("#19120C"); + public static readonly Color SurfaceBright = Color.FromArgb("#413731"); + public static readonly Color SurfaceContainerLowest = Color.FromArgb("#140D08"); + public static readonly Color SurfaceContainerLow = Color.FromArgb("#221A14"); + public static readonly Color SurfaceContainer = Color.FromArgb("#261E18"); + public static readonly Color SurfaceContainerHigh = Color.FromArgb("#312822"); + public static readonly Color SurfaceContainerHighest = Color.FromArgb("#3C332C"); + public static readonly Color Error = Color.FromArgb("#FFB4AB"); + public static readonly Color OnError = Color.FromArgb("#690005"); + public static readonly Color ErrorContainer = Color.FromArgb("#93000A"); + public static readonly Color OnErrorContainer = Color.FromArgb("#FFDAD6"); + public static readonly Color InversePrimary = Color.FromArgb("#68548E"); + public static readonly Color InverseSurface = Color.FromArgb("#E7E0E8"); + public static readonly Color InverseOnSurface = Color.FromArgb("#322F35"); + public static readonly Color Scrim = Colors.Black; + +#if ANDROID + /// The gold's static darkScheme as a real Compose ColorScheme, so the + /// REAL M3 widgets (FilterChip, SearchBar, carousels) self-theme identically. + public static AndroidX.Compose.Material3.ColorScheme ComposeScheme() + { + static AndroidX.Compose.Color C(Color c) => AndroidX.Compose.Color.FromArgb( + (byte)(c.Alpha * 255), (byte)(c.Red * 255), (byte)(c.Green * 255), (byte)(c.Blue * 255)); + return AndroidX.Compose.MaterialTheme.DarkColorScheme( + primary: C(Primary), onPrimary: C(OnPrimary), + primaryContainer: C(PrimaryContainer), onPrimaryContainer: C(OnPrimaryContainer), + inversePrimary: C(InversePrimary), + secondary: C(Secondary), onSecondary: C(OnSecondary), + secondaryContainer: C(SecondaryContainer), onSecondaryContainer: C(OnSecondaryContainer), + tertiary: C(Tertiary), onTertiary: C(OnTertiary), + tertiaryContainer: C(TertiaryContainer), onTertiaryContainer: C(OnTertiaryContainer), + background: C(Background), onBackground: C(OnBackground), + surface: C(Surface), onSurface: C(OnSurface), + surfaceVariant: C(SurfaceVariant), onSurfaceVariant: C(OnSurfaceVariant), + inverseSurface: C(InverseSurface), inverseOnSurface: C(InverseOnSurface), + error: C(Error), onError: C(OnError), + errorContainer: C(ErrorContainer), onErrorContainer: C(OnErrorContainer), + outline: C(Outline), outlineVariant: C(OutlineVariant), + scrim: C(Scrim), + surfaceBright: C(SurfaceBright), surfaceDim: C(SurfaceDim), + surfaceContainer: C(SurfaceContainer), + surfaceContainerHigh: C(SurfaceContainerHigh), + surfaceContainerHighest: C(SurfaceContainerHighest), + surfaceContainerLow: C(SurfaceContainerLow), + surfaceContainerLowest: C(SurfaceContainerLowest)); + } +#endif + + // ── Type.kt (JetcasterTypography) — line heights ride the sp values below ── + public static Comet.Text DisplayLarge(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("RobotoFlex").FontWeight((FontWeight)738).FontSize(64).LineHeight(56); + public static Comet.Text DisplayMedium(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("RobotoFlex").FontSize(45).LineHeight(52); + public static Comet.Text HeadlineMedium(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(28).LineHeight(36); + public static Comet.Text HeadlineSmall(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(24).LineHeight(32); + public static Comet.Text TitleLarge(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontSize(22).LineHeight(28); + public static Comet.Text TitleMedium(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(16).LineHeight(24); + public static Comet.Text TitleSmall(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(14).LineHeight(20); + public static Comet.Text LabelLarge(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(14).LineHeight(20); + public static Comet.Text LabelMedium(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(12).LineHeight(16); + public static Comet.Text BodyLarge(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(16).LineHeight(24); + public static Comet.Text BodyMedium(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(14).LineHeight(20); + public static Comet.Text BodySmall(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(FontWeight.Medium).FontSize(12).LineHeight(16); + } +} diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/adb_backstage.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/adb_backstage.rss new file mode 100644 index 000000000..9144c8464 --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/fixtures/adb_backstage.rss @@ -0,0 +1,303 @@ + + + + + Android Developers Backstage + Wed, 18 Feb 2026 19:30:14 +0000 + Wed, 18 Feb 2026 19:30:14 +0000 + Libsyn RSSgen 1.0 + http://androidbackstage.blogspot.com/ + en + + http://androidbackstage.blogspot.com/ + Android Backstage, a podcast by and for Android developers. Hosted by developers from the Android engineering team, this show covers topics of interest to Android programmers, with in-depth discussions and interviews with engineers on the Android team at Google. + + https://static.libsyn.com/p/assets/c/9/e/0/c9e07a90cf263f3b40be95ea3302a6a1/Android_Devs_Backstage_Thumb_v2.png + Android Developers Backstage + + + Android Developers + + + + no + + + + episodic + + + Android,programming,development,software,mobile,mobile,programming + + + + + no + + + + + + Android developers talking to Android developers about Android developmentdevelopers@android.comAndroid Developers + What's so great about R8? + What's so great about R8? + Wed, 18 Feb 2026 19:26:00 +0000 + + + Join hosts Tor and Romain as they sit down with Søren Gjesse, Chris Craik, and Shai Barack.

This week, the team dives into R8, the whole-program optimizer that handles the "last mile" of your code before it runs on a device. Learn why implementing R8 is effectively like giving your users a "free hardware upgrade" by making their current devices perform like models that are years newer.

Enable app optimization → https://goo.gle/R8-Optimizer


Chapters:
0:00 - Intro
0:23 - What is R8?
1:16 - R8 vs D8
1:40 - Tree Shaking & Dead Code
4:14 - Handling Reflection
10:10 - Performance Co-Optimization
13:03 - Kotlin-Specific Gains
16:22 - Real-World Impact
41:50 - Jetpack Compose & R8
50:51 - Keep annotations
1:03:49 - The "Free Hardware Upgrade"

]]>
+ + Join hosts Tor and Romain as they sit down with Søren Gjesse, Chris Craik, and Shai Barack.

This week, the team dives into R8, the whole-program optimizer that handles the "last mile" of your code before it runs on a device. Learn why implementing R8 is effectively like giving your users a "free hardware upgrade" by making their current devices perform like models that are years newer.

Enable app optimization → https://goo.gle/R8-Optimizer

Chapters: 0:00 - Intro 0:23 - What is R8? 1:16 - R8 vs D8 1:40 - Tree Shaking & Dead Code 4:14 - Handling Reflection 10:10 - Performance Co-Optimization 13:03 - Kotlin-Specific Gains 16:22 - Real-World Impact 41:50 - Jetpack Compose & R8 50:51 - Keep annotations 1:03:49 - The "Free Hardware Upgrade"

]]>
+ + + + 01:06:30 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)Join hosts Tor and Romain as they sit down with Søren Gjesse, Chris Craik, and Shai Barack. This week, the team dives into R8, the whole-program optimizer that handles the "last mile" of your code before it runs on a device. Learn why implementing R8 is effectively like giving your users a "free hardware upgrade" by making their current devices perform like models that are years newer. Enable app optimization → https://goo.gle/R8-Optimizer Chapters: 0:00 - Intro 0:23 - What is R8? 1:16 - R8 vs D8 1:40 - Tree Shaking & Dead Code 4:14 - Handling Reflection 10:10 - Performance Co-Optimization 13:03 - Kotlin-Specific Gains 16:22 - Real-World Impact 41:50 - Jetpack Compose & R8 50:51 - Keep annotations 1:03:49 - The "Free Hardware Upgrade"Join hosts Tor and Romain as they sit down with Søren Gjesse, Chris Craik, and Shai Barack. This week, the team dives into R8, the whole-program optimizer that handles the "last mile" of your code before it runs on a device. Learn why implementing R8 is effectively like giving your users a "free hardware upgrade" by making their current devices perform like models that are years newer. Enable app optimization → https://goo.gle/R8-Optimizer Chapters: 0:00 - Intro 0:23 - What is R8? 1:16 - R8 vs D8 1:40 - Tree Shaking & Dead Code 4:14 - Handling Reflection 10:10 - Performance Co-Optimization 13:03 - Kotlin-Specific Gains 16:22 - Real-World Impact 41:50 - Jetpack Compose & R8 50:51 - Keep annotations 1:03:49 - The "Free Hardware Upgrade"
+ + XR: Virtually here + XR: Virtually here + Wed, 14 Jan 2026 19:50:00 +0000 + + + Join hosts Tor, Chet, and Romain as they sit down with Diego Perez (Android Studio) and Patrick Fuentes (Developer Relations) to explore the new frontiers of Android XR.

This episode of Android Developers Backstage breaks down the latest announcements and Developer Preview 3, offering a technical look at how developers can start building for spatial environments today.

Resources:
Hello Android XR sample → https://goo.gle/3Nbnrre 
Build for AI Glasses with the Android XR SDK Developer Preview 3  → https://goo.gle/49lUjGr 

Chapters:
0:00 - Intro
4:42 - Latest announcements
5:46 - AI Glasses
8:28 -Developer preview 3
16:13 - Spatialization
18:29 - Tools team perspective
24:07 - Using the XR Emulator
29:00 - What is an Orbiter?
37:33 - What hardware is currently available
38:39 - Final thoughts

]]>
+ + Join hosts Tor, Chet, and Romain as they sit down with Diego Perez (Android Studio) and Patrick Fuentes (Developer Relations) to explore the new frontiers of Android XR.

This episode of Android Developers Backstage breaks down the latest announcements and Developer Preview 3, offering a technical look at how developers can start building for spatial environments today.

Resources: Hello Android XR sample → https://goo.gle/3Nbnrre Build for AI Glasses with the Android XR SDK Developer Preview 3 → https://goo.gle/49lUjGr

Chapters: 0:00 - Intro 4:42 - Latest announcements 5:46 - AI Glasses 8:28 -Developer preview 3 16:13 - Spatialization 18:29 - Tools team perspective 24:07 - Using the XR Emulator 29:00 - What is an Orbiter? 37:33 - What hardware is currently available 38:39 - Final thoughts

]]>
+ + + + 43:30 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)Join hosts Tor, Chet, and Romain as they sit down with Diego Perez (Android Studio) and Patrick Fuentes (Developer Relations) to explore the new frontiers of Android XR. This episode of Android Developers Backstage breaks down the latest announcements and Developer Preview 3, offering a technical look at how developers can start building for spatial environments today. Resources: Hello Android XR sample → https://goo.gle/3Nbnrre  Build for AI Glasses with the Android XR SDK Developer Preview 3  → https://goo.gle/49lUjGr  Chapters: 0:00 - Intro 4:42 - Latest announcements 5:46 - AI Glasses 8:28 -Developer preview 3 16:13 - Spatialization 18:29 - Tools team perspective 24:07 - Using the XR Emulator 29:00 - What is an Orbiter? 37:33 - What hardware is currently available 38:39 - Final thoughtsJoin hosts Tor, Chet, and Romain as they sit down with Diego Perez (Android Studio) and Patrick Fuentes (Developer Relations) to explore the new frontiers of Android XR. This episode of Android Developers Backstage breaks down the latest announcements and Developer Preview 3, offering a technical look at how developers can start building for spatial environments today. Resources: Hello Android XR sample → https://goo.gle/3Nbnrre  Build for AI Glasses with the Android XR SDK Developer Preview 3  → https://goo.gle/49lUjGr  Chapters: 0:00 - Intro 4:42 - Latest announcements 5:46 - AI Glasses 8:28 -Developer preview 3 16:13 - Spatialization 18:29 - Tools team perspective 24:07 - Using the XR Emulator 29:00 - What is an Orbiter? 37:33 - What hardware is currently available 38:39 - Final thoughts
+ + Bring your own model - Android Developers Backstage + Bring your own model - Android Developers Backstage + Wed, 10 Dec 2025 22:09:00 +0000 + + + In this episode Tor and Chet chat with Raluca Saucic and Eugene Yakavets about the new "Bring Your Own Model" feature in Android Studio which allows using other models than Gemini for agent mode, including models from Anthropic,  OpenAI, and more. We also discuss using local models via Ollama and LM Studio.

Resources:
Use a remote model → https://goo.gle/43N2Z5E

Use a local model → https://goo.gle/3Xij27V  

Chapters:
0:00 - Intro
0:49 - Bring your own model
4:13 - Bring your own key
7:24 - Anthropic model
13:17- How often do you need to remind the model of the context?
17:40 - Thinking vs planning
20:25 - What happens when the API needs to give multiple answers to a complex question?
24:14 - Supported models
31:18 - Are there any barriers when using a different model?
34:07 - Support for code completion
38:16 - GitHub's relationship with agents
44:35 - Using agents for development
56:36 - Studiobot
57:56 - Wrap up

 

]]>
+ + In this episode Tor and Chet chat with Raluca Saucic and Eugene Yakavets about the new "Bring Your Own Model" feature in Android Studio which allows using other models than Gemini for agent mode, including models from Anthropic, OpenAI, and more. We also discuss using local models via Ollama and LM Studio.

Resources: Use a remote model → https://goo.gle/43N2Z5E

Use a local model → https://goo.gle/3Xij27V

Chapters: 0:00 - Intro 0:49 - Bring your own model 4:13 - Bring your own key 7:24 - Anthropic model 13:17- How often do you need to remind the model of the context? 17:40 - Thinking vs planning 20:25 - What happens when the API needs to give multiple answers to a complex question? 24:14 - Supported models 31:18 - Are there any barriers when using a different model? 34:07 - Support for code completion 38:16 - GitHub's relationship with agents 44:35 - Using agents for development 56:36 - Studiobot 57:56 - Wrap up

]]>
+ + + + 58:26 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)In this episode Tor and Chet chat with Raluca Saucic and Eugene Yakavets about the new "Bring Your Own Model" feature in Android Studio which allows using other models than Gemini for agent mode, including models from Anthropic,  OpenAI, and more. We also discuss using local models via Ollama and LM Studio. Resources: Use a remote model → https://goo.gle/43N2Z5E Use a local model → https://goo.gle/3Xij27V   Chapters: 0:00 - Intro 0:49 - Bring your own model 4:13 - Bring your own key 7:24 - Anthropic model 13:17- How often do you need to remind the model of the context? 17:40 - Thinking vs planning 20:25 - What happens when the API needs to give multiple answers to a complex question? 24:14 - Supported models 31:18 - Are there any barriers when using a different model? 34:07 - Support for code completion 38:16 - GitHub's relationship with agents 44:35 - Using agents for development 56:36 - Studiobot 57:56 - Wrap up  In this episode Tor and Chet chat with Raluca Saucic and Eugene Yakavets about the new "Bring Your Own Model" feature in Android Studio which allows using other models than Gemini for agent mode, including models from Anthropic,  OpenAI, and more. We also discuss using local models via Ollama and LM Studio. Resources: Use a remote model → https://goo.gle/43N2Z5E Use a local model → https://goo.gle/3Xij27V   Chapters: 0:00 - Intro 0:49 - Bring your own model 4:13 - Bring your own key 7:24 - Anthropic model 13:17- How often do you need to remind the model of the context? 17:40 - Thinking vs planning 20:25 - What happens when the API needs to give multiple answers to a complex question? 24:14 - Supported models 31:18 - Are there any barriers when using a different model? 34:07 - Support for code completion 38:16 - GitHub's relationship with agents 44:35 - Using agents for development 56:36 - Studiobot 57:56 - Wrap up  
+ + From natural language to UI tests: A deep dive into Journeys for Android Studio + From natural language to UI tests: A deep dive into Journeys for Android Studio + Tue, 14 Oct 2025 17:00:00 +0000 + + + Hosts Tor and Chet are joined by Adarsh Fernando, a Product Manager, and Ray Buse, a Software Engineer, to discuss Journeys for Android Studio. Powered by Gemini's vision and reasoning, Journeys aims to simplify end-to-end test creation and maintenance by converting the natural language you provide to describe the steps and assertions for each test, resulting in actions and evaluations performed directly on your app.

Chapters:
0:00 - Intro
1:46 - Journeys: New AI-powered testing approach
3:40 - How Journeys Works with Gemini
4:27 - The natural language advantage
5:49 - Real-world use case: Google Maps
6:53 - Debugging with AI reasoning
8:08 - Why Journeys is important: Bridging the testing gap
9:56 - Journeys and End-to-End Testing
12:18 - Performance and Cached Journeys
24:14 - Android Studio and Firebase integration
25:27 - The development workflow
31:22 - AI for everyone: Beyond end-to-end testing
33:28 - Looking ahead: Feedback and the future 

Resources:
Journeys for Android Studio → https://goo.gle/4m9YOr3
App Testing (Android) → https://goo.gle/3HVKTqB  
Tor on Bluesky → https://goo.gle/3ViCAYS
Chet on Bluesky → https://goo.gle/4gzpccM
Ardash on Bluesky → https://goo.gle/47JGNw9

]]>
+ + Hosts Tor and Chet are joined by Adarsh Fernando, a Product Manager, and Ray Buse, a Software Engineer, to discuss Journeys for Android Studio. Powered by Gemini's vision and reasoning, Journeys aims to simplify end-to-end test creation and maintenance by converting the natural language you provide to describe the steps and assertions for each test, resulting in actions and evaluations performed directly on your app.

Chapters: 0:00 - Intro 1:46 - Journeys: New AI-powered testing approach 3:40 - How Journeys Works with Gemini 4:27 - The natural language advantage 5:49 - Real-world use case: Google Maps 6:53 - Debugging with AI reasoning 8:08 - Why Journeys is important: Bridging the testing gap 9:56 - Journeys and End-to-End Testing 12:18 - Performance and Cached Journeys 24:14 - Android Studio and Firebase integration 25:27 - The development workflow 31:22 - AI for everyone: Beyond end-to-end testing 33:28 - Looking ahead: Feedback and the future

Resources: Journeys for Android Studio → https://goo.gle/4m9YOr3 App Testing (Android) → https://goo.gle/3HVKTqB Tor on Bluesky → https://goo.gle/3ViCAYS Chet on Bluesky → https://goo.gle/4gzpccM Ardash on Bluesky → https://goo.gle/47JGNw9

]]>
+ + + + 44:57 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)Hosts Tor and Chet are joined by Adarsh Fernando, a Product Manager, and Ray Buse, a Software Engineer, to discuss Journeys for Android Studio. Powered by Gemini's vision and reasoning, Journeys aims to simplify end-to-end test creation and maintenance by converting the natural language you provide to describe the steps and assertions for each test, resulting in actions and evaluations performed directly on your app. Chapters: 0:00 - Intro 1:46 - Journeys: New AI-powered testing approach 3:40 - How Journeys Works with Gemini 4:27 - The natural language advantage 5:49 - Real-world use case: Google Maps 6:53 - Debugging with AI reasoning 8:08 - Why Journeys is important: Bridging the testing gap 9:56 - Journeys and End-to-End Testing 12:18 - Performance and Cached Journeys 24:14 - Android Studio and Firebase integration 25:27 - The development workflow 31:22 - AI for everyone: Beyond end-to-end testing 33:28 - Looking ahead: Feedback and the future  Resources: Journeys for Android Studio → https://goo.gle/4m9YOr3 App Testing (Android) → https://goo.gle/3HVKTqB   Tor on Bluesky → https://goo.gle/3ViCAYS Chet on Bluesky → https://goo.gle/4gzpccM Ardash on Bluesky → https://goo.gle/47JGNw9Hosts Tor and Chet are joined by Adarsh Fernando, a Product Manager, and Ray Buse, a Software Engineer, to discuss Journeys for Android Studio. Powered by Gemini's vision and reasoning, Journeys aims to simplify end-to-end test creation and maintenance by converting the natural language you provide to describe the steps and assertions for each test, resulting in actions and evaluations performed directly on your app. Chapters: 0:00 - Intro 1:46 - Journeys: New AI-powered testing approach 3:40 - How Journeys Works with Gemini 4:27 - The natural language advantage 5:49 - Real-world use case: Google Maps 6:53 - Debugging with AI reasoning 8:08 - Why Journeys is important: Bridging the testing gap 9:56 - Journeys and End-to-End Testing 12:18 - Performance and Cached Journeys 24:14 - Android Studio and Firebase integration 25:27 - The development workflow 31:22 - AI for everyone: Beyond end-to-end testing 33:28 - Looking ahead: Feedback and the future  Resources: Journeys for Android Studio → https://goo.gle/4m9YOr3 App Testing (Android) → https://goo.gle/3HVKTqB   Tor on Bluesky → https://goo.gle/3ViCAYS Chet on Bluesky → https://goo.gle/4gzpccM Ardash on Bluesky → https://goo.gle/47JGNw9
+ + Android developer verification + Android developer verification + Tue, 30 Sep 2025 18:07:00 +0000 + + + To protect users from malware and scams, Android has announced a new layer of security: developer verification. While this is nothing new for Google Play Store apps, Android is extending this to the entire Android ecosystem. Host Tor is joined by Matthew, Patrick, Naheed, and Raz to discuss this latest security update that deters bad actors and creates a safer environment.

 

]]>
+ + To protect users from malware and scams, Android has announced a new layer of security: developer verification. While this is nothing new for Google Play Store apps, Android is extending this to the entire Android ecosystem. Host Tor is joined by Matthew, Patrick, Naheed, and Raz to discuss this latest security update that deters bad actors and creates a safer environment.

]]>
+ + + + 45:22 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)To protect users from malware and scams, Android has announced a new layer of security: developer verification. While this is nothing new for Google Play Store apps, Android is extending this to the entire Android ecosystem. Host Tor is joined by Matthew, Patrick, Naheed, and Raz to discuss this latest security update that deters bad actors and creates a safer environment.  To protect users from malware and scams, Android has announced a new layer of security: developer verification. While this is nothing new for Google Play Store apps, Android is extending this to the entire Android ecosystem. Host Tor is joined by Matthew, Patrick, Naheed, and Raz to discuss this latest security update that deters bad actors and creates a safer environment.  
+ + Autofill in Compose + Autofill in Compose + Wed, 13 Aug 2025 16:00:00 +0000 + + + In this episode of Android Back Stage, Hosts Tor, and Chet are joined by Melba and Ralston, Software Engineers on the Compose Team, to talk about Autofill in Compose. Learn about Autofill services, best practices, and more!

Chapters:
0:00 - Intro
0:40 - What is Autofill?
1:21 - Autofill team journey and semantics
2:15 - Defining semantics in Compose
3:29 - Bridging views and Compose for Autofill
4:23 - Developer Experience: Early autofill in Compose
7:02 - Autofill services and communication flow
7:59 - User authentication and multiple autofill services
12:53 - The Autofill flow: From tap to fill
21:56 - Handling list types and saving credentials (commit)
30:11 - The "Asteriks" bug and semantics refractor
31:09 - Performance improvements in Autofill
37:48 - Compose's architectural advantages (composition over inheritance)
40:30 - Best practices and future improvements

 

]]>
+ + In this episode of Android Back Stage, Hosts Tor, and Chet are joined by Melba and Ralston, Software Engineers on the Compose Team, to talk about Autofill in Compose. Learn about Autofill services, best practices, and more!

Chapters: 0:00 - Intro 0:40 - What is Autofill? 1:21 - Autofill team journey and semantics 2:15 - Defining semantics in Compose 3:29 - Bridging views and Compose for Autofill 4:23 - Developer Experience: Early autofill in Compose 7:02 - Autofill services and communication flow 7:59 - User authentication and multiple autofill services 12:53 - The Autofill flow: From tap to fill 21:56 - Handling list types and saving credentials (commit) 30:11 - The "Asteriks" bug and semantics refractor 31:09 - Performance improvements in Autofill 37:48 - Compose's architectural advantages (composition over inheritance) 40:30 - Best practices and future improvements

]]>
+ + + + 44:22 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)In this episode of Android Back Stage, Hosts Tor, and Chet are joined by Melba and Ralston, Software Engineers on the Compose Team, to talk about Autofill in Compose. Learn about Autofill services, best practices, and more! Chapters: 0:00 - Intro 0:40 - What is Autofill? 1:21 - Autofill team journey and semantics 2:15 - Defining semantics in Compose 3:29 - Bridging views and Compose for Autofill 4:23 - Developer Experience: Early autofill in Compose 7:02 - Autofill services and communication flow 7:59 - User authentication and multiple autofill services 12:53 - The Autofill flow: From tap to fill 21:56 - Handling list types and saving credentials (commit) 30:11 - The "Asteriks" bug and semantics refractor 31:09 - Performance improvements in Autofill 37:48 - Compose's architectural advantages (composition over inheritance) 40:30 - Best practices and future improvements  In this episode of Android Back Stage, Hosts Tor, and Chet are joined by Melba and Ralston, Software Engineers on the Compose Team, to talk about Autofill in Compose. Learn about Autofill services, best practices, and more! Chapters: 0:00 - Intro 0:40 - What is Autofill? 1:21 - Autofill team journey and semantics 2:15 - Defining semantics in Compose 3:29 - Bridging views and Compose for Autofill 4:23 - Developer Experience: Early autofill in Compose 7:02 - Autofill services and communication flow 7:59 - User authentication and multiple autofill services 12:53 - The Autofill flow: From tap to fill 21:56 - Handling list types and saving credentials (commit) 30:11 - The "Asteriks" bug and semantics refractor 31:09 - Performance improvements in Autofill 37:48 - Compose's architectural advantages (composition over inheritance) 40:30 - Best practices and future improvements  
+ + Back it up, bring it back: No data left behind + Back it up, bring it back: No data left behind + Thu, 10 Jul 2025 20:00:00 +0000 + + + It can be a draw when setting up a new device. Some info transfers, while some don't. Learn how developers can ensure the best user experience when people backup and restore an Android device. Hosts Tor and Chet are joined by Graham and Piyush on the Android Consumer Experience team  and Alon on the Android Studio team to discuss Android backup and restore.

 

Chapters:

0:00 - Introduction & the user pain point

1:38 - Why backup fails: Developer challenges

4:00 - Evolution of backup testing: From scripts to Android Studio

7:15 - Beyond testing: Backup for developer workflow

8:50 - Cross-device backup & GMS core integration

10:55 - Understanding backup types: Devices vs. Cloud

14:55 - Data categories & developer control

16:05 - System-level backup: Permissions & credentials

18:35 - Default backup pitfalls & key-value agents

28:15 - Database migrations & backup stress testing

32:04 - Automated testing framework

41:02 - Recap & feedback channels

]]>
+ + It can be a draw when setting up a new device. Some info transfers, while some don't. Learn how developers can ensure the best user experience when people backup and restore an Android device. Hosts Tor and Chet are joined by Graham and Piyush on the Android Consumer Experience team and Alon on the Android Studio team to discuss Android backup and restore.

Chapters:

0:00 - Introduction & the user pain point

1:38 - Why backup fails: Developer challenges

4:00 - Evolution of backup testing: From scripts to Android Studio

7:15 - Beyond testing: Backup for developer workflow

8:50 - Cross-device backup & GMS core integration

10:55 - Understanding backup types: Devices vs. Cloud

14:55 - Data categories & developer control

16:05 - System-level backup: Permissions & credentials

18:35 - Default backup pitfalls & key-value agents

28:15 - Database migrations & backup stress testing

32:04 - Automated testing framework

41:02 - Recap & feedback channels

]]>
+ + + + 48:50 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)It can be a draw when setting up a new device. Some info transfers, while some don't. Learn how developers can ensure the best user experience when people backup and restore an Android device. Hosts Tor and Chet are joined by Graham and Piyush on the Android Consumer Experience team  and Alon on the Android Studio team to discuss Android backup and restore.   Chapters: 0:00 - Introduction & the user pain point 1:38 - Why backup fails: Developer challenges 4:00 - Evolution of backup testing: From scripts to Android Studio 7:15 - Beyond testing: Backup for developer workflow 8:50 - Cross-device backup & GMS core integration 10:55 - Understanding backup types: Devices vs. Cloud 14:55 - Data categories & developer control 16:05 - System-level backup: Permissions & credentials 18:35 - Default backup pitfalls & key-value agents 28:15 - Database migrations & backup stress testing 32:04 - Automated testing framework 41:02 - Recap & feedback channelsIt can be a draw when setting up a new device. Some info transfers, while some don't. Learn how developers can ensure the best user experience when people backup and restore an Android device. Hosts Tor and Chet are joined by Graham and Piyush on the Android Consumer Experience team  and Alon on the Android Studio team to discuss Android backup and restore.   Chapters: 0:00 - Introduction & the user pain point 1:38 - Why backup fails: Developer challenges 4:00 - Evolution of backup testing: From scripts to Android Studio 7:15 - Beyond testing: Backup for developer workflow 8:50 - Cross-device backup & GMS core integration 10:55 - Understanding backup types: Devices vs. Cloud 14:55 - Data categories & developer control 16:05 - System-level backup: Permissions & credentials 18:35 - Default backup pitfalls & key-value agents 28:15 - Database migrations & backup stress testing 32:04 - Automated testing framework 41:02 - Recap & feedback channels
+ + Bot to the Future: Two Years Later + Bot to the Future: Two Years Later + Fri, 13 Jun 2025 16:00:00 +0000 + + + Join Tor and Siva Velusamy and Sandhya Mohan from the Android Studio team to learn about the latest updates to Studio Bot (Gemini in Android Studio) that dropped at Google I/O 2025 including Agent Mode. Building on their conversation from two years ago about the Studio Bot, they explore Gemini's role in every stage of development, from design to building, testing, and maintenance, the future of coding companions, and more.


Studio Bot podcast episode → https://goo.gle/3ZM0Wwx 

 

 

]]>
+ + Join Tor and Siva Velusamy and Sandhya Mohan from the Android Studio team to learn about the latest updates to Studio Bot (Gemini in Android Studio) that dropped at Google I/O 2025 including Agent Mode. Building on their conversation from two years ago about the Studio Bot, they explore Gemini's role in every stage of development, from design to building, testing, and maintenance, the future of coding companions, and more.

Studio Bot podcast episode → https://goo.gle/3ZM0Wwx

]]>
+ + + + 46:27 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)Join Tor and Siva Velusamy and Sandhya Mohan from the Android Studio team to learn about the latest updates to Studio Bot (Gemini in Android Studio) that dropped at Google I/O 2025 including Agent Mode. Building on their conversation from two years ago about the Studio Bot, they explore Gemini's role in every stage of development, from design to building, testing, and maintenance, the future of coding companions, and more. Studio Bot podcast episode → https://goo.gle/3ZM0Wwx     Join Tor and Siva Velusamy and Sandhya Mohan from the Android Studio team to learn about the latest updates to Studio Bot (Gemini in Android Studio) that dropped at Google I/O 2025 including Agent Mode. Building on their conversation from two years ago about the Studio Bot, they explore Gemini's role in every stage of development, from design to building, testing, and maintenance, the future of coding companions, and more. Studio Bot podcast episode → https://goo.gle/3ZM0Wwx     
+ + ​​Kotlin Multiplatform: Have your code and eat it too + ​​Kotlin Multiplatform: Have your code and eat it too + Wed, 07 May 2025 16:00:00 +0000 + + + Software Engineers Dustin Lam and Yigit Boyar join host Tor Norbye to chat all about Kotlin Multiplatform. 


Resources:
Tor's Bluesky → https://goo.gle/4iLXEAg 

Yigit's Bluesky → https://goo.gle/4dmsYVj

]]>
+ + Software Engineers Dustin Lam and Yigit Boyar join host Tor Norbye to chat all about Kotlin Multiplatform.

Resources: Tor's Bluesky → https://goo.gle/4iLXEAg

Yigit's Bluesky → https://goo.gle/4dmsYVj

]]>
+ + + + 58:08 + false + + + + + + full + + + + developers@android.com (Android Developers)Software Engineers Dustin Lam and Yigit Boyar join host Tor Norbye to chat all about Kotlin Multiplatform.  Resources: Tor's Bluesky → https://goo.gle/4iLXEAg  Yigit's Bluesky → https://goo.gle/4dmsYVjAndroid DevelopersSoftware Engineers Dustin Lam and Yigit Boyar join host Tor Norbye to chat all about Kotlin Multiplatform.  Resources: Tor's Bluesky → https://goo.gle/4iLXEAg  Yigit's Bluesky → https://goo.gle/4dmsYVj
+ + Linting on the Side of Caution: Safety & Security + Linting on the Side of Caution: Safety &amp; Security + Thu, 10 Apr 2025 21:48:00 +0000 + + + What is app safety for Android? What is the Android team working on to ensure developers and users alike enjoy safe and reliable apps? Watch along as Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora take a deep dive backstage and discuss what's new in the Android safety space.

 

Resources:

Enhanced Tools for Secure & Efficient Development → https://goo.gle/42lo3i8

 

Watch more Android Developers Backstage → https://goo.gle/adb-podcast

Subscribe to Android Developers → https://goo.gle/AndroidDevs 

 

#Featured #Android #AndroidDevelopersBackstage

Speakers: Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora

]]>
+ + What is app safety for Android? What is the Android team working on to ensure developers and users alike enjoy safe and reliable apps? Watch along as Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora take a deep dive backstage and discuss what's new in the Android safety space.

Resources:

Enhanced Tools for Secure & Efficient Development → https://goo.gle/42lo3i8

Watch more Android Developers Backstage → https://goo.gle/adb-podcast

Subscribe to Android Developers → https://goo.gle/AndroidDevs

#Featured #Android #AndroidDevelopersBackstage

Speakers: Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora

]]>
+ + + + 50:31 + false + + + + + + full + + Google + + + + developers@android.com (Android Developers)What is app safety for Android? What is the Android team working on to ensure developers and users alike enjoy safe and reliable apps? Watch along as Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora take a deep dive backstage and discuss what's new in the Android safety space.   Resources: Enhanced Tools for Secure & Efficient Development → https://goo.gle/42lo3i8   Watch more Android Developers Backstage → https://goo.gle/adb-podcast Subscribe to Android Developers → https://goo.gle/AndroidDevs    #Featured #Android #AndroidDevelopersBackstage Speakers: Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed VoraWhat is app safety for Android? What is the Android team working on to ensure developers and users alike enjoy safe and reliable apps? Watch along as Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora take a deep dive backstage and discuss what's new in the Android safety space.   Resources: Enhanced Tools for Secure & Efficient Development → https://goo.gle/42lo3i8   Watch more Android Developers Backstage → https://goo.gle/adb-podcast Subscribe to Android Developers → https://goo.gle/AndroidDevs    #Featured #Android #AndroidDevelopersBackstage Speakers: Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora
+ +
+
\ No newline at end of file diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/adb_backstage_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/adb_backstage_art.jpg new file mode 100644 index 000000000..5cdb68edd Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/adb_backstage_art.jpg differ diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi.rss new file mode 100644 index 000000000..c0e92b08f --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi.rss @@ -0,0 +1,272 @@ + + + + + + https://simplecast.com + 99% Invisible + Design is everywhere in our lives, perhaps most importantly in the places where we've just stopped noticing. 99% Invisible is a weekly exploration of the process and power of design and architecture. From award winning producer Roman Mars. Learn more at 99percentinvisible.org. + Copyright © 2025 Roman Mars. All rights reserved. + en-us + Tue, 14 Jul 2026 08:00:00 +0000 + Tue, 14 Jul 2026 08:00:23 +0000 + + https://www.siriusxm.com + 99% Invisible + https://image.simplecastcdn.com/images/8436e3c1-2479-4466-ac48-fa58259960bb/a6d5b177-ab5d-430d-b3f2-b393a09d8101/3000x3000/sxm-cover-99percentinvisible-r2025-3000x3000-final.jpg?aid=rss_feed + + https://www.siriusxm.com + episodic + Design is everywhere in our lives, perhaps most importantly in the places where we've just stopped noticing. 99% Invisible is a weekly exploration of the process and power of design and architecture. From award winning producer Roman Mars. Learn more at 99percentinvisible.org. + Roman Mars + false + + https://feeds.simplecast.com/BqbsxVfO + + SiriusXM Podcasts and Roman Mars + originals@stitcher.com + + + + + + aee9545b-148e-42ac-a34f-f31c875d3e36 + If Mosquito Hawks Can Fly + A Louisiana man born into slavery designs an airship with hopes to take flight. Those plans are interrupted, but maybe not forever.

+

Listen to Family Lore wherever you get your podcasts.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Tue, 14 Jul 2026 08:00:00 +0000 + originals@stitcher.com (SiriusXM Podcasts and Roman Mars) + https://99percentinvisible.org/?p=48372 + + A Louisiana man born into slavery designs an airship with hopes to take flight. Those plans are interrupted, but maybe not forever.

+

Listen to Family Lore wherever you get your podcasts.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + If Mosquito Hawks Can Fly + SiriusXM Podcasts and Roman Mars + + 00:41:56 + A Louisiana man born into slavery designs an airship with hopes to take flight. Those plans are interrupted, but maybe not forever. + A Louisiana man born into slavery designs an airship with hopes to take flight. Those plans are interrupted, but maybe not forever. + aviation, airship, wright brothers, louisiana purchase exposition, black inventors, charles frederick page + false + full + 674 +
+ + 1f30b8c1-5797-4088-b4d7-500efd13cc89 + 100 Objects #8: Billy Possum + In 1902, an American classic was born: the teddy bear. Named after President Theodore Roosevelt, the toy became a huge hit, and sparked an idea…maybe every president from then on should have their own viral stuffed animal. Jon Mooallem tells the story of the ill-fated billy possum, and how our fickle feelings about animals have shaped the history—and perhaps the future—of our relationship with the natural world.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Fri, 10 Jul 2026 07:00:00 +0000 + originals@stitcher.com (Jon Mooallem) + https://99percentinvisible.org/?p=48287 + + In 1902, an American classic was born: the teddy bear. Named after President Theodore Roosevelt, the toy became a huge hit, and sparked an idea…maybe every president from then on should have their own viral stuffed animal. Jon Mooallem tells the story of the ill-fated billy possum, and how our fickle feelings about animals have shaped the history—and perhaps the future—of our relationship with the natural world.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + 100 Objects #8: Billy Possum + Jon Mooallem + + 00:36:10 + The teddy bear was a huge hit, and sparked an idea…maybe every president from then on should have their own viral stuffed animal. + The teddy bear was a huge hit, and sparked an idea…maybe every president from then on should have their own viral stuffed animal. + theodore roosevelt, teddy bear, conservation, wildlife, william howard taft, billy possum + false + bonus +
+ + b4538be5-96af-4cce-8b63-b5b184b8a11b + The Score + We keep score of everything these days. But what happens when the numbers start changing what we actually care about?

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Tue, 7 Jul 2026 08:00:00 +0000 + originals@stitcher.com (C. Thi Nguyen) + https://99percentinvisible.org/?p=47305 + + We keep score of everything these days. But what happens when the numbers start changing what we actually care about?

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + The Score + C. Thi Nguyen + + 00:26:08 + We keep score of everything these days. But what happens when the numbers start changing what we actually care about? + We keep score of everything these days. But what happens when the numbers start changing what we actually care about? + games, scoring, value capture, philosophy, c. thi nguyen, metrics + false + full + 673 +
+ + 113ee1b3-dc46-406c-b462-64317962193c + 100 Objects #7: The Otis Pamphlet + Before the Declaration of Independence was even a twinkle in the founding fathers' eyes, there was James Otis. Journalist Jack Hitt returns to tell the wild, tragic story of America’s forgotten proto-founding father and his inflammatory 1764 pamphlet. A story that is, in many ways, the story of the founding document of the United States of America.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Fri, 3 Jul 2026 07:00:00 +0000 + originals@stitcher.com (Jack Hitt) + https://99percentinvisible.org/?p=48226 + + Before the Declaration of Independence was even a twinkle in the founding fathers' eyes, there was James Otis. Journalist Jack Hitt returns to tell the wild, tragic story of America’s forgotten proto-founding father and his inflammatory 1764 pamphlet. A story that is, in many ways, the story of the founding document of the United States of America.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + 100 Objects #7: The Otis Pamphlet + Jack Hitt + + 00:37:26 + Journalist Jack Hitt returns to tell the wild, tragic story of America’s forgotten proto-founding father and his inflammatory 1764 pamphlet. + Journalist Jack Hitt returns to tell the wild, tragic story of America’s forgotten proto-founding father and his inflammatory 1764 pamphlet. + james otis, natural rights, american revolution, writs of assistance, taxation without representation, pamphlets + false + bonus +
+ + 41d640dc-738d-4c49-8959-26a97c00f8fc + Transatlantic Fiber-Optic Expialidocious + Before the internet went global, one undersea gamble made it possible. 

+

Jane Ruffino’s story, "Say Goodbye to the Undersea Cable That Made the Global Internet Possible," appears in the May-June issue of Wired magazine.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Tue, 30 Jun 2026 08:00:00 +0000 + originals@stitcher.com (Christopher Johnson) + https://99percentinvisible.org/?p=47210 + + Before the internet went global, one undersea gamble made it possible. 

+

Jane Ruffino’s story, "Say Goodbye to the Undersea Cable That Made the Global Internet Possible," appears in the May-June issue of Wired magazine.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + Transatlantic Fiber-Optic Expialidocious + Christopher Johnson + + 00:31:56 + Before the internet went global, one undersea gamble made it possible. + Before the internet went global, one undersea gamble made it possible. + submarine cables, telecommunications, tat-8, bell labs, fiber optics, internet infrastructure + true + full + 672 +
+ + c0bcde74-fbb7-402a-8f87-f0636fe8b934 + 100 Objects #6: "Sharpened Screwdriver" + In this episode, Roman and historians Heather Ann Thompson and Elliot Williams tell the story of the sharpened screwdriver: the object at the heart of the 1984 Bernhard Goetz subway shooting. In a difficult moment in New York City, four Black teenagers were transformed in the public imagination into armed criminals. What follows is a gripping account of how misinformation takes hold, how fear shapes public opinion, and how one narrative can ripple outward – echoing through decades of similar cases that continue to unfold today.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Fri, 26 Jun 2026 07:00:00 +0000 + originals@stitcher.com (Heather Ann Thompson, Elliot Williams) + https://99percentinvisible.org/?p=48221 + + In this episode, Roman and historians Heather Ann Thompson and Elliot Williams tell the story of the sharpened screwdriver: the object at the heart of the 1984 Bernhard Goetz subway shooting. In a difficult moment in New York City, four Black teenagers were transformed in the public imagination into armed criminals. What follows is a gripping account of how misinformation takes hold, how fear shapes public opinion, and how one narrative can ripple outward – echoing through decades of similar cases that continue to unfold today.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + 100 Objects #6: "Sharpened Screwdriver" + Heather Ann Thompson, Elliot Williams + + 00:40:28 + How was a household tool reimagined as a deadly weapon…that never actually existed? + How was a household tool reimagined as a deadly weapon…that never actually existed? + sharpened screwdriver, bernie getz, subway vigilante, racial bias, self-defense, misinformation + false + bonus +
+ + dbdeb18f-d3fa-4fdd-aa62-6cbc6d63e24c + The Most Exciting Change America Has Ever Seen + Billions of state quarters disappeared from circulation...and that was exactly the plan.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Tue, 23 Jun 2026 08:00:00 +0000 + originals@stitcher.com (Katie Thornton) + https://99percentinvisible.org/?p=47211 + + Billions of state quarters disappeared from circulation...and that was exactly the plan.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + The Most Exciting Change America Has Ever Seen + Katie Thornton + + 00:43:28 + Billions of state quarters disappeared from circulation...and that was exactly the plan. + Billions of state quarters disappeared from circulation...and that was exactly the plan. + seigniorage, missouri quarter, u.s. mint, state quarters, coin collecting, numismatics + false + full + 671 +
+ + 0478d505-7347-4a40-8c91-f2a93608b7df + 100 Objects #5: Blue Back Speller + In this episode, Roman and historian Imani Perry follow the Webster Blue Back Speller from the early days of the United States, to the heart of Black intellectual life. Through the lives of Booker T. Washington and W. E. B. Du Bois, Roman and Imani uncover how a single object became a gateway to literacy, self-determination, and an enduring debate about what education, citizenship, and freedom should mean in America.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Fri, 19 Jun 2026 07:00:00 +0000 + originals@stitcher.com (Imani Perry) + https://99percentinvisible.org/?p=48146 + + In this episode, Roman and historian Imani Perry follow the Webster Blue Back Speller from the early days of the United States, to the heart of Black intellectual life. Through the lives of Booker T. Washington and W. E. B. Du Bois, Roman and Imani uncover how a single object became a gateway to literacy, self-determination, and an enduring debate about what education, citizenship, and freedom should mean in America.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + 100 Objects #5: Blue Back Speller + Imani Perry + + 00:45:55 + Why did an ordinary schoolbook become so treasured that enslaved people risked their lives to possess it? + Why did an ordinary schoolbook become so treasured that enslaved people risked their lives to possess it? + literacy, blue-back speller, w.e.b. du bois, booker t. washington, frederick douglass, noah webster + false + bonus +
+ + 1720f7cf-ef12-407c-be36-cfb87b7b07aa + The Horn That Divided the World Cup + Love it or hate it, one buzzing horn changed the sound of the World Cup forever.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Tue, 16 Jun 2026 08:00:00 +0000 + originals@stitcher.com (James Parkinson) + https://99percentinvisible.org/?p=39575 + + Love it or hate it, one buzzing horn changed the sound of the World Cup forever.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + The Horn That Divided the World Cup + James Parkinson + + 00:33:34 + Love it or hate it, one buzzing horn changed the sound of the World Cup forever. + Love it or hate it, one buzzing horn changed the sound of the World Cup forever. + maake, world cup, vuvuzela, football, soccer, south africa + false + full + 511 +
+ + b6845f27-add0-4f61-ba36-19bc454bcce8 + 100 Objects #4: Lowe's Gas Bag + In 1861, one man and a “gas bag” filled with hydrogen sparked America's obsession with going higher, farther, into the unknown. In this episode, Roman and journalist Jack Hitt tell the story of Thaddeus Lowe — showman, scientist, and dreamer — whose balloon flight from Cincinnati went wildly off course and straight into the Civil War. Lowe pivoted to create the US military's first aerial reconnaissance unit, a precursor to the Air Force, and today his legacy spans generations— stretching all the way to the dark side of the moon.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ Fri, 12 Jun 2026 07:00:00 +0000 + originals@stitcher.com (Jack Hitt) + https://99percentinvisible.org/?p=48144 + + In 1861, one man and a “gas bag” filled with hydrogen sparked America's obsession with going higher, farther, into the unknown. In this episode, Roman and journalist Jack Hitt tell the story of Thaddeus Lowe — showman, scientist, and dreamer — whose balloon flight from Cincinnati went wildly off course and straight into the Civil War. Lowe pivoted to create the US military's first aerial reconnaissance unit, a precursor to the Air Force, and today his legacy spans generations— stretching all the way to the dark side of the moon.

+

A History of the United States in 100 Objects is a production of 99% Invisible and BBC Studios.

+

Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early. 

Start a free trial now on Apple Podcasts or by visiting siriusxm.com/podcastsplus. 


Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.

]]>
+ + 100 Objects #4: Lowe's Gas Bag + Jack Hitt + + 00:38:54 + In 1861, one man and a “gas bag” filled with hydrogen sparked America's obsession with going higher, farther, into the unknown. + In 1861, one man and a “gas bag” filled with hydrogen sparked America's obsession with going higher, farther, into the unknown. + hot air balloon, innovation, civil war, aerial reconnaissance, thaddeus lowe, gas bag, abraham lincoln + false + bonus +
+
+
\ No newline at end of file diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi_art.jpg new file mode 100644 index 000000000..f23deb3fa Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi_art.jpg differ diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish.rss new file mode 100644 index 000000000..1d621c182 --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish.rss @@ -0,0 +1,215 @@ + + + + + +No Such Thing As A Fish + +

The official channel for the show about everything: four dorks discuss the most amazing facts they’ve learned over the last seven days.

+ +

The multi award-winning, globe-travelling, 600-million-download phenomenon. Hosted by Dan Schreiber, James Harkin, Andrew Hunter Murray and Anna Ptaszynski.

+ + +]]>
+http://nosuchthingasafish.com +audioboom.com + + + +Sun, 12 Jul 2026 22:59:00 +0000 +en + + https://audioboom.com/i/41239450.png + No Such Thing As A Fish + http://nosuchthingasafish.com + + + + + +false +No Such Thing As A Fish + + + tech@qi.com + +58b1a7dd-3f14-50b4-b794-fe15e7927961 +© 2014 Quite Interesting Ltd +https://audioboom.com/channels/2399216.rss +episodic + + + Little Fish: Chromosomes are coming home + https://audioboom.com/posts/8926765 + 37 + 2 + + + 1680 + false + full + +

Dan, James and Andy discuss YOUR facts including Curaçao, chips and chess. Dan and Andy compete to see who has the best cardboard box anecdote. And we name eight more Friend of the Podcast custodians. 

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes.

Edited by James Harkin

Produced by Leying Lee

Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Sun, 12 Jul 2026 22:59:00 +0000 + audioboom-019f5884-247c-7ec6-bce3-890e8f7c5e95 + No Such Thing As A Fish + No Such Thing As A Fish +
+ + No Such Thing As No Such Thing As A Crab + https://audioboom.com/posts/8925825 + 643 + + + 3775 + false + full + +

Bertie Terrilliams of the Margate Crab Museum joins Dan, Anna and James to discuss cures, curators, crabs and KAPOW!

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes. 

Join Club Fish for ad-free episodes and exclusive bonus content at apple.co/nosuchthingasafish or nosuchthingasafish.com/patreon

Edited by James Harkin
Produced by Leying Lee
Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia

Shopify: Own your customer relationships. Own your revenue. Start with a free trial at shopify.co.uk

Get an exclusive 15% discount on Saily data plans! Use code [fish] at checkout. Download Saily app or go to https://saily.com/fish

]]>
+ Thu, 09 Jul 2026 22:55:00 +0000 + audioboom-019f4789-9d22-71d6-88c0-05caebb31b63 + No Such Thing As A Fish + No Such Thing As A Fish +
+ + Little Fish: I believed it when Pythagoras said it + https://audioboom.com/posts/8923537 + 36 + 2 + + + 1704 + false + full + +

Dan, Andy and Anna discuss YOUR facts including scaring cormorants, cleaning horses and recognising frogmen. Anna reveals what her first word was. And we name eight more Friend of the Podcast custodians. 

London Podcast Festival tickets go on sale tomorrow - visit nosuchthingasafish.com/live to join us in person, or via livestream!

Edited by James Harkin
Produced by Leying Lee
Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Sun, 05 Jul 2026 22:55:00 +0000 + audioboom-019f23de-50b6-7bfa-af98-c0be67cc74fb + No Such Thing As A Fish + No Such Thing As A Fish +
+ + No Such Thing As An Unlucky Fountain + https://audioboom.com/posts/8923496 + 642 + + + 3387 + false + full + +

Jamie Morton joins Dan, Anna and Andy to discuss symmetry, lottery, and an erotic bee. 

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes. 

Join Club Fish for ad-free episodes and exclusive bonus content at apple.co/nosuchthingasafish or nosuchthingasafish.com/patreon

Edited by James Harkin
Produced by Leying Lee
Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Thu, 02 Jul 2026 22:55:00 +0000 + audioboom-019f2393-603a-7627-b3c4-db94dfc60a1a + No Such Thing As A Fish + No Such Thing As A Fish +
+ + Little Fish: Shall we put a podcast on? What a stupid idea! + https://audioboom.com/posts/8921807 + 35 + 2 + + + 1826 + false + full + +

Dan, Andy and Anna discuss YOUR facts including pasta, Proust and pencils. Anna shares her secret 'Wheel of Life' game. And we name eight more Friend of the Podcast custodians.

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes.

Join Club Fish for ad-free episodes and exclusive bonus content at apple.co/nosuchthingasafish or nosuchthingasafish.com/patreon

Edited by James Harkin
Produced by Leying Lee
Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Sun, 28 Jun 2026 22:59:00 +0000 + audioboom-019f0ed4-4407-7928-979a-613bcb4c3468 + No Such Thing As A Fish + No Such Thing As A Fish +
+ + No Such Thing As A Glazed Lizard Donut + https://audioboom.com/posts/8920547 + 641 + + + 3422 + false + full + +

Dan, James, Anna and Andy discuss Disney Princesses, brave tailors and tangled lizards.

Head to nosuchthingasafish.com/Risummer for tickets to our July shows at the Royal Institution!

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes. 

Join Club Fish for ad-free episodes and exclusive bonus content at apple.co/nosuchthingasafish or nosuchthingasafish.com/patreon

Edited by James Harkin
Produced by Leying Lee
Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Thu, 25 Jun 2026 22:55:00 +0000 + audioboom-019efb21-989f-770c-b8be-b095d9af022a + No Such Thing As A Fish + No Such Thing As A Fish +
+ + Little Fish: Really Siding With The Baddies + https://audioboom.com/posts/8918215 + 34 + 2 + + + 1843 + false + full + +

James, Andy and Anna discuss YOUR facts including huge holes and dangerous materials. James and Andy argue over a TikTokker who doesn't exist. And we name eight more Friend of the Podcast custodians.

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes.

Join Club Fish for ad-free episodes and exclusive bonus content at apple.co/nosuchthingasafish or nosuchthingasafish.com/patreon

Edited by James Harkin
Produced by Leying Lee
Team includes Jack Chambers, Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Sun, 21 Jun 2026 22:55:00 +0000 + audioboom-019edb75-6d53-74cf-be4a-d319f0dacde8 + No Such Thing As A Fish + No Such Thing As A Fish +
+ + No Such Thing As The Metal Bloke + https://audioboom.com/posts/8918202 + 640 + + + 3233 + false + full + +

Nish Kumar joins James, Anna and Andy to discuss The Man Who Talks With String; The Man Who Sold The World; and the man you don't want directing your B-movie. 

Head to nosuchthingasafish.com/Risummer for tickets to our July shows at the Royal Institution!

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes. 

Join Club Fish for ad-free episodes and exclusive bonus content at apple.co/nosuchthingasafish or nosuchthingasafish.com/patreon

Edited by James Harkin
Produced by Leying Lee
Team includes Jack Chambers, Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Thu, 18 Jun 2026 22:55:00 +0000 + audioboom-019edb53-f68e-736b-92bf-b0a462dc1ff2 + No Such Thing As A Fish + No Such Thing As A Fish +
+ + Little Fish: I've Booked Us In A Lovely Toilet + https://audioboom.com/posts/8916004 + 33 + 2 + + + 1790 + false + full + +

Andy, James and Anna discuss YOUR facts including a road that's not a road; and lots of other places that are not where you might think. Anna discovers the true nature of God. And we name eight more Friend of the Podcast custodians. 

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes. 

Join Club Fish for ad-free episodes and exclusive bonus content at apple.co/nosuchthingasafish or nosuchthingasafish.com/patreon

Edited by James Harkin
Produced by Leying Lee
Team includes Jack Chambers, Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Sun, 14 Jun 2026 22:55:00 +0000 + audioboom-019ebc98-192b-7534-8c49-dfed8a977e61 + No Such Thing As A Fish + No Such Thing As A Fish +
+ + No Such Thing As Poddyversity Challenge + https://audioboom.com/posts/8915422 + 639 + + + 3480 + false + full + +

Dan, James, Anna and Andy discuss shamans, humans, theme parks and theme tunes.

Visit nosuchthingasafish.com for news about live shows, merchandise and more episodes. 

Join Club Fish for ad-free episodes and exclusive bonus content at apple.co/nosuchthingasafish or nosuchthingasafish.com/patreon

Edited by James Harkin
Produced by Leying Lee
Team includes Jack Chambers, Tara Dorrell, Joe Mayo and Ethan Ruparelia

]]>
+ Thu, 11 Jun 2026 22:55:00 +0000 + audioboom-019eb720-4209-774b-bef6-bb86d0b1afbe + No Such Thing As A Fish + No Such Thing As A Fish +
+
+
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish_art.jpg new file mode 100644 index 000000000..5ed27a411 Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish_art.jpg differ diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android.rss new file mode 100644 index 000000000..340dbc982 --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android.rss @@ -0,0 +1,324 @@ + + + + + + + Now in Android + Fri, 19 Dec 2025 23:45:17 +0000 + Fri, 19 Dec 2025 23:45:17 +0000 + Libsyn RSSgen 1.0 + http://d.android.com + en + + http://d.android.com + + + https://static.libsyn.com/p/assets/1/4/f/9/14f959f911553fcc27a2322813b393ee/NIA000_PodcastThumbnail-20241010-vj0kfz2md4.png + Now in Android + + + Now in Android + + + + false + + Google Developer Studio + gdevs.podcast@gmail.com + + + + episodic + + + + + + + + no + + + + + + + 123 – Android XR, Jetpack Navigation 3, and more! + 123 – Android XR, Jetpack Navigation 3, and more! + Fri, 19 Dec 2025 20:01:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers the latest releases from Android XR, the Android Studio Otter 2 feature drop with Gemini 3, the release of Android 16 QPR2, Jetpack Navigation 3, and more! 

Chapters:
0:00 - Introduction
0:31 - Android XR
1:59 - Android Studio and AI
2:31 - Jetpack updates
3:05 - Performance
3:20 - Core OS

 

Resources:
Check out the blog for all the resources → https://goo.gle/4qiDB0k 
Android XR → https://goo.gle/3Mwbaxv
Check out the latest spotlight weeks → https://goo.gle/49djqu7
Check out the latest blogs → https://goo.gle/43fhsXP
Check out the latest YouTube videos → https://goo.gle/4hPBUoe
Recent Release Notes → https://goo.gle/49AgSYA 

 

 

 

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers the latest releases from Android XR, the Android Studio Otter 2 feature drop with Gemini 3, the release of Android 16 QPR2, Jetpack Navigation 3, and more!

Chapters: 0:00 - Introduction 0:31 - Android XR 1:59 - Android Studio and AI 2:31 - Jetpack updates 3:05 - Performance 3:20 - Core OS

Resources: Check out the blog for all the resources → https://goo.gle/4qiDB0k Android XR → https://goo.gle/3Mwbaxv Check out the latest spotlight weeks → https://goo.gle/49djqu7 Check out the latest blogs → https://goo.gle/43fhsXP Check out the latest YouTube videos → https://goo.gle/4hPBUoe Recent Release Notes → https://goo.gle/49AgSYA

]]>
+ + + + 04:38 + false + + + + + + full + + Google + + + +
+ + 122 – Android Studio updates, Android 16 beta feature, and more! + 122 – Android Studio updates, Android 16 beta feature, and more! + Wed, 12 Nov 2025 19:37:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Ash covers the latest releases from Android 16 QPR2 to Android Studio Narwhal. Discover The Android Show, Androidify, the Wear OS Spotlight Week, and more!

 

Chapters:
0:00 - Introduction
0:23 - Android 16 QPR 2 and Beta
1:55 - Android Studio Narwhal
2:33 - The Android Show & Androidify
3:09 - Wear OS Spotlight Week
4:07 - Latest articles 
7:07 - Latest videos
9:01 - AndroidX updates

 

Resources:
Android 16 QPR2 Beta 2 is Here → https://goo.gle/4qQFOS2 
Sign up for the ADI Early Access Program→ https://goo.gle/4r0n42u 
Android Studio Narwhal 4 Feature Drop → https://goo.gle/49CgVmz 
Fall episode of The Android Show → https://goo.gle/3JjiDPm 
Androidify → https://goo.gle/4oPn6bo 
Wear OS Spotlight Week → https://goo.gle/3WHweTv 
Check out the latest blogs → https://goo.gle/43fhsXP  
Check out the latest YouTube videos → https://goo.gle/4hPBUoe 
Recent Release Notes → https://goo.gle/49AgSYA 

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Ash covers the latest releases from Android 16 QPR2 to Android Studio Narwhal. Discover The Android Show, Androidify, the Wear OS Spotlight Week, and more!

Chapters: 0:00 - Introduction 0:23 - Android 16 QPR 2 and Beta 1:55 - Android Studio Narwhal 2:33 - The Android Show & Androidify 3:09 - Wear OS Spotlight Week 4:07 - Latest articles 7:07 - Latest videos 9:01 - AndroidX updates

Resources: Android 16 QPR2 Beta 2 is Here → https://goo.gle/4qQFOS2 Sign up for the ADI Early Access Program→ https://goo.gle/4r0n42u Android Studio Narwhal 4 Feature Drop → https://goo.gle/49CgVmz Fall episode of The Android Show → https://goo.gle/3JjiDPm Androidify → https://goo.gle/4oPn6bo Wear OS Spotlight Week → https://goo.gle/3WHweTv Check out the latest blogs → https://goo.gle/43fhsXP Check out the latest YouTube videos → https://goo.gle/4hPBUoe Recent Release Notes → https://goo.gle/49AgSYA

]]>
+ + + + 10:32 + false + + + + + + full + + Google + + + +
+ + 121 – Android Studio Narwhal 3, Android 16 QPR2 beta, and more! + 121 – Android Studio Narwhal 3, Android 16 QPR2 beta, and more! + Wed, 03 Sep 2025 21:13:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers Android 16 QPR2 beta 1, the Android Studio Narwhal feature drop, Jetpack Compose 1.9, and more!

 

Chapters:
0:00 - Introduction
0:24 - Android 16 QPR2 beta
1:38 - Android Studio Updates
2:42 - Jetpack Compose August '25 released to stable
3:27 - Media3 1.8 Released
4:18 - Recently published Articles
4:55 - Recent published videos
5:26 - Android Developers Backstage
5:50 - AndroidX
6:45 - Recap

Resources:
📚 Android 16 QPR2 Beta 1 is here → https://goo.gle/47qDGck
📚 What's new in the Jetpack Compose August '25 release → https://goo.gle/4lTt60T
📚 Media3 1.8.0 - What's new? → https://goo.gle/427ftEb
📚 Android Studio Narwhal Feature Drop is stable - start using Agent Mode → https://goo.gle/464WITg
Advanced shared elements with shape morphing | Jetpack Compose Tips → https://goo.gle/3I72rzT
Consume a Runtime-Enabled SDK → https://goo.gle/3JIdr7o
WearOS Material 3 shape morphing | Jetpack Compose Tips → https://goo.gle/42eZ6FJ
Autofill in Compose → https://goo.gle/464SA5N

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers Android 16 QPR2 beta 1, the Android Studio Narwhal feature drop, Jetpack Compose 1.9, and more!

Chapters: 0:00 - Introduction 0:24 - Android 16 QPR2 beta 1:38 - Android Studio Updates 2:42 - Jetpack Compose August '25 released to stable 3:27 - Media3 1.8 Released 4:18 - Recently published Articles 4:55 - Recent published videos 5:26 - Android Developers Backstage 5:50 - AndroidX 6:45 - Recap

Resources: 📚 Android 16 QPR2 Beta 1 is here → https://goo.gle/47qDGck 📚 What's new in the Jetpack Compose August '25 release → https://goo.gle/4lTt60T 📚 Media3 1.8.0 - What's new? → https://goo.gle/427ftEb 📚 Android Studio Narwhal Feature Drop is stable - start using Agent Mode → https://goo.gle/464WITg Advanced shared elements with shape morphing | Jetpack Compose Tips → https://goo.gle/3I72rzT Consume a Runtime-Enabled SDK → https://goo.gle/3JIdr7o WearOS Material 3 shape morphing | Jetpack Compose Tips → https://goo.gle/42eZ6FJ Autofill in Compose → https://goo.gle/464SA5N

]]>
+ + + + 07:14 + false + + + + + + full + + Google + + + +
+ + 120 - Android Canaries, Agentic AI, 16 KB page size requirements, and more! + Now in Android: 120 – Android Canaries, Agentic AI, 16 KB page size requirements, and more! + Wed, 06 Aug 2025 16:00:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers Android Canaries, Agentic AI, 16 KB Page Size requirements, and new subscription tools, 

Resources:
Canary release channel → https://goo.gle/4lMWgjh  
Agentic AI with Gemini in Android Studio → https://goo.gle/3GUZzpi    
Prepare your Play app for devices with 16 KB page sizes → https://goo.gle/4f7FI2A
Google Play PolicyBytes - July 2025 policy updates → https://goo.gle/45k3AMi
#WeArePlay → https://youtu.be/gsFbhR0bDrM
Google Play Google I/O 2025 → https://goo.gle/4l8QNCa
Android Developer Productivity at Google I/O '25 → https://goo.gle/4lQ3R09
AI on Android at Google I/O '25 → https://goo.gle/4mnAX7N
Building excellent and adaptive apps at Google I/O '25 → https://goo.gle/3UBA8fz
Live updates & widgets → https://goo.gle/3ILBIJl
Build a runtime enabled sdk → https://goo.gle/3IRiXUK
Delightful Compose Tips → https://goo.gle/3HajT68
Compose preview →https://goo.gle/45rKS6u 

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers Android Canaries, Agentic AI, 16 KB Page Size requirements, and new subscription tools,

Resources: Canary release channel → https://goo.gle/4lMWgjh Agentic AI with Gemini in Android Studio → https://goo.gle/3GUZzpi Prepare your Play app for devices with 16 KB page sizes → https://goo.gle/4f7FI2A Google Play PolicyBytes - July 2025 policy updates → https://goo.gle/45k3AMi #WeArePlay → https://youtu.be/gsFbhR0bDrM Google Play Google I/O 2025 → https://goo.gle/4l8QNCa Android Developer Productivity at Google I/O '25 → https://goo.gle/4lQ3R09 AI on Android at Google I/O '25 → https://goo.gle/4mnAX7N Building excellent and adaptive apps at Google I/O '25 → https://goo.gle/3UBA8fz Live updates & widgets → https://goo.gle/3ILBIJl Build a runtime enabled sdk → https://goo.gle/3IRiXUK Delightful Compose Tips → https://goo.gle/3HajT68 Compose preview →https://goo.gle/45rKS6u

]]>
+ + + + 07:47 + false + + + + + + full + + Google + + + +
+ + 119 - Android 16, testing, desktop experiences, AndroidX, and more! + 119 - Android 16, testing, desktop experiences, AndroidX, and more! + Wed, 09 Jul 2025 16:00:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Meghan shares exciting news like the release of Android 16! Discover developer previews for desktop windowing experiences, building adaptive apps, and more!

Chapters and resources:

0:00 - Introduction

0:24 - Android 16 is here → https://goo.gle/45JjAcx  

1:27 - Desktop windowing on connected devices → https://goo.gle/4niBGZ4  

2:01 - Building excellent, adaptive apps → https://goo.gle/4ej8UTT   

2:43 - Testing → https://goo.gle/3HQ6Hn5  

3:25 - AndroidX releases → https://goo.gle/409hfEb

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Meghan shares exciting news like the release of Android 16! Discover developer previews for desktop windowing experiences, building adaptive apps, and more!

Chapters and resources:

0:00 - Introduction

0:24 - Android 16 is here → https://goo.gle/45JjAcx

1:27 - Desktop windowing on connected devices → https://goo.gle/4niBGZ4

2:01 - Building excellent, adaptive apps → https://goo.gle/4ej8UTT

2:43 - Testing → https://goo.gle/3HQ6Hn5

3:25 - AndroidX releases → https://goo.gle/409hfEb

]]>
+ + + + 04:36 + false + + + + + + full + + Google + + + +
+ + 118 – What's new in Android development at Google I/O 2025 (part 2) + 118 – What's new in Android development at Google I/O 2025 (part 2) + Wed, 18 Jun 2025 16:00:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, Dan Galpin covers part two of the biggest announcements from Google I/O 2025. From Jetpack Compose, Camera and Media, accessibility, Kotlin Multiplatform, Android development tools, Google Play, and more.

Check out part 1 → https://goo.gle/4n7nty5

Resources:

What's New in Jetpack Compose → https://goo.gle/45aiKVQ 

Seamless video capture, editing and playback with CameraX and Media3 → https://goo.gle/4mKGshE 

Building delightful Android camera and media experiences → https://goo.gle/3Hw0Eni 

What's new in Android development tools → https://goo.gle/3SIsBuE  

I/O 2025: What's new in Google Play → https://goo.gle/3HwERvQ

Kotlin Multiplatform: Have your code and eat it too → https://goo.gle/43zeX3a  

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, Dan Galpin covers part two of the biggest announcements from Google I/O 2025. From Jetpack Compose, Camera and Media, accessibility, Kotlin Multiplatform, Android development tools, Google Play, and more. Check out part 1 → https://goo.gle/4n7nty5

Resources:

What's New in Jetpack Compose → https://goo.gle/45aiKVQ

Seamless video capture, editing and playback with CameraX and Media3 → https://goo.gle/4mKGshE

Building delightful Android camera and media experiences → https://goo.gle/3Hw0Eni

What's new in Android development tools → https://goo.gle/3SIsBuE

I/O 2025: What's new in Google Play → https://goo.gle/3HwERvQ

Kotlin Multiplatform: Have your code and eat it too → https://goo.gle/43zeX3a

]]>
+ + + + 05:47 + false + + + + + + full + + Google + + + +
+ + Now in Android: 117 – What's new in Android development at Google I/O 2025 (part 1) + Now in Android: 117 – What's new in Android development at Google I/O 2025 (part 1) + Wed, 04 Jun 2025 16:00:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, Dan Galpin covers part one of the biggest announcements from Google I/O 2025. From Material Design's latest evolution, to building with on-device and cloud AI, to updates for wearables, automotive, XR, and more.

Stay tuned for part 2, where Dan covers Android Jetpack, Jetpack Compose, and Android Studio.

Resources:

Google I/O '25 Developer Keynote → https://goo.gle/4keiQ3b 

16 things to know for Android developers at Google I/O 2025 → https://goo.gle/43Sx5Fe 

Start building with Material 3 Expressive → https://goo.gle/4dCUvlj 

What's new in Wear OS 6 → https://goo.gle/3FvI6TF 

New in-car app experiences → https://goo.gle/3Zzh0li 

Engage users on Google TV with excellent TV apps → https://goo.gle/4mR5M5Q 

Build adaptive Android apps that shine across form factors → https://goo.gle/4jqMOQA 

On-device GenAI APIs as part of ML Kit help you easily build with Gemini Nano → 

https://goo.gle/4dAUXQV 

Updates to the Android XR SDK: Introducing Developer Preview 2 → https://goo.gle/4dz28ck 

 

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, Dan Galpin covers part one of the biggest announcements from Google I/O 2025. From Material Design's latest evolution, to building with on-device and cloud AI, to updates for wearables, automotive, XR, and more.

Stay tuned for part 2, where Dan covers Android Jetpack, Jetpack Compose, and Android Studio.

Resources:

Google I/O '25 Developer Keynote → https://goo.gle/4keiQ3b

16 things to know for Android developers at Google I/O 2025 → https://goo.gle/43Sx5Fe

Start building with Material 3 Expressive → https://goo.gle/4dCUvlj

What's new in Wear OS 6 → https://goo.gle/3FvI6TF

New in-car app experiences → https://goo.gle/3Zzh0li

Engage users on Google TV with excellent TV apps → https://goo.gle/4mR5M5Q

Build adaptive Android apps that shine across form factors → https://goo.gle/4jqMOQA

On-device GenAI APIs as part of ML Kit help you easily build with Gemini Nano →

https://goo.gle/4dAUXQV

Updates to the Android XR SDK: Introducing Developer Preview 2 → https://goo.gle/4dz28ck

]]>
+ + + + 07:02 + false + + + + + + full + + Google + + + +
+ + 116 - Google I/O, Gemini, and Jetpack Compose 1.8 + 116 - Google I/O, Gemini, and Jetpack Compose 1.8 + Thu, 08 May 2025 16:00:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover the Google I/O 2025 program lineup, the release of Jetpack Compose 1.8, Android 16 Beta 4, new Play Console insights, and updated Android Vitals metrics. 

 

Chapters:

0:00 - Introduction

0:22 - Google I/O 2025 agenda is live → https://goo.gle/3GBntFH 

1:02 - Jetpack Compose 1.8 →  https://goo.gle/44T96Xp 

1:37 - Android 16 Beta 4 → https://goo.gle/42DhBo5 

2:31 - New Play Console insights → https://goo.gle/3EOceZZ 

3:11 - Android Vitals metrics → https://goo.gle/44OMfMA 

 

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover the Google I/O 2025 program lineup, the release of Jetpack Compose 1.8, Android 16 Beta 4, new Play Console insights, and updated Android Vitals metrics.

Chapters:

0:00 - Introduction

0:22 - Google I/O 2025 agenda is live → https://goo.gle/3GBntFH

1:02 - Jetpack Compose 1.8 → https://goo.gle/44T96Xp

1:37 - Android 16 Beta 4 → https://goo.gle/42DhBo5

2:31 - New Play Console insights → https://goo.gle/3EOceZZ

3:11 - Android Vitals metrics → https://goo.gle/44OMfMA

]]>
+ + + + 03:58 + false + + + + + + full + + Google + + + +
+ + 115 - Gemini in Android Studio, Enhancing Safety and Security, and more! + 115 - Gemini in Android Studio, Enhancing Safety and Security, and more! + Fri, 25 Apr 2025 13:28:00 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover Android 16 beta 3, Gemini in Android Studio for Business and Multimodal, Android XR, Media and Camera updates, and much more.

Resources:
Samsung's One UI 7 → https://goo.gle/42Ubuvv
Quality Tiers → https://goo.gle/4cJDaqs
Canonical Layouts → https://goo.gle/3S0ElIo
Figma Widget Design Kit → http://goo.gle/4gfFs0w
Jetpack Glance → https://goo.gle/42IgJxd
Coding Widgets layout video → https://goo.gle/3RYXhXV
Codelab → https://goo.gle/3Y70E2M

 

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover Android 16 beta 3, Gemini in Android Studio for Business and Multimodal, Android XR, Media and Camera updates, and much more.

Resources: Samsung's One UI 7 → https://goo.gle/42Ubuvv Quality Tiers → https://goo.gle/4cJDaqs Canonical Layouts → https://goo.gle/3S0ElIo Figma Widget Design Kit → http://goo.gle/4gfFs0w Jetpack Glance → https://goo.gle/42IgJxd Coding Widgets layout video → https://goo.gle/3RYXhXV Codelab → https://goo.gle/3Y70E2M

]]>
+ + + + 10:18 + false + + + + + + full + + Google + + + +
+ + 114 - Google I/O 2025, Android Studio at 10, Android 16 Betas, and more! + Now in Android: 114 - Google I/O 2025, Android Studio at 10, Android 16 Betas, and more! + Fri, 21 Mar 2025 02:07:55 +0000 + + + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover the return of Google I/O, Android Studio Turning 10, the Android 16 Betas, Imagen in Firebase, the latest in AndroidX, and more!

For links to these items, check out Now in Android #114 on Medium → https://goo.gle/4hA69xv 
Catch the latest episode of #TheAndroidShow here → https://goo.gle/tas-mar25 

Watch more Now in Android → https://goo.gle/now-in-android 
Subscribe to Android Developers → https://goo.gle/AndroidDevs  

]]>
+ + Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover the return of Google I/O, Android Studio Turning 10, the Android 16 Betas, Imagen in Firebase, the latest in AndroidX, and more!

For links to these items, check out Now in Android #114 on Medium → https://goo.gle/4hA69xv Catch the latest episode of #TheAndroidShow here → https://goo.gle/tas-mar25

Watch more Now in Android → https://goo.gle/now-in-android Subscribe to Android Developers → https://goo.gle/AndroidDevs

]]>
+ + + + 06:27 + false + + + + + + full + + Google + + + +
+ +
+
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android_art.jpg new file mode 100644 index 000000000..ffea32410 Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android_art.jpg differ diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all.rss new file mode 100644 index 000000000..69560f288 --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all.rss @@ -0,0 +1,241 @@ + + + + + Reply All + http://gimletmedia.com/shows/reply-all + en + All rights reserved + "'A podcast about the internet' that is actually an unfailingly original exploration of modern life and how to survive it." - The Guardian. Hosted by Alex Goldman and Emmanuel Dzotsi from Gimlet. + + https://megaphone.imgix.net/podcasts/05f71746-a825-11e5-aeb5-a7a572df575e/image/uploads_2F1610700524297-f3e2p6fbm79-8b4536488226b56e93a99ce9f7b555b5_2FReplyAll_ShowArt.png?ixlib=rails-4.3.1&max-w=3000&max-h=3000&fit=crop&auto=format,compress + Reply All + http://gimletmedia.com/shows/reply-all + + yes + episodic + + Gimlet + "'A podcast about the internet' that is actually an unfailingly original exploration of modern life and how to survive it." - The Guardian. Hosted by Alex Goldman and Emmanuel Dzotsi from Gimlet. + + "'A podcast about the internet' that is actually an unfailingly original exploration of modern life and how to survive it." - The Guardian. Hosted by Alex Goldman and Emmanuel Dzotsi from Gimlet.

]]> +
+ + Gimlet + podcasts@gimletmedia.com + + + + + + + + https://feeds.megaphone.fm/replyall + + Big Lives: Emmanuel's New Show + Emmanuel has a new show out, Big Lives, and we're excited to share a preview of it! Alongside acclaimed journalist Kai Wright, Emmanuel digs into the BBC archive to examine the sensational stories, messy feelings, and enlightening questions surrounding the icons who have shaped our culture. From musical trailblazers like David Bowie, Amy Winehouse and Tina Turner to stars of the screen like Richard Pryor, Meg Ryan and Jane Fonda, Emmanuel and Kai see these icons in a new light and learn how each legend set the stage for our contemporary cultural landscape. In this preview, they unspool the emotional arc behind George Michael, from teen heartthrob to grieving partner to gay icon. If you like what you hear, find Big Lives wherever you get podcasts. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Mon, 30 Mar 2026 07:00:00 -0000 + full + Gimlet + + + Emmanuel has a new show out, Big Lives, and we're excited to share a preview of it! Alongside acclaimed journalist Kai Wright, Emmanuel digs into the BBC archive to examine the sensational stories, messy feelings, and enlightening questions surrounding the icons who have shaped our culture. From musical trailblazers like David Bowie, Amy Winehouse and Tina Turner to stars of the screen like Richard Pryor, Meg Ryan and Jane Fonda, Emmanuel and Kai see these icons in a new light and learn how each legend set the stage for our contemporary cultural landscape. In this preview, they unspool the emotional arc behind George Michael, from teen heartthrob to grieving partner to gay icon. If you like what you hear, find Big Lives wherever you get podcasts. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + Emmanuel has a new show out, Big Lives, and we're excited to share a preview of it! Alongside acclaimed journalist Kai Wright, Emmanuel digs into the BBC archive to examine the sensational stories, messy feelings, and enlightening questions surrounding the icons who have shaped our culture. From musical trailblazers like David Bowie, Amy Winehouse and Tina Turner to stars of the screen like Richard Pryor, Meg Ryan and Jane Fonda, Emmanuel and Kai see these icons in a new light and learn how each legend set the stage for our contemporary cultural landscape. In this preview, they unspool the emotional arc behind George Michael, from teen heartthrob to grieving partner to gay icon. If you like what you hear, find Big Lives wherever you get podcasts.

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 3246 + no + + +
+ + Casey Wants to Believe + If you like this episode and want to hear more, please sign up for our premium feed here! https://hyperfixedpod.com/joinIf you would like to join at the $60 yearly level and receive your very own Big Chub Chet's button, you can do so here:https://hyperfixedpod.com/buttonThere are a limited number so get them before they're gone! Casey has been chasing the origin of a very specific piece of ephemera for almost 15 years. He came to us for help.LINKS:Busy Beaver Button MuseumThe Button in Question (Spoilers! don't look until the episode is done!) +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 05 Dec 2024 08:00:00 -0000 + full + 190 + Gimlet + + + If you like this episode and want to hear more, please sign up for our premium feed here! https://hyperfixedpod.com/joinIf you would like to join at the $60 yearly level and receive your very own Big Chub Chet's button, you can do so here:https://hyperfixedpod.com/buttonThere are a limited number so get them before they're gone! Casey has been chasing the origin of a very specific piece of ephemera for almost 15 years. He came to us for help.LINKS:Busy Beaver Button MuseumThe Button in Question (Spoilers! don't look until the episode is done!) +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + https://hyperfixedpod.com/join
If you would like to join at the $60 yearly level and receive your very own Big Chub Chet's button, you can do so here:https://hyperfixedpod.com/buttonThere are a limited number so get them before they're gone! 
Casey has been chasing the origin of a very specific piece of ephemera for almost 15 years. He came to us for help.
LINKS:Busy Beaver Button MuseumThe Button in Question (Spoilers! don't look until the episode is done!)


Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 2057 + no + + +
+ + #189 Goodbye All + One last thing before we go. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 23 Jun 2022 10:00:00 -0000 + full + Gimlet + + One last thing before we go. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + One last thing before we go.

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 3049 + yes + + +
+ + #188 Into the Depths + Inspired by @depthsofwikipedia, this week we dive deeper into three of our favorite weird Wikipedia pages. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 09 Jun 2022 10:00:00 -0000 + full + Gimlet + + Inspired by @depthsofwikipedia, this week we dive deeper into three of our favorite weird Wikipedia pages. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + Inspired by @depthsofwikipedia, this week we dive deeper into three of our favorite weird Wikipedia pages.

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 2554 + yes + + +
+ + Introducing: Stolen - Surviving St. Michael's + We'll be back in a few weeks, in the meantime we're introducing a new show from Gimlet called Stolen: Surviving St. Michael's +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 26 May 2022 10:00:00 -0000 + full + Gimlet + + We'll be back in a few weeks, in the meantime we're introducing a new show from Gimlet called Stolen: Surviving St. Michael's +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + We'll be back in a few weeks, in the meantime we're introducing a new show from Gimlet called Stolen: Surviving St. Michael's

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 2610 + + +
+ + A Message About the Future of the Show + Some news: Emmanuel and Alex will be stepping away from making the show. And this iteration of Reply All will be ending in late June. +This isn’t a decision that was made by any one person. Reply All has always been a super collaborative project which is what we’ve loved so much about making it. Ultimately, staff who have been working on this show for a long time – in some cases five, six, seven years at this point – are ready to pursue other things. +Making this show has been an incredible pleasure. Thank you all so much for listening. +We hope you join us for our remaining episodes, including our last one, on June 23rd. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 19 May 2022 21:10:58 -0000 + full + Gimlet + + Some news: Emmanuel and Alex will be stepping away from making the show. And this iteration of Reply All will be ending in late June. +This isn’t a decision that was made by any one person. Reply All has always been a super collaborative project which is what we’ve loved so much about making it. Ultimately, staff who have been working on this show for a long time – in some cases five, six, seven years at this point – are ready to pursue other things. +Making this show has been an incredible pleasure. Thank you all so much for listening. +We hope you join us for our remaining episodes, including our last one, on June 23rd. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + Some news: Emmanuel and Alex will be stepping away from making the show. And this iteration of Reply All will be ending in late June.

This isn’t a decision that was made by any one person. Reply All has always been a super collaborative project which is what we’ve loved so much about making it. Ultimately, staff who have been working on this show for a long time – in some cases five, six, seven years at this point – are ready to pursue other things.

Making this show has been an incredible pleasure. Thank you all so much for listening.

We hope you join us for our remaining episodes, including our last one, on June 23rd.

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 128 + + +
+ + #187 Flying the Coop + A community of chicken lovers faces an unlikely foe. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 12 May 2022 10:00:00 -0000 + full + Gimlet + + A community of chicken lovers faces an unlikely foe. +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + A community of chicken lovers faces an unlikely foe.

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 2095 + yes + + +
+ + Introducing: Stuck with Damon Young + We'll be back with a new episode next week, but in the meantime, we're featuring an episode of a new show from Gimlet and Crooked Media: Stuck with Damon Young. + +You can listen to episodes here: https://open.spotify.com/show/42kxHmWquXQBJCxG0rXvu6 +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 05 May 2022 14:28:41 -0000 + full + Gimlet + + We'll be back with a new episode next week, but in the meantime, we're featuring an episode of a new show from Gimlet and Crooked Media: Stuck with Damon Young. + +You can listen to episodes here: https://open.spotify.com/show/42kxHmWquXQBJCxG0rXvu6 +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + We'll be back with a new episode next week, but in the meantime, we're featuring an episode of a new show from Gimlet and Crooked Media: Stuck with Damon Young.


You can listen to episodes here: https://open.spotify.com/show/42kxHmWquXQBJCxG0rXvu6

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 2556 + + +
+ + #186 The Contact List + Emmanuel tries a personal experiment. + + + + + +-------- +This episode was inspired by the work of Soraya Perry. Check out her work as a filmmaker, musician, and visual artist here: www.sorayaperry.com +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 21 Apr 2022 10:00:00 -0000 + full + Gimlet + + Emmanuel tries a personal experiment. + + + + + +-------- +This episode was inspired by the work of Soraya Perry. Check out her work as a filmmaker, musician, and visual artist here: www.sorayaperry.com +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + Emmanuel tries a personal experiment.






--------

This episode was inspired by the work of Soraya Perry. Check out her work as a filmmaker, musician, and visual artist here: www.sorayaperry.com

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 2333 + + +
+ + Introducing: Conviction - The Disappearance of Nuseiba Hasan + Alex chats with Habiba Nosheen about her new show, Conviction: The Disappearance of Nuseiba Hasan.  + +Listen to all the episodes of the show here: https://open.spotify.com/show/2XquJlPU9ibLYZMH0ZzFwA +Learn more about your ad choices. Visit podcastchoices.com/adchoices + Thu, 14 Apr 2022 10:00:00 -0000 + full + Gimlet + + Alex chats with Habiba Nosheen about her new show, Conviction: The Disappearance of Nuseiba Hasan.  + +Listen to all the episodes of the show here: https://open.spotify.com/show/2XquJlPU9ibLYZMH0ZzFwA +Learn more about your ad choices. Visit podcastchoices.com/adchoices + + Alex chats with Habiba Nosheen about her new show, Conviction: The Disappearance of Nuseiba Hasan. 


Listen to all the episodes of the show here: https://open.spotify.com/show/2XquJlPU9ibLYZMH0ZzFwA

Learn more about your ad choices. Visit podcastchoices.com/adchoices

]]> +
+ 2355 + + +
+
+
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all_art.jpg new file mode 100644 index 000000000..7cdcf7a0e Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all_art.jpg differ diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life.rss new file mode 100644 index 000000000..4ce08c4fd --- /dev/null +++ b/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life.rss @@ -0,0 +1,311 @@ + + + + This American Life + https://www.thisamericanlife.org/podcast/rss.xml + Each week we choose a theme. Then anything can happen. This American Life is true stories that unfold like little movies for radio. Personal stories with funny moments, big feelings, and surprising plot twists. Newsy stories that try to capture what it’s like to be alive right now. It’s the most popular weekly podcast in the world, and winner of the first ever Pulitzer Prize for a radio show or podcast. Hosted by Ira Glass and produced in collaboration with WBEZ Chicago. + en + https://www.thisamericanlife.org/podcast/rss.xml + + Copyright 1995-2026 This American Life + This American Life + Each week we choose a theme. Then anything can happen. This American Life is true stories that unfold like little movies for radio. Personal stories with funny moments, big feelings, and surprising plot twists. Newsy stories that try to capture what it’s like to be alive right now. It’s the most popular weekly podcast in the world, and winner of the first ever Pulitzer Prize for a radio show or podcast. Hosted by Ira Glass and produced in collaboration with WBEZ Chicago. + false + web@thislife.org + + + + + + + 891: The Test Case + https://www.thisamericanlife.org/891/the-test-case + <p>Some people in this country think Antifa is a dangerous domestic terror organization. Some think that’s a complete myth. This week, we go to the federal trial where, for the first time, the government sets out to prove that Antifa is real. Zoe Chace reports. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Host Ira Glass tells the story of what happened at the Prairieland Detention Center in Alvarado, Texas. The government claims this was an attack by an Antifa cell. The defense says it was a protest gone awry. This argument goes to court in a first-of-its-kind trial. (7 minutes)</li><li>Act One: Reporter Zoe Chace walks us through one of the worst days for the defense: all these guns. (5 minutes)</li><li>Act Two: Zoe takes us through the testimony of the government’s Antifa expert. The defense team is optimistic that things are going well for them. (8 minutes)</li><li>Act Three: Zoe goes to jail to talk with one of the cooperating witnesses for the prosecution, a former church lady named Lynette Sharp. She has a lot to say about the events in her life that led her to accept a plea deal and testify against her friends. (24 minutes)</li><li>Act Four: The verdicts come in. (12 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/891/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 12 Jul 2026 18:00:00 -0400 + 46177 at https://www.thisamericanlife.org + full + 891 + The Test Case + This American Life + false + + + 01:12:09 + The government sets out to prove that Antifa is real. + + Some people in this country think Antifa is a dangerous domestic terror organization. Some think that’s a complete myth. This week, we go to the federal trial where, for the first time, the government sets out to prove that Antifa is real. Zoe Chace reports. + + + +Prologue: Host Ira Glass tells the story of what happened at the Prairieland Detention Center in Alvarado, Texas. The government claims this was an attack by an Antifa cell. The defense says it was a protest gone awry. This argument goes to court in a first-of-its-kind trial. (7 minutes) + +Act One: Reporter Zoe Chace walks us through one of the worst days for the defense: all these guns. (5 minutes) + +Act Two: Zoe takes us through the testimony of the government’s Antifa expert. The defense team is optimistic that things are going well for them. (8 minutes) + +Act Three: Zoe goes to jail to talk with one of the cooperating witnesses for the prosecution, a former church lady named Lynette Sharp. She has a lot to say about the events in her life that led her to accept a plea deal and testify against her friends. (24 minutes) + +Act Four: The verdicts come in. (12 minutes) + + + 890: Maximal Americanness + https://www.thisamericanlife.org/890/maximal-americanness + <p>On this country's 250th birthday, we bring you stories about the most American people, places, objects, and social norms that make this country what it is. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Ira talks to Pablo Torre about Major League Baseball’s new challenge system, and how it’s been optimized for maximum drama. (10 minutes)</li><li>Act One: Writer Jiayang Fan wrestles with a very common question she has never quite understood. (5 minutes)</li><li>Act Two: People come from all over the country to walk down one of Michigan’s tallest sand dunes, and then promptly turn around and trudge back up. Aviva DeKornfeld talks to Americans spending their limited vacation time on this punishing activity. (8 minutes)</li><li>Act Three: Emanuele Berry talks to Ira about Season 13 of the reality TV show, Survivor, known to fans as the “race war” season. (8 minutes)</li><li>Act Four: Years before his famous dictionary, Noah Webster wrote a book that took on a life of its own and served an unexpected purpose. (8 minutes)</li><li>Act Five: Emmanuel Dzotsi investigates a musical phenomenon very particular to the United States: singers embellishing the end of the national anthem. (9 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/890/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 05 Jul 2026 18:00:00 -0400 + 46176 at https://www.thisamericanlife.org + full + 890 + Maximal Americanness + This American Life + false + + + 00:56:03 + The people, places, objects, and social norms that make this country what it is. + + On this country's 250th birthday, we bring you stories about the most American people, places, objects, and social norms that make this country what it is. + + + +Prologue: Ira talks to Pablo Torre about Major League Baseball’s new challenge system, and how it’s been optimized for maximum drama. (10 minutes) + +Act One: Writer Jiayang Fan wrestles with a very common question she has never quite understood. (5 minutes) + +Act Two: People come from all over the country to walk down one of Michigan’s tallest sand dunes, and then promptly turn around and trudge back up. Aviva DeKornfeld talks to Americans spending their limited vacation time on this punishing activity. (8 minutes) + +Act Three: Emanuele Berry talks to Ira about Season 13 of the reality TV show, Survivor, known to fans as the “race war” season. (8 minutes) + +Act Four: Years before his famous dictionary, Noah Webster wrote a book that took on a life of its own and served an unexpected purpose. (8 minutes) + +Act Five: Emmanuel Dzotsi investigates a musical phenomenon very particular to the United States: singers embellishing the end of the national anthem. (9 minutes) + + + 128: Four Corners + https://www.thisamericanlife.org/128/four-corners + <p>We try to tell the story of life in America through portraits of life on four different corners, in four different states across the nation. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Host Ira Glass talks about the Four Corners tourist monument where Arizona, Colorado, Utah and New Mexico meet. (2 minutes)</li><li>Act One: Sarah Vowell has a theory that you can tell the entire history of the United States by standing on one street corner—specifically at Michigan Avenue and Wacker Drive in Chicago—and describing all the events that happened within eyeshot of the corner. She covers three centuries of history, from Louis Joliet to Keanu Reeves. (21 minutes)</li><li>Act Two: Scott Richer and Julie Riggs of Louisville, Kentucky, were supposed to have their first kiss at the corner where South Fourth Street meets the alley behind the West End Baptist Church. But it went wrong. (7 minutes)</li><li>Act Three: Writer Mike Paterniti tells a story of dogs and a community of dogwalkers that formed on the grounds of an old cemetery at the corner of Vaughn and Clifford in Portland, Maine. (14 minutes)</li><li>Act Four: Writer Achy Obejas reads a piece of short fiction from her book, We Came All the Way from Cuba So You Could Dress Like This? (11 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/128/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 28 Jun 2026 18:00:00 -0400 + 37545 at https://www.thisamericanlife.org + full + 128 + Four Corners + This American Life + false + + + 01:00:44 + We try to tell the story of life in America through portraits of life in four different states across the nation. + + We try to tell the story of life in America through portraits of life on four different corners, in four different states across the nation. + + + +Prologue: Host Ira Glass talks about the Four Corners tourist monument where Arizona, Colorado, Utah and New Mexico meet. (2 minutes) + +Act One: Sarah Vowell has a theory that you can tell the entire history of the United States by standing on one street corner—specifically at Michigan Avenue and Wacker Drive in Chicago—and describing all the events that happened within eyeshot of the corner. She covers three centuries of history, from Louis Joliet to Keanu Reeves. (21 minutes) + +Act Two: Scott Richer and Julie Riggs of Louisville, Kentucky, were supposed to have their first kiss at the corner where South Fourth Street meets the alley behind the West End Baptist Church. But it went wrong. (7 minutes) + +Act Three: Writer Mike Paterniti tells a story of dogs and a community of dogwalkers that formed on the grounds of an old cemetery at the corner of Vaughn and Clifford in Portland, Maine. (14 minutes) + +Act Four: Writer Achy Obejas reads a piece of short fiction from her book, We Came All the Way from Cuba So You Could Dress Like This? (11 minutes) + + + 889: There’s Something About Hail Mary + https://www.thisamericanlife.org/889/theres-something-about-hail-mary + <p>We spend an hour in the last two minutes of the fourth quarter, behind and desperate, with people trying any damn thing they can think of. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Five years after Ora first started experiencing mysterious and debilitating health problems, she decides to try a treatment that she knows very well might kill her. Host Ira Glass talks to her about the experience. (9 minutes)</li><li>Act One: Two lawyers have just three months to stop their client's execution. In Texas, where this story takes place, these kinds of appeals to get people off death row fail 94% of the time. (38 minutes)</li><li>Act Two: At the Otay Mesa Detention Center in San Diego, migrants figured out an ingenious way to communicate with the activists gathered outside of the detention center’s walls. (13 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/889/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 21 Jun 2026 18:00:00 -0400 + 46170 at https://www.thisamericanlife.org + full + 889 + There’s Something About Hail Mary + This American Life + false + + + 01:06:13 + We spend an hour in the last two minutes of the fourth quarter, behind and desperate, with people trying any damn thing they can think of. + + We spend an hour in the last two minutes of the fourth quarter, behind and desperate, with people trying any damn thing they can think of. + + + +Prologue: Five years after Ora first started experiencing mysterious and debilitating health problems, she decides to try a treatment that she knows very well might kill her. Host Ira Glass talks to her about the experience. (9 minutes) + +Act One: Two lawyers have just three months to stop their client's execution. In Texas, where this story takes place, these kinds of appeals to get people off death row fail 94% of the time. (38 minutes) + +Act Two: At the Otay Mesa Detention Center in San Diego, migrants figured out an ingenious way to communicate with the activists gathered outside of the detention center’s walls. (13 minutes) + + + 888: Not Today, Hades! + https://www.thisamericanlife.org/888/not-today-hades + <p>Regular people trapped inside Greek myths. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: When a mysterious, ripped-open package arrives on Pablo's doorstep, he takes it as a sign. (4 minutes)</li><li>Act One: Pablo flies closer to the sun. (14 minutes)</li><li>Act Two: In Greek mythology, there's Hades, where everyone goes when they die. You have to cross the river Styx to get there, and there’s a gate with this three-headed dog. He’s guarding the entrance and he’s supposed to make sure only actual dead people enter. This story is about a real person in America who stood at those very gates. Which is not the easiest job it turns out, at least not right now. (24 minutes)</li><li>Act Three: A mortal gets the assignment of a lifetime — to go interview an actual god who is living on earth, traveling under the name of Lionel Messi. (11 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/888/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 07 Jun 2026 18:00:00 -0400 + 46163 at https://www.thisamericanlife.org + full + 888 + Not Today, Hades! + This American Life + false + + + 00:59:14 + Regular people trapped inside Greek myths. + + Regular people trapped inside Greek myths. + + + +Prologue: When a mysterious, ripped-open package arrives on Pablo's doorstep, he takes it as a sign. (4 minutes) + +Act One: Pablo flies closer to the sun. (14 minutes) + +Act Two: In Greek mythology, there's Hades, where everyone goes when they die. You have to cross the river Styx to get there, and there’s a gate with this three-headed dog. He’s guarding the entrance and he’s supposed to make sure only actual dead people enter. This story is about a real person in America who stood at those very gates. Which is not the easiest job it turns out, at least not right now. (24 minutes) + +Act Three: A mortal gets the assignment of a lifetime — to go interview an actual god who is living on earth, traveling under the name of Lionel Messi. (11 minutes) + + + 887: Two Is One, One Is None! + https://www.thisamericanlife.org/887/two-is-one-one-is-none + <p>One family faces the Trump administration’s ban on trans people serving in the military, and responds with a surprising secret weapon. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Geirid and Chrissy are extreme planners. But about a year ago, they were confronted with a situation that even they had no idea how to plan for. (4 minutes)</li><li>Act One: Geirid and Chrissy make an “in case of emergency, break glass” spreadsheet and get some big news. (14 minutes)</li><li>Act Two: Geirid and Chrissy have less than a month to make a life-changing decision. The government gives them two options, and they try to find a third. (21 minutes)</li><li>Act Three: A short story from Rachel Khong: Two people have a very consequential choice to make, given to them by God. (15 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/887/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 17 May 2026 18:00:00 -0400 + 46156 at https://www.thisamericanlife.org + full + 887 + Two Is One, One Is None! + This American Life + false + + + 01:00:56 + One family faces the Trump administration’s ban on trans people serving in the military. + + One family faces the Trump administration’s ban on trans people serving in the military, and responds with a surprising secret weapon. + + + +Prologue: Geirid and Chrissy are extreme planners. But about a year ago, they were confronted with a situation that even they had no idea how to plan for. (4 minutes) + +Act One: Geirid and Chrissy make an “in case of emergency, break glass” spreadsheet and get some big news. (14 minutes) + +Act Two: Geirid and Chrissy have less than a month to make a life-changing decision. The government gives them two options, and they try to find a third. (21 minutes) + +Act Three: A short story from Rachel Khong: Two people have a very consequential choice to make, given to them by God. (15 minutes) + + + 886: Blackout + https://www.thisamericanlife.org/886/blackout + <p>Since the war began in Iran, we've heard very little from people inside the country — and there's a reason for that. The entire country has been under an internet blackout. We worked with reporters Roxana Saberi and Fatemeh Jamalpour to get voice memos out of the country. Even though it was dangerous and difficult, people wanted to be heard. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Shirin's parents suddenly disappear into the blackout. (5 minutes)</li><li>Act One: It’s a war and a blackout. People want to talk about both. (17 minutes)</li><li>Act Two: What happened before America and Israel went to war with Iran. (9 minutes)</li><li>Act Three: Iranians have many opinions about the war, and about each other. (12 minutes)</li><li>Act Four: What happened inside Iran the night President Trump threatened that "a whole civilization could die." And a clue about where the internet blackout is headed. (19 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/886/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 03 May 2026 18:00:00 -0400 + 46146 at https://www.thisamericanlife.org + full + 886 + Blackout + This American Life + false + + + 00:54:19 + Iran has been under a total internet blackout since the war began. We found a way to get voice memos out. + + Since the war began in Iran, we've heard very little from people inside the country — and there's a reason for that. The entire country has been under an internet blackout. We worked with reporters Roxana Saberi and Fatemeh Jamalpour to get voice memos out of the country. Even though it was dangerous and difficult, people wanted to be heard. + + + +Prologue: Shirin's parents suddenly disappear into the blackout. (5 minutes) + +Act One: It’s a war and a blackout. People want to talk about both. (17 minutes) + +Act Two: What happened before America and Israel went to war with Iran. (9 minutes) + +Act Three: Iranians have many opinions about the war, and about each other. (12 minutes) + +Act Four: What happened inside Iran the night President Trump threatened that "a whole civilization could die." And a clue about where the internet blackout is headed. (19 minutes) + + + Ira (Reluctantly) Gives a Graduation Speech + <p>Ira always hated commencement speeches. Then he felt like he had to give this one.</p> + +<p><a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&amp;utm_source=rss&amp;utm_medium=shownotes-bonus">thisamericanlife.org/lifepartners</a></p> + Fri, 01 May 2026 00:00:00 -0400 + 46148 at https://www.thisamericanlife.org + + + + https://www.thisamericanlife.org/lifepartners + This American Life + 00:08:24 + Ira always hated commencement speeches. Then he felt like he had to give this one. + Ira always hated commencement speeches. Then he felt like he had to give this one. + + + 885: Bless This Mess + https://www.thisamericanlife.org/bless-this-mess + <p>At a time when the U.S. government is trying to make American history tidier, we try to learn from the mess. Including the untold, messy story of Paul and Essie Robeson. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Guest Host Emanuele Berry talks to Nichole Hill about the Black movie characters Nichole was curious about as a child. (7 minutes)</li><li>Act One: A giant of the Harlem Renaissance, Paul Robeson was the most famous American of his day. Until he wasn’t. Nichole Hill tells the messy, complicated story of Paul and his wife, Essie Robeson. (38 minutes)</li><li>Act Two: In 1865, a formerly enslaved man named Jourdan Anderson received a letter from his former enslaver, asking Jourdan to return to the plantation and work. Actor Laurence Fishburne reads Jourdan’s response. </li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/885/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 12 Apr 2026 18:00:00 -0400 + 46137 at https://www.thisamericanlife.org + full + 885 + Bless This Mess + This American Life + false + + + 01:01:41 + The untold, messy story of Paul and Essie Robeson. + + At a time when the U.S. government is trying to make American history tidier, we try to learn from the mess. Including the untold, messy story of Paul and Essie Robeson. + + + +Prologue: Guest Host Emanuele Berry talks to Nichole Hill about the Black movie characters Nichole was curious about as a child. (7 minutes) + +Act One: A giant of the Harlem Renaissance, Paul Robeson was the most famous American of his day. Until he wasn’t. Nichole Hill tells the messy, complicated story of Paul and his wife, Essie Robeson. (38 minutes) + +Act Two: In 1865, a formerly enslaved man named Jourdan Anderson received a letter from his former enslaver, asking Jourdan to return to the plantation and work. Actor Laurence Fishburne reads Jourdan’s response.  + + + 884: The Idiot + https://www.thisamericanlife.org/the-idiot + <p>M. Gessen returns to our show with a true-crime story that takes place entirely within their own family. This story comes to us from the producers at Serial Productions—who invented the true-crime podcast more than a decade ago—and from The New York Times. + +</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Act One: M Gessen tells Ira Glass about the surprising events that prompted them to begin reporting on their own family for their new podcast, The Idiot. They play the first episode of the series. (14 minutes)</li><li>Act Two: Ira Glass and M Gessen continue to talk through the story of M’s cousin, Allen Gessen. They play more clips from the podcast, and we finally hear about the big, shocking thing that snapped their family apart. (20 minutes)</li><li>Act Three: M Gessen tells Ira Glass about Allen’s trial, and we hear a recording of his conversation with the undercover agent. (21 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/884/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p> + Sun, 29 Mar 2026 18:00:00 -0400 + 46125 at https://www.thisamericanlife.org + full + 884 + The Idiot + This American Life + false + + + 00:59:15 + M. Gessen returns to our show with a true crime story that all takes place in their own family. + + M. Gessen returns to our show with a true-crime story that takes place entirely within their own family. This story comes to us from the producers at Serial Productions—who invented the true-crime podcast more than a decade ago—and from The New York Times. + + + +Act One: M Gessen tells Ira Glass about the surprising events that prompted them to begin reporting on their own family for their new podcast, The Idiot. They play the first episode of the series. (14 minutes) + +Act Two: Ira Glass and M Gessen continue to talk through the story of M’s cousin, Allen Gessen. They play more clips from the podcast, and we finally hear about the big, shocking thing that snapped their family apart. (20 minutes) + +Act Three: M Gessen tells Ira Glass about Allen’s trial, and we hear a recording of his conversation with the undercover agent. (21 minutes) + + + diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life_art.jpg new file mode 100644 index 000000000..e37ab813b Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life_art.jpg differ diff --git a/src/Comet/sample/Shared/Jetcaster/gold/compact-01-discover.png b/src/Comet/sample/Shared/Jetcaster/gold/compact-01-discover.png new file mode 100644 index 000000000..68bc1140d Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/gold/compact-01-discover.png differ diff --git a/src/Comet/sample/Shared/Jetcaster/gold/compact-02-podcast-details.png b/src/Comet/sample/Shared/Jetcaster/gold/compact-02-podcast-details.png new file mode 100644 index 000000000..4ff39f674 Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/gold/compact-02-podcast-details.png differ diff --git a/src/Comet/sample/Shared/Jetcaster/gold/compact-03-player.png b/src/Comet/sample/Shared/Jetcaster/gold/compact-03-player.png new file mode 100644 index 000000000..dd8c6bb21 Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/gold/compact-03-player.png differ diff --git a/src/Comet/sample/Shared/Jetchat/JetchatConversation.cs b/src/Comet/sample/Shared/Jetchat/JetchatConversation.cs new file mode 100644 index 000000000..ab9bee1a3 --- /dev/null +++ b/src/Comet/sample/Shared/Jetchat/JetchatConversation.cs @@ -0,0 +1,782 @@ +#nullable enable +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Comet; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; + +namespace CometSamples.Jetchat +{ + /// + /// A faithful Comet port of the conversation screen from Google's official + /// android/compose-samples Jetchat (the gold standard for a Compose-vs-Comet + /// comparison). Rendered on the node backend (Yoga layout, no MAUI handlers), it mirrors the + /// real screen: the centered #composers channel bar, the author-grouped message log + /// (42dp circular avatars, name+timestamp headers, the iconic RoundedCornerShape(4,20,20,20) + /// chat bubbles in primary/surfaceVariant, the 160dp sticker), and the bottom input bar. + /// Colors, spacing, shapes, the nine messages and the real avatar/sticker assets are taken + /// straight from the sample. + /// + public static class JetchatConversation + { + // Semantic color roles come from the centralized JetchatTheme (mirrors theme/Themes.kt); + // these are local aliases so the screen reads tokens, not hardcoded hex. + // Read live from JetchatTheme (computed, not cached) so a runtime scheme swap (Material You, + // JetchatTheme.ApplyScheme) reaches these before the tree is built. + internal static Color Primary => JetchatTheme.Primary; + internal static Color OnPrimary => JetchatTheme.OnPrimary; + internal static Color InversePrimary => JetchatTheme.InversePrimary; + internal static Color Secondary => JetchatTheme.Secondary; + internal static Color Surface => JetchatTheme.Surface; + internal static Color OnSurface => JetchatTheme.OnSurface; + internal static Color SurfaceVariant => JetchatTheme.SurfaceVariant; + internal static Color OnSurfaceVariant => JetchatTheme.OnSurfaceVariant; + internal static Color Tertiary => JetchatTheme.Tertiary; + internal static Color Divider => JetchatTheme.Divider; + internal static Color BarSurface => JetchatTheme.SurfaceTinted; + internal static Color Disabled => JetchatTheme.Disabled; + + // Bundled drawables (Jetchat ships these as local resources via painterResource, so they + // render offline and without a network round-trip). The backends resolve a bare name to a + // platform resource: Android drawable / iOS bundle image. + internal const string AvatarMe = "ali"; + internal const string AvatarOther = "someone_else"; + const string Sticker = "sticker"; + + sealed record Msg(string Author, string Content, string Timestamp, bool HasImage = false); + + // FakeData.initialMessages is newest-first (the LazyColumn is reverseLayout); displayed + // top-to-bottom it is the reverse — oldest at top. Authored exactly as the sample. + static readonly List Display = BuildDisplay(); + + static List BuildDisplay() + { + var list = new List + { + new("John Glenn", "Yeah its seems to be pretty new!", "8:12 PM"), + new("Taylor Brooks", "Wow! I never knew about Glance Widgets when was this added to the android ecosystem", "8:10 PM"), + new("Shangeeth Sivan", "Does anyone know about Glance Widgets its the new way to build widgets in Android!", "8:08 PM"), + new("me", "Compose newbie: I’ve scourged the internet for tutorials about async data loading but haven’t found any good ones 🫠☁️. What’s the recommended way to load async data and emit composable widgets?", "8:03 PM"), + new("John Glenn", "Compose newbie as well 🦩, have you looked at the JetNews sample? Most blog posts end up out of date pretty fast but this sample is always up to date and deals with async data loading (it’s faked but the same idea applies) 👉 https://goo.gle/jetnews", "8:04 PM"), + new("Taylor Brooks", "@aliconors Take a look at the `Flow.collectAsStateWithLifecycle()` APIs", "8:05 PM"), + new("Taylor Brooks", "You can use all the same stuff", "8:05 PM"), + new("me", "Thank you!🥷", "8:06 PM", HasImage: true), + new("me", "Check it out!", "8:07 PM"), + }; + + // ~100 extra messages so the LazyColumn actually virtualizes/recycles while scrolling. + // Varied authors + lengths (some wrap, some carry @mentions / `code` / links) so each row + // re-measures realistically rather than all being identical. + string[] authors = { "John Glenn", "Taylor Brooks", "Shangeeth Sivan", "me", "Ali Conors" }; + string[] bodies = + { + "👍", + "Sounds good to me", + "A medium-length reply that wraps onto a second line on most phones for layout variety", + "@aliconors did you try the `rememberLazyListState()` API for scroll position?", + "Here's the doc with the full async-loading walkthrough 👉 https://goo.gle/jetnews", + "Longer one: virtualization means only the rows on screen are composed and measured, so a list of hundreds should scroll just as smoothly as a list of nine — that's the whole point of LazyColumn 🚀", + "`collectAsStateWithLifecycle()` is the move", + "Nice 🔥", + }; + for (int i = 0; i < 100; i++) + list.Add(new Msg(authors[i % authors.Length], bodies[i % bodies.Length], $"7:{59 - i % 60:D2} PM")); + + return list; + } + + // Flattened rows for the message LazyColumn: the "Today" header plus each message tagged with + // its grouping (top-of-group shows the avatar + author; bottom-of-group gets extra spacing). + abstract record Row; + sealed record HeaderRow(string Day) : Row; + sealed record MsgRow(Msg M, bool TopOfGroup, double BottomSpace) : Row; + + static readonly List Rows = BuildRows(); + + // Display = [9 gold messages][~100 history]. Render history (older) first, then the gold's two + // hardcoded day dividers ("20 Aug" + "Today") around the 9-message conversation — so the newest + // real message ("Check it out!") is the LAST row and the list opens scrolled to it (Conversation.kt + // reverseLayout + the index==size-1 / index==2 DayHeaders). + const int GoldCount = 9; + + static List BuildRows() + { + var real = Display.GetRange(0, GoldCount); + var history = Display.GetRange(GoldCount, Display.Count - GoldCount); + + var rows = new List(); + AddMessageRows(rows, history); // older history (top) + rows.Add(new HeaderRow("20 Aug")); + AddMessageRows(rows, real.GetRange(0, 3)); // John 8:12, Taylor 8:10, Shangeeth 8:08 + rows.Add(new HeaderRow("Today")); + AddMessageRows(rows, real.GetRange(3, real.Count - 3)); // me 8:03 … Check it out 8:07 + return rows; + } + + // Append message rows for one contiguous segment, computing author-grouping within it (the + // first row starts a new group — a header/segment boundary precedes it). + static void AddMessageRows(List rows, IReadOnlyList msgs) + { + for (int i = 0; i < msgs.Count; i++) + { + var m = msgs[i]; + bool topOfGroup = i == 0 || msgs[i - 1].Author != m.Author; // avatar + name here + bool bottomOfGroup = i == msgs.Count - 1 || msgs[i + 1].Author != m.Author; + rows.Add(new MsgRow(m, topOfGroup, bottomOfGroup ? 8 : 4)); + } + } + + /// Builds the conversation screen. / + /// are the platform safe-area insets (status bar / home indicator) in Dp. + internal static readonly Comet.Reactive.Signal DrawerOpen = new(false); + + // Drives the "Functionality not available 🙊" AlertDialog (Jetchat's NotAvailablePopup), + // opened from the DM ("@") input selector — exactly `InputSelector.DM -> NotAvailablePopup`. + static readonly Comet.Reactive.Signal NotAvailableOpen = new(false); + + // The composer's text, two-way bound to the input field. The Send button reads it to toggle + // enabled/style, and a send appends a "me" message + clears it (UserInput.kt sendMessageEnabled). + static readonly Comet.Reactive.Signal InputText = new(string.Empty); + + // The active input selector (gold UserInput.kt InputSelector enum). Drives the expandable panel + // (emoji table / not-available pane — the F1 swap-panel capability) and the selected-icon + // highlight. The indices match the enum so the SelectorPanel slot list lines up with the icons. + const int SelNone = 0, SelMap = 1, SelDm = 2, SelEmoji = 3, SelPhone = 4, SelPicture = 5; + static readonly Comet.Reactive.Signal CurrentSelector = new(SelNone); + + // Voice-record indicator state (gold UserInput.kt RecordButton/RecordingIndicator). The live + // mm:ss elapsed time shown while the mic is held, reactively bound to the indicator's Text. The + // gold records no audio — RecordButton is a pure UI mock (it just toggles a flag) — and so do we; + // this is the gesture + indicator reproduction. Android-only: the iOS mic stays static. + static readonly Comet.Reactive.Signal RecordDuration = new("00:00"); + + /// The Jetchat sample. The drawer wraps the whole stack + /// (like the gold's ModalNavigationDrawer around the NavHost), so the jetchat logo opens + /// it over ANY destination — the conversation or a profile. Tapping a drawer profile closes the + /// drawer and Navigates the profile detail onto the stack; the profile's Message FAB + /// Pops it. + // The drawer's current destination (a channel key like "composers" or a profile name), so the + // drawer highlights the active item; "composers" is the initial channel. + static readonly Comet.Reactive.Signal Selected = new("composers"); + static string _lastChannel = "composers"; + + public static View Build(double topInset = 24, double bottomInset = 0) + { + var nav = new NavigationView(); + int depth = 0; // profiles currently pushed above the conversation + + // A drawer channel tap: return to the messages view (pop any profiles) and mark it selected. + void SelectChannel(string name) + { + Selected.Value = name; + _lastChannel = name; + while (depth > 0) { nav.Pop(); depth--; } + DrawerOpen.Value = false; + } + // A drawer profile tap: mark it selected and push the profile detail (the Message FAB / + // back restores the last channel as the selection). + void OpenProfile(string profileName) + { + Selected.Value = profileName; + DrawerOpen.Value = false; + depth++; + nav.Navigate(JetchatProfile.Screen(profileName, topInset, bottomInset, + () => { nav.Pop(); depth--; Selected.Value = _lastChannel; })); + } + + // A @mention tap (from the message formatter) resolves its handle to a profile and pushes it. + NavigateToProfile = handle => { if (ProfileForHandle(handle) is { } n) OpenProfile(n); }; + + nav.Content = ConversationView(topInset, bottomInset); + // Settings → the gold adds a home-screen widget; iOS has no equivalent, so surface the + // "functionality not available" popup (same as the DM selector). + void OpenSettings() => NotAvailableOpen.Value = true; + return new Drawer(DrawerOpen, JetchatDrawer.Content(topInset, Selected, SelectChannel, OpenProfile, OpenSettings), nav); + } + + /// The conversation screen (the drawer's content slot / nav root screen). + internal static View ConversationView(double topInset, double bottomInset) + { + var log = MessageLog(); + + return new VStack(spacing: 0f) + { + ChannelNameBar(topInset), + + // The message log (a LazyColumn) scrolls itself between the fixed bars; the + // JumpToBottom FAB floats over its bottom-center (a ZStack overlay = Jetchat's + // Messages() Box), fading in only while the log is scrolled away from the newest + // message — the reactive-visibility + scroll-state capability (JumpToBottom.kt). + new ZStack + { + log.FillHorizontal().FillVertical(), + JumpToBottom(log) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .VerticalLayoutAlignment(LayoutAlignment.End) + .Margin(bottom: 32), + + // The NotAvailable popup lives here as a zero-size overlay; it's a real Material + // AlertDialog (its own window + scrim) that pops over everything when opened. + NotAvailablePopup(), + }.FillVertical(), + + UserInput(bottomInset, log), + }.Background(Surface); + } + + // ── Channel bar: ‹jetchat-logo⟩ ⟨#composers / 42 members⟩ ⌕ ⓘ ── (no bottom line; the + // background tint separates it from the body). Mirrors a CenterAlignedTopAppBar: equal-flex + // side zones screen-center the title; 16dp edge insets via the bar's own padding (leaf + // padding is ignored by the layout engine, so spacing comes from the zones/margins). ── + static View ChannelNameBar(double topInset) => new HStack(spacing: 0f) + { + // Left zone (flex): the jetchat logo at the start; the spacer balances the right zone. + new HStack(spacing: 0f) + { + new Icon("jetchat").IconSize(28) // multicolor brand logo (no tint → keeps its colors) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .OnTap(_ => DrawerOpen.Value = true), // tap the logo → open the nav drawer + Spacer(), + }.FlexGrow(1).FlexBasis(0), + + new VStack(spacing: 0f) + { + new Text("#composers").Color(OnSurface).TitleMedium() + .HorizontalLayoutAlignment(LayoutAlignment.Center), + new Text("42 members").Color(OnSurfaceVariant).BodySmall() + .HorizontalLayoutAlignment(LayoutAlignment.Center), + }.VerticalLayoutAlignment(LayoutAlignment.Center), + + // Right zone (flex, equal weight → title sits at true screen-center): the actions at the end. + new HStack(spacing: 0f) + { + Spacer(), + BarIcon("search", () => NotAvailableOpen.Value = true).Margin(right: 20), + BarIcon("info", () => NotAvailableOpen.Value = true), + }.FlexGrow(1).FlexBasis(0), + }.Padding(new Thickness(16, topInset + 12, 16, 12)).Background(Surface); // CenterAlignedTopAppBar = plain surface + + // JetchatIcon (components/JetchatIcon.kt): the two-layer, theme-TINTED brand mark — ic_jetchat_back + // (primaryContainer) under ic_jetchat_front (primary), stacked. Unlike the static multicolor + // ic_jetchat, it tints with the Material scheme (so it tracks Material You / the brand seed). Both + // layers are tinted Icons (non-multicolor → the tinted Icon path = the gold's colorFilter.tint). + internal static View JetchatIcon(double size = 24) => new ZStack + { + new Icon("jetchat_back").Color(JetchatTheme.PrimaryContainer).IconSize(size), + new Icon("jetchat_front").Color(Primary).IconSize(size), + }.Frame(width: (float)size, height: (float)size).FlexShrink(0); + + // A real Material Icon (ImageVector / SF Symbol), 24dp, tinted onSurfaceVariant, centered. + // Search/info tap the "functionality not available" popup (Conversation.kt clickable actions). + static View BarIcon(string symbol, System.Action? onTap = null) + { + var icon = new Icon(symbol).Color(OnSurfaceVariant).IconSize(24) + .VerticalLayoutAlignment(LayoutAlignment.Center); + return onTap is null ? icon : icon.OnTap(_ => onTap()); + } + + // ── Message log: a real virtualized LazyColumn (Comet ListView), exactly like the gold + // standard's Messages() — each row template is materialized only when it scrolls into view. ── + static ListView MessageLog() => new ListView(() => Rows) + { + AnchorBottom = true, // chat log: open at the newest message (gold reverseLayout) + ViewFor = r => r switch + { + HeaderRow h => DayHeader(h.Day), + MsgRow m => MessageRow(m.M, m.TopOfGroup, m.BottomSpace), + _ => new VStack(), + }, + }; + + // ── JumpToBottom (JumpToBottom.kt): a real Material ExtendedFloatingActionButton — surface + // container, primary content, 36dp tall, a down-arrow + "Jump to bottom". Hidden until the + // log scrolls away from the newest message (reactive Opacity bound to the list's ScrolledAway + // signal); tapping animates the log to the bottom (LazyListState scroller). The icon/label + // carry no colour so they inherit the FAB's primary content colour. The gold uses + // ExtendedFloatingActionButton (always extended — never contracted); we match that here now + // that the ExtendedFAB slot bug is fixed via the RenderDirect bridge path. ── + static View JumpToBottom(ListView list) + { + var fab = new Comet.Fab( + // Colour the icon + label explicitly (not just via the FAB's contentColor): Compose's + // ExtendedFAB tints its slot content through LocalContentColor, but the SwiftUI FAB renders + // the children directly, so without an explicit colour the iOS label rendered invisible. + icon: new Icon("arrow_down").IconSize(18).Color(Primary), + label: new Text("Jump to bottom").LabelSmall().Color(Primary), + onClick: () => list.ScrollToBottom(), + height: 36, + containerColor: Surface, + contentColor: Primary, + extended: true) // always extended — the gold's ExtendedFloatingActionButton never contracts + .Opacity(0); // hidden until the log is scrolled away from the newest message + + // Reactive show/hide: the backend node drives ScrolledAway from the LazyListState; mirror + // it onto the FAB's opacity (ComposeFabNode keeps it composed but pushes it off-screen). + list.ScrolledAway.PropertyChanged += (_, __) => + fab.Opacity(list.ScrolledAway.Peek() ? 1.0 : 0.0); + + return fab; + } + + static View MessageRow(Msg m, bool topOfGroup, double bottomSpace) + { + bool isMe = m.Author == "me"; + + // Left gutter: a 42dp avatar (author ring + a surface "halo" gap around the photo) on + // the group's top message; a 74dp spacer otherwise — so a run of messages by one author + // lines up under the avatar. The 3px surface padding inside the ring is the halo. + View gutter = topOfGroup + ? new VStack(spacing: 0f) + { + new Image(isMe ? AvatarMe : AvatarOther).Frame(width: 36, height: 36).CornerRadius(18).FlexShrink(0), + } + .Frame(width: 42, height: 42) + .FlexShrink(0) // never compress the avatar's width when the row's text is wide + .Padding(new Thickness(3)) + .Background(Surface) + .CornerRadius(21) + .Border(1.5, isMe ? Primary : Tertiary) // Conversation.kt: border(1.5.dp, borderColor) + .Margin(left: 16, right: 16) + .VerticalLayoutAlignment(LayoutAlignment.Start) // stay 42×42, don't stretch to row height + : new HStack().Frame(width: 74).FlexShrink(0); + + var column = new VStack(spacing: 0f); + if (topOfGroup) + column.Add(AuthorNameTimestamp(m)); + column.Add(Bubble(m.Content, isMe)); + if (m.HasImage) + { + column.Add(new HStack().Frame(height: 4)); // 4dp gap to the image bubble + column.Add(StickerBubble(isMe)); + } + column.Add(new HStack().Frame(height: (float)bottomSpace)); + + return new HStack(spacing: 0f) + { + gutter, + column.FlexGrow(1).Padding(new Thickness(0, 0, 16, 0)), + }.Padding(new Thickness(0, topOfGroup ? 8 : 0, 0, 0)); + } + + static View AuthorNameTimestamp(Msg m) => new HStack(spacing: 8f) + { + new Text(m.Author).Color(OnSurface).TitleMedium().AlignBaseline(), + new Text(m.Timestamp).Color(OnSurfaceVariant).BodySmall().AlignBaseline(), + }.Padding(new Thickness(0, 0, 0, 4)); + + // The iconic chat bubble: RoundedCornerShape(4,20,20,20); primary/white for me, + // surfaceVariant/onSurface for others. Left-aligned and hugging its content (it grows to + // the column width only when the text wraps). + static View Bubble(string content, bool isMe) => new VStack(spacing: 0f) + { + new FormattedText(FormatMessage(content, isMe)).BodyLarge(), + } + .AsSurface() // the gold standard draws the bubble with Surface(color, shape) + .Padding(new Thickness(16)) + .Background(isMe ? Primary : SurfaceVariant) + .CornerRadius(JetchatTheme.BubbleTopStart, JetchatTheme.BubbleOther, JetchatTheme.BubbleOther, JetchatTheme.BubbleOther) + .HorizontalLayoutAlignment(LayoutAlignment.Start); + + // The C# port of Jetchat's messageFormatter (MessageFormatter.kt). One pass over the gold + // symbolPattern: links + `code` + @mentions + *bold* + _italic_ + ~strike~. @mentions are + // bold and tap to the author's profile; links tap to the browser (no underline — gold colors + // only); `code` is monospace 12sp in a tonal box. The accent is inversePrimary on my (primary) + // bubbles and primary on others; the code box is secondary(me)/surface(other) — all from source. + static readonly Regex SymbolPattern = new( + @"(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)", + RegexOptions.Compiled); + + static System.Collections.Generic.IReadOnlyList FormatMessage(string content, bool isMe) + { + var baseColor = isMe ? OnPrimary : OnSurface; + var accent = isMe ? InversePrimary : Primary; // gold: inversePrimary(me) / primary(other) + var codeBg = isMe ? Secondary : Surface; // gold: secondary(me) / surface(other) + + var runs = new System.Collections.Generic.List(); + int cursor = 0; + foreach (Match m in SymbolPattern.Matches(content)) + { + if (m.Index > cursor) + runs.Add(new TextRun(content.Substring(cursor, m.Index - cursor), Color: baseColor)); + + string tok = m.Value; + switch (tok[0]) + { + case '@': + string handle = tok.Substring(1); + runs.Add(new TextRun(tok, Color: accent, Bold: true, + OnTap: () => NavigateToProfile?.Invoke(handle))); + break; + case '`': + runs.Add(new TextRun(tok.Trim('`'), Color: baseColor, Monospace: true, + FontSize: 12, Background: codeBg)); + break; + case '*': + runs.Add(new TextRun(tok.Trim('*'), Color: baseColor, Bold: true)); + break; + case '_': + runs.Add(new TextRun(tok.Trim('_'), Color: baseColor, Italic: true)); + break; + case '~': + runs.Add(new TextRun(tok.Trim('~'), Color: baseColor, Strikethrough: true)); + break; + default: // http(s) link + runs.Add(new TextRun(tok, Color: accent, OnTap: () => OpenUrl?.Invoke(tok))); + break; + } + cursor = m.Index + m.Length; + } + if (cursor < content.Length) + runs.Add(new TextRun(content.Substring(cursor), Color: baseColor)); + return runs; + } + + // Tap hooks, wired in Build(). A @mention resolves its handle to a profile and navigates; + // a link opens the URL (the host wires a real browser-open). Both no-op until Build() runs. + internal static System.Action? NavigateToProfile; + internal static System.Action? OpenUrl; + + // The seed data's only mention is @aliconors; map the known handles to their profile names. + static string? ProfileForHandle(string handle) => handle.ToLowerInvariant() switch + { + "aliconors" => "Ali Conors", + "taylor" or "taylorbrookscodes" => "Taylor Brooks", + _ => null, + }; + + static View StickerBubble(bool isMe) => new VStack(spacing: 0f) + { + new Image(Sticker).Frame(width: 160, height: 160), + } + .Background(isMe ? Primary : SurfaceVariant) + .CornerRadius(JetchatTheme.BubbleTopStart, JetchatTheme.BubbleOther, JetchatTheme.BubbleOther, JetchatTheme.BubbleOther) + .AsSurface() + .HorizontalLayoutAlignment(LayoutAlignment.Start); + + // Row of: rule — "Today" — rule, all vertically centered (the rule aligns to the text's + // middle). Text given full height + center alignment so it isn't cropped. + static View DayHeader(string day) => new HStack(spacing: 0f) + { + HLine().FlexGrow(1).VerticalLayoutAlignment(LayoutAlignment.Center), + new Text(day).Color(OnSurfaceVariant).LabelSmall() + .VerticalLayoutAlignment(LayoutAlignment.Center).Padding(new Thickness(16, 0, 16, 0)), + HLine().FlexGrow(1).VerticalLayoutAlignment(LayoutAlignment.Center), + }.Padding(new Thickness(16, 8, 16, 8)); + + // ── Bottom input bar (same tint as the header; no top line). Row 1: a borderless, + // full-width text field bound two-way to InputText. Row 2: the blue action icons spread + // evenly to a Send button that reacts to whether there's text (UserInput.kt). ── + static View UserInput(double bottomInset, ListView log) + { + // The Send button: empty → a bordered, grey OutlinedButton (the gold's disabled Send); + // once there's text → a filled primary Button. The style is flipped reactively from the + // InputText signal (mirrors `enabled = sendMessageEnabled`). + var send = new Button("Send", () => Send(log)) + .LabelLarge().CornerRadius(18).Frame(height: 36) + .VerticalLayoutAlignment(LayoutAlignment.Center); + + void Restyle() + { + if (string.IsNullOrWhiteSpace(InputText.Peek())) + send.Outlined(true).Color(Disabled).Background(Colors.Transparent); // empty → bordered, grey + else + send.Outlined(false).Color(OnPrimary).Background(Primary); // text → filled primary + } + Restyle(); + InputText.PropertyChanged += (_, __) => Restyle(); + + // The expandable selector panel (gold UserInput.kt SelectorExpanded): one Surface that swaps + // content by the active InputSelector. Slot indices match the enum; null slots collapse it. + // EMOJI → the emoji table; MAP/PHONE/PICTURE → the 320dp "not available" pane; NONE/DM → + // nothing (DM opens the separate not-available dialog). Opening a panel grows the footer and + // shrinks the message list (the panel reports its height to the Yoga engine), exactly like + // the gold's bottom-anchored Surface; a system back press collapses it. + var panel = new SelectorPanel(CurrentSelector, new View?[] + { + null, // NONE + NotAvailablePanel(), // MAP + null, // DM (separate dialog) + EmojiPanel(), // EMOJI + NotAvailablePanel(), // PHONE + NotAvailablePanel(), // PICTURE + }).Background(JetchatTheme.SurfaceTinted8).FillHorizontal(); + + // The borderless composer field — gold UserInputText. Send-on-keyboard: the IME action key is a + // paper-plane "Send" that submits (gold KeyboardOptions(imeAction = Send)); focusing it closes + // any open selector panel (gold onTextFieldFocused) so the keyboard never overlays it. + var field = SignalExtensions.TextField(InputText, "Message #composers", completed: () => Send(log)) + .SendOnReturn() + .OnFocused(() => { CurrentSelector.Value = SelNone; log.ScrollToBottom(); }) + .Borderless().Color(OnSurface).FillHorizontal() + .VerticalLayoutAlignment(LayoutAlignment.Center) + .Padding(new Thickness(20, 0, 8, 0)); + ComposerField = field; + + // The recording indicator that replaces the field while the mic is held (gold RecordingIndicator): + // a red dot, the live mm:ss elapsed time, and a "Swipe to cancel" hint that fades as the drag + // nears the cancel threshold. Hidden (Opacity 0) until a long-press starts recording. Both + // backends honour Opacity and make a hidden node non-interactive, so the SAME swap works on iOS — + // where the record gesture is dormant (press-and-hold is a Compose-only gesture) and the + // indicator simply stays hidden over the field. + var swipeHint = new Text("Swipe to cancel").BodyLarge().Color(OnSurfaceVariant) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .VerticalLayoutAlignment(LayoutAlignment.Center); + var recordDot = new HStack().Frame(width: 10, height: 10).CornerRadius(5).Background(Colors.Red) + .VerticalLayoutAlignment(LayoutAlignment.Center); + var indicator = new HStack(spacing: 12f) + { + recordDot, + new Text(RecordDuration).BodyLarge().Color(OnSurface) + .VerticalLayoutAlignment(LayoutAlignment.Center), + swipeHint.FillHorizontal(), + }.FillHorizontal().Padding(new Thickness(20, 0, 8, 0)).Opacity(0); + + // The mic — gold RecordButton. A 48dp touch target (so the long-press lands easily) carrying the + // press-and-hold voice-record gesture: long-press starts, drag tracks the swipe offset, release + // finishes, swipe-left past the threshold cancels (gold detectDragGesturesAfterLongPress). On iOS + // the SwiftUI backend has no record gesture wired, so this is dormant there. + var micIcon = new Icon("mic").Color(OnSurfaceVariant).IconSize(24) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .VerticalLayoutAlignment(LayoutAlignment.Center); + var mic = new ZStack { micIcon } + .Frame(width: 48, height: 48).Margin(right: 8) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .OnRecord(g => HandleRecord(g, field, indicator, swipeHint, micIcon, recordDot)); + + return new VStack(spacing: 0f) + { + // Input row: a fixed 64dp row (the gold's UserInputText height). The field and the recording + // indicator share the leading cell (one fades out as the other fades in); the mic trails. + new HStack(spacing: 0f) + { + new ZStack { field, indicator }.FillHorizontal(), + mic, + }.Frame(height: 64), + // Selector row (gold UserInputSelector): five toggle icons packed at the start, a flexible + // gap, then Send. 72dp tall with 16dp bottom padding — height(72).padding(start=16, end=16, + // bottom=16). Icon→selector: mood=EMOJI, at=DM, photo=PICTURE, place=MAP, video=PHONE. + new HStack(spacing: 0f) + { + SelectorButton("mood", SelEmoji), + SelectorButton("at", SelDm), + SelectorButton("photo", SelPicture), + SelectorButton("place", SelMap), + SelectorButton("video", SelPhone), + Spacer(), + send, + }.Frame(height: 72).Padding(new Thickness(16, 0, 16, 16)), + panel, + // Safe-area fill: extends BarSurface behind the system navigation bar / home indicator. + new HStack().Frame(height: (float)bottomInset), + }.Background(BarSurface); + } + + // Append the composed text as a "me" message, clear the field, and scroll to the newest + // (the C1 LazyListState scroller). Mirrors UserInput.kt onMessageSent + resetScroll. + static void Send(ListView log) + { + var text = InputText.Peek(); + if (string.IsNullOrWhiteSpace(text)) + return; + + bool topOfGroup = Rows.Count == 0 || Rows[Rows.Count - 1] is not MsgRow last || last.M.Author != "me"; + Rows.Add(new MsgRow(new Msg("me", text.Trim(), System.DateTime.Now.ToString("h:mm tt")), topOfGroup, 8)); + + InputText.Value = string.Empty; // two-way binding clears the field + log.ReloadData(); // recompose the LazyColumn against the new row + log.ScrollToBottom(); // animate to the newest message + } + + // Drive the voice-record indicator from the press-and-hold gesture (gold RecordButton's + // onStartRecording/onFinishRecording/onCancelRecording + RecordingIndicator). The gold records + // nothing — RecordButton is a pure UI mock — so this only swaps the field for the indicator, runs + // the elapsed timer, and fades the "Swipe to cancel" hint as the drag nears the threshold. + static void HandleRecord(RecordGesture g, View field, View indicator, View swipeHint, View micIcon, View recordDot) + { + switch (g.Status) + { + case Comet.GestureStatus.Started: + swipeHint.Opacity(1); + field.Opacity(0); // fade the field out … + indicator.Opacity(1); // … and the recording indicator in + micIcon.Color(Primary); // tint the mic active (gold animates the icon colour) + // The gold's infiniteRepeatable alpha pulse on the red dot, driven by + // Comet's own animation engine (Choreographer-ticked on the node backend). + recordDot.Animate(v => v.Opacity(0.2), duration: 0.6, + repeats: true, autoReverses: true, id: "recordPulse"); + StartRecordTimer(); + break; + case Comet.GestureStatus.Running: + // Fade the hint as the leftward swipe approaches the 200dp cancel threshold (gold alpha). + double frac = System.Math.Min(1.0, System.Math.Abs(g.TotalX) / 200.0); + swipeHint.Opacity(1.0 - frac); + break; + case Comet.GestureStatus.Completed: + case Comet.GestureStatus.Canceled: + StopRecordTimer(); + recordDot.AbortAnimation("recordPulse"); + recordDot.Opacity(1); + field.Opacity(1); // restore the field + indicator.Opacity(0); + micIcon.Color(OnSurfaceVariant); + break; + } + } + + // The recording elapsed-time ticker (gold RecordingIndicator's LaunchedEffect { while(true) delay(1000) }). + static System.Threading.Timer? _recordTimer; + static int _recordSeconds; + static void StartRecordTimer() + { + _recordSeconds = 0; + RecordDuration.Value = "00:00"; + _recordTimer?.Dispose(); + _recordTimer = new System.Threading.Timer(_ => + { + _recordSeconds++; + RecordDuration.Value = $"{_recordSeconds / 60:00}:{_recordSeconds % 60:00}"; + }, null, 1000, 1000); + } + static void StopRecordTimer() + { + _recordTimer?.Dispose(); + _recordTimer = null; + } + + // A selector icon (gold InputSelectorButton): a 48dp rounded touch target. When its selector is + // active it fills with the footer's content colour (secondary) clipped to RoundedCornerShape(14) + // and the glyph flips to the contrasting onSecondary tint; otherwise a plain onSurfaceVariant + // glyph. The fill + tint flip reactively from the CurrentSelector signal (the same restyle + // pattern the Send button uses). The icon is centred in the box by a ZStack. + static View SelectorButton(string symbol, int selector) + { + var icon = new Icon(symbol).IconSize(24).Color(OnSurfaceVariant) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .VerticalLayoutAlignment(LayoutAlignment.Center); + var box = new ZStack { icon } + .Frame(width: 48, height: 48).CornerRadius(14) + .OnTap(_ => OnSelector(selector)); + + void Restyle() + { + bool sel = CurrentSelector.Peek() == selector; + box.Background(sel ? Secondary : Colors.Transparent); + icon.Color(sel ? JetchatTheme.OnSecondary : OnSurfaceVariant); + } + Restyle(); + CurrentSelector.PropertyChanged += (_, __) => Restyle(); + return box; + } + + // Tap a selector icon. EMOJI/PICTURE/MAP/PHONE open that panel; DM closes any panel and shows the + // not-available dialog (gold InputSelector.DM -> NotAvailablePopup). Matches the gold's + // non-toggling onSelectorChange — the panel's BackHandler (or picking another selector) dismisses. + static void OnSelector(int selector) + { + if (selector == SelDm) + { + CurrentSelector.Value = SelNone; + NotAvailableOpen.Value = true; + return; + } + // Tapping the already-open selector collapses it — a discoverable dismiss (the gold doesn't + // toggle, but its back-press + tap-the-field dismisses aren't obvious on a gesture-nav device). + CurrentSelector.Value = CurrentSelector.Peek() == selector ? SelNone : selector; + } + + // ── SelectorExpanded panes (gold UserInput.kt) ─────────────────────────────────────────────── + + // FunctionalityNotAvailablePanel: a 320dp pane with a vertically-centred title + subtitle, shown + // for the MAP / PHONE / PICTURE selectors. + static View NotAvailablePanel() => new VStack(spacing: 0f) + { + Spacer(), + new Text("Functionality currently not available").Color(OnSurface).TitleMedium() + .HorizontalLayoutAlignment(LayoutAlignment.Center), + new Text("Grab a beverage and check back later!").Color(OnSurfaceVariant).BodyMedium() + .HorizontalLayoutAlignment(LayoutAlignment.Center).Margin(top: 16), + Spacer(), + }.Frame(height: 320).FillHorizontal(); + + // EmojiSelector: Emojis/Stickers tabs over a 4×10 emoji table. The Emojis tab is always shown + // selected (the gold hardcodes selected=true); the Stickers tab pops the not-available dialog. + static View EmojiPanel() => new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + EmojiTab("Emojis", selected: true, onTap: null), + EmojiTab("Stickers", selected: false, onTap: () => NotAvailableOpen.Value = true), + }.Padding(new Thickness(8, 0, 8, 0)), + EmojiTable(), + }.Padding(new Thickness(8)).FillHorizontal(); + + // ExtendedSelectorInnerButton: a real Material TextButton, titleSmall, that fills onSurface@8% + // when selected (else transparent). FlexGrow gives the two tabs equal width. + static View EmojiTab(string text, bool selected, System.Action? onTap) + { + var btn = new Button(text, () => onTap?.Invoke()) + .TextButton().TitleSmall().Color(OnSurface) + .Frame(height: 36).FlexGrow(1).Margin(new Thickness(8)).CornerRadius(18); + if (selected) + btn.Background(OnSurface.WithAlpha(0.08f)); // gold: onSurface.copy(alpha = 0.08f) + return btn; + } + + // 4 rows × EMOJI_COLUMNS cells; each emoji is a centred, tappable Text (a real Compose Text + + // clickable, like the gold's EmojiTable). FlexGrow gives the columns equal width. + static View EmojiTable() + { + var col = new VStack(spacing: 0f).FillHorizontal(); + for (int x = 0; x < 4; x++) + { + var row = new HStack(spacing: 0f).FillHorizontal(); + for (int y = 0; y < EmojiColumns; y++) + { + string e = Emojis[x * EmojiColumns + y]; + row.Add(new ZStack { new Text(e).FontSize(18) } + .Frame(height: 44).FlexGrow(1).FlexBasis(0) + .OnTap(_ => AddEmoji(e))); + } + col.Add(row); + } + return col; + } + + // The gold's insert-at-cursor (UserInput.kt addText): the emoji lands at the caret, + // replacing any selection, and the caret moves past it. Falls back to appending on a + // backend without caret tracking (the field's two-way signal updates either way). + static TextField? ComposerField; + static void AddEmoji(string emoji) + { + if (ComposerField is { } f) + f.InsertAtCursor(emoji); + else + InputText.Value = InputText.Peek() + emoji; + } + + const int EmojiColumns = 10; + + // The gold's first 40 emojis (emojis[0..39] from UserInput.kt), as full code points. + static readonly string[] Emojis = + { + "\U0001F600", "\U0001F601", "\U0001F602", "\U0001F603", "\U0001F604", "\U0001F605", "\U0001F606", "\U0001F609", "\U0001F60A", "\U0001F60B", + "\U0001F60E", "\U0001F60D", "\U0001F618", "\U0001F617", "\U0001F619", "\U0001F61A", "☺", "\U0001F642", "\U0001F917", "\U0001F607", + "\U0001F913", "\U0001F914", "\U0001F610", "\U0001F611", "\U0001F636", "\U0001F644", "\U0001F60F", "\U0001F623", "\U0001F625", "\U0001F62E", + "\U0001F910", "\U0001F62F", "\U0001F62A", "\U0001F62B", "\U0001F634", "\U0001F60C", "\U0001F61B", "\U0001F61C", "\U0001F61D", "\U0001F612", + }; + + // ── NotAvailablePopup (UiExtras.kt FunctionalityNotAvailablePopup): a real Material + // AlertDialog — bodyMedium text + a "CLOSE" confirm button — shown when the DM selector is + // picked. The CLOSE button and the scrim/back both clear the open signal. ── + static View NotAvailablePopup() => new AlertDialog( + NotAvailableOpen, + text: new Text("Functionality not available \U0001F648").Color(OnSurface).BodyMedium(), + confirmButton: new Button("CLOSE", () => NotAvailableOpen.Value = false).TextButton().Color(Primary).LabelLarge()); + + static View Spacer() => new HStack().FlexGrow(1); + + // A 1dp hairline in the divider color (used for the day header rule). + static View HLine() => new HStack().Frame(height: 1).Background(Divider); + } +} diff --git a/src/Comet/sample/Shared/Jetchat/JetchatDrawer.cs b/src/Comet/sample/Shared/Jetchat/JetchatDrawer.cs new file mode 100644 index 000000000..48abb6f10 --- /dev/null +++ b/src/Comet/sample/Shared/Jetchat/JetchatDrawer.cs @@ -0,0 +1,138 @@ +#nullable enable +using Comet; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using C = CometSamples.Jetchat.JetchatConversation; + +namespace CometSamples.Jetchat +{ + /// + /// The Jetchat navigation-drawer panel (the C# port of JetchatDrawer.kt): the jetchat + /// logo header, a "Chats" section (each chat a 56dp pill with the leading logo + a + /// primaryContainer highlight when selected), a "Recent Profiles" section with avatars + /// ("Ali Conors (you)"), and a "Settings" section. Rendered inside the real + /// (Compose ModalNavigationDrawer / SwiftUI sliding panel). + /// + static class JetchatDrawer + { + public static View Content(double topInset, Comet.Reactive.Signal selected, + System.Action onChannel, System.Action onProfile, System.Action onSettings) => new VStack(spacing: 0f) + { + // DrawerHeader (JetchatDrawer.kt): Row(padding 16){ JetchatIcon(24dp) + jetchat_logo wordmark }. + // Android renders the gold two-layer tinted mark (ic_jetchat_back/front) + the jetchat_logo + // wordmark image (87x24). iOS's bundle has only jetchat.png (the square mark) — the back/front + // glyphs and the wordmark vector aren't there — so it shows the working jetchat mark + a text + // wordmark. (Drop in iOS jetchat_back/front/jetchat_logo PNGs to make both platforms identical.) + new HStack(spacing: 0f) + { + System.OperatingSystem.IsAndroid() + ? (View)C.JetchatIcon(24).VerticalLayoutAlignment(LayoutAlignment.Center) + : new Icon("jetchat").IconSize(24).VerticalLayoutAlignment(LayoutAlignment.Center), + System.OperatingSystem.IsAndroid() + ? (View)new Image("jetchat_logo").Frame(width: 87, height: 24).FlexShrink(0) + .Margin(left: 8).VerticalLayoutAlignment(LayoutAlignment.Center) + : new Text("jetchat").HeadlineSmall().Color(C.OnSurface) + .Margin(left: 8).VerticalLayoutAlignment(LayoutAlignment.Center), + }.Padding(new Thickness(16, topInset + 16, 16, 16)), + + // Edge-to-edge divider under the header (the gold's first DividerItem() has no inset). + FullDivider(), + + // DrawerItemHeader("Chats") + 56dp ChatItem pills (leading logo + label, no '#'). Tapping + // a channel returns to the messages view; the current destination is highlighted. + SectionTitle("Chats"), + ChatItem("composers", selected, onChannel), + ChatItem("droidcon-nyc", selected, onChannel), + + InsetDivider(), + SectionTitle("Recent Profiles"), + ProfileItem("Ali Conors (you)", "Ali Conors", C.AvatarMe, selected, onProfile), + ProfileItem("Taylor Brooks", "Taylor Brooks", C.AvatarOther, selected, onProfile), + + InsetDivider(), + SectionTitle("Settings"), + SettingItem("Add Widget to Home Page", onSettings), + }.Background(C.Surface); + + // DrawerItemHeader: a 52dp row, text vertically centred, bodySmall / onSurfaceVariant, start + // 28dp — so the header text left-aligns with the menu rows' icon column (12 row + 16 icon = 28). + static View SectionTitle(string text) => + new HStack(spacing: 0f) + { + new Text(text).Color(C.OnSurfaceVariant).BodySmall() + .VerticalLayoutAlignment(LayoutAlignment.Center), + }.Frame(height: 52).Padding(new Thickness(28, 0, 16, 0)); + + // ChatItem: a 56dp stadium pill — leading jetchat logo + label (bodyMedium). The pill, logo + // tint, and label colour track the destination signal reactively + // (the selected one fills with the dynamic primaryContainer and tints its content primary); + // tapping it routes back to the messages view via . + static View ChatItem(string name, Comet.Reactive.Signal selected, System.Action onChannel) + { + var icon = new Icon("jetchat").IconSize(24).FlexShrink(0) + .Margin(left: 16, top: 16, bottom: 16).VerticalLayoutAlignment(LayoutAlignment.Center); + var label = new Text(name).BodyMedium() + .VerticalLayoutAlignment(LayoutAlignment.Center).Margin(left: 12); + var pill = new HStack(spacing: 0f) { icon, label } + .Frame(height: 56).CornerRadius(28).Margin(new Thickness(12, 0, 12, 0)) + .OnTap(_ => onChannel(name)); + + void Apply() + { + bool sel = selected.Peek() == name; + pill.Background(sel ? JetchatTheme.PrimaryContainer : Colors.Transparent); + icon.Color(sel ? C.Primary : C.OnSurfaceVariant); + label.Color(sel ? C.Primary : C.OnSurface); + } + Apply(); + selected.PropertyChanged += (_, __) => Apply(); + return pill; + } + + // ProfileItem: 56dp pill — 24dp avatar + display name (bodyMedium). The display text may carry + // a "(you)" suffix while the tap navigates by the bare profile name; the pill highlights when + // its profile is the current destination. + static View ProfileItem(string display, string profileName, string avatar, + Comet.Reactive.Signal selected, System.Action onProfile) + { + var label = new Text(display).BodyMedium() + .VerticalLayoutAlignment(LayoutAlignment.Center).Margin(left: 12); + var pill = new HStack(spacing: 0f) + { + new Image(avatar).Frame(width: 24, height: 24).CornerRadius(12).FlexShrink(0) + .Margin(left: 16, top: 16, bottom: 16).VerticalLayoutAlignment(LayoutAlignment.Center), + label, + } + .Frame(height: 56).CornerRadius(28).Margin(new Thickness(12, 0, 12, 0)) + .OnTap(_ => onProfile(profileName)); + + void Apply() + { + bool sel = selected.Peek() == profileName; + pill.Background(sel ? JetchatTheme.PrimaryContainer : Colors.Transparent); + label.Color(sel ? C.Primary : C.OnSurface); + } + Apply(); + selected.PropertyChanged += (_, __) => Apply(); + return pill; + } + + // WidgetDiscoverability: a 56dp clickable pill (no icon), bodyMedium label at start 12 — same + // row shape as the chat/profile items. Tapping invokes the supplied action (the gold adds a + // home-screen widget; on iOS that surfaces the "not available" popup). + static View SettingItem(string text, System.Action onTap) => + new HStack(spacing: 0f) + { + new Text(text).Color(C.OnSurface).BodyMedium() + .VerticalLayoutAlignment(LayoutAlignment.Center).Margin(left: 12), + } + .Frame(height: 56).CornerRadius(28).Margin(new Thickness(12, 0, 12, 0)) + .OnTap(_ => onTap()); + + // DividerItem (onSurface @ 12%): edge-to-edge under the header, inset 28dp between sections. + static View FullDivider() => new HStack().FillHorizontal().Frame(height: 1).Background(C.Divider); + static View InsetDivider() => new HStack().FillHorizontal().Frame(height: 1).Background(C.Divider) + .Margin(new Thickness(28, 0, 28, 0)); + } +} diff --git a/src/Comet/sample/Shared/Jetchat/JetchatIcons.cs b/src/Comet/sample/Shared/Jetchat/JetchatIcons.cs new file mode 100644 index 000000000..98a95aec4 --- /dev/null +++ b/src/Comet/sample/Shared/Jetchat/JetchatIcons.cs @@ -0,0 +1,41 @@ +#nullable enable +using System.Collections.Generic; +using Comet; + +namespace CometSamples.Jetchat +{ + /// + /// Registers Google's Material Icons font as Comet's icon font, so every Icon("name") in + /// the shared tree renders the SAME Material glyph on iOS and Android (the cross-platform Material + /// design choice — one font, one glyph set, fully tintable). The font file itself is loaded per + /// platform first (Android ComposeFontRegistry; iOS UIAppFonts) under this family. + /// The "jetchat" brand logo is intentionally NOT mapped — it falls back to its bundled asset. + /// + static class JetchatIcons + { + public const string Font = "Material Icons"; + + // Cross-platform name → Material Icons codepoint. Names match the gold's ic_* drawables: + // @ = alternate_email, photo = insert_photo, video = duo, arrow_down = arrow_downward. + public static void Register() + { + var map = new Dictionary(); + void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint); + + Add("search", 0xE8B6); + Add("info", 0xE88E); + Add("mood", 0xE7F2); + Add("at", 0xE0E6); // alternate_email + Add("photo", 0xE251); // insert_photo + Add("place", 0xE55F); + Add("video", 0xE9A5); // duo + Add("mic", 0xE029); + Add("arrow_down", 0xE5DB); // arrow_downward + Add("create", 0xE150); + Add("chat", 0xE0B7); + Add("send", 0xE163); + + IconFont.Register(Font, map); + } + } +} diff --git a/src/Comet/sample/Shared/Jetchat/JetchatProfile.cs b/src/Comet/sample/Shared/Jetchat/JetchatProfile.cs new file mode 100644 index 000000000..a428e40a2 --- /dev/null +++ b/src/Comet/sample/Shared/Jetchat/JetchatProfile.cs @@ -0,0 +1,142 @@ +#nullable enable +using System; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.Jetchat.JetchatTheme; +using C = CometSamples.Jetchat.JetchatConversation; + +namespace CometSamples.Jetchat +{ + /// + /// The Jetchat profile detail screen — the C# port of profile/Profile.kt: a circular + /// avatar, the name (headlineSmall) + position (bodyLarge / onSurfaceVariant), a column of + /// ProfileProperty rows (each preceded by a divider; the Twitter value is a primary-colored + /// link), and a bottom-right tertiaryContainer FAB ("Message" for others, "Edit Profile" for me). + /// Pushed onto the stack by a drawer profile tap; the FAB / back arrow pop. + /// + static class JetchatProfile + { + // ProfileScreenState (data/FakeData.kt): the "me" profile and the single colleague profile. + // PhotoAspect = the photo's width/height — the gold sizes the header image fillMaxWidth and lets + // CircleShape clip it, so a square photo (ali 432x431) renders a CIRCLE and a landscape photo + // (someone_else 640x427) a wide oval. Drives the box shape via AspectRatio (no intrinsic image size). + sealed record Data(string Photo, double PhotoAspect, string Name, string Status, string DisplayName, + string Position, string Twitter, string TimeZone, bool IsMe); + + static Data For(string name) => name == "Ali Conors" + ? new Data(C.AvatarMe, 432.0 / 431.0, "Ali Conors", "Online", "aliconors", + "Senior Android Dev at Yearin\nGoogle Developer Expert", "twitter.com/aliconors", "In your timezone", true) + : new Data(C.AvatarOther, 640.0 / 427.0, "Taylor Brooks", "Away", "taylor", + "Senior Android Dev at Openlane", "twitter.com/taylorbrookscodes", + "12:25 AM local time (Eastern Daylight Time)", false); + + public static View Screen(string name, double topInset, double bottomInset, Action onBack) + { + var d = For(name); + + // The gold standard is a Box: a full-bleed scrolling Column with a FloatingActionButton + // aligned BottomEnd, floating over the content. A ZStack reproduces that — the scroll is + // the base layer (fills), the FAB the overlay (pinned bottom-right, overlapping the scroll). + // The scroll's AtTop signal drives the FAB: extended at top (scrollState.value == 0), + // contracted when scrolled away — matching the gold's derivedStateOf { scrollState.value == 0 }. + // Profile photo (ProfileHeader.kt): fillMaxWidth − 16dp each side, height from the photo's + // aspect (AspectRatio), ContentScale.Crop, clipped CircleShape. A square photo (ali) → a + // CIRCLE; a landscape photo (taylor) → a wide oval. Extracted so the parallax can translate it. + var photo = new Image(d.Photo).FillHorizontal().AspectRatio(d.PhotoAspect).FlexShrink(0) + .CornerRadius(1000).Margin(left: 16, top: 8, right: 16, bottom: 8); + + var scroll = new ScrollView + { + new VStack(spacing: 0f) + { + photo, + + // Name + position (NameAndPosition): each text baseline-anchored — the name's + // first baseline 32dp from its top, the position's 24dp, +20dp below. + new VStack(spacing: 0f) + { + new Text(d.Name).Color(T.OnSurface).HeadlineSmall().BaselineHeight(32), + new Text(d.Position).Color(T.OnSurfaceVariant).BodyLarge() + .BaselineHeight(24).Margin(bottom: 20), + }.Padding(new Thickness(16, 8, 16, 0)), + + // Property rows, each preceded by a divider; Twitter is a primary-colored link. + Property("Display name", d.DisplayName, isLink: false), + Property("Status", d.Status, isLink: false), + Property("Twitter", d.Twitter, isLink: true), + Property("Timezone", d.TimeZone, isLink: false), + + // Trailing scroll room — the gold ProfileHeader pads a containerHeight−320 spacer so + // the column always overflows (for the parallax + the FAB contract). Without it the + // profile content fits the screen, the ScrollView never scrolls, AtTop never flips, + // and the FAB can't contract. This guarantees overflow so the contract triggers. + new HStack().Frame(height: 240).FlexShrink(0), + }, + }.FillVertical(); + + // NOTE: the gold ProfileHeader parallaxes the photo via padding(top = scrollState.value / 2), + // which GROWS the photo's slot so the content below stays clear. The infrastructure for it is + // in place — ScrollView.ScrollOffset (a continuous Dp signal the scroll node marshals) plus + // general TranslationY transform support on the nodes — but a faithful (no-overlap) parallax + // needs the layout-grow approach (a per-frame reflow), deferred to avoid scroll jank on the + // profile's baseline-measured text. A pure TranslationY transform makes the photo overlap the + // rows below, so it's not wired here. + + // FAB is extended while at top, contracted when scrolled (gold: derivedStateOf { scrollState.value == 0 }). + var fab = ProfileFab(d.IsMe ? "Edit Profile" : "Message", d.IsMe ? "create" : "chat", onBack) + .ExtendedWhen(scroll.AtTop); + + return new ZStack + { + new VStack(spacing: 0f) + { + // App-bar nav icon = the jetchat logo (JetchatAppBar.kt), which opens the drawer — + // NOT a back button. The drawer wraps the whole nav stack, so it slides over the + // profile just like in the original app. + new HStack(spacing: 0f) + { + new Icon("jetchat").IconSize(32) + .Margin(left: 16, top: (float)(topInset + 12)) + .OnTap(_ => C.DrawerOpen.Value = true), + new HStack().FlexGrow(1), + }.Background(T.Surface).Padding(new Thickness(0, 0, 0, 8)), + + scroll, + }.FillHorizontal().FillVertical().Background(T.Surface), + + // The real Material ExtendedFloatingActionButton, floating bottom-right over the scroll. + fab + .HorizontalLayoutAlignment(LayoutAlignment.End) + .VerticalLayoutAlignment(LayoutAlignment.End) + .Margin(right: 16, bottom: (float)(24 + bottomInset)), + }; + } + + // ProfileProperty: a divider, then the label and value, each baseline-anchored 24dp from the + // top of its box (Jetchat's baselineHeight). Column padding: 16 start/end, 16 bottom. + static View Property(string label, string value, bool isLink) => new VStack(spacing: 0f) + { + Divider(), + new Text(label).Color(T.OnSurfaceVariant).BodySmall().BaselineHeight(24), + new Text(value).Color(isLink ? T.Primary : T.OnSurface).BodyLarge().BaselineHeight(24), + }.Padding(new Thickness(16, 0, 16, 16)); + + static View Divider() => new HStack().Frame(height: 1).Background(T.Divider); + + // The real Material 3 FloatingActionButton (Profile.kt ProfileFab): tertiaryContainer + // container, an icon + label, height 48. The icon/label carry no colour so they inherit the + // FAB's onTertiaryContainer content colour. Starts extended (at-top); ExtendedWhen(scroll.AtTop) + // is wired by the caller to contract when scrolled — matching AnimatingFabContent in the gold. + static Comet.Fab ProfileFab(string text, string icon, Action onTap) => new Comet.Fab( + icon: new Icon(icon).IconSize(24).Color(T.OnTertiaryContainer), + label: new Text(text).LabelLarge().Color(T.OnTertiaryContainer), + onClick: onTap, + height: 48, + containerColor: T.TertiaryContainer, + contentColor: T.OnTertiaryContainer, + extended: true); + } +} diff --git a/src/Comet/sample/Shared/Jetchat/JetchatTheme.cs b/src/Comet/sample/Shared/Jetchat/JetchatTheme.cs new file mode 100644 index 000000000..f358ab778 --- /dev/null +++ b/src/Comet/sample/Shared/Jetchat/JetchatTheme.cs @@ -0,0 +1,161 @@ +#nullable enable +using Comet; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; + +namespace CometSamples.Jetchat +{ + /// + /// The Jetchat design tokens, mirroring the sample's theme/ package + /// (Color.kt / Themes.kt / Typography.kt) as a single source of truth: the raw palette, + /// the semantic color roles (the light ColorScheme), the type scale, and the shapes. + /// Components read these instead of hardcoding values inline — the C# analog of + /// MaterialTheme.colorScheme / typography / shapes. + /// + static class JetchatTheme + { + // ── Color.kt: raw palette ── + static readonly Color Blue40 = Color.FromArgb("#1546F6"); + static readonly Color Blue80 = Color.FromArgb("#B8C3FF"); + static readonly Color Blue90 = Color.FromArgb("#DDE1FF"); + static readonly Color DarkBlue40 = Color.FromArgb("#3648EA"); + static readonly Color Yellow10 = Color.FromArgb("#261900"); + static readonly Color Yellow40 = Color.FromArgb("#7A5900"); + static readonly Color Yellow90 = Color.FromArgb("#FFDE9C"); + static readonly Color Grey10 = Color.FromArgb("#191C1D"); + static readonly Color Grey99 = Color.FromArgb("#FBFDFD"); + static readonly Color BlueGrey30 = Color.FromArgb("#45464F"); + static readonly Color BlueGrey50 = Color.FromArgb("#767680"); + static readonly Color BlueGrey90 = Color.FromArgb("#E2E1EC"); + + // ── Themes.kt: semantic roles. Settable so ApplyScheme can swap in the platform's + // Material You (dynamicLightColorScheme) at runtime; the values below are the static + // JetchatLightColorScheme fallback (used pre-Android-12 / when dynamic is off). Consumers + // must read these live (not cache in a static field) so the dynamic swap reaches them. ── + public static Color Primary { get; private set; } = Blue40; + public static Color OnPrimary { get; private set; } = Colors.White; + public static Color PrimaryContainer { get; private set; } = Blue90; + // inversePrimary (Themes.kt JetchatLightColorScheme): the accent for @mentions/links rendered + // ON a primary (me) bubble, where plain primary would lack contrast. Light = Blue80. + public static Color InversePrimary { get; private set; } = Blue80; + public static Color Secondary { get; private set; } = DarkBlue40; // footer Surface contentColor + // onSecondary: the contrasting tint for a selected input-selector icon (gold UserInput.kt + // contentColorFor(LocalContentColor=secondary)). Light scheme: white on the dark-blue secondary. + public static Color OnSecondary { get; private set; } = Colors.White; + public static Color Surface { get; private set; } = Grey99; + public static Color OnSurface { get; private set; } = Grey10; + public static Color SurfaceVariant { get; private set; } = BlueGrey90; + public static Color OnSurfaceVariant { get; private set; } = BlueGrey30; + public static Color Background { get; private set; } = Grey99; + public static Color Tertiary { get; private set; } = Yellow40; + public static Color Outline { get; private set; } = BlueGrey50; + // The profile FAB roles (tertiaryContainer / onTertiaryContainer) — Themes.kt JetchatLightColorScheme. + public static Color TertiaryContainer { get; private set; } = Yellow90; // #FFDE9C (was sampled pink #F8D8F0) + public static Color OnTertiaryContainer { get; private set; } = Yellow10; // #261900 + + // The footer bar = Surface(tonalElevation = 2.dp): primary composited over surface at the M3 + // 2dp overlay alpha (≈0.0694). The HEADER (CenterAlignedTopAppBar) is plain Surface. + public static Color SurfaceTinted { get; private set; } = SurfaceAtElevation(Blue40, Grey99, 2); + // The expandable input-selector panel = Surface(tonalElevation = 8.dp): primary over surface at + // the M3 8dp overlay alpha (≈0.1189) — a touch more tinted than the 2dp footer bar. + public static Color SurfaceTinted8 { get; private set; } = SurfaceAtElevation(Blue40, Grey99, 8); + public static Color Divider { get; private set; } = Grey10.WithAlpha(0.12f); // onSurface @ 12% + public static readonly Color Disabled = Color.FromArgb("#C4C6D0"); + + /// The Jetchat brand seed (Blue40). Both platforms generate their Material 3 scheme + /// from THIS color via , so the look is theme-driven and identical + /// cross-platform (the gold seeds from the device wallpaper, which no iOS app can read). + public static readonly Color SeedColor = Color.FromArgb("#1546F6"); + + /// Generate a full Material 3 tonal scheme from a single using + /// Google's material-color-utilities algorithm (HCT → tonal palettes → roles — the SAME math + /// Android's Material You uses) and apply it. The cross-platform "dynamic color" path: seed from + /// the brand color (consistent) or from content (a photo). Call BEFORE building the view tree. + /// Returns the role colors so a platform can also feed them to its native theme (Compose MaterialTheme). + public static MaterialColorUtilities.Schemes.Scheme ApplyDynamicScheme(Color seed, bool dark = false) + { + var core = MaterialColorUtilities.Palettes.CorePalette.Of(ToArgbUint(seed)); + MaterialColorUtilities.Schemes.Scheme s = dark + ? new MaterialColorUtilities.Schemes.DarkSchemeMapper().Map(core) + : new MaterialColorUtilities.Schemes.LightSchemeMapper().Map(core); + ApplyScheme( + primary: FromArgbUint(s.Primary), onPrimary: FromArgbUint(s.OnPrimary), + primaryContainer: FromArgbUint(s.PrimaryContainer), secondary: FromArgbUint(s.Secondary), + surface: FromArgbUint(s.Surface), onSurface: FromArgbUint(s.OnSurface), + surfaceVariant: FromArgbUint(s.SurfaceVariant), onSurfaceVariant: FromArgbUint(s.OnSurfaceVariant), + background: FromArgbUint(s.Background), tertiary: FromArgbUint(s.Tertiary), + tertiaryContainer: FromArgbUint(s.TertiaryContainer), onTertiaryContainer: FromArgbUint(s.OnTertiaryContainer), + outline: FromArgbUint(s.Outline)); + InversePrimary = FromArgbUint(s.InversePrimary); + OnSecondary = FromArgbUint(s.OnSecondary); + return s; + } + + /// Content-based theming: extract a Material You seed from image pixels (AARRGGBB) via + /// material-color-utilities' Quantize + Score, then apply the generated scheme — e.g. theme the + /// whole app from a profile photo, the same content-based mode Material You offers. Falls back to + /// the brand when no usable seed is found. + public static MaterialColorUtilities.Schemes.Scheme ApplyDynamicSchemeFromPixels(uint[] argbPixels, bool dark = false) + { + var seeds = MaterialColorUtilities.Utils.ImageUtils.ColorsFromImage(argbPixels); + return ApplyDynamicScheme(seeds.Count > 0 ? FromArgbUint(seeds[0]) : SeedColor, dark); + } + + // material-color-utilities works in AARRGGBB uints; convert to/from Maui colors. + static uint ToArgbUint(Color c) => + ((uint)System.Math.Round(c.Alpha * 255) << 24) | ((uint)System.Math.Round(c.Red * 255) << 16) | + ((uint)System.Math.Round(c.Green * 255) << 8) | (uint)System.Math.Round(c.Blue * 255); + + public static Color FromArgbUint(uint argb) => new Color( + ((argb >> 16) & 0xFF) / 255f, ((argb >> 8) & 0xFF) / 255f, (argb & 0xFF) / 255f, ((argb >> 24) & 0xFF) / 255f); + + /// Swap the semantic roles to a platform ColorScheme (Material You) at startup, + /// mirroring JetchatTheme(isDynamicColor = true) in Themes.kt. Call BEFORE building the + /// view tree. Derived roles (the tonal footer + the 12% divider) are recomputed from the new + /// surface/primary/onSurface. + public static void ApplyScheme(Color primary, Color onPrimary, Color primaryContainer, Color secondary, + Color surface, Color onSurface, Color surfaceVariant, Color onSurfaceVariant, Color background, + Color tertiary, Color tertiaryContainer, Color onTertiaryContainer, Color outline) + { + Primary = primary; OnPrimary = onPrimary; PrimaryContainer = primaryContainer; Secondary = secondary; + Surface = surface; OnSurface = onSurface; SurfaceVariant = surfaceVariant; OnSurfaceVariant = onSurfaceVariant; + Background = background; Tertiary = tertiary; TertiaryContainer = tertiaryContainer; + OnTertiaryContainer = onTertiaryContainer; Outline = outline; + SurfaceTinted = SurfaceAtElevation(primary, surface, 2); + SurfaceTinted8 = SurfaceAtElevation(primary, surface, 8); + Divider = onSurface.WithAlpha(0.12f); + } + + // Material 3 surfaceColorAtElevation: primary composited over surface at alpha = (4.5·ln(dp+1)+2)/100. + static Color SurfaceAtElevation(Color primary, Color surface, double dp) + { + double a = (4.5 * System.Math.Log(dp + 1) + 2) / 100.0; + return new Color( + (float)(primary.Red * a + surface.Red * (1 - a)), + (float)(primary.Green * a + surface.Green * (1 - a)), + (float)(primary.Blue * a + surface.Blue * (1 - a))); + } + + // ── Typography.kt: Jetchat uses Montserrat (titles/labels) + Karla (body), NOT Roboto. + // Each style carries the EXACT size + lineHeight from Typography.kt (not a 1.3× heuristic). ── + const string Montserrat = "Montserrat"; + const string Karla = "Karla"; + + public static T HeadlineSmall(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 24, 32); + public static T TitleLarge(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 22, 28); + public static T TitleMedium(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 16, 24); + public static T TitleSmall(this T v) where T : View => v.Type(Montserrat, FontWeight.Medium, 14, 20); + public static T BodyLarge(this T v) where T : View => v.Type(Karla, FontWeight.Regular, 16, 24); + public static T BodyMedium(this T v) where T : View => v.Type(Montserrat, FontWeight.Medium, 14, 20); + public static T BodySmall(this T v) where T : View => v.Type(Karla, FontWeight.Bold, 12, 16); + public static T LabelLarge(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 14, 20); + public static T LabelSmall(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 11, 16); + + static T Type(this T v, string family, FontWeight weight, double size, double lineHeight) where T : View => + v.FontFamily(family).FontWeight(weight).FontSize(size).LineHeight(lineHeight); + + // ── Shapes ── + // The chat bubble (RoundedCornerShape(4,20,20,20)); apply with .CornerRadius(BubbleTL,…). + public const double BubbleTopStart = 4, BubbleOther = 20; + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackCart.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackCart.cs new file mode 100644 index 000000000..315ea0b15 --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackCart.cs @@ -0,0 +1,211 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.Jetsnack.JetsnackTheme; + +namespace CometSamples.Jetsnack +{ + /// + /// My Cart (ui/home/cart/Cart.kt): "Order (N items)" header, cart rows (100dp circle + /// image | name/tagline + price | X remove + Qty stepper), the Summary block + /// (Subtotal / Shipping & Handling / Total), "Inspired by your cart" collection, and + /// the pinned gradient Checkout bar. Swipe-to-dismiss is a documented deviation + /// (rows remove via the X, the gold's accessibility path). + /// + public static class JetsnackCart + { + sealed class Line + { + public Line(OrderLine source) { Snack = source.Snack; Count = source.Count; } + public Snack Snack { get; } + public int Count { get; set; } + } + + static readonly List Lines = SnackRepo.GetCart().Select(l => new Line(l)).ToList(); + static ListView? _list; + + const long ShippingCosts = 369; + + static IReadOnlyList Rows() + { + var rows = new List { "header" }; + rows.AddRange(Lines); + rows.Add("summary"); + rows.Add("inspired"); + rows.Add("spacer"); + return rows; + } + + public static View Screen(double topInset) + { + var list = new ListView(Rows) + { + ViewFor = r => r switch + { + "header" => Header(), + Line line => CartRow(line), + "summary" => Summary(), + "inspired" => JetsnackHome.RelatedCollection(SnackRepo.GetInspiredByCart()), + _ => new HStack().Frame(height: 64), + }, + }; + _list = list; + + return new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)topInset).FlexShrink(0), + JetsnackHome.DestinationBar().FlexShrink(0), + new ZStack + { + list.HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + CheckoutBar().FlexShrink(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + }.FlexGrow(1).FlexBasis(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.UiBackground); + } + + static View Header() => + new Text(() => $"Order ({Lines.Sum(l => l.Count)} items)") + .FontFamily("Montserrat").FontWeight(FontWeight.Semibold).FontSize(20).Color(T.Brand) + .Padding(new Thickness(24, 16, 24, 4)); + + // Cart.kt CartItem: image 100 (pad v16) | name titleMedium + tagline help + price | + // X remove top-right; Qty stepper at the row's end. + static View CartRow(Line line) => new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + new Image(line.Snack.ImageRes).Frame(width: 100, height: 100).CornerRadius(50) + .Margin(new Thickness(0, 16, 0, 16)) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0), + new VStack(spacing: 0f) + { + T.TitleMedium(line.Snack.Name).Color(T.TextSecondary), + // Gold CartItem: tagline (bodyLarge Karla, textHelp) sits between the + // name and the price row, spacer 8 above the price. + T.BodyLarge(line.Snack.Tagline.Length > 0 ? line.Snack.Tagline : "A tag line") + .Color(T.TextHelp).Padding(new Thickness(0, 4, 0, 0)), + new HStack().Frame(height: 8), + new HStack(spacing: 0f) + { + T.TitleMedium(Jetsnack.FormatPrice(line.Snack.Price)).Color(T.TextPrimary) + .VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + QuantityStepper(line), + }.HorizontalLayoutAlignment(LayoutAlignment.Fill), + }.Padding(new Thickness(16, 16, 0, 16)).FlexGrow(1).FlexBasis(0), + new Icon("close").IconSize(18).Color(T.IconSecondary) + .Frame(width: 40, height: 40).Padding(new Thickness(11)) + .FlexShrink(0) + .OnTap(_ => { Lines.Remove(line); _list?.ReloadData(); }), + }.Padding(new Thickness(24, 0, 12, 0)), + JetsnackHome.Divider(1).Margin(new Thickness(24, 0, 24, 0)), + }; + + static View QuantityStepper(Line line) => new HStack(spacing: 4f) + { + new Text("Qty").FontFamily("Montserrat").FontSize(16).Color(T.TextSecondary) + .Padding(new Thickness(0, 0, 14, 0)) + .VerticalLayoutAlignment(LayoutAlignment.Center), + StepperCircle("remove", () => { if (line.Count > 1) { line.Count--; _list?.ReloadData(); } }), + new Text(line.Count.ToString()).FontFamily("Karla").FontWeight(FontWeight.Bold).FontSize(18) + .Color(T.TextPrimary) + .Frame(width: 24) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .VerticalLayoutAlignment(LayoutAlignment.Center), + StepperCircle("add", () => { line.Count++; _list?.ReloadData(); }), + }.FlexShrink(0); + + /// JetsnackGradientTintedIconButton: a circle with the interactiveSecondary + /// DIAGONAL-GRADIENT border over uiBackground (per-glyph gradient tint approximated + /// with the ramp's Shadow3 — backlogged). + internal static View StepperCircle(string icon, System.Action onTap) => + new Icon(icon).IconSize(16).Color(T.Shadow3) + .Frame(width: 28, height: 28).Padding(new Thickness(6)) + .BorderGradient(T.Gradient2_2).CornerRadius(14) + .Background(T.UiBackground) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .OnTap(_ => onTap()); + + // Cart.kt SummaryItem. + static View Summary() + { + long subtotal = Lines.Sum(l => l.Snack.Price * l.Count); + // Gold SummaryItem: label + amount both bodyLarge (Karla) in the DEFAULT dark + // content color; only the Total's AMOUNT steps up to titleMedium. + // The Total row's label hugs the amount (gold: weight(1f) + + // wrapContentWidth(End) + padding end 16); the others sit at the start. + View Row(string label, long amount, bool totalRow = false) + { + var amountText = (totalRow + ? T.TitleMedium(Jetsnack.FormatPrice(amount)) + : T.BodyLarge(Jetsnack.FormatPrice(amount))).Color(T.TextSecondary) + .FlexShrink(0); + var row = totalRow + ? new HStack(spacing: 0f) + { + new HStack().FlexGrow(1), + T.BodyLarge(label).Color(T.TextSecondary) + .Padding(new Thickness(0, 0, 16, 0)).FlexShrink(0), + amountText, + } + : new HStack(spacing: 0f) + { + T.BodyLarge(label).Color(T.TextSecondary) + .FlexGrow(1).FlexBasis(0), + amountText, + }; + return row.Padding(new Thickness(24, 6, 24, 6)).HorizontalLayoutAlignment(LayoutAlignment.Fill); + } + + return new VStack(spacing: 0f) + { + T.TitleLarge("Summary").Color(T.Brand) + .Padding(new Thickness(24, 16, 24, 8)), + Row("Subtotal", subtotal), + Row("Shipping & Handling", ShippingCosts), + new HStack().Frame(height: 8), + JetsnackHome.Divider(1).Margin(new Thickness(24, 0, 24, 0)), + new HStack().Frame(height: 8), + Row("Total", subtotal + ShippingCosts, totalRow: true), + new HStack().Frame(height: 8), + JetsnackHome.Divider(1), + }; + } + + // Cart.kt CheckoutBar: divider + gradient full-width Checkout button. + static View CheckoutBar() => new VStack(spacing: 0f) + { + JetsnackHome.Divider(1), + new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + new HStack().FlexGrow(1), + T.LabelLarge("Checkout") + .Color(T.TextInteractive).MaxLines(1) + .VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .Frame(height: 36) + .BackgroundGradient(T.Tornado1).CornerRadius(18) + .Margin(new Thickness(24, 10, 24, 10)) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + }, + }.Background(T.UiBackground.WithAlpha(0.95f)); + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackDetail.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackDetail.cs new file mode 100644 index 000000000..d40dafa6f --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackDetail.cs @@ -0,0 +1,217 @@ +#nullable enable +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.Jetsnack.JetsnackTheme; + +namespace CometSamples.Jetsnack +{ + /// + /// Snack detail (ui/snackdetail/SnackDetail.kt), values-from-source: gradient header + /// (280dp), 300dp circular image overlapping into the title block, italic + /// name/tagline + brand price, Details + Ingredients sections, related collections, + /// and the pinned cart bar (Qty stepper + gradient ADD TO CART). STATIC layout — + /// the gold's scroll parallax (Title/Image offset math) is a documented deviation. + /// + public static class JetsnackDetail + { + const float BottomBarHeight = 56f; + + static readonly Signal Count = new(1); + + static ListView? _list; + + public static View Screen(Signal snackId, double topInset, System.Action onBack) + { + JetsnackIcons.Register(); + + var list = new ListView(() => new object[] { snackId.Peek() }) + { + ViewFor = _ => Body(SnackRepo.GetSnack(snackId.Peek()), topInset), + }; + _list = list; + if (!_hooked) + { + _hooked = true; + snackId.PropertyChanged += (_, _) => { Count.Value = 1; _seeMore = false; _list?.ReloadData(); }; + Count.PropertyChanged += (_, _) => _list?.ReloadData(); + } + + return new ZStack + { + new VStack(spacing: 0f) + { + list.FlexGrow(1).FlexBasis(0), + CartBar().FlexShrink(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + // Up button floats over the header (SnackDetail.kt Up: 36dp circle, + // pad 16/10 below the status bar). + new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)(topInset + 10)).FlexShrink(0), + new HStack(spacing: 0f) + { + new Icon("arrow_back").IconSize(20).Color(T.IconInteractive) + .Frame(width: 36, height: 36).Padding(new Thickness(8)) + .Background(T.Neutral8.WithAlpha(0.32f)).CornerRadius(18) + .Margin(new Thickness(16, 0, 0, 0)) + .OnTap(_ => onBack()), + new HStack().FlexGrow(1), + }.FlexShrink(0), + new HStack().FlexGrow(1), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.UiBackground); + } + + static bool _hooked; + + // The scrolling body — one row holding the whole column (Column+verticalScroll in + // the gold; a single-row LazyColumn here so ReloadData re-binds on snack change). + static View Body(Snack snack, double topInset) => new VStack(spacing: 0f) + { + // Header: 280dp gradient band with the 300dp circular image overlapping below. + new ZStack + { + new VStack(spacing: 0f) { new HStack().Frame(height: 280) } + .Frame(height: 280) + .BackgroundGradient(T.Tornado1) + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Start), + new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)(topInset + 56)), + new Image(snack.ImageRes).Frame(width: 300, height: 300).CornerRadius(150) + .HorizontalLayoutAlignment(LayoutAlignment.Center), + }.HorizontalLayoutAlignment(LayoutAlignment.Center), + } + .Frame(height: (float)(topInset + 380)) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + + // Title block (Title): name headlineMedium ITALIC textSecondary, tagline + // titleSmall 20 italic textHelp, price brand, divider. + new VStack(spacing: 0f) + { + new HStack().Frame(height: 16), + new Text(snack.Name).FontFamily("Montserrat").FontWeight(FontWeight.Semibold).FontSize(30) + .FontSlant(Microsoft.Maui.FontSlant.Italic).Color(T.TextSecondary) + .Padding(new Thickness(24, 0, 24, 0)), + new Text(snack.Tagline).FontFamily("Karla").FontWeight(FontWeight.Bold).FontSize(20) + .FontSlant(Microsoft.Maui.FontSlant.Italic).Color(T.TextHelp) + .Padding(new Thickness(24, 0, 24, 0)), + new HStack().Frame(height: 4), + T.TitleMedium(Jetsnack.FormatPrice(snack.Price)).Color(T.TextPrimary) + .Padding(new Thickness(24, 0, 24, 8)), + JetsnackHome.Divider(1), + }, + + // Body: Details overline + lorem placeholder + SEE MORE, Ingredients, divider, + // related collections (Customers also bought / Popular). + new VStack(spacing: 0f) + { + new HStack().Frame(height: 16), + T.LabelSmall("Details").Color(T.TextHelp) + .Padding(new Thickness(24, 0, 24, 0)), + new HStack().Frame(height: 16), + new Text(DetailPlaceholder).FontFamily("Karla").FontSize(16).LineHeight(28).Color(T.TextHelp) + .LineBreakMode(LineBreakMode.WordWrap).MaxLines(_seeMore ? 0 : 6) + .Padding(new Thickness(24, 0, 24, 0)), + T.LabelLarge(_seeMore ? "SEE LESS" : "SEE MORE") + .Color(T.TextLink) + .Padding(new Thickness(24, 15, 24, 0)) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .OnTap(_ => { _seeMore = !_seeMore; _list?.ReloadData(); }), + new HStack().Frame(height: 40), + T.LabelSmall("Ingredients").Color(T.TextHelp) + .Padding(new Thickness(24, 0, 24, 0)), + new HStack().Frame(height: 4), + T.BodyLarge("Vanilla, Almond Flour, Eggs, Butter, Cream, Sugar") + .Color(T.TextHelp) + .LineBreakMode(LineBreakMode.WordWrap) + .Padding(new Thickness(24, 0, 24, 0)), + new HStack().Frame(height: 16), + JetsnackHome.Divider(1), + }, + + // Related collections (SnackRepo.getRelated) — the Home circle-row sections. + RelatedSection(snack.Id), + new HStack().Frame(height: BottomBarHeight + 8), + }; + + static bool _seeMore; + + static View RelatedSection(long snackId) + { + var column = new VStack(spacing: 0f); + foreach (var collection in SnackRepo.GetRelated(snackId)) + column.Add(JetsnackHome.RelatedCollection(collection)); + return column; + } + + /// CartBottomBar: divider + [QuantitySelector | gradient ADD TO CART]. + static View CartBar() + { + var bar = new VStack(spacing: 0f) + { + JetsnackHome.Divider(1), + new HStack(spacing: 0f) + { + QuantitySelector(), + new HStack().Frame(width: 16), + new HStack(spacing: 0f) + { + new HStack().FlexGrow(1), + T.LabelLarge("ADD TO CART") + .Color(T.TextInteractive).MaxLines(1) + .VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .Frame(height: 36) + .BackgroundGradient(T.Tornado1).CornerRadius(18) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .FlexGrow(1).FlexBasis(0), + } + .Frame(height: BottomBarHeight) + .Padding(new Thickness(24, 0, 24, 0)), + new HStack().Frame(height: 24), // gesture-nav inset + }.Background(T.UiBackground); + return bar; + } + + // QuantitySelector.kt: "Qty" (titleMedium, normal weight) + the gradient-bordered + // −/+ circles (shared with the cart) around the Karla-bold count. + static View QuantitySelector() => new HStack(spacing: 4f) + { + new Text("Qty").FontFamily("Montserrat").FontSize(16).Color(T.TextSecondary) + .Padding(new Thickness(0, 0, 14, 0)) + .VerticalLayoutAlignment(LayoutAlignment.Center), + JetsnackCart.StepperCircle("remove", () => { if (Count.Peek() > 0) Count.Value = Count.Peek() - 1; }), + new Text(() => Count.Value.ToString()).FontFamily("Karla").FontWeight(FontWeight.Bold).FontSize(18) + .Color(T.TextPrimary) + .Frame(width: 24) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .VerticalLayoutAlignment(LayoutAlignment.Center), + JetsnackCart.StepperCircle("add", () => Count.Value = Count.Peek() + 1), + }.FlexShrink(0); + + const string DetailPlaceholder = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut tempus, sem vitae " + + "convallis imperdiet, lectus nunc pharetra diam, ac rhoncus quam eros eu risus. " + + "Nulla pulvinar condimentum erat, pulvinar tempus turpis blandit ut. Etiam sed " + + "ipsum sed lacus eleifend hendrerit eu quis quam. Etiam ligula eros, finibus " + + "vestibulum tortor ac, ultrices accumsan dolor. Vivamus vel nisl a libero " + + "lobortis posuere. Aenean facilisis nibh vel ultrices bibendum. Pellentesque " + + "habitant morbi tristique senectus et netus et malesuada fames ac turpis " + + "egestas. Suspendisse ac est vitae lacus commodo efficitur at ut massa. Etiam " + + "vestibulum sit amet sapien sed varius. Aliquam non ipsum imperdiet, pulvinar " + + "enim nec, mollis risus. Fusce id tincidunt nisl."; + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackFilters.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackFilters.cs new file mode 100644 index 000000000..5720f1066 --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackFilters.cs @@ -0,0 +1,146 @@ +#nullable enable +using System.Collections.Generic; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Primitives; +using T = CometSamples.Jetsnack.JetsnackTheme; + +namespace CometSamples.Jetsnack +{ + /// + /// The Filters sheet (ui/home/FilterScreen.kt): a rounded card floated over a scrim — + /// Sort rows (icon + name + check), Price chips, Category chips. Shown/hidden by + /// reactive Opacity on the overlay (the Jetchat FAB idiom — a faded-out node + /// doesn't eat taps); content is a one-row list reloaded on any filter toggle. + /// + public static class JetsnackFilters + { + static readonly Signal SortSelection = new(SnackRepo.SortDefault); + static ListView? _sheetList; + static View? _currentOverlay; + static bool _hooked; + static bool _openHooked; + + public static View Overlay(Signal open) + { + var sheet = new ListView(() => new object[] { 0 }) + { + ViewFor = _ => SheetContent(open), + }; + _sheetList = sheet; + if (!_hooked) + { + _hooked = true; + SortSelection.PropertyChanged += (_, _) => _sheetList?.ReloadData(); + foreach (var f in AllFilters()) + f.Enabled.PropertyChanged += (_, _) => _sheetList?.ReloadData(); + } + + var overlay = new ZStack + { + // Scrim — tap closes (FilterScreen dismissOnClickOutside). + new VStack(spacing: 0f) + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.Neutral8.WithAlpha(0.45f)) + .OnTap(_ => open.Value = false), + new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + new VStack(spacing: 0f) { sheet.Frame(height: 470) } + .Frame(width: 340) + .Background(T.UiBackground).CornerRadius(16) + .HorizontalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Opacity(open.Peek() ? 1.0 : 0.0); + + open.PropertyChanged += (_, _) => overlay.Opacity(open.Peek() ? 1.0 : 0.0); + return overlay; + } + + static IEnumerable AllFilters() + { + foreach (var f in SnackRepo.PriceFilters) yield return f; + foreach (var f in SnackRepo.CategoryFilters) yield return f; + } + + static View SheetContent(Signal open) => new VStack(spacing: 0f) + { + // Header: X close | centered "Filters" titleLarge. + new HStack(spacing: 0f) + { + new Icon("close").IconSize(24).Color(T.IconSecondary) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .FlexShrink(0) + .OnTap(_ => open.Value = false), + new HStack().FlexGrow(1), + new Text("Filters").FontSize(18).FontWeight(FontWeight.Medium).Color(T.TextSecondary) + .VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + new HStack().Frame(width: 48).FlexShrink(0), + }.Frame(height: 56), + + SectionHeader("Sort"), + SortRow("android", "Android's favorite (default)"), + SortRow("star", "Rating"), + SortRow("sort_by_alpha", "Alphabetical"), + + SectionHeader("Price"), + ChipRow(SnackRepo.PriceFilters), + + SectionHeader("Category"), + ChipRows(SnackRepo.CategoryFilters, perRow: 2), + new HStack().Frame(height: 16), + }.Padding(new Thickness(16, 0, 16, 0)); + + static View SectionHeader(string title) => + new Text(title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.Brand) + .Padding(new Thickness(8, 16, 8, 8)); + + // FilterScreen SortFilters: icon + name + trailing check on the selection. + static View SortRow(string icon, string name) => new HStack(spacing: 0f) + { + new Icon(icon).IconSize(20).Color(T.TextSecondary) + .Frame(width: 36, height: 36).Padding(new Thickness(8)) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0), + new Text(name).FontSize(14).Color(T.TextSecondary) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .FlexGrow(1).FlexBasis(0), + SortSelection.Peek() == name + ? new Icon("check").IconSize(20).Color(T.Brand) + .Frame(width: 36, height: 36).Padding(new Thickness(8)) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0) + : new HStack().Frame(width: 36).FlexShrink(0), + } + .Frame(height: 44).Padding(new Thickness(8, 0, 8, 0)) + .OnTap(_ => SortSelection.Value = name); + + static View ChipRow(IReadOnlyList filters) + { + var row = new HStack(spacing: 8f); + foreach (var filter in filters) + row.Add(JetsnackHome.FilterChip(filter).FlexShrink(0)); + return row.Frame(height: 44).Padding(new Thickness(8, 4, 8, 4)); + } + + static View ChipRows(IReadOnlyList filters, int perRow) + { + var column = new VStack(spacing: 0f); + for (int i = 0; i < filters.Count; i += perRow) + { + var slice = new List(); + for (int j = i; j < filters.Count && j < i + perRow; j++) + slice.Add(filters[j]); + column.Add(ChipRow(slice)); + } + return column; + } + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackHome.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackHome.cs new file mode 100644 index 000000000..5ae452059 --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackHome.cs @@ -0,0 +1,241 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Comet; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.Jetsnack.JetsnackTheme; + +namespace CometSamples.Jetsnack +{ + /// + /// Jetsnack Feed (ui/home/Feed.kt + ui/components/Snacks.kt), values-from-source. + /// Jetsnack is a CUSTOM design system — the gold hand-composes its chrome and cards + /// from Rows/Columns/Surfaces, so composing the SAME structure from Comet primitives + /// (with the new BackgroundGradient fills) IS the faithful reproduction. + /// + public static class JetsnackHome + { + // Snacks.kt: HighlightCardWidth = 170, height 250, gradient band 160/100, image 120. + const float HighlightCardWidth = 170f; + + public static void OpenSnack(long id) => JetsnackRoot.OpenSnack(id); + + // ── Feed rows: spacer(status+56 for the overlaid DestinationBar) is handled by the + // root; list = FilterBar + per-collection section + divider (Feed.kt SnackCollectionList). ── + abstract record FeedRow; + sealed record FilterBarRow : FeedRow; + sealed record CollectionRow(SnackCollection Collection) : FeedRow; + + static IReadOnlyList FeedRows() + { + var rows = new List { new FilterBarRow() }; + rows.AddRange(SnackRepo.GetSnacks().Select(c => (FeedRow)new CollectionRow(c))); + return rows; + } + + public static View Feed(System.Action openFilters) + { + var list = new ListView(FeedRows) + { + ViewFor = r => r switch + { + FilterBarRow => FilterBar(openFilters), + CollectionRow c => CollectionSection(c.Collection), + _ => new HStack(), + }, + }; + return list; + } + + /// DestinationBar.kt: 56dp TopAppBar — "Delivery to…" titleMedium + /// textSecondary centered + expand_more tinted brand; hairline divider below. + public static View DestinationBar() => new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + new HStack().Frame(width: 48).FlexShrink(0), // balances the action slot + T.TitleMedium("Delivery to 1600 Amphitheater Way") + .Color(T.TextSecondary).MaxLines(1) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .FlexGrow(1).FlexBasis(0), + new Icon("expand_more").IconSize(24).Color(T.Brand) + .Frame(width: 48, height: 56).Padding(new Thickness(12, 16, 12, 16)) + .FlexShrink(0), + }.Frame(height: 56), + Divider(1), + }.Background(T.UiBackground); + + // FilterBar.kt: a REAL LazyRow (spacing 8, contentPadding start 12) — the gold's + // bar scrolls horizontally; the filter circle is the first item. + abstract record BarItem; + sealed record FilterCircle : BarItem; + sealed record ChipItem(Filter Filter) : BarItem; + + static ListView? _filterBar; + static bool _filterBarHooked; + + static View FilterBar(System.Action openFilters) + { + var items = new List { new FilterCircle() }; + items.AddRange(SnackRepo.Filters.Select(f => (BarItem)new ChipItem(f))); + var row = new ListView(() => items) + { + Horizontal = true, + ViewFor = item => new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + item switch + { + FilterCircle => (View)new Icon("filter_list").IconSize(20).Color(T.Brand) + .Frame(width: 46, height: 46).Padding(new Thickness(13)) + .BorderGradient(T.Gradient2_2).CornerRadius(23) + .OnTap(_ => openFilters()), + ChipItem c => FilterChip(c.Filter), + _ => new HStack(), + }, + new HStack().FlexGrow(1), + }.Frame(height: 66).Padding(new Thickness(0, 0, 8, 0)), + }; + _filterBar = row; + if (!_filterBarHooked) + { + // Subscribe ONCE (static filters outlive every rebuild); reload the + // current bar instance so the chip ON/OFF visuals follow the signal. + _filterBarHooked = true; + foreach (var f in SnackRepo.Filters) + f.Enabled.PropertyChanged += (_, _) => _filterBar?.ReloadData(); + } + return row.Frame(height: 66).Margin(left: 12) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } + + /// FilterChip.kt: shapes.small pill — OFF = uiBackground with the + /// interactiveSecondary DIAGONAL GRADIENT border, bodySmall textSecondary; + /// ON = brandSecondary fill, black text, no border. + public static View FilterChip(Filter filter) + { + bool on = filter.Enabled.Peek(); + var chip = new HStack(spacing: 0f) + { + T.BodySmall(filter.Name) + .Color(on ? Colors.Black : T.TextSecondary) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .HorizontalLayoutAlignment(LayoutAlignment.Center), + } + .Frame(height: 28) + .Padding(new Thickness(20, 6, 20, 6)) + .CornerRadius(14) + .OnTap(_ => filter.Enabled.Value = !filter.Enabled.Peek()); + return on + ? chip.Background(T.BrandSecondary) + : chip.BorderGradient(T.Gradient2_2).Background(T.UiBackground); + } + + // ── Collection section (Snacks.kt SnackCollection): header (name titleLarge BRAND, + // min 56, start 24, arrow at end) + Highlight card row or plain circle row + divider. ── + /// A collection section for reuse outside the feed (the detail's related rows). + public static View RelatedCollection(SnackCollection collection) => CollectionSection(collection); + + static View CollectionSection(SnackCollection collection) => new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + T.TitleLarge(collection.Name).Color(T.Brand) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .FlexGrow(1).FlexBasis(0), + new Icon("arrow_back").IconSize(24).Color(T.Brand) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0), + }.Frame(height: 56).Padding(new Thickness(24, 0, 4, 0)), + collection.Type == CollectionType.Highlight + ? HighlightRow(collection) + : SnackRow(collection), + Divider(2).Margin(new Thickness(0, 16, 0, 0)), + }; + + static View HighlightRow(SnackCollection collection) + { + // Index derived from the item, NOT a captured counter — ViewFor re-runs on + // every rebuild/reload and a counter would shift the gradient alternation. + var row = new ListView(() => collection.Snacks) + { + Horizontal = true, + // Wrapper padding, not item margin: the LazyRow item box is the row root's + // measured width, which excludes margins — cards rendered flush without it. + ViewFor = snack => new VStack(spacing: 0f) + { + HighlightSnackItem(snack, IndexOf(collection.Snacks, snack)), + }.Padding(new Thickness(0, 0, 16, 0)), + }; + return row.Frame(height: 266).Margin(left: 24) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } + + /// HighlightSnackItem: 170×250 r20 card — gradient band 160 (alternating + /// gradient6_1/6_2 like the gold's index % 2), 120 circle image overlapping the band, + /// name titleLarge, tagline bodyLarge textHelp. + static View HighlightSnackItem(Snack snack, int index) => new VStack(spacing: 0f) + { + new ZStack + { + // Gradient band pinned to the card TOP (Snacks.kt Box: gradient 100 at top, + // image bottom-center overlapping it). + new VStack(spacing: 0f) { new HStack().Frame(height: 100) } + .Frame(width: HighlightCardWidth, height: 100) + .BackgroundGradient(index % 2 == 0 ? T.Gradient6_1 : T.Gradient6_2) + .VerticalLayoutAlignment(LayoutAlignment.Start), + new Image(snack.ImageRes).Frame(width: 120, height: 120).CornerRadius(60) + .Margin(new Thickness(25, 40, 25, 0)), + }.Frame(width: HighlightCardWidth, height: 160), + new HStack().Frame(height: 8), + T.TitleLarge(snack.Name).Color(T.TextSecondary).MaxLines(1) + .Padding(new Thickness(16, 0, 16, 0)), + new HStack().Frame(height: 4), + T.BodyLarge(snack.Tagline).Color(T.TextHelp).MaxLines(1) + .Padding(new Thickness(16, 0, 16, 0)), + } + .Frame(width: HighlightCardWidth, height: 250) + .Background(T.UiBackground).CornerRadius(20) + .Border(1, T.UiBorder) + .OnTap(_ => OpenSnack(snack.Id)); + + static int IndexOf(System.Collections.Generic.IReadOnlyList snacks, Snack snack) + { + for (int i = 0; i < snacks.Count; i++) + if (ReferenceEquals(snacks[i], snack)) + return i; + return 0; + } + + /// SnackItem (Normal rows): 120 circle image + name titleMedium under it. + static View SnackItem(Snack snack) => new VStack(spacing: 0f) + { + new Image(snack.ImageRes).Frame(width: 120, height: 120).CornerRadius(60), + T.TitleMedium(snack.Name).Color(T.TextSecondary) + .Padding(new Thickness(0, 8, 0, 0)) + .HorizontalLayoutAlignment(LayoutAlignment.Center), + } + .Padding(new Thickness(8, 0, 8, 8)) + .OnTap(_ => OpenSnack(snack.Id)); + + static View SnackRow(SnackCollection collection) + { + var row = new ListView(() => collection.Snacks) + { + Horizontal = true, + ViewFor = SnackItem, + }; + return row.Frame(height: 160).Margin(left: 12) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } + + /// JetsnackDivider: hairline uiBorder. + public static View Divider(float thickness) => new HStack() + .Frame(height: thickness) + .Background(T.UiBorder) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackIcons.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackIcons.cs new file mode 100644 index 000000000..741567c9d --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackIcons.cs @@ -0,0 +1,42 @@ +#nullable enable +using System.Collections.Generic; +using Comet; + +namespace CometSamples.Jetsnack +{ + /// Jetsnack's Material Icons glyph map (the shared one-font approach). + static class JetsnackIcons + { + public const string Font = "Material Icons"; + + static bool _registered; + + public static void Register() + { + if (_registered) + return; + _registered = true; + + var map = new Dictionary(); + void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint); + + Add("expand_more", 0xE5CF); // destination bar + Add("filter_list", 0xE152); // filter bar + Add("arrow_forward", 0xE5C8); // section header (gold ic_arrow_back mirrored) + Add("arrow_back", 0xE5C4); // detail up + Add("home", 0xE88A); // bottom bar + Add("search", 0xE8B6); + Add("shopping_cart", 0xE8CC); + Add("account_circle", 0xE853); + Add("add", 0xE145); // qty stepper + Add("remove", 0xE15B); + Add("close", 0xE5CD); // filters sheet / cart remove + Add("star", 0xE838); // sort filter + Add("check", 0xE5CA); // sort selection + Add("sort_by_alpha", 0xE053); + Add("android", 0xE859); + + IconFont.Register(Font, map); + } + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackModel.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackModel.cs new file mode 100644 index 000000000..e2c751d83 --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackModel.cs @@ -0,0 +1,188 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; + +namespace CometSamples.Jetsnack +{ + // Verbatim port of the gold model (model/Snack.kt, SnackCollection.kt, Filter.kt, + // Search.kt) — same names, same shape. Image ids become bundled resource NAMES. + // The gold assigns Random.nextLong() ids; here ids are stable sequential (the gold + // only needs uniqueness). + + public sealed record Snack( + long Id, + string Name, + string ImageRes, + long Price, + string Tagline = "", + IReadOnlyCollection? Tags = null); + + public sealed record SnackCollection( + long Id, + string Name, + IReadOnlyList Snacks, + CollectionType Type = CollectionType.Normal); + + public enum CollectionType { Normal, Highlight } + + public sealed record OrderLine(Snack Snack, int Count); + + /// Filter.kt — enabled is mutable UI state (a signal here, matching the + /// gold's mutableStateOf). + public sealed class Filter + { + public Filter(string name, bool enabled = false, string? icon = null) + { + Name = name; + Icon = icon; + Enabled = new Comet.Reactive.Signal(enabled); + } + + public string Name { get; } + public string? Icon { get; } + public Comet.Reactive.Signal Enabled { get; } + } + + public sealed record SearchCategoryCollection(long Id, string Name, IReadOnlyList Categories); + public sealed record SearchCategory(string Name, string ImageRes); + public sealed record SearchSuggestionGroup(long Id, string Name, IReadOnlyList Suggestions); + + /// The gold's single formatPrice (ui/utils/Currency.kt) — cents → "$x.yy", + /// sign-safe (integer / and % both carry the sign in C#). + public static class Jetsnack + { + public static string FormatPrice(long price) + { + long abs = System.Math.Abs(price); + return $"{(price < 0 ? "-" : "")}${abs / 100}.{abs % 100:00}"; + } + } + + public static class SnackRepo + { + static long _nextId = 1; + static long NextId() => _nextId++; + + static Snack S(string name, string image, long price, string tagline = "") => + new(NextId(), name, image, price, tagline); + + /// model/Snack.kt `snacks` — order matters (collections slice by index). + public static readonly IReadOnlyList Snacks = new[] + { + S("Cupcake", "cupcake", 299, "A tag line"), + S("Donut", "donut", 299, "A tag line"), + S("Eclair", "eclair", 299, "A tag line"), + S("Froyo", "froyo", 299, "A tag line"), + S("Gingerbread", "gingerbread", 499, "A tag line"), + S("Honeycomb", "honeycomb", 299, "A tag line"), + S("Ice Cream Sandwich", "ice_cream_sandwich", 1299, "A tag line"), + S("Jellybean", "jelly_bean", 299, "A tag line"), + S("KitKat", "kitkat", 549, "A tag line"), + S("Lollipop", "lollipop", 299, "A tag line"), + S("Marshmallow", "marshmallow", 299, "A tag line"), + S("Nougat", "nougat", 299, "A tag line"), + S("Oreo", "oreo", 299, "A tag line"), + S("Pie", "pie", 299, "A tag line"), + S("Chips", "chips", 299), + S("Pretzels", "pretzels", 299), + S("Smoothies", "smoothies", 299), + S("Popcorn", "popcorn", 299), + S("Almonds", "almonds", 299), + S("Cheese", "cheese", 299), + S("Apples", "apples", 299, "A tag line"), + S("Apple sauce", "apple_sauce", 299, "A tag line"), + S("Apple chips", "apple_chips", 299, "A tag line"), + S("Apple juice", "apple_juice", 299, "A tag line"), + S("Apple pie", "apple_pie", 299, "A tag line"), + S("Grapes", "grapes", 299, "A tag line"), + S("Kiwi", "kiwi", 299, "A tag line"), + S("Mango", "mango", 299, "A tag line"), + }; + + // SnackCollection.kt — tastyTreats/popular + renamed copies. + static readonly SnackCollection TastyTreats = new(NextId(), "Android's picks", + Snacks.Take(13).ToArray(), CollectionType.Highlight); + static readonly SnackCollection Popular = new(NextId(), "Popular on Jetsnack", + Snacks.Skip(14).Take(5).ToArray()); + static readonly SnackCollection WfhFavs = TastyTreats with { Id = NextId(), Name = "WFH favourites" }; + static readonly SnackCollection NewlyAdded = Popular with { Id = NextId(), Name = "Newly Added" }; + static readonly SnackCollection Exclusive = TastyTreats with { Id = NextId(), Name = "Only on Jetsnack" }; + static readonly SnackCollection Also = TastyTreats with { Id = NextId(), Name = "Customers also bought" }; + static readonly SnackCollection InspiredByCartCollection = TastyTreats with { Id = NextId(), Name = "Inspired by your cart" }; + + public static IReadOnlyList GetSnacks() => new[] + { + TastyTreats, Popular, WfhFavs, NewlyAdded, Exclusive, + }; + + public static Snack GetSnack(long snackId) => Snacks.First(s => s.Id == snackId); + + public static IReadOnlyList GetRelated(long snackId) => new[] + { + Also with { Id = 900 }, Popular with { Id = 901 }, + }; + + public static SnackCollection GetInspiredByCart() => InspiredByCartCollection; + + public static IReadOnlyList GetCart() => new[] + { + new OrderLine(Snacks[4], 2), + new OrderLine(Snacks[6], 3), + new OrderLine(Snacks[8], 1), + }; + + // Filter.kt + public static readonly IReadOnlyList Filters = new[] + { + new Filter("Organic"), new Filter("Gluten-free"), new Filter("Dairy-free"), + new Filter("Sweet"), new Filter("Savory"), + }; + public static readonly IReadOnlyList PriceFilters = new[] + { + new Filter("$"), new Filter("$$"), new Filter("$$$"), new Filter("$$$$"), + }; + public static readonly IReadOnlyList SortFilters = new[] + { + new Filter("Android's favorite (default)", icon: "android"), + new Filter("Rating", icon: "star"), + new Filter("Alphabetical", icon: "sort_by_alpha"), + }; + public static readonly IReadOnlyList CategoryFilters = new[] + { + new Filter("Chips & crackers"), new Filter("Fruit snacks"), + new Filter("Desserts"), new Filter("Nuts"), + }; + public static readonly IReadOnlyList LifeStyleFilters = new[] + { + new Filter("Organic"), new Filter("Gluten-free"), new Filter("Dairy-free"), + new Filter("Sweet"), new Filter("Savory"), + }; + public static string SortDefault => SortFilters[0].Name; + } + + public static class SearchRepo + { + public static IReadOnlyList GetCategories() => new[] + { + new SearchCategoryCollection(0, "Categories", new[] + { + new SearchCategory("Chips & crackers", "chips"), + new SearchCategory("Fruit snacks", "fruit"), + new SearchCategory("Desserts", "desserts"), + new SearchCategory("Nuts", "nuts"), + }), + new SearchCategoryCollection(1, "Lifestyles", new[] + { + new SearchCategory("Organic", "organic"), + new SearchCategory("Gluten Free", "gluten_free"), + new SearchCategory("Paleo", "paleo"), + new SearchCategory("Vegan", "vegan"), + new SearchCategory("Vegetarian", "vegetarian"), + new SearchCategory("Whole30", "whole30"), + }), + }; + + public static IReadOnlyList Search(string query) => + SnackRepo.Snacks.Where(s => s.Name.Contains(query, System.StringComparison.OrdinalIgnoreCase)).ToArray(); + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackRoot.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackRoot.cs new file mode 100644 index 000000000..11b91be93 --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackRoot.cs @@ -0,0 +1,171 @@ +#nullable enable +using System.Collections.Generic; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Primitives; +using T = CometSamples.Jetsnack.JetsnackTheme; + +namespace CometSamples.Jetsnack +{ + /// + /// Jetsnack chrome + routing (ui/home/Home.kt HomeSections + JetsnackBottomBar). + /// The gold's bottom bar is its OWN composable (brand background, selected item = + /// icon + UPPERCASE label, others icon-only inactive) — hand-composed here from the + /// same structure. Routes swap through ContentSwitcher (retained-node contract). + /// + public class JetsnackRoot : View + { + /// 0 Feed, 1 Search, 2 Cart, 3 Profile (HomeSections order). + public static readonly Signal SelectedTab = new(0); + public static readonly Signal CurrentSnack = new(1); + public static readonly Signal DetailOpen = new(false); + public static readonly Signal FiltersOpen = new(false); + + static JetsnackRoot() + { + SelectedTab.PropertyChanged += (_, _) => + { + DetailOpen.Value = false; + _bottomBar?.ReloadData(); // the selected pill follows the active tab + }; + } + + public static void OpenSnack(long id) + { + CurrentSnack.Value = id; + DetailOpen.Value = true; + } + + readonly double _topInset; + + public JetsnackRoot(double topInset = 0) => _topInset = topInset; + + [Body] + View body() + { + JetsnackIcons.Register(); + + // Feed with the DestinationBar overlaid at the top (the gold's Box overlay — + // the bar floats over the scrolling feed). + var feed = new ZStack + { + new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)(_topInset + 56)).FlexShrink(0), + JetsnackHome.Feed(openFilters: () => FiltersOpen.Value = true) + .FlexGrow(1).FlexBasis(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)_topInset).Background(T.UiBackground).FlexShrink(0), + JetsnackHome.DestinationBar().HorizontalLayoutAlignment(LayoutAlignment.Fill).FlexShrink(0), + new HStack().FlexGrow(1), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill); + + // Compact push: DetailOpen swaps feed ↔ detail full-screen (system back closes) — + // the ListDetail primitive, same as JetNews. + var feedWithDetail = new ListDetail(DetailOpen, feed, + JetsnackDetail.Screen(CurrentSnack, _topInset, onBack: () => DetailOpen.Value = false)); + + var bottomBar = new ListView(() => new[] { 0 }) { ViewFor = _ => BottomBarRow() }; + _bottomBar = bottomBar; + + var routes = new ContentSwitcher(SelectedTab, new View[] + { + feedWithDetail, + JetsnackSearch.Screen(_topInset), + JetsnackCart.Screen(_topInset), + // The gold Profile IS a work-in-progress placeholder. + Placeholder("This is currently work in progress"), + }); + + return new ZStack + { + new VStack(spacing: 0f) + { + routes.FlexGrow(1).FlexBasis(0), + bottomBar.Frame(height: 80).FlexShrink(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + // Filters sheet floats over everything; reactive-opacity show/hide. + JetsnackFilters.Overlay(FiltersOpen), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.UiBackground); + } + + static readonly (string Icon, string Label)[] Tabs = + { + ("home", "HOME"), ("search", "SEARCH"), ("shopping_cart", "MY CART"), ("account_circle", "PROFILE"), + }; + + static ListView? _bottomBar; + + /// JetsnackBottomBar: brand background; SELECTED item = bordered pill with + /// icon + uppercase label (iconInteractive), others icon-only (iconInteractiveInactive). + /// Rendered as a one-row list reloaded on tab change so the pill FOLLOWS the selection + /// (a Peek-built bar froze on HOME — caught by review). + static View BottomBarRow() + { + var row = new HStack(spacing: 0f); + for (int i = 0; i < Tabs.Length; i++) + { + int index = i; + bool selected = SelectedTab.Peek() == index; + var cell = new HStack(spacing: 0f) { new HStack().FlexGrow(1) }; + var content = new HStack(spacing: 8f) + { + new Icon(Tabs[i].Icon).IconSize(24) + .Color(selected ? T.IconInteractive : T.IconInteractiveInactive) + .VerticalLayoutAlignment(LayoutAlignment.Center), + }; + if (selected) + content.Add(T.LabelLarge(Tabs[i].Label) + .Color(T.IconInteractive) + .VerticalLayoutAlignment(LayoutAlignment.Center)); + var pill = new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + content.HorizontalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .Frame(height: 40) + .Padding(new Thickness(16, 0, 16, 0)); + if (selected) + pill = (VStack)pill.Border(1, T.IconInteractive).CornerRadius(20); + cell.Add(pill.VerticalLayoutAlignment(LayoutAlignment.Center)); + cell.Add(new HStack().FlexGrow(1)); + row.Add(cell.FlexGrow(selected ? 1.6f : 1f).FlexBasis(0) + .OnTap(_ => SelectedTab.Value = index)); + } + return new VStack(spacing: 0f) + { + row.Frame(height: 56), + new HStack().Frame(height: 24), // gesture-nav inset strip + }.Background(T.Brand); + } + + static View Placeholder(string message) => new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + T.TitleMedium(message).Color(T.TextSecondary) + .HorizontalLayoutAlignment(LayoutAlignment.Center), + T.BodyLarge("Grab a beverage and check back later!").Color(T.TextHelp) + .HorizontalLayoutAlignment(LayoutAlignment.Center).Padding(new Thickness(0, 8, 0, 0)), + new HStack().FlexGrow(1), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.UiBackground); + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackSearch.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackSearch.cs new file mode 100644 index 000000000..4c8230226 --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackSearch.cs @@ -0,0 +1,116 @@ +#nullable enable +using System.Collections.Generic; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.Jetsnack.JetsnackTheme; + +namespace CometSamples.Jetsnack +{ + /// + /// Search (ui/home/search): the uiFloated search bar over the Categories/Lifestyles + /// two-column grids — each category a 10dp-corner gradient card (name titleMedium on + /// the left, image on the right; gradients alternate 2_2/2_3 per collection index). + /// Query results are the next increment (the gold's suggestion/result states). + /// + public static class JetsnackSearch + { + static readonly Signal Query = new(string.Empty); + + public static View Screen(double topInset) + { + var rows = new List { "bar" }; + foreach (var collection in SearchRepo.GetCategories()) + rows.Add(collection); + + var list = new ListView(() => rows) + { + ViewFor = r => r switch + { + string => SearchBar(), + SearchCategoryCollection c => CollectionGrid(c), + _ => new HStack(), + }, + }; + + return new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)topInset).FlexShrink(0), + list.FlexGrow(1).FlexBasis(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill) + .Background(T.UiBackground); + } + + // Search.kt SearchBar: 56dp uiFloated rounded surface, search icon + hint. + static View SearchBar() => new VStack(spacing: 0f) + { + // Wrapper padding, not margin: Fill + margin overflows the right edge + // (the fill width isn't reduced by the margin). + new HStack(spacing: 0f) + { + new Icon("search").IconSize(24).Color(T.TextHelp) + .Frame(width: 48, height: 40).Padding(new Thickness(12, 8, 12, 8)) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0), + SignalExtensions.TextField(Query, "Search Jetsnack") + .Borderless() + .VerticalLayoutAlignment(LayoutAlignment.Center) + .FlexGrow(1).FlexBasis(0), + } + .Frame(height: 40) + .Background(T.UiFloated).CornerRadius(20) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + } + .Padding(new Thickness(24, 8, 24, 8)) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + + static View CollectionGrid(SearchCategoryCollection collection) + { + var grid = new VStack(spacing: 0f) + { + T.TitleLarge(collection.Name).Color(T.Brand) + .Padding(new Thickness(24, 16, 24, 4)), + }; + // VerticalGrid: two columns, pad 16, item pad 8. + for (int i = 0; i < collection.Categories.Count; i += 2) + { + var row = new HStack(spacing: 0f) + { + CategoryCard(collection.Categories[i], (int)collection.Id) + .FlexGrow(1).FlexBasis(0), + }; + if (i + 1 < collection.Categories.Count) + row.Add(CategoryCard(collection.Categories[i + 1], (int)collection.Id) + .FlexGrow(1).FlexBasis(0)); + else + row.Add(new HStack().FlexGrow(1).FlexBasis(0)); + grid.Add(row.Padding(new Thickness(16, 0, 16, 0)) + .HorizontalLayoutAlignment(LayoutAlignment.Fill)); + } + grid.Add(new HStack().Frame(height: 4)); + return grid; + } + + // Categories.kt SearchCategory: r10 gradient card (2_2 for Categories, 2_3 for + // Lifestyles), name titleMedium left, circular image right, min image 134. + static View CategoryCard(SearchCategory category, int collectionIndex) => new HStack(spacing: 0f) + { + T.TitleMedium(category.Name).Color(T.TextSecondary) + .LineBreakMode(LineBreakMode.WordWrap) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .Padding(new Thickness(12, 4, 4, 4)) + .FlexGrow(1).FlexBasis(0), + new Image(category.ImageRes).Frame(width: 120, height: 120).CornerRadius(60) + .VerticalLayoutAlignment(LayoutAlignment.Center) + .FlexShrink(0), + } + .Frame(height: 134) + .BackgroundGradient(collectionIndex % 2 == 0 ? T.Gradient2_2 : T.Gradient2_3) + .CornerRadius(10) + .Margin(new Thickness(8)) + .OnTap(_ => { /* the gold's category filter route is toast-stubbed too */ }); + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackTheme.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackTheme.cs new file mode 100644 index 000000000..47a346427 --- /dev/null +++ b/src/Comet/sample/Shared/Jetsnack/JetsnackTheme.cs @@ -0,0 +1,83 @@ +#nullable enable +using Comet; +using Microsoft.Maui.Graphics; + +namespace CometSamples.Jetsnack +{ + /// + /// Jetsnack's CUSTOM design system palette, values-from-source (ui/theme/Color.kt + + /// Theme.kt LightColorPalette). Unlike Reply/JetNews this is NOT a Material scheme — + /// the gold provides its own JetsnackColors (brand ramps + gradient stop lists) and + /// hand-composes its chrome from them, so the Comet sample carries the same tokens. + /// (Light palette only — the golds are light; dark is a later pass.) + /// + public static class JetsnackTheme + { + static Color C(uint argb) => Color.FromUint(argb); + + // ── Color.kt ramps (the ones the light palette + gradients reference) ── + public static readonly Color Shadow11 = C(0xff001787); + public static readonly Color Shadow9 = C(0xff0009b3); + public static readonly Color Shadow5 = C(0xff4b30ed); + public static readonly Color Shadow4 = C(0xff7057f5); + public static readonly Color Shadow3 = C(0xff9b86fa); + public static readonly Color Shadow2 = C(0xffc8bbfd); + public static readonly Color Ocean11 = C(0xff005687); + public static readonly Color Ocean3 = C(0xff86f7fa); + public static readonly Color Lavender3 = C(0xffc186fa); + public static readonly Color Rose4 = C(0xfff4568b); + public static readonly Color Rose2 = C(0xfffdbbcf); + public static readonly Color Neutral8 = C(0xff121212); + public static readonly Color Neutral7 = C(0xde000000); + public static readonly Color Neutral6 = C(0x99000000); + public static readonly Color Neutral5 = C(0x61000000); + public static readonly Color Neutral4 = C(0x1f000000); + public static readonly Color Neutral1 = C(0xbdffffff); + public static readonly Color Neutral0 = C(0xffffffff); + public static readonly Color FunctionalRed = C(0xffd00036); + public static readonly Color FunctionalGreen = C(0xff52c41a); + public static readonly Color FunctionalGrey = C(0xfff6f6f6); + + // ── Theme.kt LightColorPalette roles ── + public static readonly Color Brand = Shadow5; + public static readonly Color BrandSecondary = Ocean3; + public static readonly Color UiBackground = Neutral0; + public static readonly Color UiBorder = Neutral4; + public static readonly Color UiFloated = FunctionalGrey; + public static readonly Color TextPrimary = Shadow5; // defaults to brand in the gold + public static readonly Color TextSecondary = Neutral7; + public static readonly Color TextHelp = Neutral6; + public static readonly Color TextInteractive = Neutral0; + public static readonly Color IconPrimary = Shadow5; + public static readonly Color IconSecondary = Neutral7; + public static readonly Color IconInteractive = Neutral0; + public static readonly Color IconInteractiveInactive = Neutral1; + public static readonly Color TextLink = Ocean11; + public static readonly Color Error = FunctionalRed; + + // ── Typography (Type.kt): Montserrat (SemiBold headings/labels) + Karla (body). + // Both families ship in the probes; helpers keep every screen on the gold styles. ── + public static Comet.Text TitleLarge(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(Microsoft.Maui.FontWeight.Semibold).FontSize(20); + public static Comet.Text TitleMedium(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(Microsoft.Maui.FontWeight.Semibold).FontSize(16); + public static Comet.Text BodyLarge(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Karla").FontSize(16); + public static Comet.Text BodySmall(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Karla").FontWeight(Microsoft.Maui.FontWeight.Bold).FontSize(12); + public static Comet.Text LabelLarge(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(Microsoft.Maui.FontWeight.Semibold).FontSize(14); + public static Comet.Text LabelSmall(string s) => (Comet.Text)new Comet.Text(s) + .FontFamily("Montserrat").FontWeight(Microsoft.Maui.FontWeight.Semibold).FontSize(12); + + // ── Gradient stop lists (light) — Theme.kt:42-49 ── + public static readonly Color[] Gradient6_1 = { Shadow4, Ocean3, Shadow2, Ocean3, Shadow4 }; + public static readonly Color[] Gradient6_2 = { Rose4, Lavender3, Rose2, Lavender3, Rose4 }; + public static readonly Color[] Gradient3_1 = { Shadow2, Ocean3, Shadow4 }; + public static readonly Color[] Gradient3_2 = { Rose2, Lavender3, Rose4 }; + public static readonly Color[] Gradient2_1 = { Shadow4, Shadow11 }; + public static readonly Color[] Gradient2_2 = { Ocean3, Shadow3 }; + public static readonly Color[] Gradient2_3 = { Lavender3, Rose2 }; + public static readonly Color[] Tornado1 = { Shadow4, Ocean3 }; + } +} diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-01-home.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-01-home.png new file mode 100644 index 000000000..4f9f97142 Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-01-home.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-02-home-scrolled.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-02-home-scrolled.png new file mode 100644 index 000000000..585cb8f4e Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-02-home-scrolled.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-03-filters.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-03-filters.png new file mode 100644 index 000000000..9b3a9c547 Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-03-filters.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-04-detail.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-04-detail.png new file mode 100644 index 000000000..4c2d09730 Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-04-detail.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-05-detail-scrolled.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-05-detail-scrolled.png new file mode 100644 index 000000000..7ac1e78fb Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-05-detail-scrolled.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-06-search.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-06-search.png new file mode 100644 index 000000000..1fe7c4d74 Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-06-search.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-07-cart.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-07-cart.png new file mode 100644 index 000000000..ac5d5d1da Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-07-cart.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-08-profile.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-08-profile.png new file mode 100644 index 000000000..3be28e0c7 Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-08-profile.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/expanded-1260dp-01.png b/src/Comet/sample/Shared/Jetsnack/gold/expanded-1260dp-01.png new file mode 100644 index 000000000..fce6e9312 Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/expanded-1260dp-01.png differ diff --git a/src/Comet/sample/Shared/Jetsnack/gold/medium-700dp-01.png b/src/Comet/sample/Shared/Jetsnack/gold/medium-700dp-01.png new file mode 100644 index 000000000..cb1a4fe83 Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/medium-700dp-01.png differ diff --git a/src/Comet/sample/Shared/Reply/ReplyChromeProbe.cs b/src/Comet/sample/Shared/Reply/ReplyChromeProbe.cs new file mode 100644 index 000000000..15c269cc7 --- /dev/null +++ b/src/Comet/sample/Shared/Reply/ReplyChromeProbe.cs @@ -0,0 +1,85 @@ +#nullable enable +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.Reply.ReplyTheme; + +namespace CometSamples.Reply +{ + /// + /// Reply's root: the adaptive NavigationSuite hosting a ContentSwitcher over the four + /// gold routes (Inbox = real ListDetail screens; Articles / DirectMessages / Groups = + /// EmptyComingSoon, matching ReplyApp.kt:128-153). + /// + public class ReplyProbeRoot : View + { + static readonly Signal Selected = new(0); + static readonly Signal DrawerOpen = new(false); + + [Body] + View body() + { + ReplyIcons.Register(); + + var items = new[] + { + NavItem("inbox", "Inbox"), + NavItem("article", "Articles"), + NavItem("chat_outline", "Direct Messages"), + NavItem("people_outline", "Groups"), + }; + + var routes = new ContentSwitcher(Selected, new[] + { + ReplyScreens.Inbox(), + ReplyScreens.ComingSoon(), + ReplyScreens.ComingSoon(), + ReplyScreens.ComingSoon(), + }); + + return new NavigationSuite(Selected, items, routes, + railHeader: RailHeader(), drawerHeader: DrawerHeader(), + drawerOpen: DrawerOpen, + // M3 chrome tokens for the hand-composed iOS twin (Android's real + // widgets take these from the Compose scheme). + containerColor: T.SurfaceContainer, indicatorColor: T.SecondaryContainer) + .Background(T.Background); // paints the safe-area strips too + } + + // The gold drawer variant shows labeled items — label views feed NavigationDrawerItem + // slots; the bar/rail render icon-only, matching the gold. + static NavigationItem NavItem(string icon, string label) => + new(new Icon(icon).IconSize(24).Color(T.OnSurfaceVariant), new Text(label).FontSize(14)); + + // The gold rail header: a menu affordance above the compose FAB (drawer wiring lands + // with the modal-drawer increment). + static View RailHeader() => new VStack(spacing: 4) + { + new Icon("menu").IconSize(24).Color(T.OnSurfaceVariant) + .Frame(width: 48, height: 48).Padding(new Thickness(12)) + .OnTap(_ => DrawerOpen.Value = true), + new Icon("edit").IconSize(18).Color(T.OnTertiaryContainer) + .Frame(width: 56, height: 56) + .Background(T.TertiaryContainer) + .CornerRadius(16), + }.HorizontalLayoutAlignment(LayoutAlignment.Center); + + // The gold permanent-drawer header: "REPLY" wordmark + the Compose extended FAB. + static View DrawerHeader() => new VStack(spacing: 8) + { + new Text("REPLY").FontSize(16).FontWeight(FontWeight.Semibold) + .Color(T.Primary), + new HStack(spacing: 8) + { + new Icon("edit").IconSize(18).Color(T.OnTertiaryContainer), + new Text("Compose").FontSize(14).Color(T.OnTertiaryContainer), + } + .Frame(height: 56) + .Background(T.TertiaryContainer) + .CornerRadius(16) + .Padding(new Thickness(16, 0)), + }.Padding(new Thickness(16, 16, 16, 40)); + } +} diff --git a/src/Comet/sample/Shared/Reply/ReplyData.cs b/src/Comet/sample/Shared/Reply/ReplyData.cs new file mode 100644 index 000000000..984505451 --- /dev/null +++ b/src/Comet/sample/Shared/Reply/ReplyData.cs @@ -0,0 +1,98 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; + +namespace CometSamples.Reply +{ + /// Straight port of the gold's data layer (data/Account.kt, Email.kt, + /// local/Local*DataProvider.kt): same ids, names, avatars, subjects, bodies and + /// timestamps, so list rows and detail threads match the gold pixel content. + /// Deterministic: the gold shuffles each email's threads; here they keep source + /// order (a stable fixture for the smoke scripts). + public sealed record ReplyAccount(long Id, long Uid, string FirstName, string LastName, string Email, string Avatar) + { + public string FullName => $"{FirstName} {LastName}"; + } + + public enum ReplyMailbox { Inbox, Sent, Drafts, Spam, Trash } + + public sealed record ReplyEmail( + long Id, ReplyAccount Sender, string Subject, string Body, string CreatedAt, + ReplyMailbox Mailbox = ReplyMailbox.Inbox, bool IsStarred = false, bool IsImportant = false, + IReadOnlyList? Threads = null); + + public static class ReplyData + { + // local/LocalAccountsDataProvider.kt — contact accounts (id, uid, names, avatar). + public static readonly ReplyAccount User = new(1, 0, "Jeff", "Hansen", "hikingfan@gmail.com", "avatar_10"); + static readonly Dictionary Contacts = new[] + { + new ReplyAccount(4, 1, "Tracy", "Alvarez", "tracealvie@gmail.com", "avatar_1"), + new ReplyAccount(5, 2, "Allison", "Trabucco", "atrabucco222@gmail.com", "avatar_3"), + new ReplyAccount(6, 3, "Ali", "Connors", "aliconnors@gmail.com", "avatar_5"), + new ReplyAccount(7, 4, "Alberto", "Williams", "albertowilliams124@gmail.com", "avatar_0"), + new ReplyAccount(8, 5, "Kim", "Alen", "alen13@gmail.com", "avatar_7"), + new ReplyAccount(9, 6, "Google", "Express", "express@google.com", "avatar_express"), + new ReplyAccount(10, 7, "Sandra", "Adams", "sandraadams@gmail.com", "avatar_2"), + new ReplyAccount(11, 8, "Trevor", "Hansen", "trevorhandsen@gmail.com", "avatar_8"), + new ReplyAccount(12, 9, "Sean", "Holt", "sholt@gmail.com", "avatar_6"), + new ReplyAccount(13, 10, "Frank", "Hawkins", "fhawkank@gmail.com", "avatar_4"), + }.ToDictionary(a => a.Id); + + static ReplyAccount C(long id) => Contacts[id]; + + // local/LocalEmailsDataProvider.kt `threads` — the conversation shown in every detail. + public static readonly IReadOnlyList Threads = new[] + { + new ReplyEmail(8, C(13), "Your update on Google Play Store is live!", + "Your update, 0.1.1, is now live on the Play Store and available for your alpha users to start testing.\n\nYour alpha testers will be automatically notified. If you'd rather send them a link directly, go to your Google Play Console and follow the instructions for obtaining an open alpha testing link.", + "3 hours ago", ReplyMailbox.Trash), + new ReplyEmail(5, C(13), "Update to Your Itinerary", "", "2 hours ago"), + new ReplyEmail(6, C(10), "Recipe to try", + "Raspberry Pie: We should make this pie recipe tonight! The filling is very quick to put together.", + "2 hours ago", ReplyMailbox.Sent), + new ReplyEmail(7, C(9), "Delivered", "Your shoes should be waiting for you at home!", "2 hours ago"), + new ReplyEmail(9, C(10), "(No subject)", "Hey, \n\nWanted to email and see what you thought of", "3 hours ago", ReplyMailbox.Drafts), + new ReplyEmail(1, C(6), "Brunch this weekend?", + "I'll be in your neighborhood doing errands and was hoping to catch you for a coffee this Saturday. If you don't have anything scheduled, it would be great to see you! It feels like its been forever.\n\nIf we do get a chance to get together, remind me to tell you about Kim. She stopped over at the house to say hey to the kids and told me all about her trip to Mexico.\n\nTalk to you soon,\n\nAli", + "40 mins ago"), + new ReplyEmail(2, C(5), "Bonjour from Paris", "Here are some great shots from my trip...", "1 hour ago", IsImportant: true), + }; + + // local/LocalEmailsDataProvider.kt `allEmails` — source order = gold list order. + public static readonly IReadOnlyList AllEmails = new[] + { + new ReplyEmail(0, C(9), "Package shipped!", + "Cucumber Mask Facial has shipped.\n\nKeep an eye out for a package to arrive between this Thursday and next Tuesday. If for any reason you don't receive your package before the end of next week, please reach out to us for details on your shipment.\n\nAs always, thank you for shopping with us and we hope you love our specially formulated Cucumber Mask!", + "20 mins ago", IsStarred: true, Threads: Threads), + new ReplyEmail(1, C(6), "Brunch this weekend?", + "I'll be in your neighborhood doing errands and was hoping to catch you for a coffee this Saturday. If you don't have anything scheduled, it would be great to see you! It feels like its been forever.\n\nIf we do get a chance to get together, remind me to tell you about Kim. She stopped over at the house to say hey to the kids and told me all about her trip to Mexico.\n\nTalk to you soon,\n\nAli", + "40 mins ago", Threads: Threads), + new ReplyEmail(2, C(5), "Bonjour from Paris", "Here are some great shots from my trip...", + "1 hour ago", IsImportant: true, Threads: Threads), + new ReplyEmail(3, C(8), "High school reunion?", + "Hi friends,\n\nI was at the grocery store on Sunday night.. when I ran into Genie Williams! I almost didn't recognize her afer 20 years!\n\nAnyway, it turns out she is on the organizing committee for the high school reunion this fall. I don't know if you were planning on going or not, but she could definitely use our help in trying to track down lots of missing alums. If you can make it, we're doing a little phone-tree party at her place next Saturday, hoping that if we can find one person, thee more will...", + "2 hours ago", ReplyMailbox.Sent, Threads: Threads), + new ReplyEmail(4, C(11), "Brazil trip", + "Thought we might be able to go over some details about our upcoming vacation.\n\nI've been doing a bit of research and have come across a few paces in Northern Brazil that I think we should check out. One, the north has some of the most predictable wind on the planet. I'd love to get out on the ocean and kitesurf for a couple of days if we're going to be anywhere near or around Taiba. I hear it's beautiful there and if you're up for it, I'd love to go. Other than that, I haven't spent too much time looking into places along our road trip route. I'm assuming we can find places to stay and things to do as we drive and find places we think look interesting. But... I know you're more of a planner, so if you have ideas or places in mind, lets jot some ideas down!\n\nMaybe we can jump on the phone later today if you have a second.", + "2 hours ago", IsStarred: true, Threads: Threads), + new ReplyEmail(5, C(13), "Update to Your Itinerary", "", "2 hours ago", Threads: Threads), + new ReplyEmail(6, C(10), "Recipe to try", + "Raspberry Pie: We should make this pie recipe tonight! The filling is very quick to put together.", + "2 hours ago", ReplyMailbox.Sent, Threads: Threads), + new ReplyEmail(7, C(9), "Delivered", "Your shoes should be waiting for you at home!", + "2 hours ago", Threads: Threads), + new ReplyEmail(8, C(13), "Your update on Google Play Store is live!", + "Your update, 0.1.1, is now live on the Play Store and available for your alpha users to start testing.\n\nYour alpha testers will be automatically notified. If you'd rather send them a link directly, go to your Google Play Console and follow the instructions for obtaining an open alpha testing link.", + "3 hours ago", ReplyMailbox.Trash, Threads: Threads), + new ReplyEmail(9, C(10), "(No subject)", "Hey, \n\nWanted to email and see what you thought of", + "3 hours ago", ReplyMailbox.Drafts, Threads: Threads), + new ReplyEmail(10, C(5), "Try a free TrailGo account", + "Looking for the best hiking trails in your area? TrailGo gets you on the path to the outdoors faster than you can pack a sandwich. \n\nWhether you're an experienced hiker or just looking to get outside for the afternoon, there's a segment that suits you.", + "3 hours ago", ReplyMailbox.Trash, Threads: Threads), + new ReplyEmail(11, C(5), "Free money", + "You've been selected as a winner in our latest raffle! To claim your prize, click on the link.", + "3 hours ago", ReplyMailbox.Spam, Threads: Threads), + }; + } +} diff --git a/src/Comet/sample/Shared/Reply/ReplyIcons.cs b/src/Comet/sample/Shared/Reply/ReplyIcons.cs new file mode 100644 index 000000000..6d6c9c52a --- /dev/null +++ b/src/Comet/sample/Shared/Reply/ReplyIcons.cs @@ -0,0 +1,43 @@ +#nullable enable +using System.Collections.Generic; +using Comet; + +namespace CometSamples.Reply +{ + /// + /// Reply's Material Icons glyph map (same one-font cross-platform approach as + /// JetchatIcons). Names match the gold's ic_* drawables in + /// compose-samples/Reply/app/src/main/res/drawable/. + /// + static class ReplyIcons + { + public const string Font = "Material Icons"; + + static bool _registered; + + public static void Register() + { + if (_registered) + return; + _registered = true; + + var map = new Dictionary(); + void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint); + + Add("inbox", 0xE156); + Add("article", 0xEF42); + Add("chat_outline", 0xE0CB); // ic_chat: chat_bubble_outline + Add("people_outline", 0xE7FC); // ic_group + Add("menu", 0xE5D2); + Add("menu_open", 0xE9BD); + Add("edit", 0xE3C9); // ic_edit (compose) + Add("search", 0xE8B6); + Add("arrow_back", 0xE5C4); + Add("more_vert", 0xE5D4); + Add("star_border", 0xE83A); + Add("check", 0xE5CA); + + IconFont.Register(Font, map); + } + } +} diff --git a/src/Comet/sample/Shared/Reply/ReplyScreens.cs b/src/Comet/sample/Shared/Reply/ReplyScreens.cs new file mode 100644 index 000000000..864748572 --- /dev/null +++ b/src/Comet/sample/Shared/Reply/ReplyScreens.cs @@ -0,0 +1,319 @@ +#nullable enable +using System.Collections.Generic; +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using T = CometSamples.Reply.ReplyTheme; + +namespace CometSamples.Reply +{ + /// + /// The Reply screens, values-from-source (file:line cites into the gold Kotlin app). + /// Inbox = ReplyListContent.kt; rows = ReplyEmailListItem.kt; detail = ReplyEmailDetail + + /// EmailDetailAppBar + ReplyEmailThreadItem; coming-soon = EmptyComingSoon.kt. + /// + public static class ReplyScreens + { + public static readonly Signal DetailOpen = new(false); + public static readonly Signal OpenedEmailId = new(0); + + // Hook the static signals ONCE (a per-build += on a static signal stacks a handler — + // and roots the captured list — on every body rebuild); the fields track the current + // list instances. + static ListView? _inboxList, _searchResults; + static ReplyScreens() + { + SearchQuery.PropertyChanged += (_, __) => _searchResults?.ReloadData(); + // Rows snapshot OpenedEmailId at build; reload so the opened highlight moves. + OpenedEmailId.PropertyChanged += (_, __) => _inboxList?.ReloadData(); + } + + // ── Selection mode (gold ReplyHomeViewModel.toggleSelectedEmail + ReplyEmailListItem + // combinedClickable onLongClick): long-press a row (or tap its avatar) to toggle it + // into the selection; selected rows show primaryContainer + a check avatar. ── + static readonly HashSet SelectedIds = new(); + static void ToggleSelection(long id) + { + if (!SelectedIds.Add(id)) + SelectedIds.Remove(id); + _inboxList?.ReloadData(); + } + + static ReplyEmail Opened() + { + foreach (var e in ReplyData.AllEmails) + if (e.Id == OpenedEmailId.Value) + return e; + return ReplyData.AllEmails[0]; + } + + // ── Inbox route: the ListDetail scaffold (gold ReplyInboxScreen) ── + public static View Inbox() => new ListDetail(DetailOpen, InboxList(), EmailDetail()); + + // ── The email list (gold ReplyEmailList :162-205): search bar pinned on top, + // list under it, ExtendedFAB bottom-end (compact only in gold; simplified: always). ── + static View InboxList() + { + var list = new ListView(() => ReplyData.AllEmails) + { + ViewFor = email => EmailListItem(email), + }; + _inboxList = list; + + // Gold ExtendedFAB (ReplyListContent.kt:113-126): tertiaryContainer, "Compose" + + // edit icon, expanded at the top (!canScrollBackward); collapses once scrolled. + // (The gold also re-expands on any upward scroll — lastScrolledBackward — that + // direction signal is a fidelity-pass follow-up.) + var extended = new Signal(true); + // Gold driver (ReplyListContent.kt:124-125): expanded = lastScrolledBackward || + // !canScrollBackward — re-expands on ANY upward scroll, not only at the very top. + void UpdateFab() => extended.Value = + list.LastScrolledBackward.Peek() || !list.ScrolledFromTop.Peek(); + list.ScrolledFromTop.PropertyChanged += (_, __) => UpdateFab(); + list.LastScrolledBackward.PropertyChanged += (_, __) => UpdateFab(); + var fab = new Fab( + icon: new Icon("edit").IconSize(18).Color(T.OnTertiaryContainer), + label: new Text("Compose").FontSize(14).Color(T.OnTertiaryContainer), + onClick: () => { }, + height: 56, + containerColor: T.TertiaryContainer, + contentColor: T.OnTertiaryContainer) + .ExtendedWhen(extended); + + return new ZStack + { + new VStack(spacing: 0f) + { + // The REAL M3 docked search bar (gold ReplyDockedSearchBar, + // ReplyAppBars.kt:84-168): "Search emails" placeholder + profile + // trailing; expanded popup shows history/results. + EmailSearchBar().Margin(left: 16, top: 16, right: 16, bottom: 16), + list.FlexGrow(1).FlexBasis(0), + } + .HorizontalLayoutAlignment(LayoutAlignment.Fill) + .VerticalLayoutAlignment(LayoutAlignment.Fill), + fab.HorizontalLayoutAlignment(LayoutAlignment.End) + .VerticalLayoutAlignment(LayoutAlignment.End) + .Margin(right: 16, bottom: 16), + }.Background(T.Background); + } + + static readonly Signal SearchQuery = new(""); + + // Gold filter (ReplyAppBars.kt:65-82): subject OR sender fullName startsWith, + // ignore case; empty query → history message. + static IReadOnlyList SearchResults() + { + var q = SearchQuery.Peek(); + if (string.IsNullOrEmpty(q)) + return System.Array.Empty(); + var hits = new List(); + foreach (var e in ReplyData.AllEmails) + if (e.Subject.StartsWith(q, System.StringComparison.OrdinalIgnoreCase) + || e.Sender.FullName.StartsWith(q, System.StringComparison.OrdinalIgnoreCase)) + hits.Add(e); + return hits; + } + + static View EmailSearchBar() + { + var results = new ListView(() => SearchResults()) + { + ViewFor = e => SearchResultRow(e), + }; + _searchResults = results; + + var content = new VStack(spacing: 0f) + { + // Gold: "No search history" (empty) / "No item found" (no hits) / results. + new Text(() => SearchQuery.Value.Length == 0 ? "No search history" + : SearchResults().Count == 0 ? "No item found" : "") + .FontSize(16).Color(T.OnSurface) + .Padding(new Thickness(16)), + results.FlexGrow(1).FlexBasis(0), + }.Frame(height: 420); + + return new SearchBar(SearchQuery, + placeholder: new Text("Search emails").FontSize(16).Color(T.OnSurfaceVariant), + content: content, + leading: new Icon("search").IconSize(24).Color(T.OnSurfaceVariant), + trailing: new Image("avatar_6").Frame(width: 32, height: 32).CornerRadius(16), + containerColor: T.SurfaceContainerHigh); + } + + // Gold search result rows: M3 ListItem shape — 32dp avatar, subject headline, + // sender supporting (ReplyAppBars.kt:137-155). + static View SearchResultRow(ReplyEmail email) => new HStack(spacing: 16f) + { + new Image(email.Sender.Avatar).Frame(width: 32, height: 32).CornerRadius(16).FlexShrink(0), + new VStack(spacing: 2f) + { + new Text(email.Subject).FontSize(16).Color(T.OnSurface), + new Text(email.Sender.FullName).FontSize(14).Color(T.OnSurfaceVariant), + }.FlexGrow(1).FlexBasis(0), + } + .Padding(new Thickness(16, 8, 16, 8)) + .OnTap(_ => + { + OpenedEmailId.Value = (int)email.Id; + DetailOpen.Value = true; + }); + + // ── One list row (gold ReplyEmailListItem.kt:52-142). Outer wrapper carries the + // h16/v4 gutter as PADDING (list rows are laid at full list width; root margins + // aren't part of the row's own layout box); the inner card paints/clips. ── + // Selected avatar (gold SelectedProfileImage): primary circle + centered onPrimary + // check; tapping either avatar state toggles selection (gold clickModifier). + static View AvatarSlot(ReplyEmail email) => (SelectedIds.Contains(email.Id) + ? (View)new ZStack { new Icon("check").IconSize(24).Color(T.OnPrimary) } + .Background(T.Primary) + : new Image(email.Sender.Avatar)) + .Frame(width: 40, height: 40).CornerRadius(20).FlexShrink(0) + .OnTap(_ => ToggleSelection(email.Id)); + + static View EmailListItem(ReplyEmail email) => new VStack(spacing: 0f) + { + new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + AvatarSlot(email), + new VStack(spacing: 0f) + { + new Text(email.Sender.FirstName).FontSize(12).FontWeight(FontWeight.Medium).Color(T.OnSurface), + new Text(email.CreatedAt).FontSize(12).FontWeight(FontWeight.Medium).Color(T.OnSurfaceVariant), + }.Padding(new Thickness(12, 4, 12, 4)).FlexGrow(1).FlexBasis(0), + new Icon("star_border").IconSize(24).Color(T.Outline) + .Frame(width: 40, height: 40) + .Background(T.SurfaceContainerHigh).CornerRadius(20) + .FlexShrink(0), + }, + new Text(email.Subject).FontSize(16).Color(T.OnSurface) + .Padding(new Thickness(0, 12, 0, 8)), + // Gold: bodyMedium, maxLines 2, ellipsis, RAW line breaks kept + // (ReplyEmailListItem.kt:134-139 — short first lines ellipsize on line 2). + new Text(email.Body).FontSize(14).Color(T.OnSurfaceVariant) + .LineBreakMode(LineBreakMode.WordWrap) + .MaxLines(2), + } + .Padding(new Thickness(20)) + // Gold precedence (:72-76): SELECTED wins (primaryContainer), then OPENED + // (secondaryContainer — the home state opens the first email at launch), else + // surfaceVariant. + .Background(SelectedIds.Contains(email.Id) ? T.PrimaryContainer + : email.Id == OpenedEmailId.Value ? T.SecondaryContainer : T.SurfaceVariant) + .CornerRadius(16) + .OnTap(_ => + { + OpenedEmailId.Value = (int)email.Id; + DetailOpen.Value = true; + }) + // Gold combinedClickable onLongClick: long-press toggles selection. + .OnLongPress(_ => ToggleSelection(email.Id)), + }.Padding(new Thickness(16, 4, 16, 4)); + + // ── Detail pane (gold ReplyEmailDetail :207-225 + EmailDetailAppBar) ── + static View EmailDetail() + { + var stack = new VStack(spacing: 0f) { DetailAppBar() }; + var threads = new ListView(() => Opened().Threads ?? ReplyData.Threads) + { + ViewFor = t => ThreadItem(t), + }; + stack.Add(threads.FlexGrow(1).FlexBasis(0)); + return stack.Background(T.InverseOnSurface); + } + + // Gold EmailDetailAppBar (ReplyAppBars.kt:173-228): inverseOnSurface bar; back + // FilledIconButton; centered subject titleMedium + "N Messages" labelMedium. + static View DetailAppBar() => new HStack(spacing: 0f) + { + new Icon("arrow_back").IconSize(14).Color(T.OnSurface) + .Padding(new Thickness(13)) // centers the 14dp glyph in the 40dp circle + .Frame(width: 40, height: 40) + .Background(T.Surface).CornerRadius(20) + .Margin(left: 8, top: 8, bottom: 8) + .FlexShrink(0) + .OnTap(_ => DetailOpen.Value = false), + new VStack(spacing: 2f) + { + new Text(() => Opened().Subject).FontSize(16).Color(T.OnSurfaceVariant) + .HorizontalLayoutAlignment(LayoutAlignment.Center), + new Text(() => $"{(Opened().Threads ?? ReplyData.Threads).Count} Messages").FontSize(12).Color(T.Outline) + .HorizontalLayoutAlignment(LayoutAlignment.Center), + }.FlexGrow(1).FlexBasis(0).VerticalLayoutAlignment(LayoutAlignment.Center), + new Icon("more_vert").IconSize(24).Color(T.OnSurfaceVariant) + .Frame(width: 48, height: 48) + .Margin(right: 4) + .FlexShrink(0), + }.Frame(height: 64); + + // ── One thread item (gold ReplyEmailThreadItem.kt:44-136); same wrapper/card split + // as the list rows. ── + static View ThreadItem(ReplyEmail email) => new VStack(spacing: 0f) + { + new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + new Image(email.Sender.Avatar).Frame(width: 40, height: 40).CornerRadius(20).FlexShrink(0), + new VStack(spacing: 0f) + { + new Text(email.Sender.FirstName).FontSize(12).Color(T.OnSurface), + new Text("20 mins ago").FontSize(12).Color(T.Outline), // gold hardcodes it (:72) + }.Padding(new Thickness(12, 4, 12, 4)).FlexGrow(1).FlexBasis(0), + new Icon("star_border").IconSize(24).Color(T.Outline) + .Frame(width: 40, height: 40) + .Background(T.SurfaceContainer).CornerRadius(20) + .FlexShrink(0), + }, + new Text(email.Subject).FontSize(14).Color(T.Outline) + .Padding(new Thickness(0, 12, 0, 8)), + new Text(email.Body).FontSize(16).Color(T.OnSurfaceVariant) + .LineBreakMode(LineBreakMode.WordWrap), + new HStack(spacing: 12f) + { + ThreadButton("Reply"), + ThreadButton("Reply All"), + }.Padding(new Thickness(0, 20, 0, 8)), + } + .Padding(new Thickness(20)) + .Background(T.SurfaceContainerHigh) + .CornerRadius(16), + }.Padding(new Thickness(16, 4, 16, 4)); + + // Gold: M3 Button, containerColor surfaceBright, onSurface text, weight(1f) each. + // Spacer rows/columns center the label (alignment positions a view in its parent). + static View ThreadButton(string label) => new HStack(spacing: 0f) + { + new HStack().FlexGrow(1), + new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + new Text(label).FontSize(14).Color(T.OnSurface), + new HStack().FlexGrow(1), + }, + new HStack().FlexGrow(1), + } + .Frame(height: 40) + .Background(T.SurfaceBright) + .CornerRadius(20) + .FlexGrow(1).FlexBasis(0); + + // ── EmptyComingSoon.kt ── + public static View ComingSoon() => new VStack(spacing: 8f) + { + new HStack().FlexGrow(1), + new Text("Screen under construction").FontSize(22).FontWeight(FontWeight.Bold) + .Color(T.Primary).HorizontalLayoutAlignment(LayoutAlignment.Center), + new Text("This screen is still under construction. This sample will help you learn\nabout adaptive layouts in Jetpack Compose") + .FontSize(12).Color(T.Outline) + .LineBreakMode(LineBreakMode.WordWrap) + .HorizontalLayoutAlignment(LayoutAlignment.Center) + .Padding(new Thickness(24, 0)), + new HStack().FlexGrow(1), + }.Background(T.Background); + } +} diff --git a/src/Comet/sample/Shared/Reply/ReplyTheme.cs b/src/Comet/sample/Shared/Reply/ReplyTheme.cs new file mode 100644 index 000000000..11fe87525 --- /dev/null +++ b/src/Comet/sample/Shared/Reply/ReplyTheme.cs @@ -0,0 +1,82 @@ +#nullable enable +using Microsoft.Maui.Graphics; + +namespace CometSamples.Reply +{ + /// Reply's static light scheme — every value a literal from the gold's + /// ui/theme/Color.kt (ContrastAwareReplyTheme defaults dynamicColor=false, default + /// contrast → lightScheme). Deterministic: fidelity compares exact hex, no seed + /// generation. Dark + contrast variants land at the fidelity pass if needed. + public static class ReplyTheme + { + public static readonly Color Primary = Color.FromArgb("#805610"); + public static readonly Color OnPrimary = Color.FromArgb("#FFFFFF"); + public static readonly Color PrimaryContainer = Color.FromArgb("#FFDDB3"); + public static readonly Color OnPrimaryContainer = Color.FromArgb("#291800"); + public static readonly Color Secondary = Color.FromArgb("#6F5B40"); + public static readonly Color OnSecondary = Color.FromArgb("#FFFFFF"); + public static readonly Color SecondaryContainer = Color.FromArgb("#FBDEBC"); + public static readonly Color OnSecondaryContainer = Color.FromArgb("#271904"); + public static readonly Color Tertiary = Color.FromArgb("#51643F"); + public static readonly Color OnTertiary = Color.FromArgb("#FFFFFF"); + public static readonly Color TertiaryContainer = Color.FromArgb("#D4EABB"); + public static readonly Color OnTertiaryContainer = Color.FromArgb("#102004"); + public static readonly Color Error = Color.FromArgb("#BA1A1A"); + public static readonly Color OnError = Color.FromArgb("#FFFFFF"); + public static readonly Color ErrorContainer = Color.FromArgb("#FFDAD6"); + public static readonly Color OnErrorContainer = Color.FromArgb("#410002"); + public static readonly Color Background = Color.FromArgb("#FFF8F4"); + public static readonly Color OnBackground = Color.FromArgb("#201B13"); + public static readonly Color Surface = Color.FromArgb("#FFF8F4"); + public static readonly Color OnSurface = Color.FromArgb("#201B13"); + public static readonly Color SurfaceVariant = Color.FromArgb("#F0E0CF"); + public static readonly Color OnSurfaceVariant = Color.FromArgb("#4F4539"); + public static readonly Color Outline = Color.FromArgb("#817567"); + public static readonly Color OutlineVariant = Color.FromArgb("#D3C4B4"); + public static readonly Color Scrim = Color.FromArgb("#000000"); + public static readonly Color InverseSurface = Color.FromArgb("#362F27"); + public static readonly Color InverseOnSurface = Color.FromArgb("#FCEFE2"); + public static readonly Color InversePrimary = Color.FromArgb("#F4BD6F"); + public static readonly Color SurfaceDim = Color.FromArgb("#E4D8CC"); + public static readonly Color SurfaceBright = Color.FromArgb("#FFF8F4"); + public static readonly Color SurfaceContainerLowest = Color.FromArgb("#FFFFFF"); + public static readonly Color SurfaceContainerLow = Color.FromArgb("#FFF1E5"); + public static readonly Color SurfaceContainer = Color.FromArgb("#F9ECDF"); + public static readonly Color SurfaceContainerHigh = Color.FromArgb("#F3E6DA"); + public static readonly Color SurfaceContainerHighest = Color.FromArgb("#EDE0D4"); + +#if ANDROID + /// The matching Compose ColorScheme, so the REAL Material widgets + /// (NavigationBar/Rail, drawer sheets, ripples) render Reply's palette — the gold's + /// M3 defaults derive from these roles (e.g. the nav bar container is + /// surfaceContainer, the selection pill secondaryContainer). + public static AndroidX.Compose.Material3.ColorScheme ComposeScheme() + { + static AndroidX.Compose.Color C(Color c) => AndroidX.Compose.Color.FromArgb( + (byte)(c.Alpha * 255), (byte)(c.Red * 255), (byte)(c.Green * 255), (byte)(c.Blue * 255)); + return AndroidX.Compose.MaterialTheme.LightColorScheme( + primary: C(Primary), onPrimary: C(OnPrimary), + primaryContainer: C(PrimaryContainer), onPrimaryContainer: C(OnPrimaryContainer), + inversePrimary: C(InversePrimary), + secondary: C(Secondary), onSecondary: C(OnSecondary), + secondaryContainer: C(SecondaryContainer), onSecondaryContainer: C(OnSecondaryContainer), + tertiary: C(Tertiary), onTertiary: C(OnTertiary), + tertiaryContainer: C(TertiaryContainer), onTertiaryContainer: C(OnTertiaryContainer), + background: C(Background), onBackground: C(OnBackground), + surface: C(Surface), onSurface: C(OnSurface), + surfaceVariant: C(SurfaceVariant), onSurfaceVariant: C(OnSurfaceVariant), + inverseSurface: C(InverseSurface), inverseOnSurface: C(InverseOnSurface), + error: C(Error), onError: C(OnError), + errorContainer: C(ErrorContainer), onErrorContainer: C(OnErrorContainer), + outline: C(Outline), outlineVariant: C(OutlineVariant), + scrim: C(Scrim), + surfaceBright: C(SurfaceBright), surfaceDim: C(SurfaceDim), + surfaceContainer: C(SurfaceContainer), + surfaceContainerHigh: C(SurfaceContainerHigh), + surfaceContainerHighest: C(SurfaceContainerHighest), + surfaceContainerLow: C(SurfaceContainerLow), + surfaceContainerLowest: C(SurfaceContainerLowest)); + } +#endif + } +} diff --git a/src/Comet/sample/Shared/Reply/gold/README.md b/src/Comet/sample/Shared/Reply/gold/README.md new file mode 100644 index 000000000..9ccb4f262 --- /dev/null +++ b/src/Comet/sample/Shared/Reply/gold/README.md @@ -0,0 +1,44 @@ +# Reply gold screenshots (fidelity contract) + +Captured from the **Kotlin Reply app** (`~/work/compose-samples/Reply`, debug build) +running on the **Pixel_9_Pro emulator** (API 36, physical 1280x2856 px, density 480 → +3.0x scale, 427x952 dp). Adaptive states were driven with `adb shell wm size ` +(density unchanged), matching the smoke-script `android_resize` verb. + +## Capture matrix + +| File | wm size (px) | Window dp | Nav chrome | Content | +|---|---|---|---|---| +| compact-01-inbox.png | 1280x2856 (reset) | 427x952 | Bottom nav bar | Inbox list, docked search bar + profile, expanded Compose FAB | +| compact-02-detail.png | 1280x2856 | 427x952 | Bottom nav bar | Email detail (full-screen), back button + subject app bar, Reply/Reply All per thread item | +| compact-03-articles-comingsoon.png | 1280x2856 | 427x952 | Bottom nav bar | EmptyComingSoon ("Screen under construction") | +| compact-04-search-active.png | 1280x2856 | 427x952 | Bottom nav bar | DockedSearchBar active: back arrow, "No search history", scrim over list | +| compact-05-scrolled-fab-collapsed.png | 1280x2856 | 427x952 | Bottom nav bar | List scrolled; ExtendedFAB collapsed to icon-only (`expanded = lastScrolledBackward \|\| !canScrollBackward`) | +| medium-700dp-01-inbox-rail.png | 2100x2856 | 700x952 | Navigation rail | Rail: menu, FAB, 4 destinations (centered); single-pane list | +| medium-700dp-02-modal-drawer-open.png | 2100x2856 | 700x952 | Modal drawer over rail | REPLY header + close icon, Compose extended FAB, 4 drawer items, scrim | +| expanded-960dp-01-twopane-rail.png | 2880x2856 | 960x952 | Navigation rail | TwoPane 50/50: list + detail (openedEmail ?: first email) | +| expanded-1260dp-01-twopane-permanent-drawer.png | 3780x2856 | 1260x952 | Permanent drawer | Drawer (REPLY, Compose extended FAB, labeled items) + two-pane | + +## Breakpoints (from source — ReplyNavigationComponents.kt / ReplyApp.kt) + +- **Bottom nav:** width < 600dp OR height < 480dp (`isCompact()`), or tabletop posture. +- **Permanent drawer:** width-class Expanded (≥ 840dp) **AND window width ≥ 1200dp**. +- **Nav rail:** everything in between (600–1199dp). +- **Two-pane content (`DUAL_PANE`):** width-class Expanded (≥ 840dp) — so 840–1199dp + is *rail + two-pane*; Medium (600–839dp) stays single-pane (unless folding posture). +- Modal drawer opens **only from the rail's menu item** (gestures disabled on + compact/permanent; bottom nav has no entry point). + +## Caveats (do not build these) + +- **The warm beige/green scheme is Reply's OWN static palette, not Material You.** + `ContrastAwareReplyTheme` defaults `dynamicColor = false` (Theme.kt:298) and + MainActivity passes nothing; every color is a literal in `ui/theme/Color.kt` + (primary `#805610`, tertiaryContainer `#D4EABB` = the green FAB, background + `#FFF8F4`). Fidelity uses those exact hex values — deterministic on any device, + no seed generator needed. (Contrast variants exist behind UiModeManager contrast; + default contrast = `lightScheme`/`darkScheme`.) +- Status-bar clock/battery and the bottom gesture handle are OS chrome. +- compact-04: soft keyboard is not visible because the emulator reports a hardware + keyboard (`show_ime_with_hard_keyboard` was enabled during capture to suppress + Gboard's floating toolbar pill, which contaminated an earlier take). diff --git a/src/Comet/sample/Shared/Reply/gold/compact-01-inbox.png b/src/Comet/sample/Shared/Reply/gold/compact-01-inbox.png new file mode 100644 index 000000000..520685462 Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-01-inbox.png differ diff --git a/src/Comet/sample/Shared/Reply/gold/compact-02-detail.png b/src/Comet/sample/Shared/Reply/gold/compact-02-detail.png new file mode 100644 index 000000000..0cd162074 Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-02-detail.png differ diff --git a/src/Comet/sample/Shared/Reply/gold/compact-03-articles-comingsoon.png b/src/Comet/sample/Shared/Reply/gold/compact-03-articles-comingsoon.png new file mode 100644 index 000000000..56196e54d Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-03-articles-comingsoon.png differ diff --git a/src/Comet/sample/Shared/Reply/gold/compact-04-search-active.png b/src/Comet/sample/Shared/Reply/gold/compact-04-search-active.png new file mode 100644 index 000000000..f3041957a Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-04-search-active.png differ diff --git a/src/Comet/sample/Shared/Reply/gold/compact-05-scrolled-fab-collapsed.png b/src/Comet/sample/Shared/Reply/gold/compact-05-scrolled-fab-collapsed.png new file mode 100644 index 000000000..3c61f5f06 Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-05-scrolled-fab-collapsed.png differ diff --git a/src/Comet/sample/Shared/Reply/gold/expanded-1260dp-01-twopane-permanent-drawer.png b/src/Comet/sample/Shared/Reply/gold/expanded-1260dp-01-twopane-permanent-drawer.png new file mode 100644 index 000000000..1748991a4 Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/expanded-1260dp-01-twopane-permanent-drawer.png differ diff --git a/src/Comet/sample/Shared/Reply/gold/expanded-960dp-01-twopane-rail.png b/src/Comet/sample/Shared/Reply/gold/expanded-960dp-01-twopane-rail.png new file mode 100644 index 000000000..7da20d6df Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/expanded-960dp-01-twopane-rail.png differ diff --git a/src/Comet/sample/Shared/Reply/gold/medium-700dp-01-inbox-rail.png b/src/Comet/sample/Shared/Reply/gold/medium-700dp-01-inbox-rail.png new file mode 100644 index 000000000..580fbbbc3 Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/medium-700dp-01-inbox-rail.png differ diff --git a/src/Comet/sample/Shared/Reply/gold/medium-700dp-02-modal-drawer-open.png b/src/Comet/sample/Shared/Reply/gold/medium-700dp-02-modal-drawer-open.png new file mode 100644 index 000000000..36d7efff4 Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/medium-700dp-02-modal-drawer-open.png differ diff --git a/src/Comet/sample/Shared/RuntimeDebug/SampleRuntimeDebugExtensions.cs b/src/Comet/sample/Shared/RuntimeDebug/SampleRuntimeDebugExtensions.cs index 21a3e2b9c..9c397fee8 100644 --- a/src/Comet/sample/Shared/RuntimeDebug/SampleRuntimeDebugExtensions.cs +++ b/src/Comet/sample/Shared/RuntimeDebug/SampleRuntimeDebugExtensions.cs @@ -1,7 +1,7 @@ #if DEBUG using System; using Comet; -using MauiDevFlow.Agent; +using Microsoft.Maui.DevFlow.Agent; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Maui; diff --git a/src/Comet/src/Comet.SourceGenerator/AutoNotifyGenerator.cs b/src/Comet/src/Comet.SourceGenerator/AutoNotifyGenerator.cs index 65bb31c02..2ee45c9b7 100644 --- a/src/Comet/src/Comet.SourceGenerator/AutoNotifyGenerator.cs +++ b/src/Comet/src/Comet.SourceGenerator/AutoNotifyGenerator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Text; @@ -12,7 +13,7 @@ namespace Comet.SourceGenerator { [Generator] - public class AutoNotifyGenerator : ISourceGenerator + public class AutoNotifyGenerator : IIncrementalGenerator { private const string attributeText = @" using System; @@ -29,43 +30,42 @@ public AutoNotifyAttribute() } "; - public void Initialize(GeneratorInitializationContext context) + public void Initialize(IncrementalGeneratorInitializationContext context) { - context.RegisterForPostInitialization((i) => i.AddSource("AutoNotifyAttribute", attributeText)); - // Register a syntax receiver that will be created for each generation pass - context.RegisterForSyntaxNotifications(() => new SyntaxReceiver()); + context.RegisterPostInitializationOutput((i) => i.AddSource("AutoNotifyAttribute", attributeText)); + + var fields = context.SyntaxProvider.ForAttributeWithMetadataName( + "Comet.AutoNotifyAttribute", + predicate: static (node, _) => node is VariableDeclaratorSyntax, + transform: static (ctx, _) => ctx.TargetSymbol as IFieldSymbol) + .Where(static f => f != null) + .Collect(); + + var compilationAndFields = context.CompilationProvider.Combine(fields); + + context.RegisterSourceOutput(compilationAndFields, static (spc, source) => Execute(source.Left, source.Right, spc)); } - public void Execute(GeneratorExecutionContext context) + static void Execute(Compilation compilation, ImmutableArray fields, SourceProductionContext context) { - // add the attribute text - - // retreive the populated receiver - if (!(context.SyntaxContextReceiver is SyntaxReceiver receiver) || !receiver.Fields.Any()) + if (fields.IsDefaultOrEmpty) return; - // we're going to create a new compilation that contains the attribute. - // TODO: we should allow source generators to provide source during initialize, so that this step isn't required. - CSharpParseOptions options = (context.Compilation as CSharpCompilation).SyntaxTrees[0].Options as CSharpParseOptions; - Compilation compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(attributeText, Encoding.UTF8), options)); - - var attributeSymbol = context.Compilation.GetTypeByMetadataName("Comet.AutoNotifyAttribute"); - var notifySymbol = context.Compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged"); + var attributeSymbol = compilation.GetTypeByMetadataName("Comet.AutoNotifyAttribute"); + var notifySymbol = compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged"); var notifyReadSymbol = compilation.GetTypeByMetadataName("Comet.INotifyPropertyRead"); var autoImplementedSymbol = compilation.GetTypeByMetadataName("Comet.IAutoImplemented"); // group the fields by class, and generate the source - foreach (IGrouping group in receiver.Fields.GroupBy(f => f.ContainingType)) + foreach (IGrouping group in fields.GroupBy(f => f.ContainingType, SymbolEqualityComparer.Default)) { string classSource = ProcessClass(group.Key, group.ToList(), attributeSymbol, notifySymbol, notifyReadSymbol, autoImplementedSymbol, context); if(!string.IsNullOrWhiteSpace(classSource)) context.AddSource($"{group.Key.Name}_autoNotify.cs", SourceText.From(classSource, Encoding.UTF8)); } - - - } - private string ProcessClass(INamedTypeSymbol classSymbol, List fields, ISymbol attributeSymbol, ISymbol notifySymbol, ISymbol notifyReadSymbol, ISymbol autoImplementedSymbol, GeneratorExecutionContext context) + + private static string ProcessClass(INamedTypeSymbol classSymbol, List fields, ISymbol attributeSymbol, ISymbol notifySymbol, ISymbol notifyReadSymbol, ISymbol autoImplementedSymbol, SourceProductionContext context) { if (!classSymbol.ContainingSymbol.Equals(classSymbol.ContainingNamespace, SymbolEqualityComparer.Default)) { @@ -105,7 +105,7 @@ public partial class {classSymbol.Name} : {notifyReadSymbol.ToDisplayString()} , return source.ToString(); } - private void ProcessField(StringBuilder source, IFieldSymbol fieldSymbol, ISymbol attributeSymbol) + private static void ProcessField(StringBuilder source, IFieldSymbol fieldSymbol, ISymbol attributeSymbol) { // get the name and type of the field string fieldName = fieldSymbol.Name; @@ -157,33 +157,5 @@ string chooseName(string fieldName, TypedConstant overridenNameOpt) } - /// - /// Created on demand before each generation pass - /// - class SyntaxReceiver : ISyntaxContextReceiver - { - public List Fields { get; } = new List(); - - /// - /// Called for every syntax node in the compilation, we can inspect the nodes and save any information useful for generation - /// - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - // any field with at least one attribute is a candidate for property generation - if (context.Node is FieldDeclarationSyntax fieldDeclarationSyntax - && fieldDeclarationSyntax.AttributeLists.Count > 0) - { - foreach (VariableDeclaratorSyntax variable in fieldDeclarationSyntax.Declaration.Variables) - { - // Get the symbol being declared by the field, and keep it if its annotated - IFieldSymbol fieldSymbol = context.SemanticModel.GetDeclaredSymbol(variable) as IFieldSymbol; - if (fieldSymbol.GetAttributes().Any(ad => ad.AttributeClass.ToDisplayString() == "Comet.AutoNotifyAttribute")) - { - Fields.Add(fieldSymbol); - } - } - } - } - } } } diff --git a/src/Comet/src/Comet.SourceGenerator/CometViewSourceGenerator.cs b/src/Comet/src/Comet.SourceGenerator/CometViewSourceGenerator.cs index cdf73947e..19feda98e 100644 --- a/src/Comet/src/Comet.SourceGenerator/CometViewSourceGenerator.cs +++ b/src/Comet/src/Comet.SourceGenerator/CometViewSourceGenerator.cs @@ -14,7 +14,7 @@ namespace Comet.SourceGenerator { [Generator] - public class CometViewSourceGenerator : ISourceGenerator + public class CometViewSourceGenerator : IIncrementalGenerator { private const string attributeSource = @" [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)] @@ -244,25 +244,39 @@ static CometViewSourceGenerator() }; } - Stubble.Core.StubbleVisitorRenderer stubble = new StubbleBuilder().Build(); - public void Execute(GeneratorExecutionContext context) + static readonly Stubble.Core.StubbleVisitorRenderer stubble = new StubbleBuilder().Build(); + + public void Initialize(IncrementalGeneratorInitializationContext context) { + context.RegisterPostInitializationOutput(pi => pi.AddSource("CometGenerationAttribute__", attributeSource)); - var rx = (SyntaxReceiver)context.SyntaxContextReceiver!; + var templateInfos = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => node is AttributeSyntax attrib && IsCometGenerateAttributeName(attrib), + transform: static (ctx, _) => GetTemplateInfo(ctx)) + .Where(static info => info != null); - foreach (var item in rx.TemplateInfo) + context.RegisterSourceOutput(templateInfos, static (spc, item) => { - var input = GetModelData(item.name, item.interfaceType, item.keyProperties, item.nameSpace, item.baseClass, item.propertyNameTransforms, item.propertyDefaultValues, item.skippedProperties); + var input = GetModelData(item.Name, item.InterfaceType, item.KeyProperties, item.NameSpace, item.BaseClass, item.PropertyNameTransforms, item.PropertyDefaultValues, item.SkippedProperties); var classSource = stubble.Render(classMustacheTemplate, input); - context.AddSource($"{item.name}.g.cs", classSource); + spc.AddSource($"{item.Name}.g.cs", classSource); var extensionSource = stubble.Render(extensionMustacheTemplate, input); - context.AddSource($"{item.name}Extension.g.cs", extensionSource); + spc.AddSource($"{item.Name}Extension.g.cs", extensionSource); var factorySource = stubble.Render(factoryMustacheTemplate, input); - context.AddSource($"{item.name}Factory.g.cs", factorySource); - } + spc.AddSource($"{item.Name}Factory.g.cs", factorySource); + }); + } + + static bool IsCometGenerateAttributeName(AttributeSyntax attrib) + { + var name = attrib.Name; + while (name is QualifiedNameSyntax qn) + name = qn.Right; + var id = (name as SimpleNameSyntax)?.Identifier.ValueText; + return id == "CometGenerate" || id == "CometGenerateAttribute"; } public static string GetFullName(ISymbol symbol, string ending = null) { @@ -288,7 +302,7 @@ static string Combine(string first, string second) return $"{first}.{second}"; } - dynamic GetModelData(string name, INamedTypeSymbol interfaceType, List keyProperties, string nameSpace, INamedTypeSymbol baseClass, Dictionary propertyNameTransforms, Dictionary propertyDefaultValues, List skippedProperties) + static dynamic GetModelData(string name, INamedTypeSymbol interfaceType, List keyProperties, string nameSpace, INamedTypeSymbol baseClass, Dictionary propertyNameTransforms, Dictionary propertyDefaultValues, List skippedProperties) { var interfaces = interfaceType.AllInterfaces.ToList(); interfaces.Insert(0, interfaceType); @@ -570,181 +584,176 @@ string getNewName(string key) return input; } - public void Initialize(GeneratorInitializationContext context) + sealed class TemplateInfo { - //if (!Debugger.IsAttached) - //{ - // Debugger.Launch(); - //} - - context.RegisterForPostInitialization((pi) => pi.AddSource("CometGenerationAttribute__", attributeSource)); - context.RegisterForSyntaxNotifications(() => new SyntaxReceiver()); + public string Name; + public INamedTypeSymbol InterfaceType; + public List KeyProperties; + public string NameSpace; + public INamedTypeSymbol BaseClass; + public Dictionary PropertyNameTransforms; + public Dictionary PropertyDefaultValues; + public List SkippedProperties; } - static INamedTypeSymbol cometViewSymbol; - class SyntaxReceiver : ISyntaxContextReceiver - { - public List<(string name, INamedTypeSymbol interfaceType, List keyProperties, string nameSpace, INamedTypeSymbol baseClass, Dictionary propertyNameTransforms, Dictionary propertyDefaultValues, List skippedProperties)> TemplateInfo = new(); - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) + static TemplateInfo GetTemplateInfo(GeneratorSyntaxContext context) + { + var cometView = context.SemanticModel.Compilation.GetTypeByMetadataName("Comet.View"); + var attrib = context.Node as AttributeSyntax; + if (attrib == null) + return null; + if (context.SemanticModel.GetTypeInfo(attrib).Type?.ToDisplayString() != "CometGenerateAttribute") + return null; + + var f = attrib.ArgumentList.Arguments[0].Expression as TypeOfExpressionSyntax; + var realClass = GetType(context, f, cometView); + + var keyProperties = new List(); + string name = null; + string nameSpace = null; + INamedTypeSymbol baseClass = cometView; + List defaultValues = new(); + List skippedProperties = new(); + + foreach (var arg in attrib.ArgumentList.Arguments.Skip(1)) { - var cometView = context.SemanticModel.Compilation.GetTypeByMetadataName("Comet.View"); - cometViewSymbol ??= cometView; - var attrib = context.Node as AttributeSyntax; - if (attrib != null) + var constVal = context.SemanticModel.GetConstantValue(arg.Expression); + var symbol = context.SemanticModel.GetSymbolInfo(arg.Expression); + var argName = arg.NameEquals?.Name.Identifier.ValueText; + if (argName == "ClassName") { - var type = context.SemanticModel.GetTypeInfo(attrib); - if (context.SemanticModel.GetTypeInfo(attrib).Type?.ToDisplayString() == "CometGenerateAttribute") - { - { - - var f = attrib.ArgumentList.Arguments[0].Expression as TypeOfExpressionSyntax; - var realClass = GetType(context, f); - - var keyProperties = new List(); - string name = null; - string nameSpace = null; - INamedTypeSymbol baseClass = cometView; - List defaultValues = new(); - List skippedProperties = new(); - - foreach (var arg in attrib.ArgumentList.Arguments.Skip(1)) - { - var constVal = context.SemanticModel.GetConstantValue(arg.Expression); - var symbol = context.SemanticModel.GetSymbolInfo(arg.Expression); - var argName = arg.NameEquals?.Name.Identifier.ValueText; - if (argName == "ClassName") - { - name = constVal.ToString(); - continue; - } - - if (argName == "Namespace") - { - nameSpace = constVal.ToString(); - continue; - } - if (argName == "DefaultValues") - { - var iac = (arg.Expression as ImplicitArrayCreationExpressionSyntax).Initializer.Expressions; - foreach (var i in iac) - { - var fif = context.SemanticModel.GetConstantValue(i); - defaultValues.Add(fif.ToString()); - } - } - if (argName == "Skip") - { - var iac = (arg.Expression as ImplicitArrayCreationExpressionSyntax).Initializer.Expressions; - foreach (var i in iac) - { - var fif = context.SemanticModel.GetConstantValue(i); - skippedProperties.Add(fif.ToString()); - } - } - //Strings which can only be key properties - if (arg.Expression is LiteralExpressionSyntax || (arg.Expression is InvocationExpressionSyntax && constVal.HasValue) || arg.Expression is InterpolatedStringExpressionSyntax) - { - keyProperties.Add(constVal.ToString()); - continue; - } - - - if (arg.Expression is TypeOfExpressionSyntax toe) - { - //this is our base class! - baseClass = GetType(context, toe); - continue; - } - - var exp = arg.Expression; - } - name ??= realClass.Name?.TrimStart('I'); - nameSpace ??= GetFullName(realClass.ContainingNamespace); - - Dictionary propertyTransform = new(); - Dictionary propertyDefaultValues = new(); - (bool hasParts, string key) getParts(string oldKey) - { - var hasName = oldKey.Contains(':'); - var hasValue = oldKey.Contains('='); + name = constVal.ToString(); + continue; + } - if (!hasName && !hasValue) - { - return (false, null); - } - var parts = oldKey.Split(':', '='); - var key = parts[0].Trim(); - var newName = hasName ? parts[1].Trim() : null; - var defaultValue = hasValue ? parts.Last().Trim() : null; + if (argName == "Namespace") + { + nameSpace = constVal.ToString(); + continue; + } + if (argName == "DefaultValues") + { + var iac = (arg.Expression as ImplicitArrayCreationExpressionSyntax).Initializer.Expressions; + foreach (var i in iac) + { + var fif = context.SemanticModel.GetConstantValue(i); + defaultValues.Add(fif.ToString()); + } + } + if (argName == "Skip") + { + var iac = (arg.Expression as ImplicitArrayCreationExpressionSyntax).Initializer.Expressions; + foreach (var i in iac) + { + var fif = context.SemanticModel.GetConstantValue(i); + skippedProperties.Add(fif.ToString()); + } + } + //Strings which can only be key properties + if (arg.Expression is LiteralExpressionSyntax || (arg.Expression is InvocationExpressionSyntax && constVal.HasValue) || arg.Expression is InterpolatedStringExpressionSyntax) + { + keyProperties.Add(constVal.ToString()); + continue; + } - if (newName != null) - propertyTransform[key] = newName; + if (arg.Expression is TypeOfExpressionSyntax toe) + { + //this is our base class! + baseClass = GetType(context, toe, cometView); + continue; + } - if (defaultValue != null) - propertyDefaultValues[key] = defaultValue; - return (true, key); - } - foreach (var oldKey in keyProperties.ToList()) - { - (bool hasParts, string key) = getParts(oldKey); - if (!hasParts) - { - continue; - } - var oldIndex = keyProperties.IndexOf(oldKey); - keyProperties.RemoveAt(oldIndex); - keyProperties.Insert(oldIndex, key); - } + var exp = arg.Expression; + } + name ??= realClass.Name?.TrimStart('I'); + nameSpace ??= GetFullName(realClass.ContainingNamespace); + Dictionary propertyTransform = new(); + Dictionary propertyDefaultValues = new(); + (bool hasParts, string key) getParts(string oldKey) + { + var hasName = oldKey.Contains(':'); + var hasValue = oldKey.Contains('='); - foreach (var oldKey in defaultValues) - { - (bool hasParts, string key) = getParts(oldKey); - } - foreach (var oldKey in skippedProperties.ToList()) - { - (bool hasParts, string key) = getParts(oldKey); - if (!hasParts) - { - continue; - } - skippedProperties.Remove(oldKey); - skippedProperties.Add(key); - } + if (!hasName && !hasValue) + { + return (false, null); + } + var parts = oldKey.Split(':', '='); + var key = parts[0].Trim(); + var newName = hasName ? parts[1].Trim() : null; + var defaultValue = hasValue ? parts.Last().Trim() : null; - //string name = context.SemanticModel.GetTypeInfo(attrib.ArgumentList.Arguments[0].Expression).ToString(); - //string template = context.SemanticModel.GetConstantValue(attrib.ArgumentList.Arguments[1].Expression).ToString(); - //string hash = context.SemanticModel.GetConstantValue(attrib.ArgumentList.Arguments[2].Expression).ToString(); + if (newName != null) + propertyTransform[key] = newName; - TemplateInfo.Add((name, realClass, keyProperties, nameSpace, baseClass, propertyTransform, propertyDefaultValues, skippedProperties)); - } - } + if (defaultValue != null) + propertyDefaultValues[key] = defaultValue; + return (true, key); + } + foreach (var oldKey in keyProperties.ToList()) + { + (bool hasParts, string key) = getParts(oldKey); + if (!hasParts) + { + continue; } + var oldIndex = keyProperties.IndexOf(oldKey); + keyProperties.RemoveAt(oldIndex); + keyProperties.Insert(oldIndex, key); } - static INamedTypeSymbol GetType(GeneratorSyntaxContext context, TypeOfExpressionSyntax expression) + + foreach (var oldKey in defaultValues) { - var interfaceType = context.SemanticModel.GetSymbolInfo(expression.Type); - var symbol = interfaceType.Symbol; - if (symbol == null && interfaceType.CandidateSymbols.Length > 1) + (bool hasParts, string key) = getParts(oldKey); + } + foreach (var oldKey in skippedProperties.ToList()) + { + (bool hasParts, string key) = getParts(oldKey); + if (!hasParts) { - symbol = interfaceType.CandidateSymbols.OfType().Where(x=> IsSublcassOfCometView(x)).FirstOrDefault(); + continue; } - var s = GetFullName(symbol); - return context.SemanticModel.Compilation.GetTypeByMetadataName(s); + skippedProperties.Remove(oldKey); + skippedProperties.Add(key); } - static bool IsSublcassOfCometView(INamedTypeSymbol symbol) + + return new TemplateInfo { - if (symbol.BaseType == null) - return false; - if (symbol.BaseType == cometViewSymbol) - return true; + Name = name, + InterfaceType = realClass, + KeyProperties = keyProperties, + NameSpace = nameSpace, + BaseClass = baseClass, + PropertyNameTransforms = propertyTransform, + PropertyDefaultValues = propertyDefaultValues, + SkippedProperties = skippedProperties, + }; + } - return IsSublcassOfCometView(symbol.BaseType); + static INamedTypeSymbol GetType(GeneratorSyntaxContext context, TypeOfExpressionSyntax expression, INamedTypeSymbol cometViewSymbol) + { + var interfaceType = context.SemanticModel.GetSymbolInfo(expression.Type); + var symbol = interfaceType.Symbol; + if (symbol == null && interfaceType.CandidateSymbols.Length > 1) + { + symbol = interfaceType.CandidateSymbols.OfType().Where(x => IsSublcassOfCometView(x, cometViewSymbol)).FirstOrDefault(); } + var s = GetFullName(symbol); + return context.SemanticModel.Compilation.GetTypeByMetadataName(s); + } + + static bool IsSublcassOfCometView(INamedTypeSymbol symbol, INamedTypeSymbol cometViewSymbol) + { + if (symbol.BaseType == null) + return false; + if (SymbolEqualityComparer.Default.Equals(symbol.BaseType, cometViewSymbol)) + return true; + return IsSublcassOfCometView(symbol.BaseType, cometViewSymbol); } } } diff --git a/src/Comet/src/Comet.SourceGenerator/StateWrapperGenerator.cs b/src/Comet/src/Comet.SourceGenerator/StateWrapperGenerator.cs index 92a3e94d2..003b61c37 100644 --- a/src/Comet/src/Comet.SourceGenerator/StateWrapperGenerator.cs +++ b/src/Comet/src/Comet.SourceGenerator/StateWrapperGenerator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Text; @@ -10,7 +11,7 @@ namespace Comet.SourceGenerator { [Generator] - public class StateWrapperGenerator : ISourceGenerator + public class StateWrapperGenerator : IIncrementalGenerator { private const string attributeSource = @" [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class, AllowMultiple = true)] @@ -170,15 +171,72 @@ static StateWrapperGenerator() }; } - Stubble.Core.StubbleVisitorRenderer stubble = new StubbleBuilder().Build(); + static readonly Stubble.Core.StubbleVisitorRenderer stubble = new StubbleBuilder().Build(); static Dictionary<(bool HasGet, bool HasSet), string> interfacePropertyDictionary; - public void Execute(GeneratorExecutionContext context) + public void Initialize(IncrementalGeneratorInitializationContext context) { - if (!(context.SyntaxContextReceiver is SyntaxReceiver rx) || !rx.TemplateInfo.Any()) + context.RegisterPostInitializationOutput((pi) => pi.AddSource("GenerateStateClassAttribute__", attributeSource)); + + var candidates = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => IsCandidateNode(node), + transform: static (ctx, _) => GetCandidateType(ctx)) + .Where(static t => t != null) + .Collect(); + + var compilationAndCandidates = context.CompilationProvider.Combine(candidates); + + context.RegisterSourceOutput(compilationAndCandidates, static (spc, source) => Execute(source.Left, source.Right, spc)); + } + + static bool IsCandidateNode(Microsoft.CodeAnalysis.SyntaxNode node) + { + if (node is AttributeSyntax attrib) + return IsGenerateStateClassName(attrib); + if (node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0) + return cds.AttributeLists.SelectMany(al => al.Attributes).Any(IsGenerateStateClassName); + return false; + } + + static bool IsGenerateStateClassName(AttributeSyntax attrib) + { + var name = attrib.Name; + while (name is QualifiedNameSyntax qn) + name = qn.Right; + var id = (name as SimpleNameSyntax)?.Identifier.ValueText; + return id == "GenerateStateClass" || id == "GenerateStateClassAttribute"; + } + + static INamedTypeSymbol GetCandidateType(GeneratorSyntaxContext context) + { + if (context.Node is ClassDeclarationSyntax cds && context.SemanticModel.GetDeclaredSymbol(cds).GetAttributes().Any(x => x.AttributeClass.Name == "GenerateStateClassAttribute")) + { + return context.SemanticModel.GetDeclaredSymbol(cds).OriginalDefinition as INamedTypeSymbol; + } + + if (context.Node is AttributeSyntax attrib) + { + if (context.SemanticModel.GetTypeInfo(attrib).Type?.ToDisplayString() == "GenerateStateClassAttribute") + { + if (attrib.ArgumentList == null) + return null; + var f = attrib.ArgumentList.Arguments[0].Expression as TypeOfExpressionSyntax; + return GetType(context.SemanticModel, f); + } + } + return null; + } + + static void Execute(Compilation compilation, ImmutableArray candidates, SourceProductionContext context) + { + if (candidates.IsDefaultOrEmpty) return; - var st = context.Compilation.SyntaxTrees; + var rx = new TemplateCollector(); + foreach (var candidate in candidates) + rx.AddTemplate(candidate); + + var st = compilation.SyntaxTrees; void addChildStateClasses(IEnumerable match) { @@ -192,7 +250,7 @@ void addChildStateClasses(IEnumerable match) var select = false; if (sn is ClassDeclarationSyntax cds) { - var sm = context.Compilation.GetSemanticModel(cds.SyntaxTree); + var sm = compilation.GetSemanticModel(cds.SyntaxTree); var ts = sm.GetDeclaredSymbol(cds).OriginalDefinition as INamedTypeSymbol; select = matchWith.Any(c => c == ts || c.OriginalDefinition == ts); } @@ -204,7 +262,7 @@ void addChildStateClasses(IEnumerable match) var select = false; if (cn is PropertyDeclarationSyntax pds) { - var sm = context.Compilation.GetSemanticModel(pds.SyntaxTree); + var sm = compilation.GetSemanticModel(pds.SyntaxTree); var realClass = StateWrapperGenerator.GetType(sm, pds.Type); select = !(pds.Type is NullableTypeSyntax) && IsUserClass(realClass); } @@ -213,7 +271,7 @@ void addChildStateClasses(IEnumerable match) var parentStateClassesProps = props.Select(pds => { var p = (PropertyDeclarationSyntax)pds; - var sm = context.Compilation.GetSemanticModel(p.SyntaxTree); + var sm = compilation.GetSemanticModel(p.SyntaxTree); var realClass = StateWrapperGenerator.GetType(sm, p.Type); return realClass; }).ToList(); @@ -237,9 +295,9 @@ void addChildStateClasses(IEnumerable match) } } - bool IsUserClass(ITypeSymbol realClass) => realClass?.TypeKind == TypeKind.Class && realClass.SpecialType == SpecialType.None && !realClass.ToString().StartsWith("System"); //&& realClass.isnu; + static bool IsUserClass(ITypeSymbol realClass) => realClass?.TypeKind == TypeKind.Class && realClass.SpecialType == SpecialType.None && !realClass.ToString().StartsWith("System"); //&& realClass.isnu; - IEnumerable GetAllBaseTypes(INamedTypeSymbol classType) + static IEnumerable GetAllBaseTypes(INamedTypeSymbol classType) { yield return classType; if (classType.BaseType != null) @@ -249,7 +307,7 @@ IEnumerable GetAllBaseTypes(INamedTypeSymbol classType) } } - dynamic GetModelData(string className, INamedTypeSymbol classType, string nameSpace, SyntaxReceiver sr) + static dynamic GetModelData(string className, INamedTypeSymbol classType, string nameSpace, TemplateCollector sr) { var baseClasses = GetAllBaseTypes(classType).ToList(); List<(string Type, string Name)> properties = new(); @@ -373,18 +431,7 @@ dynamic GetModelData(string className, INamedTypeSymbol classType, string nameSp return input; } - public void Initialize(GeneratorInitializationContext context) - { - //if (!Debugger.IsAttached) - //{ - // Debugger.Launch(); - //} - - context.RegisterForPostInitialization((pi) => pi.AddSource("GenerateStateClassAttribute__", attributeSource)); - context.RegisterForSyntaxNotifications(() => new SyntaxReceiver()); - } - - class SyntaxReceiver : ISyntaxContextReceiver + class TemplateCollector { public List<(string name, INamedTypeSymbol classType, string nameSpace)> TemplateInfo = new(); @@ -397,27 +444,6 @@ public void AddTemplate(INamedTypeSymbol classType) TemplateInfo.Add((name, classType, nameSpace)); } } - - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - INamedTypeSymbol realClass; - if (context.Node is ClassDeclarationSyntax cds && context.SemanticModel.GetDeclaredSymbol(cds).GetAttributes().Any(x => x.AttributeClass.Name == "GenerateStateClassAttribute")) - { - realClass = context.SemanticModel.GetDeclaredSymbol(cds).OriginalDefinition as INamedTypeSymbol;//GetType(context, f); - AddTemplate(realClass); - } - - if (context.Node is AttributeSyntax attrib) - { - if (context.SemanticModel.GetTypeInfo(attrib).Type?.ToDisplayString() == "GenerateStateClassAttribute") - { - if (attrib.ArgumentList == null) return; - var f = attrib.ArgumentList.Arguments[0].Expression as TypeOfExpressionSyntax; - realClass = StateWrapperGenerator.GetType(context.SemanticModel, f); - AddTemplate(realClass); - } - } - } } static INamedTypeSymbol GetType(SemanticModel sm, TypeOfExpressionSyntax expression) diff --git a/src/Comet/src/Comet.SourceGenerator/StyleInfrastructureGenerator.cs b/src/Comet/src/Comet.SourceGenerator/StyleInfrastructureGenerator.cs index 367993d54..d17d6ff55 100644 --- a/src/Comet/src/Comet.SourceGenerator/StyleInfrastructureGenerator.cs +++ b/src/Comet/src/Comet.SourceGenerator/StyleInfrastructureGenerator.cs @@ -14,30 +14,47 @@ namespace Comet.SourceGenerator /// - Partial class with private state fields and ResolveCurrentStyle() method /// [Generator] - public class StyleInfrastructureGenerator : ISourceGenerator + public class StyleInfrastructureGenerator : IIncrementalGenerator { - public void Initialize(GeneratorInitializationContext context) + public void Initialize(IncrementalGeneratorInitializationContext context) { - context.RegisterForSyntaxNotifications(() => new SyntaxReceiver()); + var controlStates = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => node is AttributeSyntax attrib && IsCometControlStateName(attrib), + transform: static (ctx, _) => GetControlStateInfo(ctx)) + .Where(static info => info != null) + .Collect(); + + var compilationAndControlStates = context.CompilationProvider.Combine(controlStates); + + context.RegisterSourceOutput(compilationAndControlStates, static (spc, source) => Execute(source.Left, source.Right, spc)); + } + + static bool IsCometControlStateName(AttributeSyntax attrib) + { + var name = attrib.Name; + while (name is Microsoft.CodeAnalysis.CSharp.Syntax.QualifiedNameSyntax qn) + name = qn.Right; + var id = (name as Microsoft.CodeAnalysis.CSharp.Syntax.SimpleNameSyntax)?.Identifier.ValueText; + return id == "CometControlState" || id == "CometControlStateAttribute"; } - public void Execute(GeneratorExecutionContext context) + static void Execute(Compilation compilation, System.Collections.Immutable.ImmutableArray controlStates, SourceProductionContext context) { - if (!(context.SyntaxContextReceiver is SyntaxReceiver rx) || !rx.ControlStates.Any()) + if (controlStates.IsDefaultOrEmpty) return; - foreach (var info in rx.ControlStates) + foreach (var info in controlStates) { - EmitConfigurationStruct(context, info); - EmitStyleExtensions(context, info); - EmitResolveCurrentStyle(context, info); + EmitConfigurationStruct(context, compilation, info); + EmitStyleExtensions(context, compilation, info); + EmitResolveCurrentStyle(context, compilation, info); } } - static void EmitConfigurationStruct(GeneratorExecutionContext context, ControlStateInfo info) + static void EmitConfigurationStruct(SourceProductionContext context, Compilation compilation, ControlStateInfo info) { // Skip if hand-written version already exists in the compilation - var existing = context.Compilation.GetTypeByMetadataName( + var existing = compilation.GetTypeByMetadataName( $"Comet.Styles.{info.ControlName}Configuration"); if (existing != null) return; @@ -74,10 +91,10 @@ static void EmitConfigurationStruct(GeneratorExecutionContext context, ControlSt context.AddSource($"{info.ControlName}Configuration.g.cs", sb.ToString()); } - static void EmitStyleExtensions(GeneratorExecutionContext context, ControlStateInfo info) + static void EmitStyleExtensions(SourceProductionContext context, Compilation compilation, ControlStateInfo info) { // Skip if a style extension method already exists (e.g., hand-written ControlStyleExtensions) - if (HasExistingStyleExtension(context.Compilation, info.ControlName)) + if (HasExistingStyleExtension(compilation, info.ControlName)) return; var sb = new StringBuilder(); @@ -108,11 +125,11 @@ static void EmitStyleExtensions(GeneratorExecutionContext context, ControlStateI context.AddSource($"{info.ControlName}StyleExtensions.g.cs", sb.ToString()); } - static void EmitResolveCurrentStyle(GeneratorExecutionContext context, ControlStateInfo info) + static void EmitResolveCurrentStyle(SourceProductionContext context, Compilation compilation, ControlStateInfo info) { // Look up the actual config struct from the compilation to match its fields var configTypeName = $"Comet.Styles.{info.ControlName}Configuration"; - var configType = context.Compilation.GetTypeByMetadataName(configTypeName); + var configType = compilation.GetTypeByMetadataName(configTypeName); // Determine which state fields actually exist on the config struct var validStates = new List(); @@ -131,7 +148,7 @@ static void EmitResolveCurrentStyle(GeneratorExecutionContext context, ControlSt } // Check if Theme has the 2-type-param GetControlStyle() method - var themeType = context.Compilation.GetTypeByMetadataName("Comet.Styles.Theme"); + var themeType = compilation.GetTypeByMetadataName("Comet.Styles.Theme"); var hasNewStyleApi = themeType != null && themeType.GetMembers("GetControlStyle") .OfType() .Any(m => m.TypeParameters.Length == 2); @@ -249,90 +266,85 @@ class ControlStateInfo = new List<(string, string, string)>(); } - class SyntaxReceiver : ISyntaxContextReceiver + static ControlStateInfo GetControlStateInfo(GeneratorSyntaxContext context) { - public List ControlStates { get; } = new List(); + if (!(context.Node is AttributeSyntax attrib)) + return null; - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - if (!(context.Node is AttributeSyntax attrib)) - return; + var typeInfo = context.SemanticModel.GetTypeInfo(attrib); + var typeName = typeInfo.Type?.ToDisplayString(); - var typeInfo = context.SemanticModel.GetTypeInfo(attrib); - var typeName = typeInfo.Type?.ToDisplayString(); + if (typeName != "Comet.CometControlStateAttribute") + return null; - if (typeName != "Comet.CometControlStateAttribute") - return; + var args = attrib.ArgumentList?.Arguments; + if (args == null || args.Value.Count == 0) + return null; - var args = attrib.ArgumentList?.Arguments; - if (args == null || args.Value.Count == 0) - return; + // First argument: typeof(IButton) + if (!(args.Value[0].Expression is TypeOfExpressionSyntax typeOfExpr)) + return null; - // First argument: typeof(IButton) - if (!(args.Value[0].Expression is TypeOfExpressionSyntax typeOfExpr)) - return; + var interfaceSymbolInfo = context.SemanticModel.GetSymbolInfo(typeOfExpr.Type); + if (!(interfaceSymbolInfo.Symbol is INamedTypeSymbol interfaceSymbol)) + return null; - var interfaceSymbolInfo = context.SemanticModel.GetSymbolInfo(typeOfExpr.Type); - if (!(interfaceSymbolInfo.Symbol is INamedTypeSymbol interfaceSymbol)) - return; + var info = new ControlStateInfo + { + InterfaceType = interfaceSymbol, + ControlName = interfaceSymbol.Name.TrimStart('I'), + }; - var info = new ControlStateInfo - { - InterfaceType = interfaceSymbol, - ControlName = interfaceSymbol.Name.TrimStart('I'), - }; + // Parse named arguments + foreach (var arg in args.Value.Skip(1)) + { + var argName = arg.NameEquals?.Name.Identifier.ValueText; - // Parse named arguments - foreach (var arg in args.Value.Skip(1)) + if (argName == "ControlName") { - var argName = arg.NameEquals?.Name.Identifier.ValueText; - - if (argName == "ControlName") - { - var constVal = context.SemanticModel.GetConstantValue(arg.Expression); - if (constVal.HasValue) - info.ControlName = constVal.Value.ToString(); - } - else if (argName == "States") + var constVal = context.SemanticModel.GetConstantValue(arg.Expression); + if (constVal.HasValue) + info.ControlName = constVal.Value.ToString(); + } + else if (argName == "States") + { + foreach (var expr in GetArrayExpressions(arg.Expression)) { - foreach (var expr in GetArrayExpressions(arg.Expression)) - { - var val = context.SemanticModel.GetConstantValue(expr); - if (val.HasValue) - info.States.Add(val.Value.ToString()); - } + var val = context.SemanticModel.GetConstantValue(expr); + if (val.HasValue) + info.States.Add(val.Value.ToString()); } - else if (argName == "ConfigProperties") + } + else if (argName == "ConfigProperties") + { + foreach (var expr in GetArrayExpressions(arg.Expression)) { - foreach (var expr in GetArrayExpressions(arg.Expression)) + var val = context.SemanticModel.GetConstantValue(expr); + if (val.HasValue) { - var val = context.SemanticModel.GetConstantValue(expr); - if (val.HasValue) + var parts = val.Value.ToString().Split(':'); + if (parts.Length >= 2) { - var parts = val.Value.ToString().Split(':'); - if (parts.Length >= 2) - { - var name = parts[0].Trim(); - var type = parts[1].Trim(); - var source = parts.Length >= 3 ? parts[2].Trim() : name; - info.ConfigProperties.Add((name, type, source)); - } + var name = parts[0].Trim(); + var type = parts[1].Trim(); + var source = parts.Length >= 3 ? parts[2].Trim() : name; + info.ConfigProperties.Add((name, type, source)); } } } } - - ControlStates.Add(info); } - static IEnumerable GetArrayExpressions(ExpressionSyntax expr) - { - if (expr is ImplicitArrayCreationExpressionSyntax iac) - return iac.Initializer.Expressions; - if (expr is ArrayCreationExpressionSyntax ac && ac.Initializer != null) - return ac.Initializer.Expressions; - return Enumerable.Empty(); - } + return info; + } + + static IEnumerable GetArrayExpressions(ExpressionSyntax expr) + { + if (expr is ImplicitArrayCreationExpressionSyntax iac) + return iac.Initializer.Expressions; + if (expr is ArrayCreationExpressionSyntax ac && ac.Initializer != null) + return ac.Initializer.Expressions; + return Enumerable.Empty(); } } } diff --git a/src/Comet/src/Comet.SwiftUI.Binding/ApiDefinition.cs b/src/Comet/src/Comet.SwiftUI.Binding/ApiDefinition.cs new file mode 100644 index 000000000..33d6da5a5 --- /dev/null +++ b/src/Comet/src/Comet.SwiftUI.Binding/ApiDefinition.cs @@ -0,0 +1,111 @@ +using System; +using Foundation; +using ObjCRuntime; +using UIKit; + +namespace Comet.SwiftUI.Interop +{ + // An opaque handle to a Swift CometNode (a node in the SwiftUI backend tree). + [BaseType(typeof(NSObject), Name = "CometNode")] + interface CometNode + { + [Export("kind")] + string Kind { get; } + } + + // The @objc surface that drives the SwiftUI backend tree from C#. + [BaseType(typeof(NSObject), Name = "CometSwiftUIHost")] + interface CometSwiftUIHost + { + [Static, Export("makeNodeWithKind:")] + CometNode MakeNode(string kind); + + [Static, Export("setString:property:value:")] + void SetString(CometNode node, string property, string value); + + [Static, Export("setBool:property:value:")] + void SetBool(CometNode node, string property, bool value); + + [Static, Export("setColor:property:argb:")] + void SetColor(CometNode node, string property, uint argb); + + [Static, Export("setDouble:property:value:")] + void SetDouble(CometNode node, string property, double value); + + [Static, Export("scrollNodeToBottom:")] + void ScrollToBottom(CometNode node); + + [Static, Export("clearGradientStops:")] + void ClearGradientStops(CometNode node); + + [Static, Export("clearBorderGradientStops:")] + void ClearBorderGradientStops(CometNode node); + + [Static, Export("addBorderGradientStop:argb:")] + void AddBorderGradientStop(CometNode node, uint argb); + + [Static, Export("addGradientStop:argb:")] + void AddGradientStop(CometNode node, uint argb); + + [Static, Export("clearTextRuns:")] + void ClearTextRuns(CometNode node); + + [Static, Export("addTextRun:text:colorArgb:hasColor:mono:bgArgb:hasBg:underline:")] + void AddTextRun(CometNode node, string text, uint colorArgb, bool hasColor, bool mono, uint bgArgb, bool hasBg, bool underline); + + [Static, Export("setTapHandler:handler:")] + void SetTapHandler(CometNode node, Action handler); + + [Static, Export("setTapGestureHandler:handler:")] + void SetTapGestureHandler(CometNode node, Action handler); + + [Static, Export("setStringChangeHandler:handler:")] + void SetStringChangeHandler(CometNode node, Action handler); + + [Static, Export("setBoolChangeHandler:handler:")] + void SetBoolChangeHandler(CometNode node, Action handler); + + [Static, Export("setDoubleChangeHandler:handler:")] + void SetDoubleChangeHandler(CometNode node, Action handler); + + [Static, Export("setDialogDismissHandler:handler:")] + void SetDialogDismissHandler(CometNode node, Action handler); + + [Static, Export("setFocusHandler:handler:")] + void SetFocusHandler(CometNode node, Action handler); + + [Static, Export("setScrollHandler:handler:")] + void SetScrollHandler(CometNode node, Action handler); + + [Static, Export("setScrollTopHandler:handler:")] + void SetScrollTopHandler(CometNode node, Action handler); + + [Static, Export("setLongPressGestureHandler:handler:")] + void SetLongPressGestureHandler(CometNode node, Action handler); + + [Static, Export("setRowVisibilityHandler:handler:")] + void SetRowVisibilityHandler(CometNode node, Action handler); + + [Static, Export("insertChild:atIndex:child:")] + void InsertChild(CometNode node, nint index, CometNode child); + + [Static, Export("removeChild:atIndex:")] + void RemoveChild(CometNode node, nint index); + + [Static, Export("clearChildren:")] + void ClearChildren(CometNode node); + + [Static, Export("setFrame:x:y:width:height:")] + void SetFrame(CometNode node, double x, double y, double width, double height); + + [Static, Export("measureNode:maxWidth:maxHeight:")] + CoreGraphics.CGSize MeasureNode(CometNode node, double maxWidth, double maxHeight); + + [Static, Export("screenshotPNG")] + [return: NullAllowed] + Foundation.NSData ScreenshotPng(); + + [Static, Export("hostControllerForRoot:")] + UIViewController HostController(CometNode root); + } +} diff --git a/src/Comet/src/Comet.SwiftUI.Binding/Comet.SwiftUI.Binding.csproj b/src/Comet/src/Comet.SwiftUI.Binding/Comet.SwiftUI.Binding.csproj new file mode 100644 index 000000000..a802a0631 --- /dev/null +++ b/src/Comet/src/Comet.SwiftUI.Binding/Comet.SwiftUI.Binding.csproj @@ -0,0 +1,25 @@ + + + + + net11.0-ios + 17.0 + true + disable + disable + false + + + + + + + + + Framework + True + + + + diff --git a/src/Comet/src/Comet.SwiftUI.Shim/.gitignore b/src/Comet/src/Comet.SwiftUI.Shim/.gitignore new file mode 100644 index 000000000..fa9897b64 --- /dev/null +++ b/src/Comet/src/Comet.SwiftUI.Shim/.gitignore @@ -0,0 +1,4 @@ +# Build outputs — regenerate the xcframework with ./build-xcframework.sh +build/ +CometSwiftUIShim.xcframework/ +.build/ diff --git a/src/Comet/src/Comet.SwiftUI.Shim/Package.swift b/src/Comet/src/Comet.SwiftUI.Shim/Package.swift new file mode 100644 index 000000000..71e7c4f4a --- /dev/null +++ b/src/Comet/src/Comet.SwiftUI.Shim/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "CometSwiftUIShim", + platforms: [.iOS(.v16)], + products: [ + .library(name: "CometSwiftUIShim", type: .dynamic, targets: ["CometSwiftUIShim"]), + ], + targets: [ + .target(name: "CometSwiftUIShim", path: "Sources/CometSwiftUIShim"), + ] +) diff --git a/src/Comet/src/Comet.SwiftUI.Shim/README.md b/src/Comet/src/Comet.SwiftUI.Shim/README.md new file mode 100644 index 000000000..59705c88a --- /dev/null +++ b/src/Comet/src/Comet.SwiftUI.Shim/README.md @@ -0,0 +1,31 @@ +# CometSwiftUIShim + +A small Swift package that exposes **SwiftUI** behind an `@objc`-representable surface so +.NET for iOS can drive it **without Swift-ABI interop** — everything crosses the boundary +as Objective-C-compatible types, bound with a standard `.NET`-for-iOS binding +(`../Comet.SwiftUI.Binding`). + +This is the iOS counterpart to the vendored Jetpack Compose facade on Android: the +platform-specific UI-kit bridge that Comet-Next's `ICometBackendNode` SwiftUI backend +renders through. + +## Build + +```bash +./build-xcframework.sh # produces CometSwiftUIShim.xcframework (device + simulator) +``` + +The `.xcframework` is a build output (git-ignored); regenerate it before building the +binding or the `CometSwiftUIProbe` sample. + +## Why @objc instead of Swift bindings + +Stock .NET for iOS can't call Swift directly, but it has always bound Objective-C. A Swift +`@objc` class (NSObject subclass, explicit `@objc(Name)` selectors) is ObjC-callable, so +the binding is a plain `ApiDefinition` + `NativeReference` to the framework. iOS ships the +Swift runtime, so the dynamic framework resolves it via `@rpath`. This is the plan's +"fallback" path — in practice the lower-risk primary path (no `swift-dotnet-bindings` +dependency, no `CallConvSwift`). + +Verified on the iOS 18 / iPhone 16 simulator: a SwiftUI view hosted via +`UIHostingController`, with its text supplied from C#, renders correctly. diff --git a/src/Comet/src/Comet.SwiftUI.Shim/Sources/CometSwiftUIShim/CometSwiftUIShim.swift b/src/Comet/src/Comet.SwiftUI.Shim/Sources/CometSwiftUIShim/CometSwiftUIShim.swift new file mode 100644 index 000000000..4226f8267 --- /dev/null +++ b/src/Comet/src/Comet.SwiftUI.Shim/Sources/CometSwiftUIShim/CometSwiftUIShim.swift @@ -0,0 +1,1021 @@ +import UIKit +import SwiftUI + +// A retained node in the SwiftUI backend tree, mirroring the Compose ComposeNode. +// C# drives these through the @objc host functions; SwiftUI observes them so a property +// or child change re-renders the narrowest scope. @Published vars are Swift-only (the C# +// side mutates them via the host functions, never directly). +// A styled slice of a FormattedText (the Comet TextRun): text + optional colour, monospace face, +// background highlight, and underline — assembled into an AttributedString for rendering. +struct CometTextRun { + let text: String + let color: Color? + let mono: Bool + let background: Color? + let underline: Bool +} + +@objc(CometNode) public class CometNode: NSObject, ObservableObject, Identifiable { + @objc public let kind: String + @Published var runs: [CometTextRun] = [] // FormattedText styled runs (empty => plain text) + @Published var text: String = "" + @Published var placeholder: String = "" + @Published var imageUrl: String = "" + @Published var iconName: String = "" // cross-platform symbol name → SF Symbol + @Published var iconGlyph: String = "" // icon-font codepoint (when an icon font is used) + @Published var iconFontFamily: String = "" // icon-font family name + @Published var isOn: Bool = false + @Published var doubleValue: Double = 0 + @Published var children: [CometNode] = [] + @Published var backgroundARGB: UInt32 = 0 // 0 = none + @Published var gradientStops: [UInt32] = [] // linear-gradient fill (wins over background) + @Published var gradientDirection = 0 // 0 horizontal, 1 vertical, 2 diagonal (TL→BR) + @Published var opacity: CGFloat = 1 // Comet Opacity → SwiftUI .opacity(); 1 = opaque + @Published var isVisible: Bool = true // Comet IsVisible; false => hidden + non-interactive + @Published var textColorARGB: UInt32 = 0 // 0 = inherit (default foreground) + @Published var fontSize: CGFloat = 0 // 0 = default (body) + @Published var maxLines: Int = 0 // 0 = unlimited; else lineLimit + tail truncation + @Published var fontWeight: Int = 0 // 0 = default; otherwise Maui FontWeight (100–900) + @Published var fontFamily: String = "" // custom font family (e.g. "Montserrat"); "" = system + @Published var padding: CGFloat = 0 + // Per-edge content padding (Yoga sizes the frame to include it; leaves inset their content by it). + @Published var padTop: CGFloat = 0 + @Published var padLeading: CGFloat = 0 + @Published var padBottom: CGFloat = 0 + @Published var padTrailing: CGFloat = 0 + // Per-corner radii (top-left, top-right, bottom-right, bottom-left); clips content. + @Published var cornerTL: CGFloat = 0 + @Published var cornerTR: CGFloat = 0 + @Published var cornerBR: CGFloat = 0 + @Published var cornerBL: CGFloat = 0 + @Published var elevation: CGFloat = 0 // soft drop shadow depth + @Published var borderWidth: CGFloat = 0 // stroke width (e.g. avatar ring) + @Published var borderColorARGB: UInt32 = 0 // stroke color + @Published var borderGradientStops: [UInt32] = [] // gradient stroke (wins over color) + @Published var borderGradientDirection = 2 // 0 horizontal, 1 vertical, 2 diagonal (TL→BR), 3 radial + @Published var hasTapGesture: Bool = false // view carries a Comet TapGesture + @Published var hasLongPressGesture: Bool = false + @Published var borderless: Bool = false // TextField: no rounded-border box (foundation field) + @Published var outlined: Bool = false // Button: OutlinedButton (bordered, no fill) vs filled + @Published var drawerOpen: Bool = false // Drawer: side panel shown + @Published var fabExtended: Bool = true // Fab: true = show label (pill); false = icon-only + @Published var scrollToken: Int = 0 // List: bumped to animate the log to the newest row + // AlertDialog (presented as a native SwiftUI .alert). + @Published var dialogOpen: Bool = false + @Published var dialogMessage: String = "" + @Published var dialogButton: String = "OK" + // Yoga-computed parent-relative layout frame. hasFrame flips true once C# arranges this + // node; until then the view uses native SwiftUI layout (so rendering is unchanged). + @Published var frame: CGRect = .zero + @Published var hasFrame: Bool = false + @Published var contentTopInset: CGFloat = 0 // baseline-height inset (gold baselineHeight): pad content down + // Callbacks into C# (set via host fns). ObjC blocks so .NET binds them as Actions. + var onTap: (() -> Void)? // Button action (-> Clicked) + var onTapGesture: (() -> Void)? // arbitrary-view tap gesture (-> OnGesture(Tap)) + var onLongPressGesture: (() -> Void)? // long-press gesture (-> OnGesture(LongPress); Reply selection) + var onRowVisibility: ((Double, Double) -> Void)? // list rows: (index, 1=appeared/0=disappeared) -> scroll direction + var onChangeString: ((String) -> Void)? + var onChangeBool: ((Bool) -> Void)? + var onChangeDouble: ((Double) -> Void)? + var onDialogDismiss: (() -> Void)? // native .alert dismissed (-> DialogDismissed) + var onFocused: (() -> Void)? // TextField gained focus (-> Focused; gold onTextFieldFocused) + @Published var horizontal = false // "list": row axis (LazyHStack in a horizontal ScrollView) + @Published var iconFillFrame = false // "icon": non-square asset draws at the node frame + @Published var fontItalic = false + var onScroll: ((Double) -> Void)? // ScrollView scrolled (-> ScrollView.AtTop / ScrollOffset) + var onScrollTop: ((Double) -> Void)? // list first row visibility (-> ListView.ScrolledFromTop) + + public var id: ObjectIdentifier { ObjectIdentifier(self) } + + @objc public init(kind: String) { + self.kind = kind + super.init() + } +} + +// The @objc surface C# binds. All parameters/returns are ObjC-representable. +@objc(CometSwiftUIHost) public class CometSwiftUIHost: NSObject { + + @objc(makeNodeWithKind:) + public static func makeNode(_ kind: String) -> CometNode { + return CometNode(kind: kind) + } + + @objc(setString:property:value:) + public static func setString(_ node: CometNode, property: String, value: String) { + switch property { + case "text": node.text = value + case "placeholder": node.placeholder = value + case "imageurl": node.imageUrl = value + case "icon": node.iconName = value + case "iconglyph": node.iconGlyph = value + case "iconfontfamily": node.iconFontFamily = value + case "fontfamily": node.fontFamily = value + case "dialogmessage": node.dialogMessage = value + case "dialogbutton": node.dialogButton = value + default: break + } + } + + @objc(setBool:property:value:) + public static func setBool(_ node: CometNode, property: String, value: Bool) { + switch property { + case "ison": node.isOn = value + case "hastapgesture": node.hasTapGesture = value + case "haslongpressgesture": node.hasLongPressGesture = value + case "borderless": node.borderless = value + case "outlined": node.outlined = value + case "isvisible": node.isVisible = value + case "draweropen": node.drawerOpen = value + case "horizontal": node.horizontal = value + case "iconfillframe": node.iconFillFrame = value + case "fontitalic": node.fontItalic = value + case "fabextended": node.fabExtended = value + case "dialogopen": node.dialogOpen = value + default: break + } + } + + @objc(setColor:property:argb:) + public static func setColor(_ node: CometNode, property: String, argb: UInt32) { + switch property { + case "background": node.backgroundARGB = argb + case "textcolor": node.textColorARGB = argb + case "bordercolor": node.borderColorARGB = argb + default: break + } + } + + @objc(setDouble:property:value:) + public static func setDouble(_ node: CometNode, property: String, value: Double) { + switch property { + case "padding": node.padding = CGFloat(value) + case "pad.t": node.padTop = CGFloat(value) + case "pad.l": node.padLeading = CGFloat(value) + case "pad.b": node.padBottom = CGFloat(value) + case "pad.r": node.padTrailing = CGFloat(value) + case "value": node.doubleValue = value + case "corner.tl": node.cornerTL = CGFloat(value) + case "corner.tr": node.cornerTR = CGFloat(value) + case "corner.br": node.cornerBR = CGFloat(value) + case "corner.bl": node.cornerBL = CGFloat(value) + case "elevation": node.elevation = CGFloat(value) + case "opacity": node.opacity = CGFloat(value) + case "contenttopinset": node.contentTopInset = CGFloat(value) + case "fontsize": node.fontSize = CGFloat(value) + case "maxlines": node.maxLines = Int(value) + case "fontweight": node.fontWeight = Int(value) + case "borderwidth": node.borderWidth = CGFloat(value) + case "gradientdirection": node.gradientDirection = Int(value) + case "bordergradientdirection": node.borderGradientDirection = Int(value) + default: break + } + } + + // Animate a "list" node to its newest row (the ScrollViewReader observes scrollToken). + @objc(clearGradientStops:) + public static func clearGradientStops(_ node: CometNode) { + node.gradientStops = [] + } + + @objc(clearBorderGradientStops:) + public static func clearBorderGradientStops(_ node: CometNode) { + node.borderGradientStops = [] + } + + @objc(addBorderGradientStop:argb:) + public static func addBorderGradientStop(_ node: CometNode, argb: UInt32) { + node.borderGradientStops.append(argb) + } + + @objc(addGradientStop:argb:) + public static func addGradientStop(_ node: CometNode, argb: UInt32) { + node.gradientStops.append(argb) + } + + @objc(scrollNodeToBottom:) + public static func scrollToBottom(_ node: CometNode) { + node.scrollToken &+= 1 + } + + // FormattedText styled runs (rebuilt on each Text_Runs change). + @objc(clearTextRuns:) + public static func clearTextRuns(_ node: CometNode) { + node.runs.removeAll() + } + + @objc(addTextRun:text:colorArgb:hasColor:mono:bgArgb:hasBg:underline:) + public static func addTextRun(_ node: CometNode, text: String, colorArgb: UInt32, hasColor: Bool, + mono: Bool, bgArgb: UInt32, hasBg: Bool, underline: Bool) { + node.runs.append(CometTextRun( + text: text, + color: hasColor ? colorFromARGB(colorArgb) : nil, + mono: mono, + background: hasBg ? colorFromARGB(bgArgb) : nil, + underline: underline)) + } + + @objc(setTapHandler:handler:) + public static func setTapHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) { + node.onTap = handler + } + + @objc(setTapGestureHandler:handler:) + public static func setTapGestureHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) { + node.onTapGesture = handler + } + + @objc(setStringChangeHandler:handler:) + public static func setStringChangeHandler(_ node: CometNode, handler: @escaping @convention(block) (String) -> Void) { + node.onChangeString = handler + } + + @objc(setBoolChangeHandler:handler:) + public static func setBoolChangeHandler(_ node: CometNode, handler: @escaping @convention(block) (Bool) -> Void) { + node.onChangeBool = handler + } + + @objc(setDoubleChangeHandler:handler:) + public static func setDoubleChangeHandler(_ node: CometNode, handler: @escaping @convention(block) (Double) -> Void) { + node.onChangeDouble = handler + } + + @objc(setDialogDismissHandler:handler:) + public static func setDialogDismissHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) { + node.onDialogDismiss = handler + } + + @objc(setFocusHandler:handler:) + public static func setFocusHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) { + node.onFocused = handler + } + + @objc(setScrollHandler:handler:) + public static func setScrollHandler(_ node: CometNode, handler: @escaping @convention(block) (Double) -> Void) { + node.onScroll = handler + } + + // Top-relative twin (drives ListView.ScrolledFromTop — Reply's ExtendedFAB collapse). + @objc(setScrollTopHandler:handler:) + public static func setScrollTopHandler(_ node: CometNode, handler: @escaping @convention(block) (Double) -> Void) { + node.onScrollTop = handler + } + + @objc(setLongPressGestureHandler:handler:) + public static func setLongPressGestureHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) { + node.onLongPressGesture = handler + } + + @objc(setRowVisibilityHandler:handler:) + public static func setRowVisibilityHandler(_ node: CometNode, handler: @escaping @convention(block) (Double, Double) -> Void) { + node.onRowVisibility = handler + } + + @objc(insertChild:atIndex:child:) + public static func insertChild(_ node: CometNode, atIndex index: Int, child: CometNode) { + let i = max(0, min(index, node.children.count)) + node.children.insert(child, at: i) + } + + @objc(removeChild:atIndex:) + public static func removeChild(_ node: CometNode, atIndex index: Int) { + guard index >= 0 && index < node.children.count else { return } + node.children.remove(at: index) + } + + @objc(clearChildren:) + public static func clearChildren(_ node: CometNode) { + node.children.removeAll() + } + + @objc(setFrame:x:y:width:height:) + public static func setFrame(_ node: CometNode, x: Double, y: Double, width: Double, height: Double) { + node.frame = CGRect(x: x, y: y, width: width, height: height) + node.hasFrame = true + } + + // Measures a leaf node's intrinsic size — the one place layout crosses into native. + // The content is constrained to the available width so multi-line text wraps (and reports + // its wrapped height); UIHostingController.sizeThatFits alone returns the single-line ideal. + @objc(measureNode:maxWidth:maxHeight:) + public static func measureNode(_ node: CometNode, maxWidth: Double, maxHeight: Double) -> CGSize { + let w = (maxWidth.isFinite && maxWidth > 0) ? maxWidth : UIScreen.main.bounds.width + + // Text: measure with TextKit, which reliably wraps to the width (UIHostingController's + // sizeThatFits/systemLayoutSizeFitting return the single-line ideal for SwiftUI Text). + if node.kind == "text" { + let size = node.fontSize > 0 ? node.fontSize : UIFont.preferredFont(forTextStyle: .body).pointSize + var font: UIFont = node.fontSize > 0 + ? UIFont.systemFont(ofSize: node.fontSize, weight: uiFontWeight(node.fontWeight)) + : UIFont.preferredFont(forTextStyle: .body) + if !node.fontFamily.isEmpty, let custom = customUIFont(node.fontFamily, size, node.fontWeight) { + font = custom + } + let rect = (node.text as NSString).boundingRect( + with: CGSize(width: w, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil) + // Report the ACTUAL used width (≤ constraint) so a short label hugs and the flex row + // packs it tight; the wrapped height drives multi-line bubbles. A maxLines clamp + // caps the box; the rendered Text ellipsizes via lineLimit. + var height = ceil(rect.height) + if node.maxLines > 0 { + height = min(height, ceil(font.lineHeight * CGFloat(node.maxLines))) + } + return CGSize(width: min(ceil(rect.width), w), height: height) + } + + // Icon: a square box at the symbol's point size. + if node.kind == "icon" { + let s = node.fontSize > 0 ? node.fontSize : 24 + return CGSize(width: s, height: s) + } + + // Interactive controls (button/textfield/toggle/slider) don't wrap; SwiftUI sizes them. + let host = UIHostingController(rootView: CometLeafContent(node: node)) + host.view.backgroundColor = .clear + return host.sizeThatFits(in: CGSize(width: w, height: .greatestFiniteMagnitude)) + } + + // Returns a UIViewController hosting the SwiftUI tree rooted at `root`. + @objc(hostControllerForRoot:) + public static func hostController(_ root: CometNode) -> UIViewController { + return UIHostingController(rootView: CometNodeView(node: root)) + } + + // In-app screenshot (DevFlow/ailoha style): renders the key window to a PNG, so external + // tooling can fetch the rendered UI over the agent connection (works on a physical device + // via USB port-forward, no Developer Disk Image needed). Must be called on the main thread. + @objc(screenshotPNG) + public static func screenshotPNG() -> Data? { + guard let window = activeKeyWindow() else { return nil } + let renderer = UIGraphicsImageRenderer(bounds: window.bounds) + let image = renderer.image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + return image.pngData() + } + + static func activeKeyWindow() -> UIWindow? { + for scene in UIApplication.shared.connectedScenes { + guard let ws = scene as? UIWindowScene else { continue } + if let kw = ws.windows.first(where: { $0.isKeyWindow }) ?? ws.windows.first { + return kw + } + } + return nil + } +} + +// The leaf control for a node (no children, no layout). Used for rendering leaves and, via +// UIHostingController.sizeThatFits, for the Yoga engine's intrinsic-size measurement. +struct CometLeafContent: View { + @ObservedObject var node: CometNode + @FocusState private var fieldFocused: Bool // textfield focus → node.onFocused (gold onTextFieldFocused) + @ViewBuilder + var body: some View { + switch node.kind { + case "button": + // .plain so SwiftUI doesn't tint the label with the system accent (the Comet .Color() is + // authoritative). The pill FILL + corner clip come from the wrapper (background + surface + // modifiers, the filled Material Button); an OutlinedButton draws its own border instead. + Button(action: { node.onTap?() }) { + Text(node.text) + .modifier(ForegroundModifier(argb: node.textColorARGB)) + .modifier(FontModifier(node: node)) + .padding(.horizontal, 22) + .padding(.vertical, 8) + .overlay { + if node.outlined { + Capsule().stroke(colorFromARGB(node.textColorARGB), lineWidth: 1) + } + } + } + .buttonStyle(.plain) + case "textfield": + // Borderless = the gold's foundation BasicTextField (no rounded box) — the footer input. + if node.borderless { + TextField(node.placeholder, text: Binding( + get: { node.text }, set: { node.text = $0; node.onChangeString?($0) })) + .textFieldStyle(.plain) + .focused($fieldFocused) + .onChange(of: fieldFocused) { now in if now { node.onFocused?() } } + .modifier(ForegroundModifier(argb: node.textColorARGB)) + .modifier(FontModifier(node: node)) + } else { + TextField(node.placeholder, text: Binding( + get: { node.text }, set: { node.text = $0; node.onChangeString?($0) })) + .textFieldStyle(.roundedBorder) + .focused($fieldFocused) + .onChange(of: fieldFocused) { now in if now { node.onFocused?() } } + } + case "toggle": + Toggle("", isOn: Binding( + get: { node.isOn }, + set: { node.isOn = $0; node.onChangeBool?($0) })) + .labelsHidden() + case "slider": + Slider(value: Binding( + get: { node.doubleValue }, + set: { node.doubleValue = $0; node.onChangeDouble?($0) })) + case "icon": + if !node.iconGlyph.isEmpty { + // Icon-font glyph (e.g. Material Icons) — the SAME glyph the Android backend draws, + // rendered as a sized + tinted character in the registered icon font. + Text(node.iconGlyph) + .font(.custom(node.iconFontFamily, size: node.fontSize > 0 ? node.fontSize : 24)) + .modifier(ForegroundModifier(argb: node.textColorARGB)) + } else if let logo = bundledImage(node.iconName) { + // A bundled brand logo (e.g. the jetchat mark) — colourful when untinted (header), + // a tinted template when a colour is set (the mono chat-row logo). Mirrors the Android + // multicolor-asset path. Pinned to the icon size so it measures + can't overflow the + // text — unless iconFillFrame asks for the node's (non-square) frame (the JetNews + // 80×24 wordmark, the Android IconFillFrame twin). + let logoW = node.iconFillFrame && node.frame.width > 0 ? node.frame.width : (node.fontSize > 0 ? node.fontSize : 24) + let logoH = node.iconFillFrame && node.frame.height > 0 ? node.frame.height : (node.fontSize > 0 ? node.fontSize : 24) + if node.textColorARGB != 0 { + Image(uiImage: logo).renderingMode(.template).resizable().aspectRatio(contentMode: .fit) + .frame(width: logoW, height: logoH) + .modifier(ForegroundModifier(argb: node.textColorARGB)) + } else { + Image(uiImage: logo).resizable().aspectRatio(contentMode: .fit) + .frame(width: logoW, height: logoH) + } + } else { + // Real SF Symbol, tinted + sized — the iOS native icon idiom. + Image(systemName: sfSymbol(node.iconName)) + .font(.system(size: node.fontSize > 0 ? node.fontSize : 24)) + .modifier(ForegroundModifier(argb: node.textColorARGB)) + } + case "image": + // A bundled image (a bare name like "ali") renders from the app bundle — the iOS + // counterpart of Compose's painterResource; an http(s) source loads asynchronously. + if node.imageUrl.lowercased().hasPrefix("http") { + AsyncImage(url: URL(string: node.imageUrl)) { phase in + if let image = phase.image { + image.resizable().aspectRatio(contentMode: .fill) + } else { + Color.gray.opacity(0.25) + } + } + .clipped() + } else if let ui = bundledImage(node.imageUrl) { + Image(uiImage: ui).resizable().aspectRatio(contentMode: .fill).clipped() + } else { + Color.gray.opacity(0.25) + } + default: // "text" and unknown leaves + if !node.runs.isEmpty { + // FormattedText: styled runs (the gold's AnnotatedString) → concatenated Text, so + // per-run colour / code / link styling renders (e.g. white text on the primary bubble). + runsText(node) + .modifier(FontModifier(node: node)) + } else { + Text(node.text) + .modifier(ForegroundModifier(argb: node.textColorARGB)) + .modifier(FontModifier(node: node)) + .lineLimit(node.maxLines > 0 ? node.maxLines : nil) + } + } + } +} + +// A bundled image by bare name: the asset catalog / .png via UIImage(named:), else a loose +// resource file (e.g. someone_else.jpg) found by trying common extensions in the main bundle. +private func bundledImage(_ name: String) -> UIImage? { + if let img = UIImage(named: name) { return img } + for ext in ["png", "jpg", "jpeg"] { + if let path = Bundle.main.path(forResource: name, ofType: ext), + let img = UIImage(contentsOfFile: path) { + return img + } + } + return nil +} + +// Assemble a FormattedText's styled runs by concatenating SwiftUI Text segments (this measures +// reliably via sizeThatFits, unlike Text(AttributedString)). Per-run colour wins; monospace runs +// use a system monospaced face at the base size; underline = links. (Per-run background — code-span +// highlight — isn't expressible via Text concatenation, so it's dropped; the mono face still reads.) +private func runsText(_ node: CometNode) -> Text { + let baseSize = node.fontSize > 0 ? node.fontSize : 16 + var combined = Text(verbatim: "") + for run in node.runs { + var seg = Text(verbatim: run.text) + if let c = run.color { seg = seg.foregroundColor(c) } + if run.mono { seg = seg.font(.system(size: baseSize, design: .monospaced)) } + if run.underline { seg = seg.underline() } + combined = combined + seg + } + return combined +} + +// Cross-platform symbol name → SF Symbol (the iOS counterpart of the Compose Icons mapping). +private func sfSymbol(_ name: String) -> String { + // Cross-platform GENERIC names (the shared sample uses Android/Material-ish names) → the closest + // SF Symbol. Anything not listed falls through and, if it's itself a real SF Symbol name, is used + // verbatim — so the WHOLE SF Symbol library is available (e.g. Icon("mic.fill"), Icon("paperplane")) + // without adding a case here. + switch name { + case "search": return "magnifyingglass" + case "info": return "info.circle" + case "menu": return "line.3.horizontal" + case "send": return "paperplane.fill" + case "place", "location": return "mappin.circle.fill" + case "mic", "microphone": return "mic" + case "person": return "person.crop.circle" + case "people": return "person.2.fill" + case "jetchat": return "bubble.left.and.bubble.right.fill" // logo stand-in (no bundled vector on iOS yet) + case "account": return "person.crop.circle.fill" + case "call", "phone": return "phone.fill" + case "email", "mail": return "envelope" + case "close": return "xmark" + case "settings": return "gearshape" + case "share": return "square.and.arrow.up" + case "back": return "chevron.left" + case "arrow_down", "arrow_downward", "expand_more": return "chevron.down" + case "add": return "plus" + case "edit": return "pencil" + case "mood", "emoji": return "face.smiling" + case "at": return "at" + case "photo", "image": return "photo" + case "video", "duo": return "video" + default: + // Pass the name through if it's a valid SF Symbol; otherwise a clear "unknown" glyph. + return UIImage(systemName: name) != nil ? name : "questionmark.square.dashed" + } +} + +// SwiftUI Font.Weight for a Maui FontWeight numeric (100–900). +private func swiftFontWeight(_ w: Int) -> Font.Weight { + switch w { + case 1..<200: return .thin + case 200..<300: return .ultraLight + case 300..<400: return .light + case 400..<500: return .regular + case 500..<600: return .medium + case 600..<700: return .semibold + case 700..<800: return .bold + case 800..<900: return .heavy + case 900...: return .black + default: return .regular + } +} + +private func uiFontWeight(_ w: Int) -> UIFont.Weight { + switch w { + case 1..<200: return .thin + case 200..<300: return .ultraLight + case 300..<400: return .light + case 400..<500: return .regular + case 500..<600: return .medium + case 600..<700: return .semibold + case 700..<800: return .bold + case 800..<900: return .heavy + case 900...: return .black + default: return .regular + } +} + +// A registered custom font (e.g. Montserrat/Karla, bundled + listed in UIAppFonts) at the given +// weight, or nil if not available. Derives the weight on a variable font via the descriptor. +private func customUIFont(_ family: String, _ size: CGFloat, _ weight: Int, italic: Bool = false) -> UIFont? { + // Prefer the real per-weight face by PostScript name (e.g. "Montserrat-Medium", "Karla-Bold") so + // the actual weight renders rather than a synthesized one. + var font: UIFont? + if let f = UIFont(name: weightedFontName(family, weight), size: size) { + font = f + } else if let base = UIFont(name: family, size: size) { + let traits: [UIFontDescriptor.TraitKey: Any] = [.weight: uiFontWeight(weight)] + font = UIFont(descriptor: base.fontDescriptor.addingAttributes([.traits: traits]), size: size) + } + guard var resolved = font else { return nil } + if italic { + // SwiftUI's .italic() can't slant a custom UIFont-backed Font, so bake the + // slant here: a real italic face via the symbolic trait when the family has + // one, else a synthesized oblique (skew matrix) — same idea as Android. + if let d = resolved.fontDescriptor.withSymbolicTraits( + resolved.fontDescriptor.symbolicTraits.union(.traitItalic)), + d.symbolicTraits.contains(.traitItalic) { + resolved = UIFont(descriptor: d, size: size) + } else { + let skew = CGAffineTransform(a: 1, b: 0, c: 0.25, d: 1, tx: 0, ty: 0) + resolved = UIFont(descriptor: resolved.fontDescriptor.withMatrix(skew), size: size) + } + } + return resolved +} + +// "-" PostScript-name convention for the bundled per-weight faces. +private func weightedFontName(_ family: String, _ weight: Int) -> String { + let suffix: String + switch weight { + case 700...: suffix = "Bold" + case 600..<700: suffix = "SemiBold" + case 500..<600: suffix = "Medium" + default: suffix = "Regular" + } + return "\(family)-\(suffix)" +} + +// Applies an explicit font (custom family if set, else system) at the Comet size/weight. +private struct FontModifier: ViewModifier { + @ObservedObject var node: CometNode + func body(content: Content) -> some View { + let size = node.fontSize > 0 ? node.fontSize : UIFont.preferredFont(forTextStyle: .body).pointSize + let italicize: (AnyView) -> AnyView = node.fontItalic + ? { v in AnyView(v.italic()) } + : { v in v } + if !node.fontFamily.isEmpty, + let f = customUIFont(node.fontFamily, size, node.fontWeight, italic: node.fontItalic) { + // Slant is baked into the UIFont — .italic() is a no-op on custom fonts. + return AnyView(content.font(Font(f))) + } + if node.fontSize > 0 { + return italicize(AnyView(content.font(.system(size: node.fontSize, weight: swiftFontWeight(node.fontWeight))))) + } + if node.fontWeight > 0 { + return italicize(AnyView(content.fontWeight(swiftFontWeight(node.fontWeight)))) + } + return italicize(AnyView(content)) + } +} + +// Applies an explicit text/foreground color when Comet set one (argb != 0); otherwise leaves +// the default so it adapts to light/dark like native SwiftUI text. +private struct ForegroundModifier: ViewModifier { + let argb: UInt32 + func body(content: Content) -> some View { + if argb == 0 { + content + } else { + content.foregroundColor(Color( + red: Double((argb >> 16) & 0xFF) / 255.0, + green: Double((argb >> 8) & 0xFF) / 255.0, + blue: Double(argb & 0xFF) / 255.0, + opacity: Double((argb >> 24) & 0xFF) / 255.0)) + } + } +} + +private func isYogaContainer(_ kind: String) -> Bool { + return kind == "vstack" || kind == "hstack" || kind == "zstack" +} + +// Recursively renders a CometNode tree as SwiftUI. Once C#'s Yoga engine has arranged a flow +// container (hasFrame), its children are positioned absolutely from the computed frames; +// otherwise native SwiftUI layout is used (rendering unchanged until the engine drives it). +struct CometNodeView: View { + @ObservedObject var node: CometNode + + var body: some View { + // Each node sizes + positions ITSELF from its own (observed) frame, so a re-arrange + // (reflow) re-renders just that node and the layout adapts live — the parent can't + // observe the children's frames in its own body. Order matters: size → background + // (so it fills the arranged frame) → offset (move the whole node into place). + content + .modifier(SizeModifier(node: node)) + .modifier(BackgroundModifier(argb: node.backgroundARGB, stops: node.gradientStops, + direction: node.gradientDirection)) + .modifier(SurfaceModifier(node: node)) // rounded corners + elevation (Material card) + .modifier(TapGestureModifier(node: node)) + .modifier(OffsetModifier(node: node)) + .modifier(OpacityModifier(node: node)) // Comet Opacity / IsVisible (applied last so a + // hidden node is also non-interactive) + } + + @ViewBuilder + private var content: some View { + if node.hasFrame && isYogaContainer(node.kind) { + // Children self-position via their own modifiers; just overlay them. + ZStack(alignment: .topLeading) { + ForEach(node.children) { CometNodeView(node: $0) } + } + } else { + nativeContent + } + } + + @ViewBuilder + private var nativeContent: some View { + switch node.kind { + case "navigation": + // Pin the screen to the TOP of the nav area: when the keyboard shrinks the screen's + // laid-out height below the nav's full height, the shorter screen must stay top-aligned + // (so its footer sits just above the keyboard) instead of centering in the leftover space. + ZStack(alignment: .topLeading) { + if let top = node.children.last { CometNodeView(node: top) } else { EmptyView() } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + case "drawer": + // children[0] = content (full screen); children[1] = side panel. Scrim + slide-in. + ZStack(alignment: .topLeading) { + if node.children.count > 0 { CometNodeView(node: node.children[0]) } + if node.drawerOpen { + Color.black.opacity(0.32).ignoresSafeArea() + .onTapGesture { node.onTap?() } + if node.children.count > 1 { + CometNodeView(node: node.children[1]) + .transition(.move(edge: .leading)) + } + } + } + .animation(.easeInOut(duration: 0.25), value: node.drawerOpen) + case "list" where node.horizontal: + // Horizontal list (JetNews' popular carousel): the LazyHStack twin of the + // Compose LazyRow branch. Rows are Yoga-laid at their intrinsic width by C#; + // each renders as its own lazy item. + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 0) { + ForEach(node.children) { child in + CometNodeView(node: child) + .frame(width: child.frame.width, height: child.frame.height) + .id(child.id) + } + } + } + case "list": + // Full-bleed, separator-free rows so a Yoga-laid-out row (which already carries its + // own padding) spans edge-to-edge exactly like the Compose LazyColumn. Wrapped in a + // ScrollViewReader so C# can animate the log to the newest message (JumpToBottom / + // after-send) by bumping node.scrollToken — the native counterpart of the Compose + // LazyListState scroller (Comet's IListView.ScrollToBottom -> scrollNodeToBottom). + ScrollViewReader { proxy in + List { + ForEach(node.children) { child in + CometNodeView(node: child) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .id(child.id) + // Drive ScrolledAway (the JumpToBottom FAB): the newest message is the last + // child — when it's on screen we're at the bottom (0), when it scrolls off + // we're scrolled away (1). The C# list node maps this onto IListView.ScrolledAway. + .onAppear { + if child.id == node.children.last?.id { node.onScroll?(0) } + if child.id == node.children.first?.id { node.onScrollTop?(0) } + if let idx = node.children.firstIndex(where: { $0.id == child.id }) { + node.onRowVisibility?(Double(idx), 1) + } + } + .onDisappear { + if child.id == node.children.last?.id { node.onScroll?(1) } + if child.id == node.children.first?.id { node.onScrollTop?(1) } + if let idx = node.children.firstIndex(where: { $0.id == child.id }) { + node.onRowVisibility?(Double(idx), 0) + } + } + } + } + .listStyle(.plain) + .onChange(of: node.scrollToken) { _ in + if let last = node.children.last { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + case "scroll": + // The single content view is laid out (by Yoga) taller than the viewport and + // self-positions, so a plain vertical ScrollView hosting it scrolls as one piece. + // The scroll offset is reported back to C# (-> the ScrollView's AtTop / ScrollOffset, + // which drive the profile FAB's collapse on scroll). + if #available(iOS 18.0, *) { + // onScrollGeometryChange observes the live scroll geometry, so it fires for BOTH a + // finger scroll and a programmatic contentOffset change. A GeometryReader/preference + // (the fallback below) only re-fires on a SwiftUI layout pass, which a UIScrollView + // contentOffset change does NOT trigger — so it silently misses programmatic scrolls. + ScrollView(.vertical, showsIndicators: true) { + ForEach(node.children) { CometNodeView(node: $0) } + } + .onScrollGeometryChange(for: Double.self) { geo in + Double(geo.contentOffset.y + geo.contentInsets.top) + } action: { _, newValue in + node.onScroll?(newValue) + } + } else { + // Pre-iOS-18 fallback: GeometryReader probe + preference (onScrollGeometryChange is iOS 18+). + ScrollView(.vertical, showsIndicators: true) { + ForEach(node.children) { CometNodeView(node: $0) } + .background(GeometryReader { geo in + Color.clear.preference(key: CometScrollOffsetKey.self, + value: -geo.frame(in: .named("cometScroll")).minY) + }) + } + .coordinateSpace(name: "cometScroll") + .onPreferenceChange(CometScrollOffsetKey.self) { value in node.onScroll?(Double(value)) } + } + case "alert": + // Native SwiftUI .alert (the iOS counterpart of Compose's AlertDialog): a zero-size + // host that presents modally when C# opens the dialog; the button / scrim dismiss + // routes back to C# (DialogDismissed). + Color.clear.frame(width: 0, height: 0) + .alert(node.dialogMessage, isPresented: Binding( + get: { node.dialogOpen }, + set: { node.dialogOpen = $0; if !$0 { node.onDialogDismiss?() } })) { + Button(node.dialogButton, role: .cancel) {} + } + // Cross-axis alignment matches Yoga's flex-start default (and Compose's Row=Top / Column=Start), + // NOT SwiftUI's center default — so any subtree not yet Yoga-arranged still aligns like Android + // instead of centering. Spacing is 0 because Yoga owns gaps (the arranged path bakes them into + // the child offsets); the native default 8pt would double them where the fallback is hit. + case "fab": + // iOS has no Material FAB, so the native idiom is a real Button (so it raises the + // tap, gets the system press feedback, and reads as a button to accessibility) whose + // content is an icon + optional label row. The node's frame positions + sizes it + // (from Yoga); the CometNodeView modifiers add the capsule background. + // fabExtended drives label show/hide with a 200ms easeInOut — matching the gold's + // AnimatingFabContent transition duration (transitionDuration = 200). + Button(action: { node.onTap?() }) { + HStack(spacing: 8) { + if let icon = node.children.first { CometNodeView(node: icon) } + if node.fabExtended, node.children.count > 1 { + CometNodeView(node: node.children[1]) + } + } + .animation(.easeInOut(duration: 0.2), value: node.fabExtended) + .fixedSize(horizontal: true, vertical: false) // the label is single-line — never wrap it + .frame(maxWidth: .infinity, maxHeight: .infinity) // center the row within the capsule frame + .padding(.horizontal, node.padding) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + case "hstack": + HStack(alignment: .top, spacing: 0) { ForEach(node.children) { CometNodeView(node: $0) } }.padding(node.padding) + case "zstack": + ZStack(alignment: .topLeading) { ForEach(node.children) { CometNodeView(node: $0) } }.padding(node.padding) + case "vstack": + VStack(alignment: .leading, spacing: 0) { ForEach(node.children) { CometNodeView(node: $0) } }.padding(node.padding) + case "textfield": + // Inset the field's content by its (Yoga-sized) padding so the placeholder/text sit padded + // in, matching the gold (e.g. the footer input's 20/14/20/10). Render-only — the measure + // path hosts CometLeafContent directly, so the padding isn't double-counted into the frame. + CometLeafContent(node: node) + .padding(EdgeInsets(top: node.padTop + node.contentTopInset, leading: node.padLeading, + bottom: node.padBottom, trailing: node.padTrailing)) + default: + // Leaves honour their own (Yoga-sized) content padding — e.g. a section-header Text + // padded 28 in / 18 tall. Render-only (the measure path hosts CometLeafContent directly, + // so the padding isn't double-counted into the frame). + CometLeafContent(node: node) + .padding(EdgeInsets(top: node.padTop + node.contentTopInset, leading: node.padLeading, + bottom: node.padBottom, trailing: node.padTrailing)) + } + } +} + +// Sizes a node to its Yoga-computed frame (observing the node, so reflow re-applies it). +// Icons CENTER in their frame (a 24dp star glyph in a 40dp circle — nothing else would +// center it, and topLeading pinned it to the corner); everything else keeps topLeading, +// matching Yoga's coordinate origin. +private struct SizeModifier: ViewModifier { + @ObservedObject var node: CometNode + func body(content: Content) -> some View { + if node.hasFrame { + content.frame(width: node.frame.width, height: node.frame.height, + alignment: node.kind == "icon" ? .center : .topLeading) + } else { + content + } + } +} + +// Positions a node at its Yoga-computed parent-relative offset (applied after background so +// the background fills the frame and moves with the node). +private struct OffsetModifier: ViewModifier { + @ObservedObject var node: CometNode + func body(content: Content) -> some View { + if node.hasFrame { + content.offset(x: node.frame.minX, y: node.frame.minY) + } else { + content + } + } +} + +// Applies Comet's Opacity / IsVisible. A node that is hidden (IsVisible == false) or fully +// transparent (Opacity ~ 0) is also made non-interactive via allowsHitTesting(false), so an +// invisible overlay in a ZStack lets touches fall through to the views beneath it. +private struct OpacityModifier: ViewModifier { + @ObservedObject var node: CometNode + func body(content: Content) -> some View { + let alpha = node.isVisible ? node.opacity : 0 + return content + .opacity(alpha) + .allowsHitTesting(alpha > 0.01) + } +} + +// Carries the scroll content's offset (negative minY in the scroll coordinate space) up to the +// ScrollView via a SwiftUI preference, so C# can mirror it onto AtTop / ScrollOffset. +private struct CometScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } +} + +// Routes a SwiftUI tap on an arbitrary view to the node's gesture callback (the iOS +// counterpart of Compose's Modifier.Clickable). contentShape makes the whole padded +// frame — including transparent areas of a stack — hittable. +private struct TapGestureModifier: ViewModifier { + @ObservedObject var node: CometNode + func body(content: Content) -> some View { + if node.hasTapGesture && node.hasLongPressGesture { + content + .contentShape(Rectangle()) + .onTapGesture { node.onTapGesture?() } + .onLongPressGesture { node.onLongPressGesture?() } + } else if node.hasTapGesture { + content + .contentShape(Rectangle()) + .onTapGesture { node.onTapGesture?() } + } else if node.hasLongPressGesture { + content + .contentShape(Rectangle()) + .onLongPressGesture { node.onLongPressGesture?() } + } else { + content + } + } +} + +// Rounds a node's corners (clipping its background + content) and casts a soft drop shadow — +// the Compose `.Clip(RoundedCornerShape)` + `.Shadow(elevation)` analog, so a card looks the +// same on both backends. Applied after the background so the fill is clipped to the rounded rect. +private struct SurfaceModifier: ViewModifier { + @ObservedObject var node: CometNode + func body(content: Content) -> some View { + let hasCorners = node.cornerTL > 0 || node.cornerTR > 0 || node.cornerBR > 0 || node.cornerBL > 0 + let elevation = node.elevation + let shape = UnevenRoundedRectangle( + topLeadingRadius: node.cornerTL, + bottomLeadingRadius: node.cornerBL, + bottomTrailingRadius: node.cornerBR, + topTrailingRadius: node.cornerTR, + style: .continuous) + if hasCorners || elevation > 0 || node.borderWidth > 0 || node.borderGradientStops.count > 1 { + content + .clipShape(shape) + .overlay(node.borderGradientStops.count > 1 + // Gradient stroke along the spec's axis (Android twin honors it too; + // radial borders fall back to diagonal until a consumer needs them). + ? AnyView(shape.strokeBorder(LinearGradient( + colors: node.borderGradientStops.map(argbColor), + startPoint: node.borderGradientDirection == 0 ? .leading + : node.borderGradientDirection == 1 ? .top : .topLeading, + endPoint: node.borderGradientDirection == 0 ? .trailing + : node.borderGradientDirection == 1 ? .bottom : .bottomTrailing), lineWidth: 2)) + : node.borderWidth > 0 + ? AnyView(shape.strokeBorder(colorFromARGB(node.borderColorARGB), lineWidth: node.borderWidth)) + : AnyView(EmptyView())) + .shadow(color: Color.black.opacity(elevation > 0 ? 0.18 : 0), + radius: elevation, x: 0, y: elevation > 0 ? elevation / 2 : 0) + } else { + content + } + } +} + +private func colorFromARGB(_ argb: UInt32) -> Color { + Color(red: Double((argb >> 16) & 0xFF) / 255.0, + green: Double((argb >> 8) & 0xFF) / 255.0, + blue: Double(argb & 0xFF) / 255.0, + opacity: Double((argb >> 24) & 0xFF) / 255.0) +} + +private func argbColor(_ argb: UInt32) -> Color { + Color( + red: Double((argb >> 16) & 0xFF) / 255.0, + green: Double((argb >> 8) & 0xFF) / 255.0, + blue: Double(argb & 0xFF) / 255.0, + opacity: Double((argb >> 24) & 0xFF) / 255.0) +} + +private struct BackgroundModifier: ViewModifier { + let argb: UInt32 + var stops: [UInt32] = [] + var direction: Int = 0 + func body(content: Content) -> some View { + if stops.count > 1, direction == 3 { + // Radial (Jetcaster's home scrim). SwiftUI needs explicit radii — size + // isn't known here, so a screen-scale end radius approximates Compose's + // bounds-fitted radial (documented deviation until a consumer needs more). + content.background(RadialGradient( + colors: stops.map(argbColor), + center: .center, startRadius: 0, endRadius: 500)) + } else if stops.count > 1 { + // Linear gradient (Jetsnack fills, Jetcaster scrims) — the LinearGradient + // twin of Compose's horizontal/vertical/linearGradient; stops spaced evenly. + let (start, end): (UnitPoint, UnitPoint) = switch direction { + case 1: (.top, .bottom) + case 2: (.topLeading, .bottomTrailing) + default: (.leading, .trailing) + } + content.background(LinearGradient( + colors: stops.map(argbColor), + startPoint: start, endPoint: end)) + } else if argb == 0 { + content + } else { + content.background(argbColor(argb)) + } + } +} diff --git a/src/Comet/src/Comet.SwiftUI.Shim/build-xcframework.sh b/src/Comet/src/Comet.SwiftUI.Shim/build-xcframework.sh new file mode 100755 index 000000000..45d0a53f7 --- /dev/null +++ b/src/Comet/src/Comet.SwiftUI.Shim/build-xcframework.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Builds CometSwiftUIShim.xcframework (device + simulator) from the Swift package. +# The shim exposes SwiftUI behind an @objc-representable surface so .NET for iOS can +# bind it without Swift-ABI interop. +set -euo pipefail +cd "$(dirname "$0")" + +rm -rf build CometSwiftUIShim.xcframework + +xcodebuild archive -scheme CometSwiftUIShim -destination "generic/platform=iOS Simulator" \ + -archivePath build/sim.xcarchive SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES >/dev/null +xcodebuild archive -scheme CometSwiftUIShim -destination "generic/platform=iOS" \ + -archivePath build/dev.xcarchive SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES >/dev/null + +xcodebuild -create-xcframework \ + -framework build/sim.xcarchive/Products/usr/local/lib/CometSwiftUIShim.framework \ + -framework build/dev.xcarchive/Products/usr/local/lib/CometSwiftUIShim.framework \ + -output CometSwiftUIShim.xcframework >/dev/null + +echo "built CometSwiftUIShim.xcframework:" +ls CometSwiftUIShim.xcframework/ diff --git a/src/Comet/src/Comet/AppHostBuilderExtensions.cs b/src/Comet/src/Comet/AppHostBuilderExtensions.cs deleted file mode 100644 index fcbd0a3b3..000000000 --- a/src/Comet/src/Comet/AppHostBuilderExtensions.cs +++ /dev/null @@ -1,1321 +0,0 @@ -using System; -using System.Collections.Generic; -using Comet.Handlers; -using Comet.Styles; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Maui; -using Microsoft.Maui.Animations; -using Microsoft.Maui.ApplicationModel; -using Microsoft.Maui.Devices; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Hosting; -using System.Runtime.CompilerServices; -using Microsoft.Maui.Platform; - -#if WINDOWS -using Microsoft.Maui.Graphics.Win2D; -#endif - -#if __MACOS__ -using Microsoft.Maui.Platform.MacOS.Hosting; -// Redirect MAUI built-in handler types to Platform.Maui.MacOS implementations. -// The MAUI built-in handlers (Microsoft.Maui.Handlers.*) throw NotImplementedException -// on macOS. Platform.Maui.MacOS provides AppKit-based implementations. -using ActivityIndicatorHandler = Microsoft.Maui.Platform.MacOS.Handlers.ActivityIndicatorHandler; -using ButtonHandler = Microsoft.Maui.Platform.MacOS.Handlers.ButtonHandler; -using CheckBoxHandler = Microsoft.Maui.Platform.MacOS.Handlers.CheckBoxHandler; -using ContentViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.ContentViewHandler; -using DatePickerHandler = Microsoft.Maui.Platform.MacOS.Handlers.DatePickerHandler; -using EditorHandler = Microsoft.Maui.Platform.MacOS.Handlers.EditorHandler; -using EntryHandler = Microsoft.Maui.Platform.MacOS.Handlers.EntryHandler; -using GraphicsViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.GraphicsViewHandler; -using ImageButtonHandler = Microsoft.Maui.Platform.MacOS.Handlers.ImageButtonHandler; -using ImageHandler = Microsoft.Maui.Platform.MacOS.Handlers.ImageHandler; -using IndicatorViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.IndicatorViewHandler; -using LabelHandler = Microsoft.Maui.Platform.MacOS.Handlers.LabelHandler; -using LayoutHandler = Microsoft.Maui.Platform.MacOS.Handlers.LayoutHandler; -using PickerHandler = Microsoft.Maui.Platform.MacOS.Handlers.PickerHandler; -using ProgressBarHandler = Microsoft.Maui.Platform.MacOS.Handlers.ProgressBarHandler; -using RefreshViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.RefreshViewHandler; -using SearchBarHandler = Microsoft.Maui.Platform.MacOS.Handlers.SearchBarHandler; -using SliderHandler = Microsoft.Maui.Platform.MacOS.Handlers.SliderHandler; -using StepperHandler = Microsoft.Maui.Platform.MacOS.Handlers.StepperHandler; -using SwipeViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.SwipeViewHandler; -using SwitchHandler = Microsoft.Maui.Platform.MacOS.Handlers.SwitchHandler; -using TimePickerHandler = Microsoft.Maui.Platform.MacOS.Handlers.TimePickerHandler; -using WebViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.WebViewHandler; -#endif - -namespace Comet -{ - public static class AppHostBuilderExtensions - { - // Weak tables to track event subscriptions per platform view, preventing - // duplicate handlers when mappers re-fire during SetVirtualView/Reload. -#if __IOS__ || MACCATALYST - static readonly ConditionalWeakTable _pickerEditingDidEndHandlers = new(); - static readonly ConditionalWeakTable _entryEditingChangedHandlers = new(); - static readonly ConditionalWeakTable _editorChangedHandlers = new(); - static readonly ConditionalWeakTable> _searchBarTextChangedHandlers = new(); - static readonly ConditionalWeakTable _sliderValueChangedHandlers = new(); - static readonly ConditionalWeakTable _switchValueChangedHandlers = new(); - static readonly ConditionalWeakTable _checkBoxCheckedHandlers = new(); -#elif ANDROID - static readonly ConditionalWeakTable _pickerTextChangedHandlers = new(); - static readonly ConditionalWeakTable _entryTextChangedHandlers = new(); - static readonly ConditionalWeakTable _editorTextChangedHandlers = new(); - static readonly ConditionalWeakTable _searchBarTextChangedHandlers = new(); - static readonly ConditionalWeakTable _sliderValueChangedHandlers = new(); - static readonly ConditionalWeakTable _switchValueChangedHandlers = new(); - static readonly ConditionalWeakTable _checkBoxCheckedHandlers = new(); -#endif - static void AddHandlers(this IMauiHandlersCollection collection, Dictionary handlers) => handlers.ForEach(x => collection.AddHandler(x.Key, x.Value)); - public static MauiAppBuilder UseCometApp(this MauiAppBuilder builder) - where TApp : class, IApplication - { - builder.Services.TryAddSingleton(); - builder.UseCometHandlers(); - return builder; - } -#if __MACOS__ - /// - /// Registers a Comet app for macOS AppKit. Combines Platform.Maui.MacOS setup - /// (UseMauiAppMacOS) with Comet handler overrides (UseCometHandlers). - /// - public static MauiAppBuilder UseCometAppMacOS(this MauiAppBuilder builder) - where TApp : class, IApplication - { - builder.UseMauiAppMacOS(); - builder.UseCometHandlers(); - return builder; - } -#endif - public static MauiAppBuilder UseCometHandlers(this MauiAppBuilder builder) - { - - //AnimationManger.SetTicker(new iOSTicker()); - - // Initialize the token-based theme system with Material 3 defaults. - // Fork the shared Defaults.Light via `with { }` so the framework's - // ButtonStyles.Filled registration doesn't mutate the shared singleton - // (records copy the _controlStyles ImmutableDictionary on `with`, so - // subsequent SetControlStyle calls affect only this derived theme). - var defaultTheme = Defaults.Light with { }; - defaultTheme.SetControlStyle(ButtonStyles.Filled); - ThemeManager.SetTheme(defaultTheme); - - // Register style resolution mappers for controls BEFORE the - // property-specific mappers so resolved values are available. - RegisterStyleResolutionMappers(); - - ViewHandler.ViewMapper.AppendToMapping(nameof(IGestureView.Gestures), CometViewHandler.AddGestures); - ViewHandler.ViewCommandMapper.AppendToMapping(Gesture.AddGestureProperty, CometViewHandler.AddGesture); - ViewHandler.ViewCommandMapper.AppendToMapping(Gesture.RemoveGestureProperty, CometViewHandler.RemoveGesture); - ViewHandler.ViewMapper.AppendToMapping(nameof(IView.AutomationId), (handler, view) => - { - if (view is View cometView) - ApplyInspectionMetadata(handler, cometView); - }); - ViewHandler.ViewMapper.AppendToMapping(nameof(IView.Visibility), (handler, view) => - { - if (view is View cometView) - ApplyInspectionMetadata(handler, cometView); - }); - ViewHandler.ViewMapper.AppendToMapping(nameof(IView.IsEnabled), (handler, view) => - { - if (view is View cometView) - ApplyInspectionMetadata(handler, cometView); - }); - ViewHandler.ViewMapper.AppendToMapping(nameof(IView.InputTransparent), (handler, view) => - { - if (view is View cometView) - ApplyInspectionMetadata(handler, cometView); - }); - ViewHandler.ViewMapper.AppendToMapping(nameof(IView.Semantics), (handler, view) => - { - if (view is View cometView) - ApplyInspectionMetadata(handler, cometView); - }); - - // Apply shadow to any view that has it set via environment - ViewHandler.ViewMapper.AppendToMapping("CometShadow", (handler, view) => - { - if (view is not View cometView) - return; - var shadow = cometView.GetEnvironment(EnvironmentKeys.View.Shadow); - if (shadow is null) - return; -#if __IOS__ || MACCATALYST - var platformView = handler.PlatformView as UIKit.UIView; - if (platformView is null) - return; - var layer = platformView.Layer; - layer.ShadowOpacity = shadow.Opacity; - layer.ShadowRadius = shadow.Radius; - layer.ShadowOffset = new CoreGraphics.CGSize(shadow.Offset.X, shadow.Offset.Y); - if (shadow.Paint is SolidPaint sp && sp.Color is not null) - layer.ShadowColor = sp.Color.ToPlatform().CGColor; - else - layer.ShadowColor = UIKit.UIColor.Black.CGColor; - layer.MasksToBounds = false; -#elif ANDROID - var platformView = handler.PlatformView as global::Android.Views.View; - if (platformView is null) - return; - var density = platformView.Context?.Resources?.DisplayMetrics?.Density ?? 1; - platformView.Elevation = shadow.Radius * density; -#endif - }); - - // Apply border visual styling to Border's platform view via handler mapper - LayoutHandler.Mapper.AppendToMapping("CometBorderStyling", (handler, view) => - { - if (view is not Border border) - return; - var borderStroke = (IBorderStroke)border; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - var layer = platformView.Layer; - if (borderStroke.Shape is RoundedRectangle rr) - { - layer.CornerRadius = rr.CornerRadius; - } - else if (borderStroke.Shape is not null) - { - layer.CornerRadius = 0; - } - layer.MasksToBounds = true; - if (borderStroke.Stroke is SolidPaint sp && sp.Color is not null) - { - layer.BorderColor = sp.Color.ToPlatform().CGColor; - layer.BorderWidth = (float)borderStroke.StrokeThickness; - } - else - { - layer.BorderWidth = 0; - } - var bg = border.GetBackground(); - if (bg is SolidPaint bgPaint && bgPaint.Color is not null) - { - layer.BackgroundColor = bgPaint.Color.ToPlatform().CGColor; - } -#elif ANDROID - var context = platformView.Context; - if (context is not null) - { - var drawable = new global::Android.Graphics.Drawables.GradientDrawable(); - if (borderStroke.Shape is RoundedRectangle rr) - drawable.SetCornerRadius((float)(rr.CornerRadius * context.Resources.DisplayMetrics.Density)); - if (borderStroke.Stroke is SolidPaint sp && sp.Color is not null) - drawable.SetStroke((int)(borderStroke.StrokeThickness * context.Resources.DisplayMetrics.Density), sp.Color.ToPlatform()); - var bg = border.GetBackground(); - if (bg is SolidPaint bgPaint && bgPaint.Color is not null) - drawable.SetColor(bgPaint.Color.ToPlatform()); - platformView.Background = drawable; - } -#endif - }); - - // Apply PlaceholderColor to TextField/SecureField via handler mapper - EntryHandler.Mapper.AppendToMapping("CometPlaceholderColor", (handler, view) => - { - if (view is not View cometView) - return; - var color = cometView.GetEnvironment(EnvironmentKeys.Entry.PlaceholderColor); - if (color is null) - return; - var entry = handler.PlatformView; - if (entry is null) - return; -#if __IOS__ || MACCATALYST - entry.AttributedPlaceholder = new Foundation.NSAttributedString( - entry.Placeholder ?? "", - new UIKit.UIStringAttributes { ForegroundColor = color.ToPlatform() }); -#elif ANDROID - entry.SetHintTextColor(new global::Android.Content.Res.ColorStateList( - new[] { Array.Empty() }, - new[] { (int)color.ToPlatform() })); -#endif - }); - - // Apply Keyboard type to TextField via handler mapper - EntryHandler.Mapper.AppendToMapping("CometKeyboard", (handler, view) => - { - if (view is not View cometView) - return; - var keyboard = cometView.GetEnvironment(EnvironmentKeys.Entry.Keyboard); - if (keyboard is null) - return; - var entry = handler.PlatformView; - if (entry is null) - return; -#if __IOS__ || MACCATALYST - entry.ApplyKeyboard(keyboard); -#elif ANDROID - // Android keyboard handled via MAUI's IEntry.Keyboard interface - if (view is IEntry entryView) - EntryHandler.MapKeyboard(handler, entryView); -#endif - }); - - // Apply ReturnType to TextField via handler mapper - EntryHandler.Mapper.AppendToMapping("CometReturnType", (handler, view) => - { - if (view is not View cometView) - return; - var returnType = cometView.GetEnvironment(EnvironmentKeys.Entry.ReturnType); - if (returnType is null) - return; - var entry = handler.PlatformView; - if (entry is null) - return; -#if __IOS__ || MACCATALYST - entry.ReturnKeyType = returnType.Value switch - { - ReturnType.Go => UIKit.UIReturnKeyType.Go, - ReturnType.Next => UIKit.UIReturnKeyType.Next, - ReturnType.Search => UIKit.UIReturnKeyType.Search, - ReturnType.Send => UIKit.UIReturnKeyType.Send, - ReturnType.Done => UIKit.UIReturnKeyType.Done, - _ => UIKit.UIReturnKeyType.Default, - }; -#endif - }); - - // Apply OnColor/ThumbColor to Toggle/Switch via handler mapper - SwitchHandler.Mapper.AppendToMapping("CometSwitchColors", (handler, view) => - { - if (view is not View cometView) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - var onColor = cometView.GetEnvironment(EnvironmentKeys.Switch.OnColor); - if (onColor is not null) - platformView.OnTintColor = onColor.ToPlatform(); - var thumbColor = cometView.GetEnvironment(EnvironmentKeys.Switch.ThumbColor); - if (thumbColor is not null) - platformView.ThumbTintColor = thumbColor.ToPlatform(); -#elif ANDROID - var onColor = cometView.GetEnvironment(EnvironmentKeys.Switch.OnColor); - if (onColor is not null) - platformView.TrackTintList = new global::Android.Content.Res.ColorStateList( - new[] { new[] { global::Android.Resource.Attribute.StateChecked } }, - new[] { (int)onColor.ToPlatform() }); - var thumbColor = cometView.GetEnvironment(EnvironmentKeys.Switch.ThumbColor); - if (thumbColor is not null) - platformView.ThumbTintList = new global::Android.Content.Res.ColorStateList( - new[] { Array.Empty() }, - new[] { (int)thumbColor.ToPlatform() }); -#endif - }); - - // Apply Slider track/thumb colors via handler mapper - SliderHandler.Mapper.AppendToMapping("CometSliderColors", (handler, view) => - { - if (view is not View cometView) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - var minTrackColor = cometView.GetEnvironment(EnvironmentKeys.Slider.ProgressColor); - if (minTrackColor is not null) - platformView.MinimumTrackTintColor = minTrackColor.ToPlatform(); - var maxTrackColor = cometView.GetEnvironment(EnvironmentKeys.Slider.TrackColor); - if (maxTrackColor is not null) - platformView.MaximumTrackTintColor = maxTrackColor.ToPlatform(); - var thumbColor = cometView.GetEnvironment(EnvironmentKeys.Slider.ThumbColor); - if (thumbColor is not null) - platformView.ThumbTintColor = thumbColor.ToPlatform(); -#elif ANDROID - var minTrackColor = cometView.GetEnvironment(EnvironmentKeys.Slider.ProgressColor); - if (minTrackColor is not null && platformView.ProgressTintList is not null) - platformView.ProgressTintList = global::Android.Content.Res.ColorStateList.ValueOf( - new global::Android.Graphics.Color((int)minTrackColor.ToPlatform())); - var thumbColor = cometView.GetEnvironment(EnvironmentKeys.Slider.ThumbColor); - if (thumbColor is not null) - platformView.ThumbTintList = global::Android.Content.Res.ColorStateList.ValueOf( - new global::Android.Graphics.Color((int)thumbColor.ToPlatform())); -#endif - }); - - // Apply OnValueChanged callback to Slider via handler mapper - SliderHandler.Mapper.AppendToMapping("CometSliderValueChanged", (handler, view) => - { - if (view is not View cometView) - return; - var callback = cometView.GetEnvironment>(EnvironmentKeys.Slider.ValueChanged); - if (callback is null) - return; - var slider = handler.PlatformView; - if (slider is null) - return; -#if __IOS__ || MACCATALYST - if (_sliderValueChangedHandlers.TryGetValue(slider, out var oldHandler)) - { - slider.ValueChanged -= oldHandler; - _sliderValueChangedHandlers.Remove(slider); - } - EventHandler newHandler = (s, e) => - { - try { callback(slider.Value); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Slider ValueChanged callback failed: {ex.Message}"); } - }; - _sliderValueChangedHandlers.AddOrUpdate(slider, newHandler); - slider.ValueChanged += newHandler; -#elif ANDROID - if (_sliderValueChangedHandlers.TryGetValue(slider, out var oldObj)) - { - _sliderValueChangedHandlers.Remove(slider); - } - EventHandler newAndroidHandler = (s, e) => - { - try - { - if (e.FromUser && view is ISlider iSlider) - callback(iSlider.Value); - } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Slider ValueChanged callback failed: {ex.Message}"); } - }; - _sliderValueChangedHandlers.AddOrUpdate(slider, newAndroidHandler); - slider.ProgressChanged += newAndroidHandler; -#endif - }); - - // Prevent native slider value snapping during active drag gesture - SliderHandler.Mapper.ModifyMapping(nameof(IRange.Value), (handler, view, existingAction) => - { -#if __IOS__ || MACCATALYST - if (handler.PlatformView is UIKit.UISlider uiSlider && uiSlider.Tracking) - return; -#endif - existingAction?.Invoke(handler, view); - }); - - // Apply OnToggled callback to Switch via handler mapper - SwitchHandler.Mapper.AppendToMapping("CometSwitchToggled", (handler, view) => - { - if (view is not View cometView) - return; - var callback = cometView.GetEnvironment>(EnvironmentKeys.Switch.Toggled); - if (callback is null) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - if (_switchValueChangedHandlers.TryGetValue(platformView, out var oldSwitchHandler)) - { - platformView.ValueChanged -= oldSwitchHandler; - _switchValueChangedHandlers.Remove(platformView); - } - EventHandler newSwitchHandler = (s, e) => - { - try { callback(platformView.On); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Switch Toggled callback failed: {ex.Message}"); } - }; - _switchValueChangedHandlers.AddOrUpdate(platformView, newSwitchHandler); - platformView.ValueChanged += newSwitchHandler; -#elif ANDROID - if (_switchValueChangedHandlers.TryGetValue(platformView, out var oldSwitchObj)) - { - _switchValueChangedHandlers.Remove(platformView); - } - EventHandler newSwitchAndroidHandler = (s, e) => - { - try { callback(e.IsChecked); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Switch Toggled callback failed: {ex.Message}"); } - }; - _switchValueChangedHandlers.AddOrUpdate(platformView, newSwitchAndroidHandler); - platformView.CheckedChange += newSwitchAndroidHandler; -#endif - }); - - // Apply OnCheckedChanged callback to CheckBox via handler mapper - CheckBoxHandler.Mapper.AppendToMapping("CometCheckBoxCheckedChanged", (handler, view) => - { - if (view is not View cometView) - return; - var callback = cometView.GetEnvironment>(EnvironmentKeys.CheckBox.IsCheckedChanged); - if (callback is null) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - if (_checkBoxCheckedHandlers.TryGetValue(platformView, out var oldCheckHandler)) - { - platformView.CheckedChanged -= oldCheckHandler; - _checkBoxCheckedHandlers.Remove(platformView); - } - EventHandler newCheckHandler = (s, e) => - { - try { callback(platformView.IsChecked); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] CheckBox CheckedChanged callback failed: {ex.Message}"); } - }; - _checkBoxCheckedHandlers.AddOrUpdate(platformView, newCheckHandler); - platformView.CheckedChanged += newCheckHandler; -#elif ANDROID - if (_checkBoxCheckedHandlers.TryGetValue(platformView, out var oldCheckObj)) - { - _checkBoxCheckedHandlers.Remove(platformView); - } - EventHandler newCheckAndroidHandler = (s, e) => - { - try { callback(e.IsChecked); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] CheckBox CheckedChanged callback failed: {ex.Message}"); } - }; - _checkBoxCheckedHandlers.AddOrUpdate(platformView, newCheckAndroidHandler); - platformView.CheckedChange += newCheckAndroidHandler; -#endif - }); - - // Apply OnValueChanged callback to Stepper via handler mapper - StepperHandler.Mapper.AppendToMapping("CometStepperValueChanged", (handler, view) => - { - if (view is not View cometView) - return; - var callback = cometView.GetEnvironment>(EnvironmentKeys.Stepper.ValueChanged); - if (callback is null) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - platformView.ValueChanged += (s, e) => - { - try { callback(platformView.Value); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Stepper ValueChanged callback failed: {ex.Message}"); } - }; -#elif ANDROID - // Android MauiStepper is a custom LinearLayout without a direct ValueChanged event - // TODO: Wire up button click events on the MauiStepper's internal +/- buttons -#endif - }); - - // Apply OnSelectedIndexChanged callback to Picker via handler mapper - PickerHandler.Mapper.AppendToMapping("CometPickerSelectedIndexChanged", (handler, view) => - { - if (view is not View cometView) - return; - var callback = cometView.GetEnvironment>(EnvironmentKeys.Picker.SelectedIndexChanged); - if (callback is null) - return; - var picker = handler.PlatformView; - if (picker is null) - return; -#if __IOS__ || MACCATALYST - // Remove previous subscription to prevent duplicates on re-map - if (_pickerEditingDidEndHandlers.TryGetValue(picker, out var oldHandler)) - { - picker.EditingDidEnd -= oldHandler; - _pickerEditingDidEndHandlers.Remove(picker); - } - EventHandler newHandler = (s, e) => - { - try - { - if (view is IPicker iPicker) - callback(iPicker.SelectedIndex); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"[Comet] Picker SelectedIndexChanged callback failed: {ex.Message}"); - } - }; - _pickerEditingDidEndHandlers.AddOrUpdate(picker, newHandler); - picker.EditingDidEnd += newHandler; -#elif ANDROID - picker.AfterTextChanged += (s, e) => - { - try - { - if (view is IPicker iPicker) - callback(iPicker.SelectedIndex); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"[Comet] Picker SelectedIndexChanged callback failed: {ex.Message}"); - } - }; -#endif - }); - - // Apply Aspect to Image via handler mapper - ImageHandler.Mapper.AppendToMapping("CometImageAspect", (handler, view) => - { - if (view is not View cometView) - return; - var aspect = cometView.GetEnvironment(EnvironmentKeys.Image.Aspect); - if (aspect is null) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - platformView.ContentMode = aspect.Value switch - { - Microsoft.Maui.Aspect.AspectFit => UIKit.UIViewContentMode.ScaleAspectFit, - Microsoft.Maui.Aspect.AspectFill => UIKit.UIViewContentMode.ScaleAspectFill, - Microsoft.Maui.Aspect.Fill => UIKit.UIViewContentMode.ScaleToFill, - _ => UIKit.UIViewContentMode.ScaleAspectFit - }; -#elif ANDROID - platformView.SetScaleType(aspect.Value switch - { - Microsoft.Maui.Aspect.AspectFit => global::Android.Widget.ImageView.ScaleType.FitCenter, - Microsoft.Maui.Aspect.AspectFill => global::Android.Widget.ImageView.ScaleType.CenterCrop, - Microsoft.Maui.Aspect.Fill => global::Android.Widget.ImageView.ScaleType.FitXy, - _ => global::Android.Widget.ImageView.ScaleType.FitCenter - }); -#endif - }); - - // Apply PlaceholderColor and Keyboard to TextEditor (Editor) via handler mapper - EditorHandler.Mapper.AppendToMapping("CometEditorPlaceholderColor", (handler, view) => - { - if (view is not View cometView) - return; - var color = cometView.GetEnvironment(EnvironmentKeys.Entry.PlaceholderColor); - if (color is null) - return; - var editor = handler.PlatformView; - if (editor is null) - return; -#if __IOS__ || MACCATALYST - // iOS UITextView doesn't have a built-in placeholder; MAUI handles it - // through the EditorHandler. We can set the placeholder color via attributed string. -#elif ANDROID - editor.SetHintTextColor(new global::Android.Content.Res.ColorStateList( - new[] { Array.Empty() }, - new[] { (int)color.ToPlatform() })); -#endif - }); - - EditorHandler.Mapper.AppendToMapping("CometEditorKeyboard", (handler, view) => - { - if (view is not View cometView) - return; - var keyboard = cometView.GetEnvironment(EnvironmentKeys.Entry.Keyboard); - if (keyboard is null) - return; - var editor = handler.PlatformView; - if (editor is null) - return; -#if __IOS__ || MACCATALYST - editor.ApplyKeyboard(keyboard); -#elif ANDROID - if (view is IEditor editorView) - EditorHandler.MapKeyboard(handler, editorView); -#endif - }); - - // Apply ProgressBar track/progress colors via handler mapper - ProgressBarHandler.Mapper.AppendToMapping("CometProgressBarColors", (handler, view) => - { - if (view is not View cometView) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - var progressColor = cometView.GetEnvironment(EnvironmentKeys.ProgressBar.ProgressColor); - if (progressColor is not null) - platformView.ProgressTintColor = progressColor.ToPlatform(); - var trackColor = cometView.GetEnvironment(EnvironmentKeys.ProgressBar.TrackColor); - if (trackColor is not null) - platformView.TrackTintColor = trackColor.ToPlatform(); -#elif ANDROID - var progressColor = cometView.GetEnvironment(EnvironmentKeys.ProgressBar.ProgressColor); - if (progressColor is not null) - platformView.ProgressTintList = global::Android.Content.Res.ColorStateList.ValueOf( - new global::Android.Graphics.Color((int)progressColor.ToPlatform())); - var trackColor = cometView.GetEnvironment(EnvironmentKeys.ProgressBar.TrackColor); - if (trackColor is not null) - platformView.ProgressBackgroundTintList = global::Android.Content.Res.ColorStateList.ValueOf( - new global::Android.Graphics.Color((int)trackColor.ToPlatform())); -#endif - }); - - // Apply DatePicker format via handler mapper - DatePickerHandler.Mapper.AppendToMapping("CometDatePickerFormat", (handler, view) => - { - if (view is not View cometView) - return; - var format = cometView.GetEnvironment(EnvironmentKeys.DatePicker.Format); - if (string.IsNullOrEmpty(format)) - return; - if (view is IDatePicker datePicker) - { - // Format is handled by MAUI's IDatePicker.Format property - // The environment value is read by the generated DatePicker class - } - }); - - // Apply DatePicker text color via handler mapper - DatePickerHandler.Mapper.AppendToMapping("CometDatePickerTextColor", (handler, view) => - { - if (view is not View cometView) - return; - var color = cometView.GetEnvironment(EnvironmentKeys.DatePicker.TextColor); - if (color is null) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - // UIDatePicker uses tintColor for text color on iOS 15+ - platformView.TintColor = color.ToPlatform(); -#elif ANDROID - platformView.SetTextColor(color.ToPlatform()); -#endif - }); - - // Apply Button CornerRadius/BorderWidth/BorderColor via handler mapper - ButtonHandler.Mapper.AppendToMapping("CometButtonStyling", (handler, view) => - { - if (view is not View cometView) - return; - var platformView = handler.PlatformView; - if (platformView is null) - return; -#if __IOS__ || MACCATALYST - var cornerRadius = cometView.GetEnvironment(EnvironmentKeys.Button.CornerRadius); - if (cornerRadius is not null) - platformView.Layer.CornerRadius = cornerRadius.Value; - var borderWidth = cometView.GetEnvironment(EnvironmentKeys.Button.BorderWidth); - if (borderWidth is not null) - platformView.Layer.BorderWidth = (float)borderWidth.Value; - var borderColor = cometView.GetEnvironment(EnvironmentKeys.Button.BorderColor); - if (borderColor is not null) - platformView.Layer.BorderColor = borderColor.ToPlatform().CGColor; - if (cornerRadius is not null || borderWidth is not null) - platformView.ClipsToBounds = true; -#elif ANDROID - var cornerRadius = cometView.GetEnvironment(EnvironmentKeys.Button.CornerRadius); - var borderWidth = cometView.GetEnvironment(EnvironmentKeys.Button.BorderWidth); - var borderColor = cometView.GetEnvironment(EnvironmentKeys.Button.BorderColor); - if (cornerRadius is not null || borderWidth is not null || borderColor is not null) - { - var context = platformView.Context; - if (context is not null) - { - var drawable = new global::Android.Graphics.Drawables.GradientDrawable(); - if (cornerRadius is not null) - drawable.SetCornerRadius((float)(cornerRadius.Value * context.Resources.DisplayMetrics.Density)); - if (borderWidth is not null && borderColor is not null) - drawable.SetStroke((int)(borderWidth.Value * context.Resources.DisplayMetrics.Density), borderColor.ToPlatform()); - else if (borderWidth is not null) - drawable.SetStroke((int)(borderWidth.Value * context.Resources.DisplayMetrics.Density), global::Android.Graphics.Color.Transparent); - var bg = cometView.GetBackground(); - if (bg is SolidPaint bgPaint && bgPaint.Color is not null) - drawable.SetColor(bgPaint.Color.ToPlatform()); - platformView.Background = drawable; - } - } -#endif - }); - - // Apply OnTextChanged callback to Entry via handler mapper - EntryHandler.Mapper.AppendToMapping("CometEntryTextChanged", (handler, view) => - { - if (view is not View cometView) - return; - var callback = cometView.GetEnvironment>(EnvironmentKeys.Entry.TextChanged); - if (callback is null) - return; - var entry = handler.PlatformView; - if (entry is null) - return; -#if __IOS__ || MACCATALYST - // Remove previous subscription to prevent duplicates on re-map - if (_entryEditingChangedHandlers.TryGetValue(entry, out var oldHandler)) - { - entry.EditingChanged -= oldHandler; - _entryEditingChangedHandlers.Remove(entry); - } - EventHandler newHandler = (s, e) => - { - // Suppress reactive view rebuilds during text input to prevent focus loss. - // The native TextField already shows the typed text; a rebuild would - // transfer the handler to a new view instance, causing resignFirstResponder. - Reactive.ReactiveScheduler.SuppressNotifications = true; - try { callback(entry.Text ?? string.Empty); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Entry TextChanged callback failed: {ex.Message}"); } - finally { Reactive.ReactiveScheduler.SuppressNotifications = false; } - }; - _entryEditingChangedHandlers.AddOrUpdate(entry, newHandler); - entry.EditingChanged += newHandler; -#elif ANDROID - entry.AfterTextChanged += (s, e) => - { - Reactive.ReactiveScheduler.SuppressNotifications = true; - try { callback(entry.Text ?? string.Empty); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Entry TextChanged callback failed: {ex.Message}"); } - finally { Reactive.ReactiveScheduler.SuppressNotifications = false; } - }; -#endif - }); - - // Apply OnTextChanged callback to Editor via handler mapper - EditorHandler.Mapper.AppendToMapping("CometEditorTextChanged", (handler, view) => - { - if (view is not View cometView) - return; - var callback = cometView.GetEnvironment>(EnvironmentKeys.Entry.TextChanged); - if (callback is null) - return; - var editor = handler.PlatformView; - if (editor is null) - return; -#if __IOS__ || MACCATALYST - // Remove previous subscription to prevent duplicates on re-map - if (_editorChangedHandlers.TryGetValue(editor, out var oldHandler)) - { - editor.Changed -= oldHandler; - _editorChangedHandlers.Remove(editor); - } - EventHandler newHandler = (s, e) => - { - Reactive.ReactiveScheduler.SuppressNotifications = true; - try { callback(editor.Text ?? string.Empty); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Editor TextChanged callback failed: {ex.Message}"); } - finally { Reactive.ReactiveScheduler.SuppressNotifications = false; } - }; - _editorChangedHandlers.AddOrUpdate(editor, newHandler); - editor.Changed += newHandler; -#elif ANDROID - editor.AfterTextChanged += (s, e) => - { - Reactive.ReactiveScheduler.SuppressNotifications = true; - try { callback(editor.Text ?? string.Empty); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] Editor TextChanged callback failed: {ex.Message}"); } - finally { Reactive.ReactiveScheduler.SuppressNotifications = false; } - }; -#endif - }); - - // Apply OnTextChanged callback to SearchBar via handler mapper - SearchBarHandler.Mapper.AppendToMapping("CometSearchBarTextChanged", (handler, view) => - { - if (view is not View cometView) - return; - var callback = cometView.GetEnvironment>(EnvironmentKeys.Entry.TextChanged); - if (callback is null) - return; - var searchBar = handler.PlatformView; - if (searchBar is null) - return; -#if __IOS__ || MACCATALYST - if (_searchBarTextChangedHandlers.TryGetValue(searchBar, out var oldHandler)) - { - searchBar.TextChanged -= oldHandler; - _searchBarTextChangedHandlers.Remove(searchBar); - } - EventHandler newHandler = (s, e) => - { - Reactive.ReactiveScheduler.SuppressNotifications = true; - try { callback(e.SearchText ?? string.Empty); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] SearchBar TextChanged callback failed: {ex.Message}"); } - finally { Reactive.ReactiveScheduler.SuppressNotifications = false; } - }; - _searchBarTextChangedHandlers.AddOrUpdate(searchBar, newHandler); - searchBar.TextChanged += newHandler; -#elif ANDROID - if (_searchBarTextChangedHandlers.TryGetValue(searchBar, out var oldObj)) - { - _searchBarTextChangedHandlers.Remove(searchBar); - } - EventHandler newAndroidHandler = (s, e) => - { - Reactive.ReactiveScheduler.SuppressNotifications = true; - try { callback(e.NewText ?? string.Empty); } - catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[Comet] SearchBar TextChanged callback failed: {ex.Message}"); } - finally { Reactive.ReactiveScheduler.SuppressNotifications = false; } - }; - _searchBarTextChangedHandlers.AddOrUpdate(searchBar, newAndroidHandler); - searchBar.QueryTextChange += newAndroidHandler; -#endif - }); - - // WebView Source mapper: load URL via platform delegate - WebViewHandler.Mapper.AppendToMapping(nameof(IWebView.Source), (handler, view) => - { - if (view is IWebView webView) - { - var src = webView.Source; - if (src is CometUrlWebViewSource urlSrc && !string.IsNullOrEmpty(urlSrc.Url)) - { - if (handler.PlatformView is IWebViewDelegate del) - del.LoadUrl(urlSrc.Url); - } - else if (src is CometHtmlWebViewSource htmlSrc && !string.IsNullOrEmpty(htmlSrc.Html)) - { - if (handler.PlatformView is IWebViewDelegate del2) - del2.LoadHtml(htmlSrc.Html, null); - } - } - }); - - Lerp.Lerps[typeof(FrameConstraints)] = new Lerp - { - Calculate = (s, e, progress) => { - var start = (FrameConstraints)s; - var end = (FrameConstraints)(e); - return start.Lerp(end, progress); - } - }; - builder.ConfigureMauiHandlers((handlersCollection) => handlersCollection.AddHandlers(new Dictionary - { - { typeof(AbstractLayout), typeof(LayoutHandler) }, - { typeof(AbsoluteLayout), typeof(LayoutHandler) }, - { typeof(FlexLayout), typeof(LayoutHandler) }, - { typeof(Border), typeof(LayoutHandler) }, - { typeof(RadioGroup), typeof(LayoutHandler) }, - { typeof(ActivityIndicator), typeof(ActivityIndicatorHandler) }, - { typeof(MauiViewHost), typeof(Handlers.MauiViewHostHandler) }, - { typeof(NativeHost), typeof(Handlers.NativeHostHandler) }, - { typeof(Button), typeof(ButtonHandler) }, - { typeof(CheckBox), typeof(CheckBoxHandler) }, - #if __MACOS__ - { typeof(CometWindow), typeof(Comet.Handlers.CometWindowHandler) }, -#else - { typeof(CometWindow), typeof(WindowHandler) }, -#endif - { typeof(DatePicker), typeof(DatePickerHandler) }, - { typeof(FlyoutView), typeof(FlyoutViewHandler) }, - { typeof(Frame), typeof(ContentViewHandler) }, - { typeof(GraphicsView), typeof(GraphicsViewHandler) }, - { typeof(Image) , typeof(ImageHandler) }, - { typeof(ImageButton) , typeof(ImageButtonHandler) }, - { typeof(IndicatorView), typeof(IndicatorViewHandler) }, - { typeof(Picker), typeof(PickerHandler) }, - { typeof(ProgressBar), typeof(ProgressBarHandler) }, - { typeof(RadioButton), typeof(Handlers.RadioButtonHandler) }, - { typeof(RefreshView), typeof(RefreshViewHandler) }, - { typeof(SearchBar), typeof(SearchBarHandler) }, - { typeof(SecureField), typeof(EntryHandler) }, - { typeof(Slider), typeof(SliderHandler) }, - { typeof(Stepper), typeof(StepperHandler) }, - { typeof(Spacer), typeof(SpacerHandler) }, - { typeof(SwipeView), typeof(SwipeViewHandler) }, - { typeof(TabView), typeof(TabViewHandler) }, - { typeof(TextEditor), typeof(EditorHandler) }, - { typeof(TextField), typeof(EntryHandler) }, - { typeof(Text), typeof(LabelHandler) }, - { typeof(TimePicker), typeof(TimePickerHandler) }, - { typeof(Toggle), typeof(SwitchHandler) }, - { typeof(Toolbar), typeof(ToolbarHandler) }, - { typeof(CometApp), typeof(ApplicationHandler) }, - { typeof(ListView),typeof(ListViewHandler) }, - { typeof(CollectionView),typeof(Handlers.CollectionViewHandler) }, - { typeof(CarouselView),typeof(Handlers.CollectionViewHandler) }, - { typeof(BoxView), typeof(Handlers.ShapeViewHandler) }, -#if __MOBILE__ - {typeof(ScrollView), typeof(Handlers.ScrollViewHandler) }, - {typeof(ShapeView), typeof(Handlers.ShapeViewHandler)}, -#elif __MACOS__ - {typeof(ScrollView), typeof(Handlers.ScrollViewHandler) }, - {typeof(ShapeView), typeof(Handlers.ShapeViewHandler)}, -#else - - {typeof(ScrollView), typeof(Microsoft.Maui.Handlers.ScrollViewHandler) }, -#endif - - -#if __IOS__ - {typeof(NavigationView), typeof (Handlers.NavigationViewHandler)}, - {typeof(View), typeof(CometViewHandler)}, -#elif __MACOS__ - {typeof(NavigationView), typeof (Handlers.NavigationViewHandler)}, - {typeof(View), typeof(CometViewHandler)}, -#elif __ANDROID__ - {typeof(NavigationView), typeof (Handlers.NavigationViewHandler)}, - {typeof(View), typeof(CometViewHandler)}, -#else - - {typeof(NavigationView), typeof (Microsoft.Maui.Handlers.NavigationViewHandler)}, -#endif -#if __MACOS__ - {typeof(WebView), typeof(Microsoft.Maui.Platform.MacOS.Handlers.WebViewHandler)}, -#else - {typeof(WebView), typeof(Microsoft.Maui.Handlers.WebViewHandler)}, -#endif - {typeof(MenuBar), typeof(Microsoft.Maui.Handlers.MenuBarHandler)}, - {typeof(MenuBarItem), typeof(Microsoft.Maui.Handlers.MenuBarItemHandler)}, - {typeof(MenuFlyoutItem), typeof(Microsoft.Maui.Handlers.MenuFlyoutItemHandler)}, - {typeof(MenuFlyoutSubItem), typeof(Microsoft.Maui.Handlers.MenuFlyoutSubItemHandler)}, - {typeof(MenuFlyoutSeparator), typeof(Microsoft.Maui.Handlers.MenuFlyoutSeparatorHandler)}, - })); - - // Register MAUI Controls handlers needed by Comet's CollectionViewHandler - // and CarouselViewHandler, which internally create MAUI Controls views and - // call ToPlatform() on them. - builder.ConfigureMauiHandlers(h => - { - h.TryAddHandler(); - h.TryAddHandler(); - h.TryAddHandler(); - h.TryAddHandler(); - h.TryAddHandler(); - h.TryAddHandler(); - h.TryAddHandler(); - h.TryAddHandler(); - h.TryAddHandler(); - }); - - // MAUI 10 moved MaxLines/LineBreakMode off ILabel to the concrete Label class. - // Comet's Text (View, not Label) can't provide them, so UILabel defaults to - // numberOfLines=1 and truncation. Fix by customizing the LabelHandler mapper. - LabelHandler.Mapper.AppendToMapping("CometTextWordWrap", (handler, view) => - { - if (view is Text) - { -#if __IOS__ || MACCATALYST - handler.PlatformView.Lines = 0; - handler.PlatformView.LineBreakMode = UIKit.UILineBreakMode.WordWrap; -#elif WINDOWS - handler.PlatformView.TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap; -#elif __ANDROID__ - handler.PlatformView.SetMaxLines(int.MaxValue); -#endif - } - }); - - // Register standard MAUI Controls handlers for MauiViewHost embedding. - // These enable Microsoft.Maui.Controls types (Label, Entry, Border, etc.) - // to be rendered when hosted inside a Comet view tree via MauiViewHost. - builder.ConfigureMauiHandlers((handlersCollection) => - { - handlersCollection.AddHandler(); -#if __MACOS__ - // On macOS, Platform.Maui.MacOS's SetupDefaults() already registered - // macOS-specific handlers for MAUI Controls types. Use those instead - // of MAUI's built-in handlers which throw NotImplementedException. - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); -#else - // Interface-based handler registrations (matches MAUI's own registrations) - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); - handlersCollection.TryAddHandler(); -#endif - }); - - -#if !__MACOS__ - // macOS (AppKit) uses NSApplication.InvokeOnMainThread set in ThreadHelper's - // field initializer. Don't overwrite it with MAUI's MainThread which has no - // macOS implementation. - ThreadHelper.SetFireOnMainThread(MainThread.BeginInvokeOnMainThread); -#endif - - return builder; - } - - static void ApplyInspectionMetadata(IViewHandler handler, View view) - { - if (handler?.PlatformView is null || view is null) - return; - - var automationId = view.AutomationId; - var isEnabled = view.IsEnabled; - var isVisible = view.IsVisible; - var inputTransparent = view.InputTransparent; - var hasGestures = view is IGestureView gv && gv.Gestures?.Count > 0; - - // Use explicit AutomationId if set, otherwise fall back to View.Id - // so every Comet view gets a stable platform identifier for - // automation tools (MauiDevFlow, Appium, accessibility inspectors). - var platformId = !string.IsNullOrWhiteSpace(automationId) - ? automationId - : view.Id; - -#if __IOS__ || MACCATALYST - if (handler.PlatformView is UIKit.UIView platformView) - { - if (!string.IsNullOrWhiteSpace(platformId)) - platformView.AccessibilityIdentifier = platformId; - - if (!string.IsNullOrWhiteSpace(automationId) || hasGestures) - platformView.IsAccessibilityElement = true; - - platformView.Hidden = !isVisible; - platformView.UserInteractionEnabled = isEnabled && !inputTransparent; - if (hasGestures) - platformView.UserInteractionEnabled = true; - } -#elif ANDROID - if (handler.PlatformView is global::Android.Views.View platformView) - { - if (!string.IsNullOrWhiteSpace(platformId)) - platformView.ContentDescription = platformId; - - platformView.Enabled = isEnabled; - platformView.Visibility = isVisible - ? global::Android.Views.ViewStates.Visible - : global::Android.Views.ViewStates.Gone; - // Only set Clickable on non-ViewGroup views or on ViewGroups that - // have explicit Comet gestures. Layout containers (VStack, HStack, etc.) - // should NOT be clickable — a clickable ViewGroup consumes touch events - // even when the touch lands on a child Button, preventing the child's - // click handler from firing. - if (platformView is global::Android.Views.ViewGroup) - { - platformView.Clickable = hasGestures; - } - else - { - // For leaf views (Button, EditText, etc.), preserve MAUI's default - // clickable state — don't override unless inputTransparent is set. - if (inputTransparent) - platformView.Clickable = false; - } - - // Layout containers (ViewGroups) must not steal focus from focusable - // children like EditText. Set DescendantFocusability so children get - // focus first, and only mark the container focusable if it has gestures. - if (platformView is global::Android.Views.ViewGroup viewGroup) - { - viewGroup.DescendantFocusability = global::Android.Views.DescendantFocusability.AfterDescendants; - - // Don't restrict focus on scrollable containers — they need proper - // gesture handling for scroll interception to work. - bool isScrollable = platformView is global::Android.Widget.ScrollView - || platformView is global::Android.Widget.HorizontalScrollView; - - if (!hasGestures && !isScrollable) - { - viewGroup.Focusable = false; - viewGroup.FocusableInTouchMode = false; - } - } - } -#endif - } - - - /// - /// Registers handler mapper entries for IControlStyle resolution on Button, - /// Toggle, TextField, and Slider. These resolve the active control style - /// from the environment (scoped or theme-level) and apply it as per-instance - /// defaults via the target view's cascading context. Explicit fluent - /// properties (written to LocalContext with cascades:false) take priority - /// over style-resolved values, preserving user intent without leaking one - /// view's state (pressed/hovered/etc.) onto every control of the same type. - /// - static void RegisterStyleResolutionMappers() - { - // Button style resolution - ButtonHandler.Mapper.AppendToMapping("CometButtonStyleResolution", (handler, view) => - { - if (view is not Button button) - return; - - var resolved = button.ResolveCurrentStyle(new ButtonConfiguration - { - TargetView = button, - IsEnabled = button.GetEnvironment(nameof(IView.IsEnabled)) ?? true, - Label = ((IText)button).Text, - }); - - if (resolved is null || resolved == ViewModifier.Empty) - return; - - ApplyModifierAsInstanceDefaults(button, resolved); - }); - - // Toggle style resolution - SwitchHandler.Mapper.AppendToMapping("CometToggleStyleResolution", (handler, view) => - { - if (view is not Toggle toggle) - return; - - var resolved = toggle.ResolveCurrentStyle(new ToggleConfiguration - { - TargetView = toggle, - IsOn = ((ISwitch)toggle).IsOn, - IsEnabled = toggle.GetEnvironment(nameof(IView.IsEnabled)) ?? true, - }); - - if (resolved is null || resolved == ViewModifier.Empty) - return; - - ApplyModifierAsInstanceDefaults(toggle, resolved); - }); - - // TextField style resolution - EntryHandler.Mapper.AppendToMapping("CometTextFieldStyleResolution", (handler, view) => - { - if (view is not TextField textField) - return; - - var resolved = textField.ResolveCurrentStyle(new TextFieldConfiguration - { - TargetView = textField, - IsEnabled = textField.GetEnvironment(nameof(IView.IsEnabled)) ?? true, - Placeholder = ((IPlaceholder)textField).Placeholder, - }); - - if (resolved is null || resolved == ViewModifier.Empty) - return; - - ApplyModifierAsInstanceDefaults(textField, resolved); - }); - - // Slider style resolution - SliderHandler.Mapper.AppendToMapping("CometSliderStyleResolution", (handler, view) => - { - if (view is not Slider slider) - return; - - var resolved = slider.ResolveCurrentStyle(new SliderConfiguration - { - TargetView = slider, - Value = ((ISlider)slider).Value, - Minimum = ((IRange)slider).Minimum, - Maximum = ((IRange)slider).Maximum, - IsEnabled = slider.GetEnvironment(nameof(IView.IsEnabled)) ?? true, - }); - - if (resolved is null || resolved == ViewModifier.Empty) - return; - - ApplyModifierAsInstanceDefaults(slider, resolved); - }); - } - - /// - /// Applies a resolved 's property writes as - /// per-instance defaults on . State-dependent values - /// (IsPressed / IsHovered / IsFocused colors) must stay scoped to the - /// instance that owns the state — writing them to a global type-scoped - /// environment would leak one view's pressed appearance onto every other - /// view of the same type. - /// - /// Values are written to the view's cascading context (cascades: true), - /// which sits below the view's LocalContext in the lookup chain. Explicit - /// fluent setters like .Background(Colors.Red) use cascades:false - /// and therefore win over style-resolved defaults. See STYLE_THEME_SPEC.md - /// §4.8, §10.3. - /// - static void ApplyModifierAsInstanceDefaults(View view, ViewModifier modifier) - { - // Snapshot what the modifier would write (without mutating the view) - // so we can replay each write to the view's cascading context at the - // right priority. - Dictionary<(ContextualObject view, string property, bool cascades), (object oldValue, object newValue)> changes; - ContextualObject.MonitorChanges(); - try - { - modifier.Apply(view); - } - finally - { - changes = ContextualObject.StopMonitoringChanges(); - } - - if (changes is null) - return; - - foreach (var entry in changes) - { - // Only replay writes that targeted this specific view — the - // modifier may set properties on child/composed views it - // created internally, and those should reach their own - // contexts untouched. - if (!ReferenceEquals(entry.Key.view, view)) - continue; - - var key = entry.Key.property; - var newValue = entry.Value.newValue; - if (newValue is null) - continue; - - // cascades: true routes to the view's Context slot, which is - // below LocalContext (explicit fluent) but above the parent / - // global environment. Exactly the precedence a style default - // should have, and scoped to this instance only. - view.SetEnvironment(key, newValue, cascades: true); - } - } - } -} diff --git a/src/Comet/src/Comet/Backend/AlertDialog.Backend.cs b/src/Comet/src/Comet/Backend/AlertDialog.Backend.cs new file mode 100644 index 000000000..86c586985 --- /dev/null +++ b/src/Comet/src/Comet/Backend/AlertDialog.Backend.cs @@ -0,0 +1,34 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission + dismiss write-back for AlertDialog (mirrors Drawer.Backend). + public partial class AlertDialog + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + // Push the open state, and (once) forward future signal changes to the node so toggling + // IsOpen shows/dismisses the dialog without a full re-render. + node.ApplyProperty(PropertyIds.Dialog_IsOpen, PropertyValue.From(IsOpen.Peek())); + + if (!_hooked) + { + _hooked = true; + IsOpen.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.Dialog_IsOpen, PropertyValue.From(IsOpen.Peek())); + } + } + + protected internal override void OnBackendEvent(Backend.EventId id) + { + // Scrim tap / back press dismissed the dialog — reflect it back into the signal. + if (id == Backend.EventIds.DialogDismissed) + IsOpen.Value = false; + } + } +} diff --git a/src/Comet/src/Comet/Backend/BackendContext.cs b/src/Comet/src/Comet/Backend/BackendContext.cs new file mode 100644 index 000000000..98a61ded0 --- /dev/null +++ b/src/Comet/src/Comet/Backend/BackendContext.cs @@ -0,0 +1,48 @@ +#nullable enable +using System; + +namespace Comet.Backend +{ + /// + /// A per-frame tick source the backend supplies so Comet's animation engine can + /// drive interpolation without taking a dependency on MAUI's IAnimationManager. + /// Implemented per platform (Choreographer on Android, CADisplayLink on iOS). + /// + public interface ICometFrameTicker + { + /// Subscribes a per-frame callback; returns a token whose disposal unsubscribes. + IDisposable Subscribe(Action onFrame); + } + + /// + /// Ambient services a backend hands to the nodes it creates — the Comet-owned + /// replacement for IMauiContext. Carries the DI container and the platform + /// frame ticker; platform backends may subclass to add native handles (e.g. an + /// Android Context or the root composition). + /// + public class BackendContext + { + public BackendContext(IServiceProvider services, ICometFrameTicker? ticker = null) + { + Services = services ?? throw new ArgumentNullException(nameof(services)); + Ticker = ticker; + } + + /// The application service provider (replaces IMauiContext.Services). + public IServiceProvider Services { get; } + + /// The per-platform animation tick source, if the backend provides one. + public ICometFrameTicker? Ticker { get; } + } + + /// + /// A node that can produce a backend node for itself. Comet's View-derived + /// controls implement this via their generated platform partial; selecting the + /// concrete node by virtual dispatch (rather than a reflective registry) is what + /// lets the trimmer drop unused controls and their backend nodes together. + /// + public interface ICometBackendNodeFactory + { + ICometBackendNode CreateBackendNode(BackendContext context); + } +} diff --git a/src/Comet/src/Comet/Backend/Button.Backend.cs b/src/Comet/src/Comet/Backend/Button.Backend.cs new file mode 100644 index 000000000..d9e22c36d --- /dev/null +++ b/src/Comet/src/Comet/Backend/Button.Backend.cs @@ -0,0 +1,57 @@ +#nullable enable +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + // Backend property emission for Button. Merges with the generated `partial class Button`. + public partial class Button + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + var text = Text?.CurrentValue; + if (text is not null) + node.ApplyProperty(PropertyIds.Button_Text, PropertyValue.From(text)); + + if (this.GetEnvironment(EnvironmentKeys.Colors.Color) is { } color) + node.ApplyProperty(PropertyIds.Button_TextColor, PropertyValue.From(color)); + + // Emit the outlined flag whenever it was set (true or false) so a reactive toggle back to + // filled reaches the node — the set-only patch would otherwise leave it stuck outlined. + if (this.GetEnvironment(this, "Comet.ButtonOutlined", false) is { } outlined) + node.ApplyProperty(PropertyIds.Button_Outlined, PropertyValue.From(outlined)); + + if (this.GetEnvironment(this, "Comet.ButtonTextButton", false) == true) + node.ApplyProperty(PropertyIds.Button_TextButton, PropertyValue.From(true)); + } + + /// Renders this button as a Material TextButton (no fill, no border — just the + /// label in the content color), the gold standard's dialog/confirm button style. + public Button TextButton() + { + this.SetEnvironment("Comet.ButtonTextButton", true, false); + return this; + } + + /// Renders this button as a Material OutlinedButton (a bordered, no-fill + /// button) instead of the default filled style. + public Button Outlined() => Outlined(true); + + /// Toggles the Material OutlinedButton (bordered, no-fill) vs the default + /// filled style. Settable both ways so it can be flipped reactively (e.g. a Send button that + /// is outlined while empty and fills once there's text). + public Button Outlined(bool on) + { + this.SetEnvironment("Comet.ButtonOutlined", (object)on, false); + return this; + } + + protected internal override void OnBackendEvent(Backend.EventId id) + { + if (id == Backend.EventIds.Clicked) + Clicked?.Invoke(); + } + } +} diff --git a/src/Comet/src/Comet/Backend/CometAnimationDriver.cs b/src/Comet/src/Comet/Backend/CometAnimationDriver.cs new file mode 100644 index 000000000..c9322d17e --- /dev/null +++ b/src/Comet/src/Comet/Backend/CometAnimationDriver.cs @@ -0,0 +1,50 @@ +#nullable enable +using System; +using Microsoft.Maui.Animations; + +namespace Comet.Backend +{ + /// + /// Comet-owned animation manager for the node backends: drives + /// interpolation from a platform frame ticker + /// (Choreographer on Android, CADisplayLink on iOS) with no MAUI host in the + /// loop. View.GetAnimationManager() falls back here when there is no + /// IMauiContext — before this, view.Animate(...) silently did + /// nothing on the node backends. + /// + public static class CometAnimationDriver + { + /// The shared manager, or null until a backend installs a ticker. + public static IAnimationManager? Shared { get; private set; } + + /// Installs the platform frame ticker. First caller wins (one ticker per + /// process); subsequent calls are no-ops so multiple backend roots can call it. + public static void Initialize(ICometFrameTicker ticker) + { + if (ticker is null) + throw new ArgumentNullException(nameof(ticker)); + Shared ??= new AnimationManager(new FrameTickerAdapter(ticker)); + } + + /// Adapts to the MAUI animation + /// library's so the stock + /// (which Comet's Animation types already target) can run unmodified. + sealed class FrameTickerAdapter : Ticker + { + readonly ICometFrameTicker _ticker; + IDisposable? _subscription; + + public FrameTickerAdapter(ICometFrameTicker ticker) => _ticker = ticker; + + public override bool IsRunning => _subscription is not null; + + public override void Start() => _subscription ??= _ticker.Subscribe(_ => Fire?.Invoke()); + + public override void Stop() + { + _subscription?.Dispose(); + _subscription = null; + } + } + } +} diff --git a/src/Comet/src/Comet/Backend/CometBackendBridge.cs b/src/Comet/src/Comet/Backend/CometBackendBridge.cs new file mode 100644 index 000000000..75cdc367c --- /dev/null +++ b/src/Comet/src/Comet/Backend/CometBackendBridge.cs @@ -0,0 +1,118 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace Comet.Backend +{ + /// Creates the backend node for a given view. Pluggable so host tests can + /// substitute a recording fake for the platform node. + public delegate ICometBackendNode CometNodeFactory(View view); + + /// + /// Walks a Comet view tree and materializes it into a retained + /// tree, applying each view's set-only properties and + /// nesting container children. This is the initial-mount counterpart to the diff's + /// incremental node patching. + /// + public static class CometBackendBridge + { + [ThreadStatic] + static List? _collector; + + /// + /// Collects every node created by Materialize calls inside the scope into + /// . Own-content nodes use this to track a hosted subtree's + /// node GENERATION so the previous generation can be disposed on swap — without it, + /// stale nodes stay subscribed to statics (AfterFlush, control signals, window + /// metrics) and every swap leaks a generation that keeps reacting. Scopes nest: an + /// inner own-content node's children collect into ITS scope only. + /// + public static NodeCollectionScope CollectNodes(List into) + { + var previous = _collector; + _collector = into; + return new NodeCollectionScope(previous, into); + } + + public readonly struct NodeCollectionScope : IDisposable + { + readonly List? _previous; + readonly List _mine; + internal NodeCollectionScope(List? previous, List mine) + { + _previous = previous; + _mine = mine; + } + public void Dispose() + { + if (_collector == _mine) + _collector = _previous; + } + } + + /// Materializes using each control's own + /// CreateBackendNode (production path). + public static ICometBackendNode Materialize(View view, BackendContext context) + => Materialize(view, v => v.CreateBackendNode(context), context); + + /// Materializes under a known view so own-content + /// nodes (navigation screens, list rows) register beneath their container in the dev + /// tree and can be pruned as a subtree when replaced. + internal static ICometBackendNode Materialize(View view, BackendContext context, View? parent) + => Materialize(view, v => v.CreateBackendNode(context), context, parent); + + /// Materializes using a supplied node factory + /// (test path). + public static ICometBackendNode Materialize(View view, CometNodeFactory factory, BackendContext context) + => Materialize(view, factory, context, parent: null); + + static ICometBackendNode Materialize(View view, CometNodeFactory factory, BackendContext context, View? parent) + { + if (view is null) throw new ArgumentNullException(nameof(view)); + if (factory is null) throw new ArgumentNullException(nameof(factory)); + + // Components and [Body] views render to their concrete subtree first (GetView + // runs CheckForBody, so a lazily-discovered [Body] materializes correctly); + // plain views return themselves. + var rendered = view.GetView(); + + var node = factory(rendered); + _collector?.Add(node); + rendered.Node = node; + node.SetEventSink(new ViewEventSink(rendered)); + + // Register ONLY the reload roots ([Body]/Component views, which collapse to a + // different rendered subtree) as hot-reload active views, and only when hot reload + // is enabled. TriggerReload calls their Reload() so the replaced type re-renders + // and diffs onto the retained nodes. Registering every materialized node (leaf) — + // as an earlier revision did via the Node setter — leaked into the global + // ActiveViews list in Release too (no IsEnabled gate, never pruned). + if (!ReferenceEquals(rendered, view) && Microsoft.Maui.HotReload.MauiHotReloadHelper.IsEnabled) + Microsoft.Maui.HotReload.MauiHotReloadHelper.AddActiveView(view); + + // Track for the in-process dev agent (no-op unless enabled) BEFORE applying + // properties: own-content nodes (lists) materialize their children during + // ApplyAllSetProperties, and those children resolve their parent through this entry. + DevTools.CometDevRegistry.Register(rendered, node, parent); + + rendered.ApplyAllSetProperties(node); + + // Nodes that manage their own content (navigation, lists) pull the views they + // need themselves; don't materialize the static child tree for them. + if (rendered is IContainerView container && node is not IBackendManagesOwnContent) + { + var children = container.GetChildren(); + for (int i = 0; i < children.Count; i++) + { + var child = children[i]; + // Establish the parent link so .Navigation (and other inherited context) + // propagates down the materialized tree. + child.Parent = rendered; + node.InsertChild(i, Materialize(child, factory, context, rendered)); + } + } + + return node; + } + } +} diff --git a/src/Comet/src/Comet/Backend/CometBackendLayoutEngine.cs b/src/Comet/src/Comet/Backend/CometBackendLayoutEngine.cs new file mode 100644 index 000000000..494460856 --- /dev/null +++ b/src/Comet/src/Comet/Backend/CometBackendLayoutEngine.cs @@ -0,0 +1,231 @@ +#nullable enable +using System; +using Comet.Layout; +using Comet.Layout.Yoga; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Primitives; +using YogaFlexDirection = Comet.Layout.Yoga.FlexDirection; +using YogaFlexAlign = Comet.Layout.Yoga.FlexAlign; + +namespace Comet.Backend +{ + /// + /// Computes layout for a materialized Comet view tree with the C# Yoga flexbox engine and + /// pushes the result onto the retained backend nodes through + /// / . This makes Yoga the single cross-platform + /// layout authority — the Compose/SwiftUI backends become positioned hosts rather than each + /// running its own native flexbox — so a given Comet tree lays out identically everywhere. + /// + /// + /// Each leaf's intrinsic size is obtained by crossing into native exactly once via the + /// node's Measure (e.g. a SwiftUI Text or Compose text measures itself); the + /// stack/flex math runs in C#. The computed frame is parent-relative and pushed via + /// Arrange. Own-content nodes (lists, navigation) are treated as leaves here — they + /// virtualize/host their own children natively. + /// Layout style (explicit size, margin, flex-grow/shrink/basis, alignment) is read from + /// the owning view via the shared , the same translation the + /// MAUI-facing layout managers use, so behaviour stays consistent across the two paths. + /// + public static class CometBackendLayoutEngine + { + /// Lays out within and + /// pushes the computed frames onto the backend node tree. + public static void Layout(View root, Size available) + { + if (root is null) throw new ArgumentNullException(nameof(root)); + + var yoga = Build(root, YogaFlexDirection.Column); + + // The root fills the available space (the screen), so a root container's background + // covers the whole surface and its children lay out within — rather than Yoga + // shrinking the root to its content height. + yoga.Width = Comet.Layout.Yoga.YogaValue.Point((float)available.Width); + yoga.Height = Comet.Layout.Yoga.YogaValue.Point((float)available.Height); + + yoga.CalculateLayout((float)available.Width, (float)available.Height); + Arrange(root, yoga); + } + + /// Lays out at a fixed with + /// its height wrapping the content, pushes the computed frames onto the node tree, and + /// returns the content's natural size. This is the model for a scroll viewport or a + /// virtualized list row: the cross axis is pinned to the host's width while the main axis + /// grows to whatever the content needs (the part that then scrolls / sets the row height). + public static Size LayoutContent(View content, double width) + { + if (content is null) throw new ArgumentNullException(nameof(content)); + + var yoga = Build(content, YogaFlexDirection.Column); + + // Pin the width; leave height Auto so Yoga wraps to the content's natural height + // rather than filling a (non-existent) viewport. + yoga.Width = Comet.Layout.Yoga.YogaValue.Point((float)width); + + yoga.CalculateLayout((float)width, float.NaN); + Arrange(content, yoga); + return new Size(yoga.LayoutWidth, yoga.LayoutHeight); + } + + /// Measures 's natural (unconstrained) size — both axes + /// wrap to the content. This is the intrinsic-size source for a composite NATIVE control that + /// sizes itself (a FAB, a chip): the engine measures the real Comet sub-tree (so text width + /// comes from the actual font/measure path, not an estimate), and the node adds the control's + /// documented insets. It does NOT arrange the content (no frames pushed) — the native control + /// lays its own content out; this only reports the size up to the parent's Yoga layout. + public static Size Measure(View content) + { + if (content is null) throw new ArgumentNullException(nameof(content)); + + var yoga = Build(content, YogaFlexDirection.Column); + yoga.CalculateLayout(float.NaN, float.NaN); // NaN/NaN ⇒ both axes size to content + return new Size(yoga.LayoutWidth, yoga.LayoutHeight); + } + + static YogaNode Build(View view, YogaFlexDirection parentDirection) + { + var node = new YogaNode(); + YogaMeasureBridge.ApplyStyle(node, view, parentDirection); + + // Comet Padding → Yoga padding for containers AND leaves: a container's padding + // insets its children (their arranged frames include it); a leaf's padding grows + // its measured box (Yoga adds it around the measure-func result) and the leaf + // node insets its own content at render (ComposeNode.PadsOwnContent). + var pad = view.GetPadding(); + if (pad.Left != 0) node.SetPadding(YogaEdge.Left, YogaValue.Point((float)pad.Left)); + if (pad.Top != 0) node.SetPadding(YogaEdge.Top, YogaValue.Point((float)pad.Top)); + if (pad.Right != 0) node.SetPadding(YogaEdge.Right, YogaValue.Point((float)pad.Right)); + if (pad.Bottom != 0) node.SetPadding(YogaEdge.Bottom, YogaValue.Point((float)pad.Bottom)); + + if (IsLayoutContainer(view)) + { + bool isDepth = view is ZStack; + var direction = view is HStack ? YogaFlexDirection.Row : YogaFlexDirection.Column; + node.FlexDirection = direction; + + if (isDepth) + { + // ZStack → Compose Box: children overlap (z-order = child order, so a later + // child paints over an earlier one). Each child is absolutely positioned within + // this container, so its background/clickable cover its own arranged frame and + // the layers don't push each other in flow. The container is Relative so it forms + // the containing block for those absolute children (otherwise they'd escape to + // the root). Insetless children are centered by the container's justify/align; + // a child's Comet alignment becomes Yoga insets (see ApplyZStackOverlay). + node.PositionType = Comet.Layout.Yoga.FlexPositionType.Relative; + node.JustifyContent = Comet.Layout.Yoga.FlexJustify.Center; + node.AlignItems = YogaFlexAlign.Center; + } + else + { + // Comet's stack default alignment is Fill → flexbox stretch: children fill the + // cross axis, so a Text gets a definite width and wraps (grows in height) rather + // than measuring at single-line intrinsic width. + node.AlignItems = YogaFlexAlign.Stretch; + + if (view is IStackLayout stack && stack.Spacing > 0) + node.SetGap(YogaGutter.All, (float)stack.Spacing); + } + + var children = ((IContainerView)view).GetChildren(); + for (int i = 0; i < children.Count; i++) + { + var childNode = Build(children[i], direction); + if (isDepth) + ApplyZStackOverlay(childNode, children[i], (ContainerView)view); + node.InsertChild(childNode, node.ChildCount); + } + } + else + { + // Leaf: its intrinsic size comes from the native control via the backend node. + var leaf = view; + + // baselineHeight: pin the first baseline at a fixed offset from the top. The first + // baseline is independent of wrap width (it's the first line), so compute the top pad + // once, push it to the node (which insets its content), and grow the measured box to + // match — exactly Jetchat's baselineHeight layout (pad = height − firstBaseline). + float baselinePad = 0f; + if (leaf.GetBaselineHeight() is double targetBaseline && + leaf.Node?.MeasureBaseline(double.PositiveInfinity, double.PositiveInfinity) is double firstBaseline) + { + baselinePad = (float)Math.Max(0, targetBaseline - firstBaseline); + leaf.Node?.SetContentTopInset(baselinePad); + } + + node.MeasureFunction = (_, availableWidth, widthMode, availableHeight, heightMode) => + { + var w = Resolve(availableWidth, widthMode); + var h = Resolve(availableHeight, heightMode); + var size = leaf.Node?.Measure(w, h) ?? Size.Zero; + return new YogaSize((float)size.Width, (float)size.Height + baselinePad); + }; + + // Baseline-aligned text: report the node's first-baseline offset so Yoga can line up + // the row on a shared baseline. When the node has no text baseline, fall back to the + // node height (Yoga's default), which degrades to bottom alignment. + if (leaf.GetBaselineAlign()) + node.BaselineFunction = (_, w, h) => + (float)(leaf.Node?.MeasureBaseline(w, h) ?? h); + } + + return node; + } + + // Positions a ZStack child as a Compose Box layer: absolute, with its Comet alignment + // translated to Yoga insets so it overlaps the siblings rather than stacking in flow — + // Fill stretches edge-to-edge, Start/End pins to one edge, Center/unset defers to the + // container's centered justify/align. Insets win over justify/align in Yoga, so an + // edge-pinned overlay and a centered base layer coexist (e.g. a full-bleed background + // with a bottom-right FAB floating over it). + static void ApplyZStackOverlay(YogaNode child, View childView, ContainerView zstack) + { + child.PositionType = Comet.Layout.Yoga.FlexPositionType.Absolute; + ApplyOverlayInset(child, childView.GetHorizontalLayoutAlignment(zstack, LayoutAlignment.Center), + YogaEdge.Left, YogaEdge.Right); + ApplyOverlayInset(child, childView.GetVerticalLayoutAlignment(zstack, LayoutAlignment.Center), + YogaEdge.Top, YogaEdge.Bottom); + } + + static void ApplyOverlayInset(YogaNode child, LayoutAlignment alignment, YogaEdge start, YogaEdge end) + { + switch (alignment) + { + case LayoutAlignment.Fill: // both edges pinned → stretch to fill the container + child.SetPosition(start, YogaValue.Point(0)); + child.SetPosition(end, YogaValue.Point(0)); + break; + case LayoutAlignment.Start: + child.SetPosition(start, YogaValue.Point(0)); + break; + case LayoutAlignment.End: + child.SetPosition(end, YogaValue.Point(0)); + break; + // Center (and unset, which defaults to Center here): no inset — the container's + // Center justify/align positions the child. + } + } + + static void Arrange(View view, YogaNode node) + { + view.Node?.Arrange(new Rect(node.LayoutX, node.LayoutY, node.LayoutWidth, node.LayoutHeight)); + + if (IsLayoutContainer(view)) + { + var children = ((IContainerView)view).GetChildren(); + for (int i = 0; i < children.Count && i < node.ChildCount; i++) + Arrange(children[i], node.GetChild(i)); + } + } + + // A flow container whose children Yoga positions. Own-content nodes (list/nav) host + // their own children and so are leaves to the layout pass. + static bool IsLayoutContainer(View view) + => view is IContainerView && view.Node is not IBackendManagesOwnContent; + + static double Resolve(float available, YogaMeasureMode mode) + => (mode == YogaMeasureMode.Undefined || float.IsNaN(available)) + ? double.PositiveInfinity + : available; + } +} diff --git a/src/Comet/src/Comet/Backend/CometWindowMetrics.cs b/src/Comet/src/Comet/Backend/CometWindowMetrics.cs new file mode 100644 index 000000000..e88e2220b --- /dev/null +++ b/src/Comet/src/Comet/Backend/CometWindowMetrics.cs @@ -0,0 +1,102 @@ +#nullable enable +using Comet.Reactive; +using Microsoft.Maui.Graphics; + +namespace Comet.Backend +{ + /// Material window width class — the M3 breakpoints adaptive layouts key off + /// (Compact < 600dp ≤ Medium < 840dp ≤ Expanded). + public enum WindowWidthClass { Compact, Medium, Expanded } + + /// Material window height class (Compact < 480dp ≤ Medium < 900dp ≤ Expanded). + public enum WindowHeightClass { Compact, Medium, Expanded } + + /// + /// Per-root reactive window geometry: the root view's available size in Dp as a + /// , plus the Material window-size-class derivations adaptive + /// UIs switch on (Reply's list-detail / navigation-suite chrome). Reading + /// , , or inside + /// a body/binding tracks the dependency, so a resize (rotation, split-screen, soft + /// keyboard, desktop window drag) re-renders exactly the views that read it. + /// + /// + /// The backend root that hosts a view tree owns one instance and calls + /// whenever its native view's laid-out size changes. Views resolve + /// theirs via — environment first + /// (a root installs its instance with ), + /// falling back to , the single-window default kept current by + /// today's Compose/SwiftUI roots. This replaces the process-wide static + /// ComposeNode.AvailableSize as the contract adaptive views program against; + /// the platform roots keep current (full re-plumb of the statics + /// lands with the first adaptive sample). + /// + public sealed class CometWindowMetrics + { + /// The single-window default. Backend roots keep it current; views that + /// resolve no per-root instance from their environment read this one. + public static CometWindowMetrics Shared { get; } = new(); + + /// The root's available size in Dp. Zero until the first native layout — + /// consumers treating zero as "unknown" should defer or use a platform fallback. + public Signal SizeDp { get; } = new(Size.Zero); + + /// The window's safe-area insets in Dp: status bar + navigation bar + display + /// cutout under Android edge-to-edge; the iOS safe-area insets. Zero until the first + /// native inset dispatch. Chrome that hosts app content (NavigationSuite) insets its + /// content slot by this automatically; the real Material widgets keep handling their + /// OWN insets internally. + public Signal SafeAreaDp { get; } = new(default); + + /// Reactive Material width class of the current size. + public WindowWidthClass WidthClass => ClassifyWidth(SizeDp.Value.Width); + + /// Reactive Material height class of the current size. + public WindowHeightClass HeightClass => ClassifyHeight(SizeDp.Value.Height); + + /// Called by the owning backend root when the native root view's laid-out + /// size changes. No-ops on equal sizes (Signal's equality gate), so per-frame + /// layout callbacks are safe to forward directly. + public void Update(Size sizeDp) => SizeDp.Value = sizeDp; + + /// Called by the owning backend root when the window's safe-area insets + /// change (equality-gated like ). + public void UpdateSafeArea(Microsoft.Maui.Thickness insetsDp) + { +#if DEBUG + // Safe-area changes refresh the whole NavigationSuite subtree — surface them in + // dev builds so unexpected jitter (a rebuild storm source) is visible in the log. + if (SafeAreaDp.Peek() != insetsDp) + System.Console.WriteLine( + $"[Metrics] SafeAreaDp {SafeAreaDp.Peek()} -> {insetsDp}"); +#endif + SafeAreaDp.Value = insetsDp; + } + + /// M3 width breakpoints: Compact < 600 ≤ Medium < 840 ≤ Expanded. + public static WindowWidthClass ClassifyWidth(double widthDp) => + widthDp < 600 ? WindowWidthClass.Compact + : widthDp < 840 ? WindowWidthClass.Medium + : WindowWidthClass.Expanded; + + /// M3 height breakpoints: Compact < 480 ≤ Medium < 900 ≤ Expanded. + public static WindowHeightClass ClassifyHeight(double heightDp) => + heightDp < 480 ? WindowHeightClass.Compact + : heightDp < 900 ? WindowHeightClass.Medium + : WindowHeightClass.Expanded; + } + + public static class WindowMetricsExtensions + { + internal const string EnvironmentKey = "Comet.WindowMetrics"; + + /// Installs a per-root into the view's + /// environment (cascades to descendants). Called by a backend root on its root view. + public static T WindowMetrics(this T view, CometWindowMetrics metrics) where T : View + => view.SetEnvironment(EnvironmentKey, metrics, cascades: true); + + /// The window metrics governing this view: the nearest environment-installed + /// instance, else . + public static CometWindowMetrics GetWindowMetrics(this View view) + => view.GetEnvironment(EnvironmentKey) ?? CometWindowMetrics.Shared; + } +} diff --git a/src/Comet/src/Comet/Backend/Drawer.Backend.cs b/src/Comet/src/Comet/Backend/Drawer.Backend.cs new file mode 100644 index 000000000..c794a8f57 --- /dev/null +++ b/src/Comet/src/Comet/Backend/Drawer.Backend.cs @@ -0,0 +1,36 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission + dismiss write-back for Drawer. + public partial class Drawer + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + // Push the open state, and (once) forward future signal changes to the node so a nav + // button toggling IsOpen slides the drawer without a full re-render. + node.ApplyProperty(PropertyIds.Drawer_IsOpen, PropertyValue.From(IsOpen.Peek())); + + if (!_hooked) + { + _hooked = true; + IsOpen.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.Drawer_IsOpen, PropertyValue.From(IsOpen.Peek())); + } + } + + protected internal override void OnBackendEvent(Backend.EventId id) + { + // Gesture open/dismiss (edge-swipe, scrim tap) — reflect back into the signal. + if (id == Backend.EventIds.DrawerClosed) + IsOpen.Value = false; + else if (id == Backend.EventIds.DrawerOpened) + IsOpen.Value = true; + } + } +} diff --git a/src/Comet/src/Comet/Backend/Events.cs b/src/Comet/src/Comet/Backend/Events.cs new file mode 100644 index 000000000..805496345 --- /dev/null +++ b/src/Comet/src/Comet/Backend/Events.cs @@ -0,0 +1,98 @@ +#nullable enable +using System; +using Microsoft.Maui.Graphics; + +namespace Comet.Backend +{ + /// + /// A dense identifier for an event a backend node raises back into the Comet + /// view (clicked, text changed, …). Mirrors so the same + /// generated-switch dispatch pattern applies. + /// + public readonly struct EventId : IEquatable + { + public ushort Value { get; } + + public EventId(ushort value) => Value = value; + + public bool Equals(EventId other) => Value == other.Value; + public override bool Equals(object? obj) => obj is EventId other && Equals(other); + public override int GetHashCode() => Value; + public override string ToString() => $"EventId({Value})"; + + public static bool operator ==(EventId a, EventId b) => a.Value == b.Value; + public static bool operator !=(EventId a, EventId b) => a.Value != b.Value; + } + + /// Stable registry of constants. + public static class EventIds + { + public static readonly EventId Clicked = new(1); + public static readonly EventId TextChanged = new(2); // payload: string + public static readonly EventId ValueChanged = new(3); // payload: double + public static readonly EventId Toggled = new(4); // payload: bool + public static readonly EventId Completed = new(5); + public static readonly EventId SelectionChanged = new(6);// payload: int + public static readonly EventId Appeared = new(7); + public static readonly EventId Disappeared = new(8); + public static readonly EventId Focused = new(9); + public static readonly EventId Unfocused = new(10); + public static readonly EventId DrawerClosed = new(11); // user dismissed the drawer + public static readonly EventId DrawerOpened = new(15); // user gesture-opened the drawer + public static readonly EventId DialogDismissed = new(12);// user dismissed an AlertDialog + public static readonly EventId SelectorPanelDismissed = new(13); // user back-dismissed an input-selector panel + public static readonly EventId DetailClosed = new(14); // user back-closed a compact ListDetail detail pane + } + + /// The category of a recognized gesture flowing back from a backend node. + public enum GestureKind : byte + { + Tap = 0, + DoubleTap, + LongPress, + Pan, + Pinch, + Swipe, + Pointer, + } + + /// The phase of a continuous gesture (pan/pinch/pointer). + public enum GestureState : byte + { + Began = 0, + Changed, + Ended, + Cancelled, + } + + /// + /// Payload accompanying a gesture callback. A small readonly struct passed + /// by in so continuous gestures don't allocate per frame. + /// + public readonly struct GestureData + { + public GestureState State { get; } + public Point Position { get; } + public Point Delta { get; } + public float Scale { get; } + + public GestureData(GestureState state, Point position, Point delta = default, float scale = 1f) + { + State = state; + Position = position; + Delta = delta; + Scale = scale; + } + } + + /// + /// Receives events and gestures a backend node raises. The Comet view implements + /// (or adapts to) this so native interactions drive reactive state. + /// + public interface ICometEventSink + { + void OnEvent(EventId id); + void OnEvent(EventId id, T payload); + void OnGesture(GestureKind kind, in GestureData data); + } +} diff --git a/src/Comet/src/Comet/Backend/Fab.Backend.cs b/src/Comet/src/Comet/Backend/Fab.Backend.cs new file mode 100644 index 000000000..96e68d6a4 --- /dev/null +++ b/src/Comet/src/Comet/Backend/Fab.Backend.cs @@ -0,0 +1,15 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend event routing for Fab. The node raises Clicked when the FAB is tapped. + public partial class Fab + { + protected internal override void OnBackendEvent(Backend.EventId id) + { + if (id == Backend.EventIds.Clicked) + Clicked?.Invoke(); + } + } +} diff --git a/src/Comet/src/Comet/Backend/FormattedText.Backend.cs b/src/Comet/src/Comet/Backend/FormattedText.Backend.cs new file mode 100644 index 000000000..83f3574d9 --- /dev/null +++ b/src/Comet/src/Comet/Backend/FormattedText.Backend.cs @@ -0,0 +1,28 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission for FormattedText (the runs + the base font, reusing Text_*). + public partial class FormattedText + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + node.ApplyProperty(PropertyIds.Text_Runs, PropertyValue.FromObject(Runs)); + + if (this.GetEnvironment(EnvironmentKeys.Fonts.Size) is { } fontSize) + node.ApplyProperty(PropertyIds.Text_FontSize, PropertyValue.From(fontSize)); + + if (this.GetEnvironment(EnvironmentKeys.Fonts.LineHeight) is { } lineHeight) + node.ApplyProperty(PropertyIds.Text_LineHeight, PropertyValue.From(lineHeight)); + + if (this.GetEnvironment(EnvironmentKeys.Text.LineBreak) is { } lineBreak and not TextLineBreak.Default) + node.ApplyProperty(PropertyIds.Text_LineBreak, PropertyValue.From((int)lineBreak)); + + if (this.GetEnvironment(EnvironmentKeys.Fonts.Family) is { Length: > 0 } family) + node.ApplyProperty(PropertyIds.Text_FontFamily, PropertyValue.From(family)); + } + } +} diff --git a/src/Comet/src/Comet/Backend/GradientSpec.cs b/src/Comet/src/Comet/Backend/GradientSpec.cs new file mode 100644 index 000000000..48a4b81c6 --- /dev/null +++ b/src/Comet/src/Comet/Backend/GradientSpec.cs @@ -0,0 +1,39 @@ +#nullable enable +namespace Comet +{ + /// Axis of a linear gradient. The gold apps use exactly these three: + /// horizontal (Jetsnack fills), vertical (Jetcaster scrims, JetLagged + /// backgrounds), and top-leading→bottom-trailing diagonal (Jetsnack borders). + public enum GradientDirection + { + Horizontal, + Vertical, + Diagonal, + /// Center-out radial (Jetcaster's radialGradientScrim home background). + Radial, + } + + /// + /// The single gradient wire shape carried by GradientBackground / + /// GradientBorder typed patches (v2 of the original bare Color[] payload — + /// defined before more callers baked the array shape in). + /// + /// are spaced evenly along and + /// carry per-stop alpha in their ARGB (Jetcaster's eased scrims precompute their + /// decay into N stops app-side). + /// + /// // model the + /// gold's offsetGradientBackground parallax: the gradient spans + /// ExtentDp along the axis starting at -OffsetDp (instead of filling + /// the node bounds), tiling mirrored beyond it. Supported for the Horizontal and + /// Vertical axes on Android; Diagonal/Radial ignore them (no gold consumer). The + /// SwiftUI shim renders direction + stops and documents extent/offset as a + /// deviation until a consumer needs it there. + /// + public sealed record GradientSpec( + Microsoft.Maui.Graphics.Color[] Stops, + GradientDirection Direction = GradientDirection.Horizontal, + float? ExtentDp = null, + float OffsetDp = 0f, + bool Mirror = false); +} diff --git a/src/Comet/src/Comet/Backend/ICometBackendNode.cs b/src/Comet/src/Comet/Backend/ICometBackendNode.cs new file mode 100644 index 000000000..9fd4ea028 --- /dev/null +++ b/src/Comet/src/Comet/Backend/ICometBackendNode.cs @@ -0,0 +1,88 @@ +#nullable enable +using System; +using Microsoft.Maui.Graphics; + +namespace Comet.Backend +{ + /// + /// One node in the retained backend tree — the single abstraction a platform + /// renderer (Jetpack Compose, SwiftUI, future WinUI) implements. Comet's diff + /// produces a stream of typed mutations against these nodes; the backend turns + /// them into native UI. + /// + /// + /// + /// Nodes are retained: created once per Comet view instance and kept + /// across rebuilds, so the diff applies the minimal patch set rather than + /// re-emitting the tree. Only properties a view actually set are ever applied + /// (see the generated ApplyAllSetProperties / ApplyChangedProperties), + /// which is how default values stop crossing the boundary. + /// + /// + /// Layout is computed in C# by Comet's Yoga engine; the backend is a positioned + /// host. is the one call that crosses into native to obtain a + /// leaf's intrinsic size; pushes the Yoga-computed frame down. + /// + /// + public interface ICometBackendNode : IDisposable + { + /// Applies a single typed property value. Called only for set/changed properties. + void ApplyProperty(PropertyId id, in PropertyValue value); + + /// Inserts a child node at the given index in this node's child list. + void InsertChild(int index, ICometBackendNode child); + + /// Removes the child at the given index. + void RemoveChildAt(int index); + + /// Moves a child from one index to another (keyed-reorder support). + void MoveChild(int fromIndex, int toIndex); + + /// + /// Returns this node's intrinsic size under the given constraints. Used for leaf + /// measurement by the Yoga bridge. Container nodes that defer to Yoga may return + /// . + /// + Size Measure(double widthConstraint, double heightConstraint); + + /// + /// Returns this node's first text baseline as an offset (Dp) from the top of its measured + /// box, or null when the node has no text baseline. Used by the layout engine to + /// align a row of text on a shared baseline (the Yoga baseline function). The offset must + /// match where the backend actually renders the baseline, not just the font ascent. + /// + double? MeasureBaseline(double width, double height) => null; + + /// Insets this node's rendered content down by within its + /// arranged box (the layout engine has already grown the box to match). Used to realize + /// baselineHeight: pad the top so the text's first baseline lands at a fixed offset. + /// Default no-op for backends that don't support it. + void SetContentTopInset(double dp) { } + + /// Positions this node at the Yoga-computed frame (parent-relative). + void Arrange(Rect frame); + + /// Sets (or clears) the sink that receives this node's events and gestures. + void SetEventSink(ICometEventSink? sink); + + /// + /// Called when a diff transferred this retained node from an old view instance to + /// (see View.TransferBackendNodeFrom). Own-content + /// nodes re-point their captured view reference to in all + /// cases; they invalidate content they materialized from the old tree ONLY when + /// is true — an ordinary reactive re-render (Component + /// SetState) must preserve retained state (navigation stack, list scroll, cached + /// screens), whereas a hot reload replaces the code and needs a re-materialize. Default + /// no-op (leaf nodes carry no owner reference or materialized content). + /// + void OnOwnerViewChanged(View newView, bool isHotReload) { } + } + + /// + /// Marker for backend nodes that render their own content dynamically (e.g. a + /// navigation stack or a virtualized list) rather than via the static child tree. + /// The bridge skips generic child materialization for these — the node pulls the + /// views it needs itself. + /// + public interface IBackendManagesOwnContent { } +} diff --git a/src/Comet/src/Comet/Backend/Icon.Backend.cs b/src/Comet/src/Comet/Backend/Icon.Backend.cs new file mode 100644 index 000000000..d53628df0 --- /dev/null +++ b/src/Comet/src/Comet/Backend/Icon.Backend.cs @@ -0,0 +1,38 @@ +#nullable enable +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + // Backend property emission for Icon. + public partial class Icon + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + if (!string.IsNullOrEmpty(Symbol)) + { + // If an icon font is registered and maps this symbol, render the font glyph (same on + // every backend); otherwise fall back to the platform-native icon set (SF Symbol / Material + // ImageVector / bundled asset). + if (IconFont.TryGlyph(Symbol, out var glyph)) + { + node.ApplyProperty(PropertyIds.Icon_Glyph, PropertyValue.From(glyph)); + node.ApplyProperty(PropertyIds.Icon_FontFamily, PropertyValue.From(IconFont.Family!)); + } + else + node.ApplyProperty(PropertyIds.Icon_Symbol, PropertyValue.From(Symbol)); + } + + if (this.GetEnvironment(EnvironmentKeys.Colors.Color) is { } tint) + node.ApplyProperty(PropertyIds.Icon_Tint, PropertyValue.From(tint)); + + if (this.GetEnvironment(this, "Comet.IconSize", false) is { } size && size > 0) + node.ApplyProperty(PropertyIds.Icon_Size, PropertyValue.From(size)); + + if (this.GetEnvironment(this, "Comet.IconFillFrame", false) is true) + node.ApplyProperty(PropertyIds.Icon_FillFrame, PropertyValue.From(true)); + } + } +} diff --git a/src/Comet/src/Comet/Backend/Image.Backend.cs b/src/Comet/src/Comet/Backend/Image.Backend.cs new file mode 100644 index 000000000..b702fe147 --- /dev/null +++ b/src/Comet/src/Comet/Backend/Image.Backend.cs @@ -0,0 +1,23 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission for Image. Merges with the `partial class Image`. + public partial class Image + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + // The string source (a URL or resource name) is what the backends resolve; the + // IImageSource path is a later step. + var source = StringSource?.CurrentValue; + if (!string.IsNullOrEmpty(source)) + node.ApplyProperty(PropertyIds.Image_Source, PropertyValue.From(source)); + + if (this.GetEnvironment("Aspect") is { } aspect) + node.ApplyProperty(PropertyIds.Image_Aspect, PropertyValue.From((int)aspect)); + } + } +} diff --git a/src/Comet/src/Comet/Backend/ListDetail.Backend.cs b/src/Comet/src/Comet/Backend/ListDetail.Backend.cs new file mode 100644 index 000000000..74934e2cb --- /dev/null +++ b/src/Comet/src/Comet/Backend/ListDetail.Backend.cs @@ -0,0 +1,30 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission + back-close write-back for ListDetail (the Drawer pattern). + public partial class ListDetail + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + node.ApplyProperty(PropertyIds.ListDetail_IsDetailOpen, PropertyValue.From(IsDetailOpen.Peek())); + if (!_hooked) + { + _hooked = true; + IsDetailOpen.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.ListDetail_IsDetailOpen, PropertyValue.From(IsDetailOpen.Peek())); + } + } + + protected internal override void OnBackendEvent(Backend.EventId id) + { + // Compact detail dismissed by the system back press — reflect into the signal. + if (id == Backend.EventIds.DetailClosed) + IsDetailOpen.Value = false; + } + } +} diff --git a/src/Comet/src/Comet/Backend/ListView.Backend.cs b/src/Comet/src/Comet/Backend/ListView.Backend.cs new file mode 100644 index 000000000..1e9b1c717 --- /dev/null +++ b/src/Comet/src/Comet/Backend/ListView.Backend.cs @@ -0,0 +1,17 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend emission for ListView. The actual rows are pulled lazily by the Compose + // LazyColumn via IListView; here we just signal (re)composition on data changes. + public partial class ListView + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + // Bump the list node's version so its LazyColumn recomposes against current rows. + node.ApplyProperty(PropertyIds.List_Version, PropertyValue.From(0)); + } + } +} diff --git a/src/Comet/src/Comet/Backend/NavigationChrome.Backend.cs b/src/Comet/src/Comet/Backend/NavigationChrome.Backend.cs new file mode 100644 index 000000000..8c20e3914 --- /dev/null +++ b/src/Comet/src/Comet/Backend/NavigationChrome.Backend.cs @@ -0,0 +1,126 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission for the nav chrome controls: push the selected index and + // (once) forward future signal changes so a selection re-highlights the platform items + // without a full re-render. Mirrors Drawer.Backend.cs / SelectorPanel.Backend.cs. + + public partial class NavigationBar + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + node.ApplyProperty(PropertyIds.Nav_SelectedIndex, PropertyValue.From(SelectedIndex.Peek())); + if (!_hooked) + { + _hooked = true; + SelectedIndex.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.Nav_SelectedIndex, PropertyValue.From(SelectedIndex.Peek())); + } + } + } + + public partial class NavigationRail + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + node.ApplyProperty(PropertyIds.Nav_SelectedIndex, PropertyValue.From(SelectedIndex.Peek())); + if (!_hooked) + { + _hooked = true; + SelectedIndex.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.Nav_SelectedIndex, PropertyValue.From(SelectedIndex.Peek())); + } + } + } + + public partial class ContentSwitcher + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + node.ApplyProperty(PropertyIds.ContentSwitcher_Index, PropertyValue.From(Index.Peek())); + if (!_hooked) + { + _hooked = true; + Index.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.ContentSwitcher_Index, PropertyValue.From(Index.Peek())); + } + } + } + + public partial class IconToggleButton + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + node.ApplyProperty(PropertyIds.Toggle_IsOn, PropertyValue.From(IsChecked)); + } + } + + public partial class FilterChip + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + node.ApplyProperty(PropertyIds.Toggle_IsOn, PropertyValue.From(IsSelected)); + } + } + + public partial class TabBar + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + node.ApplyProperty(PropertyIds.Nav_SelectedIndex, PropertyValue.From(SelectedIndex.Peek())); + if (!_hooked) + { + _hooked = true; + SelectedIndex.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.Nav_SelectedIndex, PropertyValue.From(SelectedIndex.Peek())); + } + } + } + + public partial class NavigationSuite + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + node.ApplyProperty(PropertyIds.Nav_SelectedIndex, PropertyValue.From(SelectedIndex.Peek())); + if (DrawerOpen is { } drawer) + node.ApplyProperty(PropertyIds.Drawer_IsOpen, PropertyValue.From(drawer.Peek())); + if (!_hooked) + { + _hooked = true; + SelectedIndex.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.Nav_SelectedIndex, PropertyValue.From(SelectedIndex.Peek())); + if (DrawerOpen is { } d) + d.PropertyChanged += (_, _) => + Node?.ApplyProperty(PropertyIds.Drawer_IsOpen, PropertyValue.From(d.Peek())); + } + } + + protected internal override void OnBackendEvent(Backend.EventId id) + { + // Gesture open/dismiss (scrim tap / swipe / back) — reflect into the signal. + if (id == Backend.EventIds.DrawerClosed && DrawerOpen is { } drawer) + drawer.Value = false; + else if (id == Backend.EventIds.DrawerOpened && DrawerOpen is { } opened) + opened.Value = true; + } + } +} diff --git a/src/Comet/src/Comet/Backend/PropertyId.cs b/src/Comet/src/Comet/Backend/PropertyId.cs new file mode 100644 index 000000000..ec554e443 --- /dev/null +++ b/src/Comet/src/Comet/Backend/PropertyId.cs @@ -0,0 +1,145 @@ +#nullable enable +using System; + +namespace Comet.Backend +{ + /// + /// A dense, comparison-cheap identifier for a renderable property, replacing the + /// string keys used by the legacy environment dictionary on the render hot path. + /// + /// + /// Ids are assigned in stable, contiguous ranges (see ): + /// 0–63 are reserved for View-common properties; control-specific + /// ranges start at 64. Phase 1 hand-authors the registry; a source generator will + /// later emit the same shape so unused controls' ids fall away with the controls. + /// + public readonly struct PropertyId : IEquatable + { + public ushort Value { get; } + + public PropertyId(ushort value) => Value = value; + + public bool Equals(PropertyId other) => Value == other.Value; + public override bool Equals(object? obj) => obj is PropertyId other && Equals(other); + public override int GetHashCode() => Value; + public override string ToString() => $"PropertyId({Value})"; + + public static bool operator ==(PropertyId a, PropertyId b) => a.Value == b.Value; + public static bool operator !=(PropertyId a, PropertyId b) => a.Value != b.Value; + } + + /// + /// The stable registry of constants. View-common ids occupy + /// 0–63; each control claims a 16-wide range starting at 64. + /// + public static class PropertyIds + { + // --- View common (0–63) --- + public static readonly PropertyId Opacity = new(1); + public static readonly PropertyId IsVisible = new(2); + public static readonly PropertyId BackgroundColor = new(3); + public static readonly PropertyId TranslationX = new(4); + public static readonly PropertyId TranslationY = new(5); + public static readonly PropertyId ScaleX = new(6); + public static readonly PropertyId ScaleY = new(7); + public static readonly PropertyId Rotation = new(8); + public static readonly PropertyId RotationX = new(9); + public static readonly PropertyId RotationY = new(10); + public static readonly PropertyId AnchorX = new(11); + public static readonly PropertyId AnchorY = new(12); + public static readonly PropertyId IsEnabled = new(13); + public static readonly PropertyId ClipShape = new(14); + public static readonly PropertyId Shadow = new(15); + public static readonly PropertyId Border = new(16); + public static readonly PropertyId CornerRadius = new(17); + public static readonly PropertyId AutomationId = new(18); + public static readonly PropertyId HasTapGesture = new(19); + public static readonly PropertyId Padding = new(20); + public static readonly PropertyId HasRecordGesture = new(21); // press-and-hold voice-record drag (gold RecordButton) + public static readonly PropertyId HasLongPressGesture = new(22); // long-press (gold combinedClickable onLongClick — Reply selection) + + // --- Text (64–79) --- + public static readonly PropertyId Text_Value = new(64); + public static readonly PropertyId Text_Color = new(65); + public static readonly PropertyId Text_FontSize = new(66); + public static readonly PropertyId Text_FontFamily = new(67); + public static readonly PropertyId Text_FontWeight = new(68); + public static readonly PropertyId Text_HorizontalAlignment = new(69); + public static readonly PropertyId Text_VerticalAlignment = new(70); + public static readonly PropertyId Text_LineBreakMode = new(71); + public static readonly PropertyId Text_MaxLines = new(72); + public static readonly PropertyId Text_Runs = new(73); // FormattedText: IReadOnlyList + public static readonly PropertyId Text_LineHeight = new(74); // explicit line height (sp) + public static readonly PropertyId Text_LineBreak = new(75); // wrap strategy ((int)TextLineBreak) + public static readonly PropertyId Text_Italic = new(76); // FontSlant.Italic + + // --- Button (80–95) --- + public static readonly PropertyId Button_Text = new(80); + public static readonly PropertyId Button_TextColor = new(81); + public static readonly PropertyId Button_FontSize = new(82); + public static readonly PropertyId Button_Outlined = new(83); + public static readonly PropertyId Button_TextButton = new(84); // Material TextButton (no fill/border) + + // --- TextField / Entry (96–111) --- + public static readonly PropertyId TextField_Text = new(96); + public static readonly PropertyId TextField_Placeholder = new(97); + public static readonly PropertyId TextField_TextColor = new(98); + public static readonly PropertyId TextField_PlaceholderColor = new(99); + public static readonly PropertyId TextField_IsPassword = new(100); + public static readonly PropertyId TextField_Keyboard = new(101); + public static readonly PropertyId TextField_Borderless = new(102); + public static readonly PropertyId TextField_ReturnType = new(103); // soft-keyboard action key (ImeAction) + public static readonly PropertyId TextField_Outlined = new(104); // Material OutlinedTextField + public static readonly PropertyId TextField_LeadingIcon = new(105); // leading icon symbol name + + // --- Image (112–127) --- + public static readonly PropertyId Image_Source = new(112); + public static readonly PropertyId Image_Aspect = new(113); + + // --- Stack layouts (128–143) --- + public static readonly PropertyId Stack_Orientation = new(128); + public static readonly PropertyId Stack_Spacing = new(129); + public static readonly PropertyId Container_Surface = new(130); + public static readonly PropertyId Container_Card = new(131); // real M3 Card (clickable when tappable) + public static readonly PropertyId GradientBackground = new(132); // Color[] stops, horizontal (Jetsnack) + public static readonly PropertyId GradientBorder = new(133); // Color[] stops, 2dp diagonal stroke (Jetsnack) + + // --- Toggle / Switch (144–159) --- + public static readonly PropertyId Toggle_IsOn = new(144); + + // --- Slider (160–175) --- + public static readonly PropertyId Slider_Value = new(160); + public static readonly PropertyId Slider_Minimum = new(161); + public static readonly PropertyId Slider_Maximum = new(162); + + // --- List (176–191) --- + // Bumped whenever the list's data changes, to recompose the LazyColumn. + public static readonly PropertyId List_Version = new(176); + + // --- Icon (192–207) --- + public static readonly PropertyId Icon_Symbol = new(192); + public static readonly PropertyId Icon_Tint = new(193); + public static readonly PropertyId Icon_Size = new(194); + public static readonly PropertyId Icon_Glyph = new(195); // icon-font codepoint string + public static readonly PropertyId Icon_FontFamily = new(196); // icon-font family name + public static readonly PropertyId Icon_FillFrame = new(197); // non-square asset fills the frame (no slack centering) + + // --- Drawer (208–223) --- + public static readonly PropertyId Drawer_IsOpen = new(208); + + // --- Dialog / AlertDialog (224–239) --- + public static readonly PropertyId Dialog_IsOpen = new(224); + + // --- SelectorPanel (240–255) --- + // The active panel index (gold UserInput.kt InputSelector); 0 = collapsed. + public static readonly PropertyId SelectorPanel_Index = new(240); + + // --- Navigation chrome (256–271): NavigationBar / NavigationRail / adaptive primitives --- + // The selected destination index; the chrome controls share the id. + public static readonly PropertyId Nav_SelectedIndex = new(256); + // ListDetail: compact-mode detail pane visibility. + public static readonly PropertyId ListDetail_IsDetailOpen = new(257); + // ContentSwitcher: the active route/view index. + public static readonly PropertyId ContentSwitcher_Index = new(258); + } +} diff --git a/src/Comet/src/Comet/Backend/PropertyValue.cs b/src/Comet/src/Comet/Backend/PropertyValue.cs new file mode 100644 index 000000000..86c05bd0d --- /dev/null +++ b/src/Comet/src/Comet/Backend/PropertyValue.cs @@ -0,0 +1,113 @@ +#nullable enable +using System; +using Microsoft.Maui.Graphics; + +namespace Comet.Backend +{ + /// Discriminates the payload carried by a . + public enum PropertyValueKind : byte + { + None = 0, + Bool, + Int, + Long, + Single, + Double, + Color, + String, + Object, + } + + /// + /// A small tagged union used to carry a single property value across the + /// boundary without boxing primitives. + /// + /// + /// Numeric and boolean values live in an inline long (reinterpreted as + /// needed); reference values (string, , + /// arbitrary objects) live in a single object slot. Primitives therefore cost + /// zero heap allocations. + /// + /// This is a plain readonly struct rather than a ref struct on + /// purpose: the diff layer and test recorders need to store values + /// (e.g. a recorded patch stream), which a ref struct forbids. It is + /// passed by in on the hot path to avoid copies. + /// + /// + public readonly struct PropertyValue : IEquatable + { + readonly long _bits; + readonly object? _obj; + + public PropertyValueKind Kind { get; } + + PropertyValue(PropertyValueKind kind, long bits, object? obj) + { + Kind = kind; + _bits = bits; + _obj = obj; + } + + public static PropertyValue None => default; + + public static PropertyValue From(bool value) => new(PropertyValueKind.Bool, value ? 1 : 0, null); + public static PropertyValue From(int value) => new(PropertyValueKind.Int, value, null); + public static PropertyValue From(long value) => new(PropertyValueKind.Long, value, null); + public static PropertyValue From(float value) => new(PropertyValueKind.Single, BitConverter.DoubleToInt64Bits(value), null); + public static PropertyValue From(double value) => new(PropertyValueKind.Double, BitConverter.DoubleToInt64Bits(value), null); + public static PropertyValue From(Color? value) => new(PropertyValueKind.Color, 0, value); + public static PropertyValue From(string? value) => new(PropertyValueKind.String, 0, value); + public static PropertyValue FromObject(object? value) => new(PropertyValueKind.Object, 0, value); + + public bool AsBool => _bits != 0; + public int AsInt => (int)_bits; + public long AsLong => _bits; + public float AsSingle => (float)BitConverter.Int64BitsToDouble(_bits); + public double AsDouble => BitConverter.Int64BitsToDouble(_bits); + public Color? AsColor => _obj as Color; + public string? AsString => _obj as string; + public object? AsObject => _obj; + + public bool Equals(PropertyValue other) + { + if (Kind != other.Kind) + return false; + return Kind switch + { + PropertyValueKind.None => true, + PropertyValueKind.Color or PropertyValueKind.String or PropertyValueKind.Object + => Equals(_obj, other._obj), + _ => _bits == other._bits, + }; + } + + public override bool Equals(object? obj) => obj is PropertyValue pv && Equals(pv); + + public override int GetHashCode() + { + return Kind switch + { + PropertyValueKind.None => 0, + PropertyValueKind.Color or PropertyValueKind.String or PropertyValueKind.Object + => HashCode.Combine(Kind, _obj), + _ => HashCode.Combine(Kind, _bits), + }; + } + + public override string ToString() + { + return Kind switch + { + PropertyValueKind.None => "None", + PropertyValueKind.Bool => $"Bool({AsBool})", + PropertyValueKind.Int => $"Int({AsInt})", + PropertyValueKind.Long => $"Long({AsLong})", + PropertyValueKind.Single => $"Single({AsSingle})", + PropertyValueKind.Double => $"Double({AsDouble})", + PropertyValueKind.Color => $"Color({_obj})", + PropertyValueKind.String => $"String({_obj})", + _ => $"Object({_obj})", + }; + } + } +} diff --git a/src/Comet/src/Comet/Backend/SelectorPanel.Backend.cs b/src/Comet/src/Comet/Backend/SelectorPanel.Backend.cs new file mode 100644 index 000000000..80eff75a4 --- /dev/null +++ b/src/Comet/src/Comet/Backend/SelectorPanel.Backend.cs @@ -0,0 +1,42 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission + back-dismiss write-back for SelectorPanel. + public partial class SelectorPanel + { + bool _hooked; + + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + // Push the active index, and (once) forward future signal changes so a selector tap swaps + // the panel without a full re-render. The change also re-runs Yoga layout (AfterFlush), + // growing/collapsing the footer as the panel's measured height changes. + node.ApplyProperty(PropertyIds.SelectorPanel_Index, PropertyValue.From(Selector.Peek())); + + if (!_hooked) + { + _hooked = true; + Selector.PropertyChanged += (_, _) => + { + Node?.ApplyProperty(PropertyIds.SelectorPanel_Index, PropertyValue.From(Selector.Peek())); + // The panel's measured height changes with the index, so the layout must reflow (grow + // the footer / shrink the message list). A bare signal set marks no view or effect + // dirty, so it wouldn't otherwise schedule a flush — request one so the layout-driving + // backend's AfterFlush hook re-runs Yoga layout. + Comet.Reactive.ReactiveScheduler.EnsureFlushScheduled(); + }; + } + } + + protected internal override void OnBackendEvent(Backend.EventId id) + { + // The node intercepted a system back press while a panel was open — collapse it. + if (id == Backend.EventIds.SelectorPanelDismissed) + Selector.Value = 0; + } + } +} diff --git a/src/Comet/src/Comet/Backend/Slider.Backend.cs b/src/Comet/src/Comet/Backend/Slider.Backend.cs new file mode 100644 index 000000000..d5049d600 --- /dev/null +++ b/src/Comet/src/Comet/Backend/Slider.Backend.cs @@ -0,0 +1,28 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission + drag write-back for Slider (ISlider, double value). + public partial class Slider + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + if (Value is { } v) + node.ApplyProperty(PropertyIds.Slider_Value, PropertyValue.From(v.CurrentValue)); + } + + protected internal override void OnBackendEvent(Backend.EventId id, T payload) + { + // User dragged the slider. Optimistic self-update (controlled component), then + // write the new value back through the (possibly two-way) Value subscription. + if (id == Backend.EventIds.ValueChanged && payload is double d) + { + Node?.ApplyProperty(PropertyIds.Slider_Value, PropertyValue.From(d)); + Value?.Set(d); + } + } + } +} diff --git a/src/Comet/src/Comet/Backend/Text.Backend.cs b/src/Comet/src/Comet/Backend/Text.Backend.cs new file mode 100644 index 000000000..9e16d0bda --- /dev/null +++ b/src/Comet/src/Comet/Backend/Text.Backend.cs @@ -0,0 +1,45 @@ +#nullable enable +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + // Backend property emission for Text. Merges with the generated `partial class Text`. + // This is the hand-authored golden shape the source generator will emit per control. + public partial class Text + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + var text = Value?.CurrentValue; + if (text is not null) + node.ApplyProperty(PropertyIds.Text_Value, PropertyValue.From(text)); + + // TextColor is stored under the shared Color environment key (renamed at generation). + if (this.GetEnvironment(EnvironmentKeys.Colors.Color) is { } color) + node.ApplyProperty(PropertyIds.Text_Color, PropertyValue.From(color)); + + if (this.GetEnvironment(EnvironmentKeys.Fonts.Size) is { } fontSize) + node.ApplyProperty(PropertyIds.Text_FontSize, PropertyValue.From(fontSize)); + + if (this.GetEnvironment(EnvironmentKeys.Fonts.LineHeight) is { } lineHeight) + node.ApplyProperty(PropertyIds.Text_LineHeight, PropertyValue.From(lineHeight)); + + if (this.GetEnvironment(EnvironmentKeys.Fonts.Weight) is { } weight) + node.ApplyProperty(PropertyIds.Text_FontWeight, PropertyValue.From((int)weight)); + + if (this.GetEnvironment(EnvironmentKeys.Fonts.Family) is { Length: > 0 } family) + node.ApplyProperty(PropertyIds.Text_FontFamily, PropertyValue.From(family)); + + if (this.GetEnvironment(EnvironmentKeys.Text.MaxLines) is { } maxLines and > 0) + node.ApplyProperty(PropertyIds.Text_MaxLines, PropertyValue.From(maxLines)); + + if (this.GetEnvironment(EnvironmentKeys.Text.LineBreak) is { } lineBreak and not TextLineBreak.Default) + node.ApplyProperty(PropertyIds.Text_LineBreak, PropertyValue.From((int)lineBreak)); + + if (this.GetEnvironment(EnvironmentKeys.Fonts.Slant) is Microsoft.Maui.FontSlant.Italic) + node.ApplyProperty(PropertyIds.Text_Italic, PropertyValue.From(true)); + } + } +} diff --git a/src/Comet/src/Comet/Backend/TextField.Backend.cs b/src/Comet/src/Comet/Backend/TextField.Backend.cs new file mode 100644 index 000000000..e53b1bbc4 --- /dev/null +++ b/src/Comet/src/Comet/Backend/TextField.Backend.cs @@ -0,0 +1,127 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission + input write-back for TextField. + public partial class TextField + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + var text = Text?.CurrentValue; + if (text is not null) + node.ApplyProperty(PropertyIds.TextField_Text, PropertyValue.From(text)); + + var placeholder = Placeholder?.CurrentValue; + if (placeholder is not null) + node.ApplyProperty(PropertyIds.TextField_Placeholder, PropertyValue.From(placeholder)); + + if (this.GetEnvironment(EnvironmentKeys.Colors.Color) is { } color) + node.ApplyProperty(PropertyIds.TextField_TextColor, PropertyValue.From(color)); + + if (this.GetEnvironment(this, "Comet.TextFieldBorderless", false) == true) + node.ApplyProperty(PropertyIds.TextField_Borderless, PropertyValue.From(true)); + + if (this.GetEnvironment(this, "Comet.TextFieldOutlined", false) == true) + node.ApplyProperty(PropertyIds.TextField_Outlined, PropertyValue.From(true)); + + if (this.GetEnvironment(this, "Comet.TextFieldLeadingIcon", false) is { Length: > 0 } leading) + node.ApplyProperty(PropertyIds.TextField_LeadingIcon, PropertyValue.From(leading)); + + // Soft-keyboard "Send" action key (the gold composer). A dedicated bool flag set/read EXACTLY + // like Borderless (cascades:false) — the generic ReturnType env (enum, cascades:true) doesn't + // round-trip through the node-backend GetEnvironment read. + if (this.GetEnvironment(this, "Comet.TextFieldSendAction", false) == true) + node.ApplyProperty(PropertyIds.TextField_ReturnType, PropertyValue.From((int)ReturnType.Send)); + } + + /// Makes the soft-keyboard action key a "Send" (ImeAction.Send) that fires + /// Completed when pressed — the chat-composer submit-on-keyboard behavior. + public TextField SendOnReturn() + { + this.SetEnvironment("Comet.TextFieldSendAction", true, false); + return this; + } + + /// Renders the REAL Material OutlinedTextField (the gold JetNews + /// search field) — self-themed outline + label metrics. + public TextField Outlined() + { + this.SetEnvironment("Comet.TextFieldOutlined", true, false); + return this; + } + + /// A leading icon inside the field, from the cross-platform symbol set + /// (e.g. "search" — the gold search field's magnifier). + public TextField LeadingIcon(string symbol) + { + this.SetEnvironment("Comet.TextFieldLeadingIcon", (object)symbol, false); + return this; + } + + /// Renders this field with no Material container or indicator line (a foundation + /// BasicTextField) so it blends into its surroundings — e.g. a chat composer. + public TextField Borderless() + { + this.SetEnvironment("Comet.TextFieldBorderless", true, false); + return this; + } + + protected internal override void OnBackendEvent(Backend.EventId id, T payload) + { + // User edited the field. Optimistically reflect the new text on this control's own + // node (the TextField is a controlled component), then write back through the + // (possibly two-way) Text subscription so a bound Signal updates and dependents + // re-render. See Toggle.OnBackendEvent for why the optimistic step is required. + if (id == Backend.EventIds.TextChanged && payload is string s) + { + Node?.ApplyProperty(PropertyIds.TextField_Text, PropertyValue.From(s)); + Text?.Set(s); + } + } + + System.Action? _textInserter; + + /// Registered by a backend node that tracks the caret (selection): receives + /// programmatic insert requests and applies them at the current cursor. + internal void RegisterTextInserter(System.Action inserter) => _textInserter = inserter; + + /// Inserts at the caret (replacing any selection) and + /// places the caret after it — e.g. an emoji picker inserting mid-string. Appends when + /// the backend doesn't track a caret. + public void InsertAtCursor(string text) + { + if (string.IsNullOrEmpty(text)) + return; + if (_textInserter is not null) + { + _textInserter(text); + return; + } + var appended = (Text?.CurrentValue ?? string.Empty) + text; + Node?.ApplyProperty(PropertyIds.TextField_Text, PropertyValue.From(appended)); + Text?.Set(appended); + } + + System.Action? _onFocused; + + /// Runs when the field gains focus (the gold's onTextFieldFocused) — e.g. close an + /// open input-selector panel so the soft keyboard doesn't overlay it. + public TextField OnFocused(System.Action onFocused) + { + _onFocused = onFocused; + return this; + } + + protected internal override void OnBackendEvent(Backend.EventId id) + { + // The soft-keyboard action key was pressed (e.g. Send) — fire the field's Completed handler. + if (id == Backend.EventIds.Completed) + Completed?.Invoke(); + else if (id == Backend.EventIds.Focused) + _onFocused?.Invoke(); + } + } +} diff --git a/src/Comet/src/Comet/Backend/Toggle.Backend.cs b/src/Comet/src/Comet/Backend/Toggle.Backend.cs new file mode 100644 index 000000000..b315f1a14 --- /dev/null +++ b/src/Comet/src/Comet/Backend/Toggle.Backend.cs @@ -0,0 +1,32 @@ +#nullable enable +using Comet.Backend; + +namespace Comet +{ + // Backend property emission + input write-back for Toggle (ISwitch; IsOn -> Value). + public partial class Toggle + { + protected internal override void ApplyAllSetProperties(ICometBackendNode node) + { + base.ApplyAllSetProperties(node); + + if (Value is { } isOn) + node.ApplyProperty(PropertyIds.Toggle_IsOn, PropertyValue.From(isOn.CurrentValue)); + } + + protected internal override void OnBackendEvent(Backend.EventId id, T payload) + { + // User flipped the switch. Optimistically reflect the new value on this control's + // own node (the Switch is a controlled component — its knob shows Node state), then + // write back through the (possibly two-way) Value subscription so a bound Signal + // updates and dependents re-render. The optimistic step is required because Set() + // pre-updates the subscription's cached value, so the later flush sees "no change" + // for this control and would otherwise leave its own node stale. + if (id == Backend.EventIds.Toggled && payload is bool b) + { + Node?.ApplyProperty(PropertyIds.Toggle_IsOn, PropertyValue.From(b)); + Value?.Set(b); + } + } + } +} diff --git a/src/Comet/src/Comet/Backend/View.Backend.cs b/src/Comet/src/Comet/Backend/View.Backend.cs new file mode 100644 index 000000000..95362ea48 --- /dev/null +++ b/src/Comet/src/Comet/Backend/View.Backend.cs @@ -0,0 +1,288 @@ +#nullable enable +using System; +using Comet.Backend; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + /// + /// Backend-bridge surface on . Materializes a view's renderable + /// state into the platform-agnostic protocol, + /// emitting only properties the view actually set (defaults never cross the + /// boundary). This is the seam that replaces the MAUI PropertyMapper path. + /// + /// + /// Phase 1 reads values through the existing typed accessors and emits set-only + /// patches by comparing against documented defaults; the later typed-storage + /// conversion swaps the dictionary reads for fields + set-bits behind this same API, + /// a behavior-preserving change. ApplyAllSetProperties is the hand-authored + /// golden shape the source generator will eventually emit per control. + /// + public partial class View + { + ICometBackendNode? backendNode; + + /// The retained backend node for this view, if it has been materialized. + /// Hot-reload active-view registration is NOT done here: registering every + /// materialized node (hundreds of list rows) leaked into MauiHotReloadHelper's global + /// ActiveViews (no IsEnabled gate, never pruned). The bridge registers only the reload + /// ROOTS ([Body]/Component views), gated on IsEnabled — see CometBackendBridge. + internal ICometBackendNode? Node + { + get => backendNode; + set => backendNode = value; + } + + /// + /// Node-backend twin of the ViewHandler transfer in UpdateFromOldView: the new + /// view adopts the old view's retained node, events rebind to the new view, and the + /// new view's set properties are re-emitted (unchanged values no-op on the backend's + /// retained state, changed values patch through and recompose). + /// + internal void TransferBackendNodeFrom(View oldView, bool isHotReload) + { + var node = oldView.Node; + if (node is null) + return; + + oldView.Node = null; + Node = node; + node.SetEventSink(new ViewEventSink(this)); + // Always re-point the node's owner reference to this new view; only a hot reload + // (code changed) invalidates the node's materialized content — an ordinary + // re-render preserves retained state (nav stack, list scroll). See the interface doc. + node.OnOwnerViewChanged(this, isHotReload); + ApplyChangedProperties(oldView, node); + } + + /// + /// Creates the platform backend node for this control. Overridden by each + /// control's generated platform partial (e.g. ComposeTextNode); base + /// throws so a missing override is a loud error rather than a silent blank view. + /// Host tests bypass this via a pluggable node factory on the bridge. + /// + protected internal virtual ICometBackendNode CreateBackendNode(Backend.BackendContext context) + => throw new NotSupportedException( + $"{GetType().Name} has no backend node. The control's platform partial must override CreateBackendNode."); + + /// + /// Emits every property this view has set onto . Base + /// emits the View-common visual/transform properties; control partials + /// override, call base, then add their own. + /// + protected internal virtual void ApplyAllSetProperties(ICometBackendNode node) + { + // Visual. Emit Opacity whenever it was *explicitly* set (env key present), even when it + // equals 1 — a reactive toggle back to 1 must still reach the node (the set-only patch + // would otherwise drop it and leave a faded node stuck). Views that never set opacity + // emit nothing, preserving the defaults-don't-cross contract. + var opacity = this.GetEnvironment(EnvironmentKeys.View.Opacity); + if (opacity is { } op) + node.ApplyProperty(PropertyIds.Opacity, PropertyValue.From(op)); + + if (!IsVisible) + node.ApplyProperty(PropertyIds.IsVisible, PropertyValue.From(false)); + + if (this.GetBackground() is SolidPaint { Color: { } bg }) + node.ApplyProperty(PropertyIds.BackgroundColor, PropertyValue.From(bg)); + + var pad = this.GetPadding(); + if (pad.Left != 0 || pad.Top != 0 || pad.Right != 0 || pad.Bottom != 0) + node.ApplyProperty(PropertyIds.Padding, PropertyValue.FromObject(pad)); + + if (this is IStackLayout stack) + node.ApplyProperty(PropertyIds.Stack_Spacing, PropertyValue.From(stack.Spacing)); + + // Opt-in: render this container as a Material Surface (color + shape) rather than a plain + // Box — used for chat bubbles, which the gold standard draws with Surface(color, shape). + if (this.GetEnvironment(this, "Comet.AsSurface", false) == true) + node.ApplyProperty(PropertyIds.Container_Surface, PropertyValue.From(true)); + + if (this.GetEnvironment(this, "Comet.AsCard", false) is true) + node.ApplyProperty(PropertyIds.Container_Card, PropertyValue.From(true)); + + if (this.GetEnvironment(this, "Comet.BackgroundGradient", false) is { Stops.Length: > 1 } gradient) + node.ApplyProperty(PropertyIds.GradientBackground, PropertyValue.FromObject(gradient)); + + if (this.GetEnvironment(this, "Comet.BorderGradient", false) is { Stops.Length: > 1 } borderGradient) + node.ApplyProperty(PropertyIds.GradientBorder, PropertyValue.FromObject(borderGradient)); + + // Surface styling read from the canonical styling vocabulary (ClipShape / Shadow / + // Border), so .ClipShape/.Shadow/.RoundedBorder, ButtonStyles, ViewModifier and the + // .CornerRadius/.Elevation/.Border sugar all flow to the backend through one path. + if (this.GetClipShape() is { } clip && ToCornerRadii(clip) is { IsZero: false } corners) + node.ApplyProperty(PropertyIds.CornerRadius, PropertyValue.FromObject(corners)); + + if (this.GetShadow() is { } shadow && shadow.Radius > 0) + node.ApplyProperty(PropertyIds.Shadow, PropertyValue.From((double)shadow.Radius)); + + if (this.GetBorder() is { } borderShape) + { + var strokeWidth = borderShape.GetLineWidth(this, 0f); + var strokeColor = borderShape.GetStrokeColor(this, null!); + if (strokeWidth > 0 && strokeColor is not null) + node.ApplyProperty(PropertyIds.Border, PropertyValue.FromObject(new BorderSpec(strokeWidth, strokeColor))); + } + + // Transforms. Translation is emitted whenever explicitly set (env key present), even back + // to 0 — so a reactive parallax that returns to the origin clears rather than sticking at the + // last offset (the same always-emit reasoning as Opacity above). Scale/rotation keep the + // identity-skip (not driven reactively here). + var t = (ITransform)this; + if (this.GetEnvironment(nameof(ITransform.TranslationX)) is { } tx) + node.ApplyProperty(PropertyIds.TranslationX, PropertyValue.From(tx)); + if (this.GetEnvironment(nameof(ITransform.TranslationY)) is { } ty) + node.ApplyProperty(PropertyIds.TranslationY, PropertyValue.From(ty)); + if (t.ScaleX != 1) node.ApplyProperty(PropertyIds.ScaleX, PropertyValue.From(t.ScaleX)); + if (t.ScaleY != 1) node.ApplyProperty(PropertyIds.ScaleY, PropertyValue.From(t.ScaleY)); + if (t.Rotation != 0) node.ApplyProperty(PropertyIds.Rotation, PropertyValue.From(t.Rotation)); + if (t.RotationX != 0) node.ApplyProperty(PropertyIds.RotationX, PropertyValue.From(t.RotationX)); + if (t.RotationY != 0) node.ApplyProperty(PropertyIds.RotationY, PropertyValue.From(t.RotationY)); + + if (!IsEnabled) + node.ApplyProperty(PropertyIds.IsEnabled, PropertyValue.From(false)); + + // Tap gestures become a clickable modifier on the backend node. + if (HasTapGesture()) + node.ApplyProperty(PropertyIds.HasTapGesture, PropertyValue.From(true)); + + // A record gesture becomes a detectDragGesturesAfterLongPress modifier (gold RecordButton). + if (HasRecordGesture()) + node.ApplyProperty(PropertyIds.HasRecordGesture, PropertyValue.From(true)); + + // A long-press gesture becomes a combinedClickable onLongClick (Reply row selection). + if (HasLongPressGesture()) + node.ApplyProperty(PropertyIds.HasLongPressGesture, PropertyValue.From(true)); + } + + // Parse a ClipShape into the per-corner radii the backend nodes consume. Only the rounded + // shapes carry corner data; any other shape clips but contributes no corner radius. + static CornerRadii? ToCornerRadii(Comet.Shape shape) => shape switch + { + RoundedRectangle rr => new CornerRadii(rr.CornerRadius), + AsymmetricRoundedRectangle ar => new CornerRadii(ar.TopLeft, ar.TopRight, ar.BottomRight, ar.BottomLeft), + _ => null, + }; + + bool HasTapGesture() + { + var gestures = Gestures; + if (gestures is null) + return false; + for (int i = 0; i < gestures.Count; i++) + if (gestures[i] is TapGesture) + return true; + return false; + } + + bool HasRecordGesture() + { + var gestures = Gestures; + if (gestures is null) + return false; + for (int i = 0; i < gestures.Count; i++) + if (gestures[i] is RecordGesture) + return true; + return false; + } + + bool HasLongPressGesture() + { + var gestures = Gestures; + if (gestures is null) + return false; + for (int i = 0; i < gestures.Count; i++) + if (gestures[i] is LongPressGesture) + return true; + return false; + } + + /// + /// Applies the minimal set of property changes between an old view instance and + /// this one onto the (transferred) . Phase 1 re-emits the + /// full set-only patch; the generator will narrow this to a per-bit comparison. + /// + protected internal virtual void ApplyChangedProperties(View old, ICometBackendNode node) + => ApplyAllSetProperties(node); + + /// + /// Pushes the current property values to this view's backend node after a reactive + /// change. Phase 1 re-emits the full set-only patch (cheap: writing a backend + /// MutableState to an unchanged value is a no-op under structural equality); the + /// generator will narrow this to the single changed property. + /// + internal void UpdateBackendNode() + { + if (Node is { } node) + ApplyAllSetProperties(node); + } + + /// + /// Handles a no-payload event raised by this view's backend node (e.g. a Compose + /// button click). Controls override to invoke their reactive handlers. No-op base. + /// + protected internal virtual void OnBackendEvent(Backend.EventId id) { } + + /// + /// Handles an event carrying a payload (e.g. a TextField's new string, a Switch's + /// new bool) so the control can write it back to its bound state. No-op base. + /// + protected internal virtual void OnBackendEvent(Backend.EventId id, T payload) { } + + /// Handles a gesture raised by this view's backend node (e.g. a Compose + /// clickable tap), invoking the matching Comet gesture recognizers. + protected internal virtual void OnBackendGesture(Backend.GestureKind kind, in Backend.GestureData data) + { + var gestures = Gestures; + if (gestures is null) + return; + + if (kind == Backend.GestureKind.Tap) + { + for (int i = 0; i < gestures.Count; i++) + if (gestures[i] is TapGesture tap) + tap.Invoke(); + return; + } + + if (kind == Backend.GestureKind.LongPress) + { + for (int i = 0; i < gestures.Count; i++) + if (gestures[i] is LongPressGesture lp) + lp.Invoke(); + return; + } + + // The voice-record drag arrives as a Pan with the long-press/drag/release phases; + // feed them to the RecordGesture, which accumulates the offset and applies the + // swipe-to-cancel threshold (gold RecordButton). + if (kind == Backend.GestureKind.Pan) + { + for (int i = 0; i < gestures.Count; i++) + { + if (gestures[i] is not RecordGesture rec) + continue; + switch (data.State) + { + case Backend.GestureState.Began: rec.Begin(); break; + case Backend.GestureState.Changed: rec.Drag(data.Delta.X, data.Delta.Y); break; + case Backend.GestureState.Ended: rec.End(); break; + case Backend.GestureState.Cancelled: rec.Cancel(); break; + } + } + } + } + } + + /// Routes a backend node's events to its owning Comet view. + sealed class ViewEventSink : Backend.ICometEventSink + { + readonly View _view; + public ViewEventSink(View view) => _view = view; + + public void OnEvent(Backend.EventId id) => _view.OnBackendEvent(id); + public void OnEvent(Backend.EventId id, T payload) => _view.OnBackendEvent(id, payload); + public void OnGesture(Backend.GestureKind kind, in Backend.GestureData data) => _view.OnBackendGesture(kind, data); + } +} diff --git a/src/Comet/src/Comet/Comet.csproj b/src/Comet/src/Comet/Comet.csproj index a4d573adc..c139db189 100644 --- a/src/Comet/src/Comet/Comet.csproj +++ b/src/Comet/src/Comet/Comet.csproj @@ -1,10 +1,9 @@ + net11.0-android;net11.0-ios;net11.0-maccatalyst - $(TargetFrameworks);net11.0-windows10.0.19041.0 - - true disable @@ -26,13 +25,14 @@ - - - - - - + @@ -42,4 +42,25 @@ + + + + + + + + + + + + + + + + diff --git a/src/Comet/src/Comet/Controls/AlertDialog.cs b/src/Comet/src/Comet/Controls/AlertDialog.cs new file mode 100644 index 000000000..c345f2367 --- /dev/null +++ b/src/Comet/src/Comet/Controls/AlertDialog.cs @@ -0,0 +1,58 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; + +namespace Comet +{ + /// + /// A Material 3 AlertDialog: a modal popup (its own overlay window + scrim) shown while + /// is true. Maps to the platform's real dialog — Compose + /// AlertDialog (the SwiftUI alert is a follow-up). Mirrors Jetchat's + /// FunctionalityNotAvailablePopup. The slot views (, optional + /// , and the buttons) are ordinary Comet views laid out by Material inside the + /// dialog; a scrim tap / back press writes back to false. The dialog occupies + /// zero space in its parent layout (the backend node manages its own content), so it can sit + /// anywhere in the tree — its position there doesn't affect where it appears on screen. + /// + public partial class AlertDialog : View, IContainerView + { + public AlertDialog(Signal isOpen, View text, View confirmButton, View? title = null, View? dismissButton = null) + { + IsOpen = isOpen; + Text = text; + ConfirmButton = confirmButton; + Title = title; + DismissButton = dismissButton; + + text.Parent = this; + confirmButton.Parent = this; + if (title is not null) title.Parent = this; + if (dismissButton is not null) dismissButton.Parent = this; + } + + /// Drives visibility: the dialog composes while true and dismisses (writes false) + /// itself when the user taps the scrim / presses back. + public Signal IsOpen { get; } + + /// The body text slot (Material text slot). Required. + public View Text { get; } + + /// The confirm-action slot (Material confirmButton, e.g. a "CLOSE" TextButton). + /// Required. + public View ConfirmButton { get; } + + /// Optional headline slot (Material title). + public View? Title { get; } + + /// Optional secondary-action slot (Material dismissButton). + public View? DismissButton { get; } + + public IReadOnlyList GetChildren() + { + var children = new List { Text, ConfirmButton }; + if (Title is not null) children.Add(Title); + if (DismissButton is not null) children.Add(DismissButton); + return children; + } + } +} diff --git a/src/Comet/src/Comet/Controls/ContentSwitcher.cs b/src/Comet/src/Comet/Controls/ContentSwitcher.cs new file mode 100644 index 000000000..ad30d3355 --- /dev/null +++ b/src/Comet/src/Comet/Controls/ContentSwitcher.cs @@ -0,0 +1,30 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; + +namespace Comet +{ + /// + /// An index-driven full-slot content swap: shows Views[Index], switching + /// reactively as the signal changes — the routing primitive for NavigationSuite content + /// (Reply's four destinations; JetNews routes). The swap lives inside the backend node + /// (MutableState, the NavigationSuite idiom) because body-level structure swaps don't + /// reach the retained node tree. Unlike (a collapsible input + /// footer), every view fills the host's bounds and index 0 is an ordinary view. + /// + public partial class ContentSwitcher : View, IContainerView + { + public ContentSwitcher(Signal index, IReadOnlyList views) + { + Index = index; + Views = views; + foreach (var v in views) + v.Parent = this; + } + + public Signal Index { get; } + public IReadOnlyList Views { get; } + + public IReadOnlyList GetChildren() => Views; + } +} diff --git a/src/Comet/src/Comet/Controls/Drawer.cs b/src/Comet/src/Comet/Controls/Drawer.cs new file mode 100644 index 000000000..a6b9de22b --- /dev/null +++ b/src/Comet/src/Comet/Controls/Drawer.cs @@ -0,0 +1,31 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; + +namespace Comet +{ + /// + /// A modal navigation drawer: a panel that slides in over the + /// , dimming it. Maps to the platform's real drawer — Compose + /// ModalNavigationDrawer and a SwiftUI sliding panel — driven by an + /// signal (set it true from a nav button; the drawer writes it back + /// to false when dismissed by tap/swipe). + /// + public partial class Drawer : View, IContainerView + { + public Drawer(Signal isOpen, View side, View content) + { + IsOpen = isOpen; + Side = side; + Content = content; + side.Parent = this; + content.Parent = this; + } + + public Signal IsOpen { get; } + public View Side { get; } + public View Content { get; } + + public IReadOnlyList GetChildren() => new[] { Side, Content }; + } +} diff --git a/src/Comet/src/Comet/Controls/Fab.cs b/src/Comet/src/Comet/Controls/Fab.cs new file mode 100644 index 000000000..27975ea54 --- /dev/null +++ b/src/Comet/src/Comet/Controls/Fab.cs @@ -0,0 +1,57 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Comet.Reactive; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + /// + /// A Material 3 floating action button. Drives the REAL Compose + /// ExtendedFloatingActionButton — never a styled pill. The backend treats it as a + /// self-sizing native overlay (Option C): it measures the content's intrinsic size for the + /// parent's Yoga layout, then positions the FAB at that offset and lets the native control + /// size + lay out its own content. and are + /// app-styled views (so the label uses the app's font); leave their colour unset to inherit + /// the FAB's . + /// + public partial class Fab : View, IContainerView + { + public Fab(View icon, View label, Action onClick, double height, + Color? containerColor = null, Color? contentColor = null, bool extended = true) + { + IconView = icon; + LabelView = label; + Clicked = onClick; + Height = height; + ContainerColor = containerColor; + ContentColor = contentColor; + Extended = extended; + icon.Parent = this; + label.Parent = this; + } + + public View IconView { get; } + public View LabelView { get; } + public Action Clicked { get; } + public double Height { get; } + public Color? ContainerColor { get; } + public Color? ContentColor { get; } + + /// Initial extended state (true = icon+label pill; false = icon-only square). + /// Override reactively with . + public bool Extended { get; } + + /// When set, the backend node subscribes to this signal and re-animates the FAB + /// between its extended (true) and contracted (false) states. Drives the Material 3 + /// ExtendedFloatingActionButton(expanded=…) parameter on Android and a label + /// show/hide animation on iOS. + public Signal? ExtendedSignal { get; private set; } + + /// Wire a reactive signal that drives the extend/contract animation. Returns + /// this so the call can be chained with other modifiers. + public Fab ExtendedWhen(Signal signal) { ExtendedSignal = signal; return this; } + + public IReadOnlyList GetChildren() => new[] { IconView, LabelView }; + } +} diff --git a/src/Comet/src/Comet/Controls/FilterChip.cs b/src/Comet/src/Comet/Controls/FilterChip.cs new file mode 100644 index 000000000..5754e6977 --- /dev/null +++ b/src/Comet/src/Comet/Controls/FilterChip.cs @@ -0,0 +1,33 @@ +#nullable enable +using System.Collections.Generic; + +namespace Comet +{ + /// + /// A Material 3 filter chip (Compose FilterChip) — the Jetcaster category + /// tabs: a selectable pill whose label reflects the selected state (the widget + /// self-themes the fill, check mark, and outline). Taps report through + /// ; rows typically rebuild with the new state. + /// + public partial class FilterChip : View, IContainerView + { + public FilterChip(bool selected, System.Action onClick, View label, View? leadingIcon = null) + { + IsSelected = selected; + OnClick = onClick; + LabelView = label; + LeadingIconView = leadingIcon; + label.Parent = this; + if (leadingIcon is not null) + leadingIcon.Parent = this; + } + + public bool IsSelected { get; } + public System.Action OnClick { get; } + public View LabelView { get; } + public View? LeadingIconView { get; } + + public IReadOnlyList GetChildren() => + LeadingIconView is null ? new[] { LabelView } : new[] { LabelView, LeadingIconView }; + } +} diff --git a/src/Comet/src/Comet/Controls/FormattedText.cs b/src/Comet/src/Comet/Controls/FormattedText.cs new file mode 100644 index 000000000..88ab45a58 --- /dev/null +++ b/src/Comet/src/Comet/Controls/FormattedText.cs @@ -0,0 +1,35 @@ +#nullable enable +using System.Collections.Generic; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + /// A styled run within a — a slice of text with optional + /// color, a monospace ("code") face, a background highlight, an underline/strikethrough, bold or + /// italic weight, a per-run font size, and an optional tap handler (clickable @mention / link). + /// The Comet analog of a single Compose SpanStyle (+ LinkAnnotation when tappable). + public sealed record TextRun( + string Text, + Color? Color = null, + bool Monospace = false, + Color? Background = null, + bool Underline = false, + bool Bold = false, + bool Italic = false, + bool Strikethrough = false, + double? FontSize = null, + System.Action? OnTap = null); + + /// + /// Inline rich text: a single wrapping paragraph composed of styled s + /// (the Comet analog of a Compose AnnotatedString / an attributed string). Used for chat + /// bubbles that style @mentions, code spans, and links. The base font (size/family) is + /// set with the usual .FontSize()/.FontFamily(); per-run overrides win. + /// + public partial class FormattedText : View + { + public FormattedText(IReadOnlyList runs) => Runs = runs; + + public IReadOnlyList Runs { get; } + } +} diff --git a/src/Comet/src/Comet/Controls/HStack.cs b/src/Comet/src/Comet/Controls/HStack.cs index c90d35540..bd36284b9 100644 --- a/src/Comet/src/Comet/Controls/HStack.cs +++ b/src/Comet/src/Comet/Controls/HStack.cs @@ -4,7 +4,7 @@ namespace Comet { - public class HStack : AbstractLayout, IStackLayout + public partial class HStack : AbstractLayout, IStackLayout { private readonly LayoutAlignment alignment; private readonly float? spacing; diff --git a/src/Comet/src/Comet/Controls/Icon.cs b/src/Comet/src/Comet/Controls/Icon.cs new file mode 100644 index 000000000..7d3a9cae4 --- /dev/null +++ b/src/Comet/src/Comet/Controls/Icon.cs @@ -0,0 +1,33 @@ +#nullable enable +namespace Comet +{ + /// + /// A platform-vector icon. The is a cross-platform name (e.g. + /// "search", "info", "menu", "send") that each backend maps to its + /// native icon set — Compose Icons.* (ImageVector) and SwiftUI SF Symbols — so a + /// Comet Icon renders as the real Material / SF icon rather than a glyph in a label. + /// Tint with .Color(...) and size with .IconSize(...) (default 24dp). + /// + public partial class Icon : View + { + public Icon(string symbol) => Symbol = symbol; + + public string Symbol { get; } + } + + public static class IconExtensions + { + /// Sets the icon's square size in Dp (default 24). + /// The asset draws at the FRAME's size instead of the square + /// IconSize-with-centering-slack rule — for non-square bundled vectors + /// (JetNews' 80×24 wordmark). Frame the view to the asset's aspect. + public static T IconFillFrame(this T icon) where T : Icon + => icon.SetEnvironment("Comet.IconFillFrame", true, false); + + public static T IconSize(this T icon, double size) where T : Icon + { + icon.SetEnvironment("Comet.IconSize", size, false); + return icon; + } + } +} diff --git a/src/Comet/src/Comet/Controls/IconButton.cs b/src/Comet/src/Comet/Controls/IconButton.cs new file mode 100644 index 000000000..a5652aa7d --- /dev/null +++ b/src/Comet/src/Comet/Controls/IconButton.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.Collections.Generic; + +namespace Comet +{ + /// + /// A Material 3 icon button (Compose IconButton) — the gold's standard + /// icon-action control (Jetcaster's queue-add / overflow, JetNews' app-bar + /// actions): a 48dp tap target with a bounded state layer around a single icon + /// slot. Promoted from the M3 review backlog — a styled Icon+OnTap look-alike + /// is a defect under the exact-widget rule. + /// + public partial class IconButton : View, IContainerView + { + public IconButton(System.Action onClick, View icon) + { + OnClick = onClick; + IconView = icon; + icon.Parent = this; + } + + public System.Action OnClick { get; } + public View IconView { get; } + + public IReadOnlyList GetChildren() => new[] { IconView }; + } +} diff --git a/src/Comet/src/Comet/Controls/IconFont.cs b/src/Comet/src/Comet/Controls/IconFont.cs new file mode 100644 index 000000000..cdfa4631e --- /dev/null +++ b/src/Comet/src/Comet/Controls/IconFont.cs @@ -0,0 +1,53 @@ +#nullable enable +using System.Collections.Generic; + +namespace Comet +{ + /// + /// An icon font (e.g. Google's Material Icons): a single font whose glyphs are addressed by + /// codepoint, so Icon("name") renders the SAME glyph on every backend instead of each + /// platform's own icon set. Register the family + a name→glyph(codepoint) map once at startup; + /// any whose symbol is mapped renders that glyph in this font, and unmapped + /// symbols (e.g. a brand logo) fall back to the platform-native icon path. + /// + /// + /// Codepoints (not ligatures) are used so rendering doesn't depend on the text engine enabling + /// the font's ligature table. The font file itself must be loaded by each platform the usual way + /// (Android ComposeFontRegistry; iOS UIAppFonts) under the same . + /// + public static class IconFont + { + static Dictionary _map = new(); + + /// The registered icon-font family name, or null if none. + public static string? Family { get; private set; } + + /// Register the icon font and its name→glyph map (glyph = the codepoint string, + /// e.g. "" for mic). Call after the font file is loaded under . + public static void Register(string family, IDictionary glyphs) + { + // Same family registered again (another sample/screen in this process): merge the + // maps (last write wins per name) instead of clobbering the earlier glyph set. + if (Family == family) + { + foreach (var kv in glyphs) + _map[kv.Key] = kv.Value; + return; + } + Family = family; + _map = new Dictionary(glyphs); + } + + /// Resolves a cross-platform icon name to its glyph in the registered font. + public static bool TryGlyph(string name, out string glyph) + { + if (Family is not null && _map.TryGetValue(name, out var g)) + { + glyph = g; + return true; + } + glyph = string.Empty; + return false; + } + } +} diff --git a/src/Comet/src/Comet/Controls/IconToggleButton.cs b/src/Comet/src/Comet/Controls/IconToggleButton.cs new file mode 100644 index 000000000..e653ffcdd --- /dev/null +++ b/src/Comet/src/Comet/Controls/IconToggleButton.cs @@ -0,0 +1,29 @@ +#nullable enable +using System.Collections.Generic; + +namespace Comet +{ + /// + /// A Material 3 icon toggle button (Compose IconToggleButton) — the gold's + /// bookmark control: a 48dp tap target whose icon reflects a checked state; taps + /// report the flipped value through . The icon slot is a + /// view so the app supplies its checked/unchecked glyph (rows typically rebuild with + /// the new state via ReloadData). + /// + public partial class IconToggleButton : View, IContainerView + { + public IconToggleButton(bool isChecked, System.Action onChange, View icon) + { + IsChecked = isChecked; + OnChange = onChange; + IconView = icon; + icon.Parent = this; + } + + public bool IsChecked { get; } + public System.Action OnChange { get; } + public View IconView { get; } + + public IReadOnlyList GetChildren() => new[] { IconView }; + } +} diff --git a/src/Comet/src/Comet/Controls/Image.cs b/src/Comet/src/Comet/Controls/Image.cs index f2fe08ee7..ea6ec74f1 100644 --- a/src/Comet/src/Comet/Controls/Image.cs +++ b/src/Comet/src/Comet/Controls/Image.cs @@ -7,7 +7,7 @@ namespace Comet { - public class Image : View, Microsoft.Maui.IImage + public partial class Image : View, Microsoft.Maui.IImage { protected static Dictionary ImageHandlerPropertyMapper = new(HandlerPropertyMapper) { diff --git a/src/Comet/src/Comet/Controls/ListDetail.cs b/src/Comet/src/Comet/Controls/ListDetail.cs new file mode 100644 index 000000000..c5ac14726 --- /dev/null +++ b/src/Comet/src/Comet/Controls/ListDetail.cs @@ -0,0 +1,47 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; + +namespace Comet +{ + /// + /// The adaptive list-detail primitive (docs/adaptive-primitives-design.md): two panes at + /// expanded widths (the gold's accompanist TwoPane — 50/50 split, 16dp gap, ≥840dp), + /// a single pane below that where swaps list ↔ detail (the gold's + /// full-screen detail with a BackHandler that closes it — the node raises the + /// back-press as an event and this control writes the signal back to false). The pane swap + /// lives inside the backend node (MutableState — the idiom) + /// because body-level structure swaps don't reach the retained node tree. + /// + public partial class ListDetail : View, IContainerView + { + public ListDetail(Signal isDetailOpen, View list, View detail, double listFraction = 0.5) + { + IsDetailOpen = isDetailOpen; + List = list; + Detail = detail; + ListFraction = listFraction; + list.Parent = this; + detail.Parent = this; + } + + /// Compact: true shows the detail pane full-screen (back writes it false). + /// Expanded: both panes always show — the signal only tracks what the detail displays. + public Signal IsDetailOpen { get; } + public View List { get; } + public View Detail { get; } + + /// Two-pane list share of the width. Reply: 0.5 (50/50 TwoPane); JetNews' + /// ListDetailScene keeps the list ≈ a third of an expanded window. + public double ListFraction { get; } + + /// The gold two-pane threshold: WindowWidthSizeClass.Expanded (≥ 840dp) — + /// ReplyApp.kt:76-88 (folding postures out of scope). Pure for host tests + every backend. + public static bool TwoPaneFor(double widthDp) => widthDp >= 840; + + /// The gold split: 50/50 with a 16dp gap (ReplyListContent.kt:98). + public const double GapDp = 16; + + public IReadOnlyList GetChildren() => new[] { List, Detail }; + } +} diff --git a/src/Comet/src/Comet/Controls/ListView.cs b/src/Comet/src/Comet/Controls/ListView.cs index 685ba1269..67951be37 100644 --- a/src/Comet/src/Comet/Controls/ListView.cs +++ b/src/Comet/src/Comet/Controls/ListView.cs @@ -9,6 +9,15 @@ namespace Comet { + /// Horizontal list container flavor — the plain LazyRow or one of + /// the real Material 3 carousels the Jetcaster gold uses. + public enum ListCarousel + { + None, + MultiBrowse, + Uncontained, + } + public interface IListView : IView { int Sections(); @@ -21,6 +30,46 @@ public interface IListView : IView View FooterFor(int section); bool ShouldDisposeViews { get; } void OnSelected(int section, int index); + + /// Node-backend scroll-state bridge (Compose LazyColumn). The backend node + /// writes true while the list is scrolled away from the bottom (more content below); + /// a view (e.g. a JumpToBottom button) reads it to show/hide reactively. No-op on the + /// classic MAUI handler path. + Comet.Reactive.Signal ScrolledAway { get; } + + /// The backend node registers a delegate that animates the list to its end; calling + /// invokes it. Null until the node has rendered. + void RegisterScroller(System.Action scrollToBottom); + + /// Open the list at its LAST row (chat-log semantics — Jetchat). Ordinary + /// lists (an inbox) leave this false and open at the top. + bool AnchorBottom { get; } + + /// Horizontal (carousel) orientation — Compose LazyRow. + bool Horizontal { get; } + + /// Adaptive grid mode: when > 0 the vertical list renders as the REAL + /// Compose LazyVerticalGrid(GridCells.Adaptive(minDp)) (Jetcaster Home's + /// Adaptive(362dp) content grid). 0 = plain LazyColumn. + double GridAdaptiveMinWidth { get; } + + /// Which horizontal container renders the row items — the plain + /// LazyRow or one of the real M3 carousels (Jetcaster). + ListCarousel Carousel { get; } + + /// Item width (Dp) for the carousel kinds: MultiBrowse's + /// preferredItemWidth / Uncontained's fixed itemWidth. + double CarouselItemWidth { get; } + + /// Reactive "scrolled away from the TOP" flag (content exists above the + /// viewport — Compose canScrollBackward). Reply's ExtendedFAB collapse reads + /// this; is the bottom-relative twin. + Comet.Reactive.Signal ScrolledFromTop { get; } + + /// Reactive scroll DIRECTION flag: true while the most recent scroll moved + /// toward the start (Compose LazyListState.lastScrolledBackward). The gold + /// Reply ExtendedFAB re-expands on any upward scroll, not only at the very top. + Comet.Reactive.Signal LastScrolledBackward { get; } } public class ListView : ListView @@ -112,6 +161,37 @@ void DisposeObservable() public Func Count { get; set; } + bool _reloading; + + /// Re-pulls the items snapshot before signaling recomposition. A Func-backed + /// source that returns a NEW list per call (a filtered search) — or one reading state + /// via an untracked Peek — never re-fires the subscription, so the ctor-time snapshot + /// would stay stale forever and ReloadData would rebuild against it. The guard breaks + /// the notify cycle (re-evaluation with a fresh list instance fires + /// ViewPropertyChanged(Items), which calls ReloadData again). + public override void ReloadData() + { + if (_reloading) + return; + _reloading = true; + try + { + _items?.Reevaluate(); + currentItems = _items?.CurrentValue; + // Invalidate the row-view cache: rows snapshot state at build time (an + // opened-row highlight), so a reload must rebuild them — serving cached + // views re-emits the stale snapshot. Same clear+dispose as Dispose(). + var staleViews = CurrentViews?.ToList(); + CurrentViews?.Clear(); + staleViews?.ForEach(x => x.Value?.Dispose()); + base.ReloadData(); + } + finally + { + _reloading = false; + } + } + protected override int GetCount(int section) => currentItems?.Count ?? Count?.Invoke() ?? 0; protected override object GetItemAt(int section, int index) => currentItems.SafeGetAtIndex(index, ItemFor); @@ -150,7 +230,7 @@ protected override void Dispose(bool disposing) } } - public class ListView : View, IEnumerable, IEnumerable, IListView + public partial class ListView : View, IEnumerable, IEnumerable, IListView { public static bool HandlerSupportsVirtualization { get; set; } = true; @@ -162,6 +242,44 @@ public class ListView : View, IEnumerable, IEnumerable, IListView public Action<(object item,int section, int row)> ItemSelected { get; set; } + /// Reactive "scrolled away from the bottom" flag, driven by the node backend's + /// LazyListState. Bind a JumpToBottom affordance's visibility to this. + public Comet.Reactive.Signal ScrolledAway { get; } = new(false); + + /// Open the list at its LAST row (a chat log's newest-at-bottom initial + /// position — Jetchat). Default false: ordinary lists (an inbox) open at the top. + public bool AnchorBottom { get; set; } + + /// Lay the list HORIZONTALLY (a card carousel — Compose LazyRow). + /// Default false = vertical. Set before the node materializes. + public bool Horizontal { get; set; } + + /// Adaptive-grid mode (Compose LazyVerticalGrid(GridCells.Adaptive)): + /// the minimum cell width in Dp, 0 = plain vertical list. Set before materialize. + public double GridAdaptiveMinWidth { get; set; } + + /// Horizontal container flavor: LazyRow (default) or a real M3 carousel. + public ListCarousel Carousel { get; set; } + + /// Carousel item width in Dp (MultiBrowse preferred / Uncontained fixed). + public double CarouselItemWidth { get; set; } + + /// Reactive "scrolled away from the top" flag (top-relative twin of + /// ); drives Reply's ExtendedFAB collapse. + public Comet.Reactive.Signal ScrolledFromTop { get; } = new(false); + + /// Reactive scroll direction: true while the most recent scroll moved toward + /// the start (lastScrolledBackward); the gold Reply FAB re-expands on it. + public Comet.Reactive.Signal LastScrolledBackward { get; } = new(false); + + System.Action _scrollToBottom; + + void IListView.RegisterScroller(System.Action scrollToBottom) => _scrollToBottom = scrollToBottom; + + /// Animate the list to its end (newest message). No-op until the backend node has + /// rendered and registered its scroller. + public void ScrollToBottom() => _scrollToBottom?.Invoke(); + protected virtual int GetSections() => 1; protected virtual int GetCount(int section) => views?.Count ?? 0; @@ -184,6 +302,9 @@ public virtual void Add(View view) public virtual void ReloadData() { ViewHandler?.Invoke(nameof(ReloadData)); + // Node backend (Compose/SwiftUI): re-emit the list version so the LazyColumn recomposes + // against the current rows (the classic handler path uses ViewHandler.Invoke above). + UpdateBackendNode(); } protected virtual void OnSelected(int section, int index) diff --git a/src/Comet/src/Comet/Controls/NativeHost.cs b/src/Comet/src/Comet/Controls/NativeHost.cs index e935a5914..24c3a62c2 100644 --- a/src/Comet/src/Comet/Controls/NativeHost.cs +++ b/src/Comet/src/Comet/Controls/NativeHost.cs @@ -133,12 +133,8 @@ public NativeHost MeasureUsing(Func measure) public bool TryGetNativeView(out T resolvedView) where T : class { - if (ViewHandler is Handlers.INativeHostHandler handler && handler.GetNativeView() is T typedView) - { - resolvedView = typedView; - return true; - } - + // The handler-hosted resolution went with the legacy render path (Phase 5); a + // node-backend host resolves through its own GetOrCreateNativeView instead. resolvedView = null; return false; } @@ -151,11 +147,7 @@ protected override void OnHandlerChange() void RequestNativeViewUpdate() { - if (ViewHandler is Handlers.INativeHostHandler handler) - { - handler.SyncNativeView(); - InvalidateMeasurement(); - } + // Handler-driven native sync went with the legacy render path (Phase 5). } public override Size GetDesiredSize(Size availableSize) @@ -170,10 +162,6 @@ public override Size GetDesiredSize(Size availableSize) if (TryMeasureOverride(availableSize, out measured)) { } - else if (ViewHandler is Handlers.INativeHostHandler handler) - { - measured = handler.MeasureNativeView(availableSize); - } else { measured = new Size( diff --git a/src/Comet/src/Comet/Controls/NavigationBar.cs b/src/Comet/src/Comet/Controls/NavigationBar.cs new file mode 100644 index 000000000..569abf097 --- /dev/null +++ b/src/Comet/src/Comet/Controls/NavigationBar.cs @@ -0,0 +1,49 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + /// + /// A Material 3 bottom navigation bar: drives the REAL Compose NavigationBar + + /// NavigationBarItem widgets (never a styled HStack), selection driven by a reactive + /// signal. Tapping an item calls , which + /// writes the signal and invokes the item's — so app + /// chrome that reads the signal (screen swap, adaptive nav suite) re-renders reactively. + /// + public partial class NavigationBar : View, IContainerView + { + public NavigationBar(Signal selectedIndex, IReadOnlyList items, + Color? containerColor = null) + { + SelectedIndex = selectedIndex; + Items = items; + ContainerColor = containerColor; + foreach (var item in items) + item.Parent = this; + } + + public Signal SelectedIndex { get; } + public IReadOnlyList Items { get; } + public Color? ContainerColor { get; } + + /// Selection entry point shared by the platform item widgets and tests: + /// writes (reactive consumers re-render) and invokes the + /// item's . + public void SelectItem(int index) + { + SelectedIndex.Value = index; + if (index >= 0 && index < Items.Count) + Items[index].OnSelect?.Invoke(); + } + + public IReadOnlyList GetChildren() + { + var children = new List(Items.Count); + foreach (var item in Items) + children.Add(item); + return children; + } + } +} diff --git a/src/Comet/src/Comet/Controls/NavigationItem.cs b/src/Comet/src/Comet/Controls/NavigationItem.cs new file mode 100644 index 000000000..292f978d9 --- /dev/null +++ b/src/Comet/src/Comet/Controls/NavigationItem.cs @@ -0,0 +1,33 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace Comet +{ + /// + /// One destination inside a or . + /// / are app-styled views passed to the real + /// Material 3 item composable's icon/label slots (leave colours unset to inherit the item's + /// selected/unselected content colours). Not a standalone renderable — the parent chrome + /// node drives the platform item widget and invokes on tap. + /// + public partial class NavigationItem : View, IContainerView + { + public NavigationItem(View icon, View? label = null, Action? onSelect = null) + { + IconView = icon; + LabelView = label; + OnSelect = onSelect; + icon.Parent = this; + if (label is not null) + label.Parent = this; + } + + public View IconView { get; } + public View? LabelView { get; } + public Action? OnSelect { get; } + + public IReadOnlyList GetChildren() + => LabelView is null ? new[] { IconView } : new[] { IconView, LabelView }; + } +} diff --git a/src/Comet/src/Comet/Controls/NavigationRail.cs b/src/Comet/src/Comet/Controls/NavigationRail.cs new file mode 100644 index 000000000..3d0c9b2d5 --- /dev/null +++ b/src/Comet/src/Comet/Controls/NavigationRail.cs @@ -0,0 +1,52 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + /// + /// A Material 3 navigation rail: drives the REAL Compose NavigationRail + + /// NavigationRailItem widgets. The optional renders above the + /// destinations inside the rail's content column (the gold Reply pattern: a menu item + FAB). + /// Selection contract is identical to — one + /// signal, from item taps. + /// + public partial class NavigationRail : View, IContainerView + { + public NavigationRail(Signal selectedIndex, IReadOnlyList items, + View? header = null, Color? containerColor = null) + { + SelectedIndex = selectedIndex; + Items = items; + HeaderView = header; + ContainerColor = containerColor; + foreach (var item in items) + item.Parent = this; + if (header is not null) + header.Parent = this; + } + + public Signal SelectedIndex { get; } + public IReadOnlyList Items { get; } + public View? HeaderView { get; } + public Color? ContainerColor { get; } + + /// See — same contract. + public void SelectItem(int index) + { + SelectedIndex.Value = index; + if (index >= 0 && index < Items.Count) + Items[index].OnSelect?.Invoke(); + } + + public IReadOnlyList GetChildren() + { + var children = new List(); + if (HeaderView is not null) + children.Add(HeaderView); + children.AddRange(Items); + return children; + } + } +} diff --git a/src/Comet/src/Comet/Controls/NavigationSuite.cs b/src/Comet/src/Comet/Controls/NavigationSuite.cs new file mode 100644 index 000000000..e38c4acab --- /dev/null +++ b/src/Comet/src/Comet/Controls/NavigationSuite.cs @@ -0,0 +1,127 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; + +namespace Comet +{ + /// Which nav chrome a shows for the current window. + public enum NavigationSuiteVariant + { + BottomBar, + Rail, + PermanentDrawer, + /// No persistent chrome — content fills the window; navigation happens + /// through the modal drawer alone (JetNews' drawer-first compact chrome). + None, + } + + /// + /// The adaptive navigation chrome (docs/adaptive-primitives-design.md): ONE control that + /// hosts the app content and switches between a bottom NavigationBar, a + /// NavigationRail (with optional header — Reply's menu + FAB), and a + /// PermanentDrawerSheet (with optional header — Reply's "REPLY" + Compose FAB) as + /// the window crosses the gold breakpoints (). The backend node + /// owns the swap internally (MutableState, same idiom as Drawer/SelectorPanel) because a + /// body-level container-type swap does not propagate to the retained node tree — see the + /// skipped reproduction in BackendBodyMetricsTests. + /// Selection contract matches : one + /// signal, from item taps; the content + /// view swaps screens by reading the signal in bindings. + /// + public partial class NavigationSuite : View, IContainerView + { + public NavigationSuite(Signal selectedIndex, IReadOnlyList items, + View content, View? railHeader = null, View? drawerHeader = null, + Signal? drawerOpen = null, + Microsoft.Maui.Graphics.Color? containerColor = null, + Microsoft.Maui.Graphics.Color? indicatorColor = null, + System.Func? variantFor = null, + bool railShowsSelectedLabel = false, + Signal? variantSignal = null) + { + SelectedIndex = selectedIndex; + Items = items; + Content = content; + RailHeaderView = railHeader; + DrawerHeaderView = drawerHeader; + DrawerOpen = drawerOpen; + ContainerColor = containerColor; + IndicatorColor = indicatorColor; + VariantPolicy = variantFor; + RailShowsSelectedLabel = railShowsSelectedLabel; + VariantSignal = variantSignal; + foreach (var item in items) + item.Parent = this; + content.Parent = this; + if (railHeader is not null) + railHeader.Parent = this; + if (drawerHeader is not null) + drawerHeader.Parent = this; + } + + public Signal SelectedIndex { get; } + public IReadOnlyList Items { get; } + public View Content { get; } + public View? RailHeaderView { get; } + public View? DrawerHeaderView { get; } + + /// When set, the whole suite wraps in a modal navigation drawer (the gold + /// wraps the app — ReplyNavigationComponents.kt:122-137): the sheet shows + /// + labeled items. Open it from chrome (the rail's + /// menu item); scrim tap / back / swipe dismissal writes the signal back to false. + public Signal? DrawerOpen { get; } + + /// M3 chrome container color (bar/rail background — surfaceContainer in the + /// gold). Android's real widgets theme themselves from the Compose scheme; the iOS + /// twin composes its own chrome and needs the tokens explicitly. + public Microsoft.Maui.Graphics.Color? ContainerColor { get; } + + /// Selected-item indicator (pill) color — secondaryContainer in the gold. + public Microsoft.Maui.Graphics.Color? IndicatorColor { get; } + + /// App-specific breakpoint policy; null = the Reply defaults + /// (). JetNews: rail ≥ 840dp, otherwise . + public System.Func? VariantPolicy { get; } + + /// Rail items show their label under the icon when selected + /// (M3 alwaysShowLabel=false — the JetNews rail). Off = icon-only (Reply). + public bool RailShowsSelectedLabel { get; } + + /// Optional out-signal the backend writes with the ACTIVE variant + /// ((int)) so content can adapt (JetNews swaps + /// its home list chrome: app bar on compact, search field on expanded). + public Signal? VariantSignal { get; } + + /// The active-variant resolution every backend uses: the app policy if + /// supplied, else the Reply defaults. + public NavigationSuiteVariant VariantForWindow(double widthDp, double heightDp) => + VariantPolicy?.Invoke(widthDp, heightDp) ?? VariantFor(widthDp, heightDp); + + /// See — same contract. + public void SelectItem(int index) + { + SelectedIndex.Value = index; + if (index >= 0 && index < Items.Count) + Items[index].OnSelect?.Invoke(); + } + + /// The gold (Reply) chrome breakpoints — ReplyNavigationComponents.kt:77-107: + /// bottom bar when width < 600dp OR height < 480dp; permanent drawer when width + /// ≥ 1200dp; rail between. Pure so hosts test it and every backend shares it. + public static NavigationSuiteVariant VariantFor(double widthDp, double heightDp) => + widthDp < 600 || heightDp < 480 ? NavigationSuiteVariant.BottomBar + : widthDp >= 1200 ? NavigationSuiteVariant.PermanentDrawer + : NavigationSuiteVariant.Rail; + + public IReadOnlyList GetChildren() + { + var children = new List { Content }; + if (RailHeaderView is not null) + children.Add(RailHeaderView); + if (DrawerHeaderView is not null) + children.Add(DrawerHeaderView); + children.AddRange(Items); + return children; + } + } +} diff --git a/src/Comet/src/Comet/Controls/NavigationView.cs b/src/Comet/src/Comet/Controls/NavigationView.cs index fe26a463c..23f4ef575 100644 --- a/src/Comet/src/Comet/Controls/NavigationView.cs +++ b/src/Comet/src/Comet/Controls/NavigationView.cs @@ -2,7 +2,7 @@ namespace Comet { - public class NavigationView : ContentView, IStackNavigationView + public partial class NavigationView : ContentView, IStackNavigationView { readonly object _viewsLock = new(); List _views = new List(); diff --git a/src/Comet/src/Comet/Controls/ScrollView.cs b/src/Comet/src/Comet/Controls/ScrollView.cs index 32f0714a4..99ea89a41 100644 --- a/src/Comet/src/Comet/Controls/ScrollView.cs +++ b/src/Comet/src/Comet/Controls/ScrollView.cs @@ -1,12 +1,13 @@ using System; using System.Collections; using System.Collections.Generic; +using Comet.Reactive; using Microsoft.Maui.Graphics; using Microsoft.Maui; namespace Comet { - public class ScrollView : ContentView, IEnumerable, IScrollView + public partial class ScrollView : ContentView, IEnumerable, IScrollView { public ScrollView(Orientation orientation = Orientation.Vertical) { @@ -15,6 +16,16 @@ public ScrollView(Orientation orientation = Orientation.Vertical) public Orientation Orientation { get; } + /// True while the scroll content is at the very top (scroll offset == 0); false + /// once the user has scrolled away. The backend node drives this from the native scroll + /// state so a floating button (e.g. ProfileFab) can reactively extend/contract. + public Signal AtTop { get; } = new(true); + + /// The continuous vertical scroll offset in Dp (0 at the top), driven by the backend + /// node from the native scroll state — for a parallax header that translates with the scroll + /// (e.g. the profile photo moving at half the scroll speed). + public Signal ScrollOffset { get; } = new(0); + ScrollBarVisibility IScrollView.HorizontalScrollBarVisibility => this.GetPropertyValue() ?? ScrollBarVisibility.Default; ScrollBarVisibility IScrollView.VerticalScrollBarVisibility => this.GetPropertyValue() ?? ScrollBarVisibility.Default; diff --git a/src/Comet/src/Comet/Controls/SearchBar.cs b/src/Comet/src/Comet/Controls/SearchBar.cs new file mode 100644 index 000000000..d619c519a --- /dev/null +++ b/src/Comet/src/Comet/Controls/SearchBar.cs @@ -0,0 +1,81 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Comet.Reactive; + +namespace Comet +{ + /// + /// A Material 3 docked search bar: drives the REAL Compose state-based + /// SearchBar + ExpandedDockedSearchBar pair (the successor of the gold's + /// DockedSearchBar — expansion animates on input-field focus, back collapses, + /// both handled inside the widget). The typed text flows OUT reactively through + /// ; put the results UI in and read the + /// signal there (a bound status text + a reloaded on change). + /// Slot views (placeholder/leading/trailing) are app-styled Comet views. + /// + public partial class SearchBar : View, IContainerView + { + public SearchBar(Signal query, View placeholder, View content, + View? leading = null, View? trailing = null, Action? onSearch = null, + Microsoft.Maui.Graphics.Color? containerColor = null) + { + Query = query; + PlaceholderView = placeholder; + ContentView = content; + LeadingView = leading; + TrailingView = trailing; + OnSearch = onSearch; + ContainerColor = containerColor; + placeholder.Parent = this; + content.Parent = this; + if (leading is not null) + leading.Parent = this; + if (trailing is not null) + trailing.Parent = this; + } + + /// The live query text — the node writes every edit into it (equality-gated), + /// so results UI that reads it re-renders per keystroke. + public Signal Query { get; } + public View PlaceholderView { get; } + public View ContentView { get; } + public View? LeadingView { get; } + public View? TrailingView { get; } + /// Invoked on the IME Search action with the current text. + public Action? OnSearch { get; } + + /// Pill/pane container color (M3 default: surfaceContainerHigh). The Android + /// widget themes itself from the Compose scheme; the iOS twin composes its own chrome + /// and needs the token explicitly. + public Microsoft.Maui.Graphics.Color? ContainerColor { get; } + + /// Whether the bar is expanded (pane open). Lives on the CONTROL, not the + /// backend node: an ancestor own-content refresh re-materializes the node, and + /// node-local expansion state would silently collapse the pane. + public Signal Expanded { get; } = new(false); + + public IReadOnlyList GetChildren() + { + var children = new List { PlaceholderView, ContentView }; + if (LeadingView is not null) + children.Add(LeadingView); + if (TrailingView is not null) + children.Add(TrailingView); + return children; + } + + /// Programmatic text entry (the dev agent's fill action targeting the + /// SearchBar element): drives the SAME pipeline as typing — expands the bar and + /// writes the query signal. On Android the input field is a native M3 facade widget + /// (not a registered Comet element), so this is the only agent-reachable path there. + protected internal override void OnBackendEvent(Backend.EventId id, T payload) + { + if (id == Backend.EventIds.TextChanged && payload is string s) + { + Expanded.Value = true; + Query.Value = s; + } + } + } +} diff --git a/src/Comet/src/Comet/Controls/SelectorPanel.cs b/src/Comet/src/Comet/Controls/SelectorPanel.cs new file mode 100644 index 000000000..1706a0223 --- /dev/null +++ b/src/Comet/src/Comet/Controls/SelectorPanel.cs @@ -0,0 +1,46 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; + +namespace Comet +{ + /// + /// An inline, reactive content-swap panel: shows one of chosen by an integer + /// signal. Index 0 (and any null / out-of-range slot) shows nothing, + /// collapsing the panel to zero height. Maps to the platform's expandable input panel — on Android + /// the gold-standard Jetchat's SelectorExpanded (a Surface that swaps an emoji table / + /// "not available" pane by InputSelector); iOS gets a no-op twin. + /// The active panel's measured height feeds the shared Yoga engine, so opening it grows this + /// view (and shrinks a sibling flex slot, e.g. the message list) — exactly the gold's bottom-anchored + /// Surface(tonalElevation) growth, reflowed after each reactive flush. The backend node + /// intercepts the system back press to dismiss, writing back to 0. + /// + public partial class SelectorPanel : View, IContainerView + { + public SelectorPanel(Signal selector, IReadOnlyList panels) + { + Selector = selector; + Panels = panels; + foreach (var p in panels) + if (p is not null) + p.Parent = this; + } + + /// The active panel index. 0 (and any null slot) collapses the panel; index + /// k shows Panels[k]. The node writes it back to 0 on a back-press dismiss. + public Signal Selector { get; } + + /// Candidate panels, indexed by value. Null slots render nothing + /// (e.g. the collapsed state, or a selector handled by a separate dialog). + public IReadOnlyList Panels { get; } + + public IReadOnlyList GetChildren() + { + var children = new List(); + foreach (var p in Panels) + if (p is not null) + children.Add(p); + return children; + } + } +} diff --git a/src/Comet/src/Comet/Controls/TabBar.cs b/src/Comet/src/Comet/Controls/TabBar.cs new file mode 100644 index 000000000..80518d878 --- /dev/null +++ b/src/Comet/src/Comet/Controls/TabBar.cs @@ -0,0 +1,40 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Reactive; +using Microsoft.Maui.Graphics; + +namespace Comet +{ + /// + /// A Material 3 primary tab row (Compose PrimaryTabRow + Tab) — the + /// JetNews Interests switcher. Text-label tabs; the signal drives the selected + /// index (and the sliding indicator) reactively. Styling tokens ride on the + /// control so the Android node styles the real widgets and an iOS twin can + /// hand-compose identically. + /// + public partial class TabBar : View + { + public TabBar(Signal selectedIndex, IReadOnlyList titles, + Color? selectedColor = null, Color? unselectedColor = null, + string? fontFamily = null, double fontSize = 16, int fontWeight = 500) + { + SelectedIndex = selectedIndex; + Titles = titles; + SelectedColor = selectedColor; + UnselectedColor = unselectedColor; + FontFamily = fontFamily; + FontSize = fontSize; + FontWeight = fontWeight; + } + + public Signal SelectedIndex { get; } + public IReadOnlyList Titles { get; } + public Color? SelectedColor { get; } + public Color? UnselectedColor { get; } + public string? FontFamily { get; } + public double FontSize { get; } + public int FontWeight { get; } + + public void SelectItem(int index) => SelectedIndex.Value = index; + } +} diff --git a/src/Comet/src/Comet/Controls/VStack.cs b/src/Comet/src/Comet/Controls/VStack.cs index 17b3c42e5..d9d011db0 100644 --- a/src/Comet/src/Comet/Controls/VStack.cs +++ b/src/Comet/src/Comet/Controls/VStack.cs @@ -4,7 +4,7 @@ namespace Comet { - public class VStack : AbstractLayout, IStackLayout + public partial class VStack : AbstractLayout, IStackLayout { private readonly LayoutAlignment alignment; private readonly float? spacing; diff --git a/src/Comet/src/Comet/Controls/View.cs b/src/Comet/src/Comet/Controls/View.cs index a822a0121..ee2184b3e 100644 --- a/src/Comet/src/Comet/Controls/View.cs +++ b/src/Comet/src/Comet/Controls/View.cs @@ -34,7 +34,7 @@ public HandlerChangingEventArgs(IElementHandler oldHandler, IElementHandler newH public IElementHandler NewHandler { get; } } - public class View : ContextualObject, IDisposable, IView, IHotReloadableView, ISafeAreaView, IContentTypeHash, IAnimator, ITitledElement, IGestureView, IVisualTreeElement, IPadding + public partial class View : ContextualObject, IDisposable, IView, IHotReloadableView, ISafeAreaView, IContentTypeHash, IAnimator, ITitledElement, IGestureView, IVisualTreeElement, IPadding { static internal readonly WeakList ActiveViews = new WeakList(); static internal readonly object ActiveViewsLock = new object(); @@ -234,7 +234,7 @@ protected virtual void OnUnloaded() Unloaded?.Invoke(this, EventArgs.Empty); } - internal void UpdateFromOldView(View view) + internal void UpdateFromOldView(View view, bool isHotReload = false) { // Suppress reactive notifications during internal state transfer. // Setting Gestures/ViewHandler goes through SetEnvironment → ReactiveEnvironment → @@ -252,6 +252,7 @@ internal void UpdateFromOldView(View view) var oldView = view.ViewHandler; this.ReloadHandler = view.ReloadHandler; this.Gestures = view.Gestures; + TransferBackendNodeFrom(view, isHotReload); view.ViewHandler = null; view.replacedView?.Dispose(); this.ViewHandler = oldView; @@ -295,6 +296,9 @@ void ResetView(bool isHotReload = false) animations?.ForEach(x => x.Dispose()); ViewHandler?.SetVirtualView(this); ReloadHandler?.Reload(); + // On the node backend a reload can change intrinsic sizes without any signal + // flush; schedule one so the backend roots' AfterFlush re-layout runs. + Reactive.ReactiveScheduler.EnsureFlushScheduled(); } finally { @@ -326,6 +330,8 @@ public Func Body public bool HasContent => Body is not null; public View GetView() => GetRenderView(); View replacedView; + /// The hot-reload replacement installed on this view, if any (see ). + internal View HotReloadReplacedView => replacedView; internal void SetHotReloadReplacement(View replacement, bool transferState = true) { if (replacement is null || replacement == this) @@ -354,8 +360,16 @@ protected virtual View GetRenderView() : null; if (replaced is not null && replaced != this) { + // Diff the replacement's render against the outgoing built view so retained + // state transfers — on the node backend this moves the ICometBackendNode to + // the new subtree (patches are pushed at diff time; there is no lazy platform + // pull that would otherwise pick the new render up). + var outgoingBuilt = builtView; SetHotReloadReplacement(replaced); - return builtView = replacedView.GetRenderView(); + var newBuilt = replacedView.GetRenderView(); + if (outgoingBuilt is not null && newBuilt is not null && newBuilt != outgoingBuilt) + newBuilt = newBuilt.Diff(outgoingBuilt, true); + return builtView = newBuilt; } CheckForBody(); if (Body is null) @@ -561,6 +575,7 @@ public virtual void ViewPropertyChanged(string property, object value) } var newPropName = GetHandlerPropertyName(property); ViewHandler?.UpdateValue(newPropName); + UpdateBackendNode(); builtView?.ViewPropertyChanged(property, value); if (measurementValid && PropertyChangeShouldTriggerLayout(newPropName)) { @@ -739,6 +754,14 @@ protected virtual void Dispose(bool disposing) try { + // Release the retained backend node so it can unhook static events + // (ReactiveScheduler.AfterFlush, window-metrics signals). A node that was + // TRANSFERRED to a replacement view is already null here (TransferNode + // clears the old view's reference before this runs). + var node = Node; + Node = null; + node?.Dispose(); + var vh = ViewHandler; ViewHandler = null; (vh as IDisposable)?.Dispose(); @@ -924,7 +947,11 @@ void AddAnimationsToManager(Animation animation) } protected virtual IMauiContext GetMauiContext() => ViewHandler?.MauiContext ?? BuiltView?.GetMauiContext(); - IAnimationManager GetAnimationManager() => GetMauiContext()?.Services.GetRequiredService(); + // Node backends have no MauiContext: fall back to the Comet-owned driver (a + // platform frame ticker installed by the backend root); null until one exists. + IAnimationManager GetAnimationManager() + => GetMauiContext()?.Services.GetRequiredService() + ?? Comet.Backend.CometAnimationDriver.Shared; void AddAllAnimationsToManager() { diff --git a/src/Comet/src/Comet/Controls/ZStack.cs b/src/Comet/src/Comet/Controls/ZStack.cs index 11b2e6223..73d6ed1c2 100644 --- a/src/Comet/src/Comet/Controls/ZStack.cs +++ b/src/Comet/src/Comet/Controls/ZStack.cs @@ -3,7 +3,7 @@ namespace Comet { - public class ZStack : AbstractLayout + public partial class ZStack : AbstractLayout { protected override ILayoutManager CreateLayoutManager() => new ZStackLayoutManager(this); protected override Thickness GetDefaultPadding() => Thickness.Zero; diff --git a/src/Comet/src/Comet/DevTools/CometDevAgent.DevFlow.cs b/src/Comet/src/Comet/DevTools/CometDevAgent.DevFlow.cs new file mode 100644 index 000000000..56eba23f3 --- /dev/null +++ b/src/Comet/src/Comet/DevTools/CometDevAgent.DevFlow.cs @@ -0,0 +1,449 @@ +#nullable enable +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using Comet.Backend; + +namespace Comet.DevTools +{ + /// + /// DevFlow/ailoha CLI wire-compatible surface for . Serves the + /// subset of /api/v1/* routes the maui devflow ui / ailoha CLI calls, + /// translating Comet's own / tree into the + /// protocol's ElementInfo model and mapping element-id actions onto the same event + /// sink a native interaction uses. On the iOS simulator the CLI connects straight to + /// localhost:9223 (shared loopback, no port-forward), so running the agent on that + /// port makes a standalone Comet app drivable by the stock CLI — no MAUI host required. + /// + public sealed partial class CometDevAgent + { + /// The port the DevFlow CLI defaults to (AgentClient host=localhost port=9223). + public const int DevFlowPort = 9223; + const int MaxDragDurationMs = 10_000; + + string RouteDevFlow(string method, string rawPath, string body) + { + // Split path and query (the CLI resolves selectors via ?type=&text=&automationId=). + string path = rawPath; + string query = ""; + var qi = rawPath.IndexOf('?'); + if (qi >= 0) { path = rawPath.Substring(0, qi); query = rawPath.Substring(qi + 1); } + + switch (method, path) + { + case ("GET", "/api/v1/agent/status"): + return RunOnMain(StatusJson); + case ("GET", "/api/v1/agent/capabilities"): + return CapabilitiesJson(); + case ("GET", "/api/v1/ui/tree"): + return RunOnMain(DevFlowTreeJson); + case ("GET", "/api/v1/ui/elements"): + return RunOnMain(() => ElementsJson(query)); + + case ("POST", "/api/v1/ui/actions/tap"): + return RunOnMain(() => + { + var view = ResolveElement(body); + // Bubble to the nearest tap-bearing ancestor — the semantic counterpart + // of a hit-test targeting the gesture's owner (a Text inside a tappable + // card). Without this, taps on leaf elements silently no-op (the iOS + // drawer-row bug; Android smoke masked it with coordinate taps). + var target = view; + for (int depth = 0; target is not null && !HasTap(target) && depth < 32; depth++) + target = target.Parent as View; + target ??= view; + target.OnBackendEvent(EventIds.Clicked); + target.OnBackendGesture(GestureKind.Tap, new GestureData(GestureState.Ended, default)); + return ActionOk(); + }); + + case ("POST", "/api/v1/ui/actions/longpress"): + return RunOnMain(() => + { + // Long-press twin of tap: bubble to the nearest long-press-bearing + // ancestor (a Text inside a selectable card) and fire the gesture. + var view = ResolveElement(body); + var target = view; + for (int depth = 0; target is not null && !HasLongPress(target) && depth < 32; depth++) + target = target.Parent as View; + target ??= view; + target.OnBackendGesture(GestureKind.LongPress, new GestureData(GestureState.Ended, default)); + return ActionOk(); + }); + + case ("POST", "/api/v1/ui/actions/fill"): + return RunOnMain(() => + { + var view = ResolveElement(body); + view.OnBackendEvent(EventIds.TextChanged, GetString(body, "text") ?? ""); + return ActionOk(); + }); + + case ("POST", "/api/v1/ui/actions/clear"): + return RunOnMain(() => + { + ResolveElement(body).OnBackendEvent(EventIds.TextChanged, ""); + return ActionOk(); + }); + + case ("POST", "/api/v1/ui/actions/focus"): + // Fire the element's Focused event — the same endpoint the native @FocusState + // callback routes to (shim onFocused -> sink OnEvent(Focused) -> View.OnBackendEvent), + // so focus-reactive UI runs exactly as for a real focus (e.g. focusing the input + // dismisses the active selector panel). Mirrors tap/fill driving Comet backend events. + return RunOnMain(() => + { + ResolveElement(body).OnBackendEvent(EventIds.Focused); + return ActionOk(); + }); + + case ("POST", "/api/v1/ui/actions/scroll"): + // Drive the underlying native scroll view so the shim's scroll-offset detection + // (GeometryReader/onScroll on iOS) fires exactly as it does for a finger — which is + // what reactive scroll-driven UI (JumpToBottom show/hide, profile FAB contract) needs. + return RunOnMain(() => ScrollAction(body)); + + case ("POST", "/api/v1/ui/actions/back"): + return RunOnMain(() => + { + PopNavigation(); + return ActionOk(); + }); + + case ("POST", "/api/v1/ui/actions/drag"): + // Real input injection (not a semantic event): body {x1,y1,x2,y2,durationMs?} + // in physical px. One generic verb covers pull-to-refresh, pager swipes, + // swipe-to-dismiss, drawer drags, and flings (velocity falls out of duration). + // NOT RunOnMain — the injector blocks this worker thread while it marshals the + // individual motion events to the UI thread over the gesture's duration. + return DragAction(body); + + default: + // 200 + success:false keeps the CLI from hanging on unimplemented routes. + return "{\"success\":false,\"error\":\"unimplemented\"}"; + } + } + + internal static string DragAction(string body) + { + var inject = CometDevRegistry.DragInjector; + if (inject is null) + return "{\"success\":false,\"error\":\"drag is not supported on this platform (no injector registered)\"}"; + + var x1Value = GetDouble(body, "x1"); + var y1Value = GetDouble(body, "y1"); + var x2Value = GetDouble(body, "x2"); + var y2Value = GetDouble(body, "y2"); + if (!double.IsFinite(x1Value) || !double.IsFinite(y1Value) || + !double.IsFinite(x2Value) || !double.IsFinite(y2Value) || + x1Value < -float.MaxValue || x1Value > float.MaxValue || + y1Value < -float.MaxValue || y1Value > float.MaxValue || + x2Value < -float.MaxValue || x2Value > float.MaxValue || + y2Value < -float.MaxValue || y2Value > float.MaxValue) + { + return "{\"success\":false,\"error\":\"drag coordinates must be finite single-precision values\"}"; + } + + var durationValue = 300d; + using (var document = JsonDocument.Parse(body)) + { + if (document.RootElement.TryGetProperty("durationMs", out var durationElement) && + (durationElement.ValueKind != JsonValueKind.Number || + !durationElement.TryGetDouble(out durationValue))) + { + return $"{{\"success\":false,\"error\":\"durationMs must be an integer from 1 to {MaxDragDurationMs}\"}}"; + } + } + if (!double.IsFinite(durationValue) || durationValue != System.Math.Truncate(durationValue) || + durationValue < 1 || durationValue > MaxDragDurationMs) + { + return $"{{\"success\":false,\"error\":\"durationMs must be an integer from 1 to {MaxDragDurationMs}\"}}"; + } + + var x1 = (float)x1Value; + var y1 = (float)y1Value; + var x2 = (float)x2Value; + var y2 = (float)y2Value; + var durationMs = (int)durationValue; + + return inject(x1, y1, x2, y2, durationMs) + ? ActionOk() + : "{\"success\":false,\"error\":\"drag injection failed\"}"; + } + + static View ResolveElement(string body) + { + var idStr = GetString(body, "elementId"); + if (idStr is null || !int.TryParse(idStr, out var id)) + throw new System.InvalidOperationException("elementId is required"); + return Resolve(id); + } + + static bool HasTap(View view) + { + var gestures = view.Gestures; + if (gestures is null) + return false; + for (int i = 0; i < gestures.Count; i++) + if (gestures[i] is TapGesture) + return true; + return false; + } + + static bool HasLongPress(View view) + { + var gestures = view.Gestures; + if (gestures is null) + return false; + for (int i = 0; i < gestures.Count; i++) + if (gestures[i] is LongPressGesture) + return true; + return false; + } + + static string ActionOk() => "{\"success\":true}"; + + // Drives the frontmost scrollable native UIScrollView (the view backing the SwiftUI + // ScrollView/List) by the requested delta. A real content-offset change makes SwiftUI + // republish its scroll geometry, so the shim's onScroll preference fires and Comet's + // AtTop/ScrolledAway signals update — the same path a finger drives. dy>0 scrolls toward the + // end (content moves up). No-op off iOS (Compose/Android owns its own scrolling). + static string ScrollAction(string body) + { +#if IOS + var sv = FindScrollableView(); + if (sv is not null) + { + double dy = TryGetDouble(body, "dy"); + double dx = TryGetDouble(body, "dx"); + var o = sv.ContentOffset; + var inset = sv.AdjustedContentInset; + double minY = -(double)inset.Top; + double maxY = System.Math.Max(minY, (double)(sv.ContentSize.Height - sv.Bounds.Height + inset.Bottom)); + double ny = System.Math.Clamp((double)o.Y + dy, minY, maxY); + sv.SetContentOffset(new CoreGraphics.CGPoint((double)o.X + dx, ny), animated: false); + sv.LayoutIfNeeded(); + } +#endif + return ActionOk(); + } + +#if IOS + static double TryGetDouble(string body, string key) + { + try { return GetDouble(body, key); } catch { return 0; } + } + + // The largest visible UIScrollView in the key window whose content overflows its bounds (the + // on-screen scroll). SwiftUI ScrollView and List both render through a UIScrollView, so this + // resolves either of Comet's scroll/list nodes without an id mapping. + static UIKit.UIScrollView? FindScrollableView() + { + UIKit.UIWindow? window = null; + var windows = UIKit.UIApplication.SharedApplication.Windows; + foreach (var w in windows) + if (w.IsKeyWindow) { window = w; break; } + if (window is null && windows.Length > 0) window = windows[0]; + if (window is null) return null; + + UIKit.UIScrollView? best = null; + double bestArea = 0; + var stack = new Stack(); + stack.Push(window); + while (stack.Count > 0) + { + var v = stack.Pop(); + if (v is UIKit.UIScrollView sv && !sv.Hidden && sv.Alpha > 0.01 + && sv.ContentSize.Height > sv.Bounds.Height + 1) + { + double area = (double)(sv.Bounds.Width * sv.Bounds.Height); + if (area > bestArea) { bestArea = area; best = sv; } + } + foreach (var sub in v.Subviews) stack.Push(sub); + } + return best; + } +#endif + + // Resolves the CLI's selector query (?type=&text=&automationId=) to matching elements. + // Returns a flat List JSON array; the CLI takes the id(s) and acts by id. + static string ElementsJson(string query) + { + var ps = ParseQuery(query); + ps.TryGetValue("type", out var type); + ps.TryGetValue("text", out var text); + ps.TryGetValue("automationId", out var automationId); + + var nodes = CometDevRegistry.Snapshot(); + var byId = new Dictionary(); + foreach (var n in nodes) byId[n.Id] = n; + + var matches = new List(); + foreach (var n in nodes) + { + if (type is not null && !string.Equals(StripGeneric(n.Type), type, System.StringComparison.OrdinalIgnoreCase)) + continue; + if (automationId is not null && !string.Equals(n.AutomationId, automationId, System.StringComparison.OrdinalIgnoreCase)) + continue; + if (text is not null && !TextMatches(n.Text, text)) + continue; + matches.Add(n); + } + + var sb = new StringBuilder(); + sb.Append('['); + var empty = new Dictionary>(); + for (int i = 0; i < matches.Count; i++) + { + if (i > 0) sb.Append(','); + WriteElement(sb, matches[i], empty); // flat: no children needed for resolution + } + sb.Append(']'); + return sb.ToString(); + } + + static bool TextMatches(string? actual, string wanted) + { + if (actual is null) return false; + return string.Equals(actual, wanted, System.StringComparison.OrdinalIgnoreCase) + || actual.IndexOf(wanted, System.StringComparison.OrdinalIgnoreCase) >= 0; + } + + static string StripGeneric(string type) + { + var tick = type.IndexOf('`'); + return tick >= 0 ? type.Substring(0, tick) : type; + } + + static Dictionary ParseQuery(string query) + { + var result = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + foreach (var pair in query.Split('&')) + { + if (pair.Length == 0) continue; + var eq = pair.IndexOf('='); + if (eq < 0) continue; + var key = System.Uri.UnescapeDataString(pair.Substring(0, eq)); + var val = System.Uri.UnescapeDataString(pair.Substring(eq + 1)); + result[key] = val; + } + return result; + } + + static string StatusJson() => + "{\"timestamp\":\"" + System.DateTimeOffset.UtcNow.ToString("O") + "\"," + + "\"running\":true," + + "\"agent\":{\"name\":\"Comet.DevTools.CometDevAgent\",\"version\":\"1\"," + + "\"framework\":\"comet\",\"frameworkVersion\":\"net11.0\"}," + + "\"device\":{\"platform\":\"" + PlatformName() + "\",\"deviceType\":\"simulator\"," + + "\"windowCount\":1}}"; + + static string PlatformName() + { +#if IOS + return "iOS"; +#elif ANDROID + return "Android"; +#else + return "Unknown"; +#endif + } + + static string CapabilitiesJson() => + "{\"agent\":{\"name\":\"Comet.DevTools.CometDevAgent\",\"version\":\"1\",\"framework\":\"comet\"}," + + "\"capabilities\":{" + + "\"ui.tree\":{\"version\":1,\"features\":[\"type\",\"text\",\"accessibility-id\"]}," + + "\"ui.actions\":{\"version\":1,\"features\":[\"tap\",\"fill\",\"clear\",\"back\",\"scroll\",\"focus\"" + + (CometDevRegistry.DragInjector is not null ? ",\"drag\"" : "") + "]}}}"; + + // Builds the nested ElementInfo tree the CLI expects, resolving the registry's flat + // parentId list into a hierarchy. The CLI deserializes a List, and a Comet + // app can track MULTIPLE parentless views (e.g. a Drawer plus content materialized + // under a different owner), so EVERY root is emitted — dropping all but the first + // hides the entire content tree. + static string DevFlowTreeJson() + { + var nodes = CometDevRegistry.Snapshot(); + var byParent = new Dictionary>(); + var roots = new List(); + foreach (var n in nodes) + { + if (n.ParentId < 0 || !ExistsInSnapshot(nodes, n.ParentId)) + roots.Add(n); + if (!byParent.TryGetValue(n.ParentId, out var list)) + byParent[n.ParentId] = list = new(); + list.Add(n); + } + + var sb = new StringBuilder(); + sb.Append('['); + for (int i = 0; i < roots.Count; i++) + { + if (i > 0) sb.Append(','); + WriteElement(sb, roots[i], byParent); + } + sb.Append(']'); + return sb.ToString(); + } + + // A node whose recorded parent has been unregistered (its subtree owner was replaced) + // is still live UI — treat it as a root rather than orphaning it out of the response. + static bool ExistsInSnapshot(List nodes, int id) + { + foreach (var n in nodes) + if (n.Id == id) + return true; + return false; + } + + static void WriteElement(StringBuilder sb, CometDevRegistry.NodeInfo n, + Dictionary> byParent) + { + bool tappable = n.Props.ContainsKey("tappable"); + string role = n.Type == "Button" || tappable ? "button" + : n.Type == "TextField" ? "textbox" + : n.Type == "Toggle" ? "checkbox" + : n.Type == "ListView`1" || n.Type == "ListView" ? "list" + : n.Type == "Text" ? "text" + : "none"; + + sb.Append('{'); + sb.Append("\"id\":").Append(JsonEncode(n.Id.ToString())); + sb.Append(",\"parentId\":").Append(n.ParentId < 0 ? "null" : JsonEncode(n.ParentId.ToString())); + sb.Append(",\"type\":").Append(JsonEncode(n.Type)); + sb.Append(",\"fullType\":").Append(JsonEncode("Comet." + n.Type)); + sb.Append(",\"framework\":\"comet\""); + sb.Append(",\"role\":").Append(JsonEncode(role)); + if (n.AutomationId is not null) sb.Append(",\"automationId\":").Append(JsonEncode(n.AutomationId)); + if (n.Text is not null) sb.Append(",\"text\":").Append(JsonEncode(n.Text)); + if (n.Value is not null) sb.Append(",\"value\":").Append(JsonEncode(n.Value)); + sb.Append(",\"isVisible\":true"); + sb.Append(",\"isEnabled\":").Append(n.Enabled ? "true" : "false"); + if (tappable) sb.Append(",\"gestures\":[\"tap\"]"); + + sb.Append(",\"children\":["); + if (byParent.TryGetValue(n.Id, out var kids)) + { + for (int i = 0; i < kids.Count; i++) + { + if (i > 0) sb.Append(','); + WriteElement(sb, kids[i], byParent); + } + } + sb.Append("]}"); + } + + // Finds a NavigationView among tracked views and pops it (for `ui actions back`). + static void PopNavigation() + { + foreach (var n in CometDevRegistry.Snapshot()) + { + if (n.Type == "NavigationView" && CometDevRegistry.Find(n.Id) is NavigationView nav) + { + nav.Pop(); + return; + } + } + } + } +} diff --git a/src/Comet/src/Comet/DevTools/CometDevAgent.cs b/src/Comet/src/Comet/DevTools/CometDevAgent.cs new file mode 100644 index 000000000..201298939 --- /dev/null +++ b/src/Comet/src/Comet/DevTools/CometDevAgent.cs @@ -0,0 +1,406 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Comet.Backend; + +namespace Comet.DevTools +{ + /// + /// A tiny in-process HTTP agent that lets external tooling (the DevFlow/ailoha CLI, an AI + /// agent, or plain curl) inspect the live Comet UI tree and drive semantic actions — + /// the same architecture ailoha uses for every framework, applied to Comet's own + /// tree so it works identically on the SwiftUI and Compose + /// backends. + /// + /// + /// Actions are dispatched semantically: a tap resolves an element id to its + /// owning and raises the same event the native control raises through + /// ViewEventSink. No screen geometry or HID injection is needed, so verification is + /// deterministic and framework-independent. + /// Built on (not HttpListener, which is unavailable + /// on the iOS CoreCLR runtime) with a hand-rolled HTTP/1.1 read. The host supplies a + /// main-thread dispatcher because reactive writes must run on the UI thread. + /// Endpoints: GET /status, GET /tree, POST /tap, + /// POST /fill, POST /toggle, POST /slider — each action body is + /// { "id": N, ... } and the response echoes the post-action tree. + /// + public sealed partial class CometDevAgent + { + readonly int _port; + readonly Action _dispatchToMain; + TcpListener? _listener; + volatile bool _running; + + public CometDevAgent(int port, Action dispatchToMain) + { + _port = port; + _dispatchToMain = dispatchToMain ?? throw new ArgumentNullException(nameof(dispatchToMain)); + } + + /// The port the listener actually bound (may differ from the requested + /// port when it was already taken — see ). + public int Port { get; private set; } + + /// Enables tracking and starts the listener on a background thread. + /// A dev-tool port collision (another agent/app on the requested port — the sim + /// shares the host's loopback) must not crash the app: scan forward up to 10 + /// ports and report the one that bound. + public void Start() + { + CometDevRegistry.Enabled = true; + SocketException? lastError = null; + for (int candidate = _port; candidate < _port + 10; candidate++) + { + try + { + var listener = new TcpListener(IPAddress.Loopback, candidate); + listener.Start(); + _listener = listener; + Port = candidate; + break; + } + catch (SocketException ex) + { + lastError = ex; + } + } + if (_listener is null) + { + System.Diagnostics.Debug.WriteLine($"[CometDevAgent] no free port in {_port}..{_port + 9}: {lastError?.Message}"); + return; // dev agent unavailable; the app runs fine without it + } + // Always surface the bound port. When it differs from the requested one (the CLI's + // default target), the developer must pass `-ap ` — there is no broker on this + // platform to auto-publish it, and the sim shares the host loopback so a foreign + // process (or a second app) can hold the default port. Hosts that DO have a broker + // (the Android probe) read the authoritative Port property and publish it. + if (Port == _port) + Console.WriteLine($"[CometDevAgent] listening on port {Port}"); + else + Console.WriteLine($"[CometDevAgent] requested port {_port} was in use; listening on {Port} — drive it with: maui devflow ui -ap {Port}"); + _running = true; + var thread = new Thread(AcceptLoop) { IsBackground = true, Name = "CometDevAgent" }; + thread.Start(); + } + + public void Stop() + { + _running = false; + try { _listener?.Stop(); } catch { } + } + + void AcceptLoop() + { + while (_running) + { + TcpClient client; + try { client = _listener!.AcceptTcpClient(); } + catch { if (_running) continue; else break; } + + try { HandleClient(client); } + catch { /* never let one bad request kill the loop */ } + finally { try { client.Close(); } catch { } } + } + } + + void HandleClient(TcpClient client) + { + using var stream = client.GetStream(); + var (method, path, body) = ReadRequest(stream); + if (method is null) + return; + + // Screenshot returns binary PNG, not JSON. + var bare = path; + var qi = bare.IndexOf('?'); + if (qi >= 0) bare = bare.Substring(0, qi); + if (method == "GET" && (bare == "/api/v1/ui/screenshot" || bare == "/screenshot")) + { + var png = RunOnMainBytes(() => CometDevRegistry.ScreenshotProvider?.Invoke()); + if (png is { Length: > 0 }) + WriteBinaryResponse(stream, png, "image/png"); + else + WriteResponse(stream, 503, "{\"ok\":false,\"error\":\"no screenshot provider\"}"); + return; + } + + string json; + int status = 200; + try + { + json = Route(method, path, body); + } + catch (Exception ex) + { + status = 400; + json = $"{{\"ok\":false,\"error\":{JsonEncode(ex.Message)}}}"; + } + + WriteResponse(stream, status, json); + } + + byte[]? RunOnMainBytes(Func work) + { + var tcs = new TaskCompletionSource(); + _dispatchToMain(() => + { + try { tcs.SetResult(work()); } + catch { tcs.SetResult(null); } + }); + try { return tcs.Task.GetAwaiter().GetResult(); } + catch { return null; } + } + + static void WriteBinaryResponse(NetworkStream stream, byte[] payload, string contentType) + { + var head = $"HTTP/1.1 200 OK\r\nContent-Type: {contentType}\r\n" + + $"Content-Length: {payload.Length}\r\nConnection: close\r\n\r\n"; + var headBytes = Encoding.ASCII.GetBytes(head); + stream.Write(headBytes, 0, headBytes.Length); + stream.Write(payload, 0, payload.Length); + stream.Flush(); + } + + string Route(string method, string path, string body) + { + // DevFlow/ailoha CLI wire-compatible surface (so `maui devflow ui …` drives Comet). + // Passed the raw path so the handler can read query params (e.g. ?text=&type=). + var rawPath = path; + + // Strip query string for the simple-route switch below. + var q = path.IndexOf('?'); + if (q >= 0) path = path.Substring(0, q); + + if (path.StartsWith("/api/v1/", System.StringComparison.Ordinal)) + return RouteDevFlow(method, rawPath, body); + + switch (method, path) + { + case ("GET", "/status"): + return RunOnMain(() => $"{{\"ok\":true,\"framework\":\"comet\",\"nodes\":{CometDevRegistry.Snapshot().Count}}}"); + + case ("GET", "/"): + case ("GET", "/tree"): + return RunOnMain(() => TreeJson()); + + case ("POST", "/tap"): + return RunOnMain(() => + { + var id = GetInt(body, "id"); + var view = Resolve(id); + view.OnBackendEvent(EventIds.Clicked); + view.OnBackendGesture(GestureKind.Tap, new GestureData(GestureState.Ended, default)); + return Ok(id, "tap"); + }); + + case ("POST", "/fill"): + return RunOnMain(() => + { + var id = GetInt(body, "id"); + var text = GetString(body, "text") ?? ""; + Resolve(id).OnBackendEvent(EventIds.TextChanged, text); + return Ok(id, "fill"); + }); + + case ("POST", "/toggle"): + return RunOnMain(() => + { + var id = GetInt(body, "id"); + var value = GetBool(body, "value"); + Resolve(id).OnBackendEvent(EventIds.Toggled, value); + return Ok(id, "toggle"); + }); + + case ("POST", "/slider"): + return RunOnMain(() => + { + var id = GetInt(body, "id"); + var value = GetDouble(body, "value"); + Resolve(id).OnBackendEvent(EventIds.ValueChanged, value); + return Ok(id, "slider"); + }); + + default: + return "{\"ok\":false,\"error\":\"unknown route\"}"; + } + } + + static View Resolve(int id) => + CometDevRegistry.Find(id) ?? throw new InvalidOperationException($"no element with id {id}"); + + static string Ok(int id, string action) => + $"{{\"ok\":true,\"action\":{JsonEncode(action)},\"id\":{id},\"tree\":{TreeArray()}}}"; + + static string TreeJson() => $"{{\"ok\":true,\"framework\":\"comet\",\"nodes\":{TreeArray()}}}"; + + static string TreeArray() + { + var nodes = CometDevRegistry.Snapshot(); + var sb = new StringBuilder(); + sb.Append('['); + for (int i = 0; i < nodes.Count; i++) + { + var n = nodes[i]; + if (i > 0) sb.Append(','); + sb.Append('{'); + sb.Append("\"id\":").Append(n.Id); + sb.Append(",\"parentId\":").Append(n.ParentId); + sb.Append(",\"type\":").Append(JsonEncode(n.Type)); + sb.Append(",\"enabled\":").Append(n.Enabled ? "true" : "false"); + if (n.AutomationId is not null) sb.Append(",\"automationId\":").Append(JsonEncode(n.AutomationId)); + if (n.Text is not null) sb.Append(",\"text\":").Append(JsonEncode(n.Text)); + if (n.Value is not null) sb.Append(",\"value\":").Append(JsonEncode(n.Value)); + sb.Append(",\"props\":{"); + bool first = true; + foreach (var (k, v) in n.Props) + { + if (!first) sb.Append(','); + first = false; + sb.Append(JsonEncode(k)).Append(':').Append(JsonEncode(v)); + } + sb.Append("}}"); + } + sb.Append(']'); + return sb.ToString(); + } + + // --- main-thread marshaling --- + + string RunOnMain(Func work) + { + var tcs = new TaskCompletionSource(); + _dispatchToMain(() => + { + try { tcs.SetResult(work()); } + catch (Exception ex) { tcs.SetException(ex); } + }); + try { return tcs.Task.GetAwaiter().GetResult(); } + catch (Exception ex) { return $"{{\"ok\":false,\"error\":{JsonEncode(ex.Message)}}}"; } + } + + // --- minimal HTTP + JSON helpers --- + + static (string? method, string path, string body) ReadRequest(NetworkStream stream) + { + var headerBytes = new List(512); + int prev = -1, prev2 = -1, prev3 = -1; + int b; + // Read until CRLFCRLF (end of headers). + while ((b = stream.ReadByte()) != -1) + { + headerBytes.Add((byte)b); + if (prev3 == '\r' && prev2 == '\n' && prev == '\r' && b == '\n') + break; + prev3 = prev2; prev2 = prev; prev = b; + } + if (headerBytes.Count == 0) + return (null, "", ""); + + var header = Encoding.ASCII.GetString(headerBytes.ToArray()); + var firstLine = header.Substring(0, header.IndexOf('\r')); + var parts = firstLine.Split(' '); + if (parts.Length < 2) + return (null, "", ""); + var method = parts[0]; + var path = parts[1]; + + int contentLength = 0; + foreach (var line in header.Split(new[] { "\r\n" }, StringSplitOptions.None)) + { + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + int.TryParse(line.Substring("Content-Length:".Length).Trim(), out contentLength); + } + + string body = ""; + if (contentLength > 0) + { + var buf = new byte[contentLength]; + int read = 0; + while (read < contentLength) + { + int r = stream.Read(buf, read, contentLength - read); + if (r <= 0) break; + read += r; + } + body = Encoding.UTF8.GetString(buf, 0, read); + } + + return (method, path, body); + } + + static void WriteResponse(NetworkStream stream, int status, string json) + { + var payload = Encoding.UTF8.GetBytes(json); + var reason = status == 200 ? "OK" : "Bad Request"; + var head = $"HTTP/1.1 {status} {reason}\r\n" + + "Content-Type: application/json\r\n" + + $"Content-Length: {payload.Length}\r\n" + + "Connection: close\r\n\r\n"; + var headBytes = Encoding.ASCII.GetBytes(head); + stream.Write(headBytes, 0, headBytes.Length); + stream.Write(payload, 0, payload.Length); + stream.Flush(); + } + + static int GetInt(string body, string key) + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); + if (doc.RootElement.TryGetProperty(key, out var el) && el.TryGetInt32(out var v)) + return v; + throw new InvalidOperationException($"missing integer '{key}'"); + } + + static string? GetString(string body, string key) + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); + return doc.RootElement.TryGetProperty(key, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() : null; + } + + static bool GetBool(string body, string key) + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); + return doc.RootElement.TryGetProperty(key, out var el) && + (el.ValueKind == JsonValueKind.True || + (el.ValueKind == JsonValueKind.String && bool.TryParse(el.GetString(), out var b) && b)); + } + + static double GetDouble(string body, string key) + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); + if (doc.RootElement.TryGetProperty(key, out var el) && el.TryGetDouble(out var v)) + return v; + throw new InvalidOperationException($"missing number '{key}'"); + } + + static string JsonEncode(string s) + { + var sb = new StringBuilder(s.Length + 2); + sb.Append('"'); + foreach (var c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4")); + else sb.Append(c); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + } +} diff --git a/src/Comet/src/Comet/DevTools/CometDevRegistry.cs b/src/Comet/src/Comet/DevTools/CometDevRegistry.cs new file mode 100644 index 000000000..15838ec6d --- /dev/null +++ b/src/Comet/src/Comet/DevTools/CometDevRegistry.cs @@ -0,0 +1,285 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.DevTools +{ + /// + /// Opt-in inspection registry for the in-process dev agent (). + /// Mirrors the ailoha/DevFlow model: external tooling needs a stable, queryable view of + /// the live UI plus a way to drive semantic actions. Because the new Comet renders through + /// its own tree (not MAUI handlers), the old + /// CometViewResolver tree-walk no longer applies — this registry is the seam instead. + /// + /// + /// Registration is gated on so production pays only a single bool + /// check per materialized node. When enabled, each materialized is + /// assigned a stable integer id and linked to its parent, so the agent can present a tree + /// and resolve an elementId back to the owning view to synthesize an event through + /// the exact same path a real native interaction would take. + /// + public static class CometDevRegistry + { + sealed class Entry + { + public int Id; + public int ParentId = -1; + public WeakReference View = null!; + } + + static readonly object _gate = new(); + static readonly Dictionary _byId = new(); + static readonly ConditionalWeakTable> _viewIds = new(); + static int _next = 1; + + /// When false, is a no-op (production default). + public static bool Enabled { get; set; } + + /// + /// Optional platform hook that renders the current UI to a PNG (DevFlow-style in-app + /// screenshot). Set by the backend (e.g. the SwiftUI host); invoked on the UI thread by + /// the agent's /ui/screenshot endpoint. Null when the platform can't snapshot. + /// + public static Func? ScreenshotProvider { get; set; } + + /// + /// Optional platform hook that injects a REAL coordinate drag through the platform's + /// input pipeline — (x1, y1, x2, y2, durationMs) → success, coordinates in + /// physical pixels. Unlike the semantic actions, this exercises the native gesture + /// machinery itself (pull-to-refresh, pager swipes, swipe-to-dismiss, flings — the + /// gesture's velocity comes from the event timing, so a short duration flings). + /// Called from the agent's worker thread and expected to BLOCK until the gesture + /// completes (implementations marshal individual events to the UI thread). Null when + /// the platform can't inject (the agent reports drag as unsupported). + /// + public static Func? DragInjector { get; set; } + + /// Drops all tracked nodes (e.g. before a fresh root mount). + public static void Reset() + { + lock (_gate) + { + _byId.Clear(); + _viewIds.Clear(); + _next = 1; + } + } + + /// + /// Records (and its backend node, via the view) under a stable + /// id, linked to . Idempotent per view instance. + /// + internal static void Register(View view, ICometBackendNode node, View? parent) + { + if (!Enabled || view is null) + return; + + lock (_gate) + { + int parentId = -1; + if (parent is not null && _viewIds.TryGetValue(parent, out var pBox)) + parentId = pBox.Value; + + if (_viewIds.TryGetValue(view, out var existing)) + { + if (_byId.TryGetValue(existing.Value, out var e)) + e.ParentId = parentId; + return; + } + + int id = _next++; + _viewIds.Add(view, new StrongBox(id)); + _byId[id] = new Entry { Id = id, ParentId = parentId, View = new WeakReference(view) }; + } + } + + /// Stops tracking a single view (e.g. when its backend node is disposed). + internal static void Unregister(View view) + { + if (!Enabled || view is null) + return; + lock (_gate) + { + if (_viewIds.TryGetValue(view, out var box)) + { + _byId.Remove(box.Value); + _viewIds.Remove(view); + } + } + } + + /// + /// Stops tracking and everything materialized beneath it + /// (resolved through the parent links), so a replaced navigation screen or list row set + /// drops out of the tree deterministically rather than waiting for GC. + /// + internal static void UnregisterSubtree(View root, bool includeRoot) + { + if (!Enabled || root is null) + return; + lock (_gate) + { + if (!_viewIds.TryGetValue(root, out var rootBox)) + return; + int rootId = rootBox.Value; + + // Collect rootId + all descendants via the parentId graph (closure). + var ids = new HashSet { rootId }; + bool grew = true; + while (grew) + { + grew = false; + foreach (var e in _byId.Values) + if (ids.Contains(e.ParentId) && ids.Add(e.Id)) + grew = true; + } + + if (!includeRoot) + ids.Remove(rootId); + + foreach (var id in ids) + { + if (_byId.TryGetValue(id, out var e)) + { + if (e.View.TryGetTarget(out var v)) + _viewIds.Remove(v); + _byId.Remove(id); + } + } + } + } + + /// Resolves a tracked id back to its live view, or null if collected. + public static View? Find(int id) + { + lock (_gate) + { + if (_byId.TryGetValue(id, out var e) && e.View.TryGetTarget(out var v)) + return v; + return null; + } + } + + /// A flattened snapshot of one tracked node for the agent's tree response. + public sealed class NodeInfo + { + public int Id; + public int ParentId; + public string Type = ""; + public string? AutomationId; + public string? Text; + public string? Value; + public bool Enabled = true; + public Dictionary Props = new(); + } + + /// + /// Builds a snapshot of all live tracked nodes. Touches view state (reads each view's + /// set properties) so callers must invoke it on the UI thread. + /// + public static List Snapshot() + { + var list = new List(); + List entries; + lock (_gate) + entries = new List(_byId.Values); + + foreach (var e in entries) + { + if (!e.View.TryGetTarget(out var view)) + continue; + + var props = ReadProps(view); + var info = new NodeInfo + { + Id = e.Id, + ParentId = e.ParentId, + Type = view.GetType().Name, + AutomationId = string.IsNullOrEmpty(view.AutomationId) ? null : view.AutomationId, + Enabled = view.IsEnabled, + }; + + // Friendly, queryable subset. + foreach (var (id, value) in props) + { + var name = FriendlyName(id); + if (name is null) + continue; + info.Props[name] = Stringify(value); + } + + info.Text = info.Props.TryGetValue("text", out var t) ? t + : info.Props.TryGetValue("buttonText", out var bt) ? bt + // An Icon whose symbol resolved through a registered icon font emits + // Icon_Glyph (a PUA char), not Icon_Symbol — expose the symbol name + // directly so icons stay queryable by name either way. + : view is Icon icon && !string.IsNullOrEmpty(icon.Symbol) ? icon.Symbol + : null; + info.Value = info.Props.TryGetValue("isOn", out var on) ? on + : info.Props.TryGetValue("sliderValue", out var sv) ? sv + : info.Props.TryGetValue("text", out var tv) ? tv + : null; + + list.Add(info); + } + + list.Sort((a, b) => a.Id.CompareTo(b.Id)); + return list; + } + + /// Replays a view's set-only emission into a recorder to read current props + /// with zero per-control code (the same shape the backend node receives). + static Dictionary ReadProps(View view) + { + var rec = new RecordingNode(); + try { view.ApplyAllSetProperties(rec); } + catch { /* a partially-built view may throw; return what we captured */ } + return rec.Captured; + } + + static string? FriendlyName(PropertyId id) + { + var v = id.Value; + if (v == PropertyIds.Text_Value.Value) return "text"; + if (v == PropertyIds.Icon_Symbol.Value) return "text"; // Icon("arrow_back") queryable by symbol + if (v == PropertyIds.Button_Text.Value) return "buttonText"; + if (v == PropertyIds.TextField_Text.Value) return "text"; + if (v == PropertyIds.TextField_Placeholder.Value) return "placeholder"; + if (v == PropertyIds.Toggle_IsOn.Value) return "isOn"; + if (v == PropertyIds.Slider_Value.Value) return "sliderValue"; + if (v == PropertyIds.BackgroundColor.Value) return "background"; + if (v == PropertyIds.HasTapGesture.Value) return "tappable"; + if (v == PropertyIds.Opacity.Value) return "opacity"; + return null; + } + + static string Stringify(in PropertyValue value) => value.Kind switch + { + PropertyValueKind.Bool => value.AsBool ? "true" : "false", + PropertyValueKind.Int => value.AsInt.ToString(), + PropertyValueKind.Long => value.AsLong.ToString(), + PropertyValueKind.Single => value.AsSingle.ToString("0.###"), + PropertyValueKind.Double => value.AsDouble.ToString("0.###"), + PropertyValueKind.Color => value.AsColor?.ToHex() ?? "", + PropertyValueKind.String => value.AsString ?? "", + _ => value.AsObject?.ToString() ?? "", + }; + + /// A throwaway backend node that captures applied properties for inspection. + sealed class RecordingNode : ICometBackendNode + { + public readonly Dictionary Captured = new(); + public void ApplyProperty(PropertyId id, in PropertyValue value) => Captured[id] = value; + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + public Size Measure(double w, double h) => Size.Zero; + public void Arrange(Rect frame) { } + public void SetEventSink(ICometEventSink? sink) { } + public void Dispose() { } + } + } +} diff --git a/src/Comet/src/Comet/EnvironmentAware.cs b/src/Comet/src/Comet/EnvironmentAware.cs index 3b1a4f34b..15c601ed1 100644 --- a/src/Comet/src/Comet/EnvironmentAware.cs +++ b/src/Comet/src/Comet/EnvironmentAware.cs @@ -66,7 +66,14 @@ public static string GetControlStateKey(ControlState state, string key) => state == ControlState.Default ? key : $"{state}.{key}"; internal object GetValue(string key, ContextualObject current, View view, string styledKey, string typedKey, bool cascades) + => GetValue(key, current, view, styledKey, typedKey, cascades, depth: 0); + + internal object GetValue(string key, ContextualObject current, View view, string styledKey, string typedKey, bool cascades, int depth) { + // A parent CYCLE (re-parenting bugs) would recurse forever and stack-overflow the + // process; no real tree is 256 levels deep — treat deeper as "not found". + if (depth > 256) + return null; try { //Example Environment lookup... @@ -124,7 +131,7 @@ internal object GetValue(string key, ContextualObject current, View view, string if (result.hasValue) return result.value; } - return view?.GetValue(key, current, view.Parent, styledKey, typedKey, cascades); + return view?.GetValue(key, current, view.Parent, styledKey, typedKey, cascades, depth + 1); } catch { diff --git a/src/Comet/src/Comet/EnvironmentData.cs b/src/Comet/src/Comet/EnvironmentData.cs index 7974d7730..362c941ba 100644 --- a/src/Comet/src/Comet/EnvironmentData.cs +++ b/src/Comet/src/Comet/EnvironmentData.cs @@ -18,6 +18,7 @@ public static class Fonts public const string Size = "Font.Size"; public const string Weight = "Font.Weight"; public const string Family = "Font.Family"; + public const string LineHeight = "Font.LineHeight"; public const string Slant = "Font.Slant"; //public const string Attributes = "Font.Attributes"; } @@ -47,6 +48,8 @@ public static class Layout public const string VerticalLayoutAlignment = "Layout.VerticalSizing"; public const string FrameConstraints = "Layout.FrameConstraints"; public const string IgnoreSafeArea = "Layout.IgnoreSafeArea"; + public const string BaselineAlign = "Layout.BaselineAlign"; + public const string BaselineHeight = "Layout.BaselineHeight"; } public static class View @@ -81,6 +84,8 @@ public static class TabView public static class Text { + public const string MaxLines = "Comet.Text.MaxLines"; + public const string LineBreak = "Comet.Text.LineBreak"; public const string HorizontalAlignment = nameof(Microsoft.Maui.ITextAlignment.HorizontalTextAlignment); public const string VerticalAlignment = nameof(Microsoft.Maui.ITextAlignment.VerticalTextAlignment); public static class Style diff --git a/src/Comet/src/Comet/Gestures/RecordGesture.cs b/src/Comet/src/Comet/Gestures/RecordGesture.cs new file mode 100644 index 000000000..2b1226419 --- /dev/null +++ b/src/Comet/src/Comet/Gestures/RecordGesture.cs @@ -0,0 +1,78 @@ +using System; +namespace Comet +{ + /// + /// Press-and-hold voice-record gesture — the gold Jetchat RecordButton's + /// detectDragGesturesAfterLongPress (RecordButton.kt): a long-press starts + /// recording, a horizontal drag tracks the swipe-to-cancel offset, releasing finishes, + /// and dragging left past the threshold cancels. The backend drives the phases via + /// / / / ; + /// the accumulated offset ( / in dp) and the + /// are read by the handler each invoke. + /// + public class RecordGesture : Gesture + { + public RecordGesture(Action action) : base(action) { } + + /// Phase of the gesture: Started (hold engaged) → Running (dragging) → + /// Completed (released) / Canceled (lifted early or swiped past the threshold). + public GestureStatus Status { get; private set; } + + /// Cumulative horizontal drag in dp (negative = leftward = toward cancel). + public double TotalX { get; private set; } + + /// Cumulative vertical drag in dp. + public double TotalY { get; private set; } + + // Gold thresholds (RecordButton.kt voiceRecordingGesture): swipe left ≥200dp while + // staying within ±80dp vertically cancels the in-progress recording. + const double CancelThresholdDp = 200; + const double VerticalThresholdDp = 80; + + bool _dragging; + + /// Long-press engaged — begin recording. + public void Begin() + { + TotalX = TotalY = 0; + _dragging = true; + Status = GestureStatus.Started; + Invoke(); + } + + /// A per-frame drag delta (dp) while held; accumulates the offset and + /// auto-cancels once the leftward swipe passes the threshold. + public void Drag(double dx, double dy) + { + if (!_dragging) + return; + TotalX += dx; + TotalY += dy; + Status = GestureStatus.Running; + if (TotalX < 0 && Math.Abs(TotalX) >= CancelThresholdDp && Math.Abs(TotalY) <= VerticalThresholdDp) + { + _dragging = false; + Status = GestureStatus.Canceled; + } + Invoke(); + } + + /// Pointer released — finish recording (no-op if already cancelled). + public void End() + { + if (!_dragging) + return; + _dragging = false; + Status = GestureStatus.Completed; + Invoke(); + } + + /// Gesture cancelled by the system (parent intercept, etc.). + public void Cancel() + { + _dragging = false; + Status = GestureStatus.Canceled; + Invoke(); + } + } +} diff --git a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Android.cs b/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Android.cs deleted file mode 100644 index d7b69947c..000000000 --- a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Android.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using AView = global::Android.Views.View; -using AFrameLayout = global::Android.Widget.FrameLayout; -using AViewGroup = global::Android.Views.ViewGroup; -using AMeasureSpecMode = global::Android.Views.MeasureSpecMode; - -namespace Comet.Handlers -{ -public partial class CollectionViewHandler : ViewHandler -{ -AView _hostedPlatformView; - -protected override AFrameLayout CreatePlatformView() => new AFrameLayout(Context); - -public static void MapListViewProperty(IElementHandler handler, IListView virtualView) -{ -var cvHandler = (CollectionViewHandler)handler; -cvHandler._currentListViewRef = new WeakReference(virtualView); - -if (cvHandler._mauiItemsView is Microsoft.Maui.Controls.CollectionView existingCv -&& !IsCarouselView(virtualView)) -{ -UpdateCollectionView(existingCv, virtualView); -return; -} - -if (IsCarouselView(virtualView)) -{ -var carousel = new Microsoft.Maui.Controls.CarouselView(); -ConfigureMauiCarouselView(carousel, virtualView); -RefreshItemsSource(carousel, virtualView); -cvHandler._mauiItemsView = carousel; -} -else -{ -var cv = new Microsoft.Maui.Controls.CollectionView(); -cvHandler.InitCollectionView(cv); -MapCometItemsLayout(cv, virtualView); -MapCometInfiniteScroll(cv, virtualView); -UpdateCollectionView(cv, virtualView); -cvHandler._mauiItemsView = cv; -} -cvHandler.EmbedMauiItemsView(); -} - -#nullable enable -public static void MapReloadData(CollectionViewHandler handler, IListView virtualView, object? value) -#nullable restore -{ -if (handler._mauiItemsView is not null) -RefreshItemsSource(handler._mauiItemsView, virtualView); -} - -void EmbedMauiItemsView() -{ -if (_mauiItemsView is null || MauiContext is null) -return; - -if (_hostedPlatformView is not null) -PlatformView.RemoveView(_hostedPlatformView); - -try -{ -_hostedPlatformView = _mauiItemsView.ToPlatform(MauiContext); -} -catch (Exception ex) -{ -Console.WriteLine($"[CollectionViewHandler] EmbedMauiItemsView failed: {ex.Message}"); -return; -} - -if (_hostedPlatformView is not null) -{ -PlatformView.AddView(_hostedPlatformView, -new AFrameLayout.LayoutParams( -AViewGroup.LayoutParams.MatchParent, -AViewGroup.LayoutParams.MatchParent)); -PlatformView.RequestLayout(); -} -} - -protected override void DisconnectHandler(AFrameLayout platformView) -{ -if (_hostedPlatformView is not null) -{ -platformView.RemoveView(_hostedPlatformView); -_hostedPlatformView = null; -} -if (_mauiItemsView?.Handler is IElementHandler hostedHandler) -{ -hostedHandler.DisconnectHandler(); -if (hostedHandler is IDisposable disposable) -disposable.Dispose(); -} -_mauiItemsView = null; -base.DisconnectHandler(platformView); -} - -public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) -{ -var w = double.IsInfinity(widthConstraint) ? 400 : widthConstraint; -var h = double.IsInfinity(heightConstraint) ? 800 : heightConstraint; - -// Measure the platform FrameLayout so its children (MauiRecyclerView) get sized -if (PlatformView is not null && Context is not null) -{ -var density = Context.Resources.DisplayMetrics.Density; -PlatformView.Measure( -AView.MeasureSpec.MakeMeasureSpec((int)(w * density), AMeasureSpecMode.Exactly), -AView.MeasureSpec.MakeMeasureSpec((int)(h * density), AMeasureSpecMode.Exactly)); -} - -return new Microsoft.Maui.Graphics.Size(w, h); -} - -public override void PlatformArrange(Microsoft.Maui.Graphics.Rect frame) -{ -base.PlatformArrange(frame); - -// Ensure the FrameLayout and its MauiRecyclerView child get proper layout -if (PlatformView is not null && Context is not null) -{ -var density = Context.Resources.DisplayMetrics.Density; -var widthPx = (int)(frame.Width * density); -var heightPx = (int)(frame.Height * density); -PlatformView.Measure( -AView.MeasureSpec.MakeMeasureSpec(widthPx, AMeasureSpecMode.Exactly), -AView.MeasureSpec.MakeMeasureSpec(heightPx, AMeasureSpecMode.Exactly)); -PlatformView.Layout(0, 0, widthPx, heightPx); -} -} -} -} diff --git a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.MacOS.cs deleted file mode 100644 index 19642beac..000000000 --- a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.MacOS.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System; -using AppKit; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Comet.MacOS; - -namespace Comet.Handlers -{ - public partial class CollectionViewHandler : ViewHandler - { - protected override CollectionViewNSContainer CreatePlatformView() => new CollectionViewNSContainer(); - - public static void MapListViewProperty(IElementHandler handler, IListView virtualView) - { - var cvHandler = (CollectionViewHandler)handler; - cvHandler._currentListViewRef = new WeakReference(virtualView); - - if (cvHandler._mauiItemsView is Microsoft.Maui.Controls.CollectionView existingCv - && !IsCarouselView(virtualView)) - { - UpdateCollectionView(existingCv, virtualView); - return; - } - - if (IsCarouselView(virtualView)) - { - var carousel = new Microsoft.Maui.Controls.CarouselView(); - ConfigureMauiCarouselView(carousel, virtualView); - RefreshItemsSource(carousel, virtualView); - cvHandler._mauiItemsView = carousel; - } - else - { - var cv = new Microsoft.Maui.Controls.CollectionView(); - cvHandler.InitCollectionView(cv); - MapCometItemsLayout(cv, virtualView); - MapCometInfiniteScroll(cv, virtualView); - UpdateCollectionView(cv, virtualView); - cvHandler._mauiItemsView = cv; - } - cvHandler.EmbedMauiItemsView(); - } - -#nullable enable - public static void MapReloadData(CollectionViewHandler handler, IListView virtualView, object? value) -#nullable restore - { - if (handler._mauiItemsView is not null) - RefreshItemsSource(handler._mauiItemsView, virtualView); - } - - void EmbedMauiItemsView() - { - if (_mauiItemsView is null || MauiContext is null) - return; - - try - { - var platformView = _mauiItemsView.ToMacOSPlatform(MauiContext); - PlatformView.SetContent(platformView, _mauiItemsView); - } - catch (Exception ex) - { - Console.WriteLine($"[CollectionViewHandler.MacOS] EmbedMauiItemsView failed: {ex.Message}"); - } - } - - protected override void DisconnectHandler(CollectionViewNSContainer platformView) - { - platformView.ClearContent(); - if (_mauiItemsView?.Handler is IElementHandler hostedHandler) - { - hostedHandler.DisconnectHandler(); - if (hostedHandler is IDisposable disposable) - disposable.Dispose(); - } - _mauiItemsView = null; - base.DisconnectHandler(platformView); - } - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - return new Microsoft.Maui.Graphics.Size( - double.IsInfinity(widthConstraint) ? 400 : widthConstraint, - double.IsInfinity(heightConstraint) ? 600 : heightConstraint); - } - - public class CollectionViewNSContainer : NSView - { - NSView _contentView; - IView _virtualView; - - public CollectionViewNSContainer() { WantsLayer = true; } - - public void SetContent(NSView platformView, IView virtualView) - { - _contentView?.RemoveFromSuperview(); - _contentView = platformView; - _virtualView = virtualView; - if (_contentView is not null) - { - _contentView.AutoresizingMask = NSViewResizingMask.WidthSizable | NSViewResizingMask.HeightSizable; - AddSubview(_contentView); - NeedsLayout = true; - } - } - - public void ClearContent() - { - _contentView?.RemoveFromSuperview(); - _contentView = null; - _virtualView = null; - } - - public override void Layout() - { - base.Layout(); - if (_contentView is null || Bounds.Width <= 0 || Bounds.Height <= 0) - return; - - _virtualView?.Measure(Bounds.Width, Bounds.Height); - _virtualView?.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, Bounds.Width, Bounds.Height)); - _contentView.Frame = Bounds; - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Standard.cs b/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Standard.cs deleted file mode 100644 index 50b70a838..000000000 --- a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Standard.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class CollectionViewHandler : ViewHandler - { - public static void MapListViewProperty(IElementHandler handler, IListView virtualView) - { - } - - public static void MapReloadData(CollectionViewHandler handler, IListView virtualView, object? value) - { - } - - protected override object CreatePlatformView() => throw new NotImplementedException(); - } -} diff --git a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Windows.cs b/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Windows.cs deleted file mode 100644 index 04a04965a..000000000 --- a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.Windows.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using WGrid = Microsoft.UI.Xaml.Controls.Grid; -using WFrameworkElement = Microsoft.UI.Xaml.FrameworkElement; - -namespace Comet.Handlers -{ - public partial class CollectionViewHandler : ViewHandler - { - WFrameworkElement _hostedPlatformView; - - protected override WGrid CreatePlatformView() => new WGrid(); - - public static void MapListViewProperty(IElementHandler handler, IListView virtualView) - { - var cvHandler = (CollectionViewHandler)handler; - cvHandler._currentListViewRef = new WeakReference(virtualView); - - if (cvHandler._mauiItemsView is Microsoft.Maui.Controls.CollectionView existingCv - && !IsCarouselView(virtualView)) - { - UpdateCollectionView(existingCv, virtualView); - return; - } - - if (IsCarouselView(virtualView)) - { - var carousel = new Microsoft.Maui.Controls.CarouselView(); - ConfigureMauiCarouselView(carousel, virtualView); - RefreshItemsSource(carousel, virtualView); - cvHandler._mauiItemsView = carousel; - } - else - { - var cv = new Microsoft.Maui.Controls.CollectionView(); - cvHandler.InitCollectionView(cv); - MapCometItemsLayout(cv, virtualView); - MapCometInfiniteScroll(cv, virtualView); - UpdateCollectionView(cv, virtualView); - cvHandler._mauiItemsView = cv; - } - cvHandler.EmbedMauiItemsView(); - } - -#nullable enable - public static void MapReloadData(CollectionViewHandler handler, IListView virtualView, object? value) -#nullable restore - { - if (handler._mauiItemsView is not null) - RefreshItemsSource(handler._mauiItemsView, virtualView); - } - - void EmbedMauiItemsView() - { - if (_mauiItemsView is null || MauiContext is null) - return; - - if (_hostedPlatformView is not null) - PlatformView.Children.Remove(_hostedPlatformView); - - try - { - _hostedPlatformView = _mauiItemsView.ToPlatform(MauiContext) as WFrameworkElement; - } - catch (Exception ex) - { - Console.WriteLine($"[CollectionViewHandler] EmbedMauiItemsView failed: {ex.Message}"); - return; - } - - if (_hostedPlatformView is not null) - { - _hostedPlatformView.HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Stretch; - _hostedPlatformView.VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Stretch; - PlatformView.Children.Add(_hostedPlatformView); - } - } - - protected override void DisconnectHandler(WGrid platformView) - { - if (_hostedPlatformView is not null) - { - platformView.Children.Remove(_hostedPlatformView); - _hostedPlatformView = null; - } - if (_mauiItemsView?.Handler is IElementHandler hostedHandler) - { - hostedHandler.DisconnectHandler(); - if (hostedHandler is IDisposable disposable) - disposable.Dispose(); - } - _mauiItemsView = null; - base.DisconnectHandler(platformView); - } - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - var w = double.IsInfinity(widthConstraint) ? 400 : widthConstraint; - var h = double.IsInfinity(heightConstraint) ? 800 : heightConstraint; - return new Microsoft.Maui.Graphics.Size(w, h); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.cs b/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.cs deleted file mode 100644 index 19c66c8c7..000000000 --- a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class CollectionViewHandler - { - public static readonly PropertyMapper Mapper = - new PropertyMapper(ViewHandler.ViewMapper) - { - ["ListView"] = MapListViewProperty, - }; - - public static readonly CommandMapper ActionMapper = - new CommandMapper - { - [nameof(ListView.ReloadData)] = MapReloadData, - }; - - public CollectionViewHandler() : base(Mapper, ActionMapper) { } - - Microsoft.Maui.Controls.ItemsView _mauiItemsView; - - // Stable reference updated on each MapListViewProperty call so that - // one-time callbacks (ItemTemplate, SelectionChanged) always resolve - // to the *current* Comet view, even after body rebuilds. - WeakReference _currentListViewRef; - - /// - /// Detects whether the Comet IListView is a CarouselView (generic or non-generic). - /// - static bool IsCarouselView(IListView listView) - { - var type = listView.GetType(); - while (type is not null) - { - if (type == typeof(Comet.CarouselView)) - return true; - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Comet.CarouselView<>)) - return true; - type = type.BaseType; - } - return false; - } - - /// - /// Configuration for MAUI CarouselView: item template and carousel-specific properties. - /// - static void ConfigureMauiCarouselView(Microsoft.Maui.Controls.CarouselView carousel, IListView listView) - { - var loop = GetPropertyValue(listView, nameof(Comet.CarouselView.Loop)); - carousel.Loop = loop; - - var isBounceEnabled = GetPropertyValue(listView, nameof(Comet.CarouselView.IsBounceEnabled)); - carousel.IsBounceEnabled = isBounceEnabled; - - var isSwipeEnabled = GetPropertyValue(listView, nameof(Comet.CarouselView.IsSwipeEnabled)); - carousel.IsSwipeEnabled = isSwipeEnabled; - - var isScrollAnimated = GetPropertyValue(listView, nameof(Comet.CarouselView.IsScrollAnimated)); - carousel.IsScrollAnimated = isScrollAnimated; - - var peekAreaInsets = GetPropertyValue(listView, nameof(Comet.CarouselView.PeekAreaInsets)); - if (peekAreaInsets > 0) - carousel.PeekAreaInsets = new Microsoft.Maui.Thickness(peekAreaInsets); - - // Sync position from the Comet CarouselView to the MAUI CarouselView - var positionProp = listView.GetType().GetProperty("Position"); - if (positionProp is not null) - { - var posValue = positionProp.GetValue(listView); - if (posValue is Reactive.PropertySubscription posSub) - carousel.Position = posSub.CurrentValue; - else if (posValue is int posInt) - carousel.Position = posInt; - } - - var listViewRef = new WeakReference(listView); - carousel.ItemTemplate = new Microsoft.Maui.Controls.DataTemplate(() => - { - var container = new Microsoft.Maui.Controls.ContentView(); - container.BindingContextChanged += (s, e) => - { - if (container.BindingContext is not CollectionViewItemProxy proxy) - return; - if (!listViewRef.TryGetTarget(out var currentListView)) - return; - var cometView = currentListView.ViewFor(proxy.Section, proxy.Row); - container.Content = cometView is not null ? new CometHost(cometView) : null; - }; - return container; - }); - - carousel.PositionChanged += (s, e) => - { - var callback = GetPropertyValue>(listView, "PositionChanged"); - callback?.Invoke(e.CurrentPosition); - }; - } - - /// - /// One-time setup for a new MAUI CollectionView: template + event handlers. - /// These reference the handler's so they - /// survive Comet body rebuilds without re-registration. - /// - void InitCollectionView(Microsoft.Maui.Controls.CollectionView cv) - { - cv.ItemTemplate = new Microsoft.Maui.Controls.DataTemplate(() => - { - var container = new Microsoft.Maui.Controls.ContentView(); - container.BindingContextChanged += (s, e) => - { - if (container.BindingContext is not CollectionViewItemProxy proxy) - return; - if (_currentListViewRef?.TryGetTarget(out var lv) != true) - return; - var cometView = lv.ViewFor(proxy.Section, proxy.Row); - container.Content = cometView is not null ? new CometHost(cometView) : null; - }; - return container; - }); - - cv.SelectionChanged += (s, e) => - { - if (_currentListViewRef?.TryGetTarget(out var lv) != true) - return; - - var current = e.CurrentSelection; - if (current is null || current.Count == 0) - return; - - // Find the most recently added item for accurate multi-select reporting - var previous = e.PreviousSelection; - object target = current[current.Count - 1]; - if (previous is not null) - { - foreach (var item in current) - { - if (!previous.Contains(item)) - { - target = item; - break; - } - } - } - - if (target is CollectionViewItemProxy proxy) - lv.OnSelected(proxy.Section, proxy.Row); - }; - } - - /// - /// Updates mutable properties on an existing MAUI CollectionView from - /// the current Comet view (selection mode, scroll-to, items, etc.). - /// - static void UpdateCollectionView(Microsoft.Maui.Controls.CollectionView cv, IListView listView) - { - MapCometSelectionMode(cv, listView); - MapCometEmptyView(cv, listView); - MapCometHeaderFooter(cv, listView); - MapCometScrollTo(cv, listView); - RefreshItemsSource(cv, listView); - } - - /// - /// Rebuilds the items list from IListView sections/rows. - /// - static void RefreshItemsSource(Microsoft.Maui.Controls.ItemsView itemsView, IListView listView) - { - var items = new List(); - var sections = listView.Sections(); - for (int s = 0; s < sections; s++) - { - var rows = listView.Rows(s); - for (int r = 0; r < rows; r++) - items.Add(new CollectionViewItemProxy(s, r)); - } - itemsView.ItemsSource = items; - } - - static void MapCometItemsLayout(Microsoft.Maui.Controls.CollectionView cv, IListView listView) - { - var cometLayout = GetPropertyValue(listView, nameof(Comet.CollectionView.ItemsLayout)); - if (cometLayout is null) - return; - - if (cometLayout is Comet.GridItemsLayout gridLayout) - { - var orientation = gridLayout.Orientation == ItemsLayoutOrientation.Vertical - ? Microsoft.Maui.Controls.ItemsLayoutOrientation.Vertical - : Microsoft.Maui.Controls.ItemsLayoutOrientation.Horizontal; - - cv.ItemsLayout = new Microsoft.Maui.Controls.GridItemsLayout(gridLayout.Span, orientation) - { - HorizontalItemSpacing = gridLayout.ItemSpacing, - VerticalItemSpacing = gridLayout.ItemSpacing - }; - } - else - { - var orientation = cometLayout.Orientation == ItemsLayoutOrientation.Vertical - ? Microsoft.Maui.Controls.ItemsLayoutOrientation.Vertical - : Microsoft.Maui.Controls.ItemsLayoutOrientation.Horizontal; - - cv.ItemsLayout = new Microsoft.Maui.Controls.LinearItemsLayout(orientation) - { - ItemSpacing = cometLayout.ItemSpacing - }; - } - } - - static void MapCometSelectionMode(Microsoft.Maui.Controls.CollectionView cv, IListView listView) - { - var mode = GetPropertyValue(listView, nameof(Comet.CollectionView.SelectionMode)); - - cv.SelectionMode = mode switch - { - Comet.SelectionMode.None => Microsoft.Maui.Controls.SelectionMode.None, - Comet.SelectionMode.Multiple => Microsoft.Maui.Controls.SelectionMode.Multiple, - _ => Microsoft.Maui.Controls.SelectionMode.Single, - }; - } - - static void MapCometEmptyView(Microsoft.Maui.Controls.CollectionView cv, IListView listView) - { - var emptyView = GetPropertyValue(listView, nameof(Comet.CollectionView.EmptyView)); - cv.EmptyView = emptyView is not null ? new CometHost(emptyView) : null; - } - - static void MapCometHeaderFooter(Microsoft.Maui.Controls.CollectionView cv, IListView listView) - { - var header = listView.HeaderView(); - cv.Header = header is not null ? new CometHost(header) : null; - - var footer = listView.FooterView(); - cv.Footer = footer is not null ? new CometHost(footer) : null; - } - - /// - /// Gets a property value from a view by name, supporting both generic and non-generic CollectionView types. - /// - static T GetPropertyValue(IListView listView, string propertyName) - { - var prop = listView.GetType().GetProperty(propertyName); - if (prop is not null) - { - var value = prop.GetValue(listView); - if (value is T typed) - return typed; - } - return default; - } - - static void MapCometInfiniteScroll(Microsoft.Maui.Controls.CollectionView cv, IListView listView) - { - var threshold = GetPropertyValue(listView, nameof(Comet.CollectionView.RemainingItemsThreshold)); - if (threshold <= 0) - return; - - cv.RemainingItemsThreshold = threshold; - - var action = GetPropertyValue(listView, nameof(Comet.CollectionView.RemainingItemsThresholdReached)); - var command = GetPropertyValue>(listView, nameof(Comet.CollectionView.RemainingItemsThresholdReachedCommand)); - - cv.RemainingItemsThresholdReachedCommand = new Microsoft.Maui.Controls.Command((param) => - { - action?.Invoke(); - if (param is int index) - command?.Invoke(index); - }); - } - - static void MapCometScrollTo(Microsoft.Maui.Controls.CollectionView cv, IListView listView) - { - var scrollToProp = listView.GetType().GetProperty("ScrollToRequested"); - if (scrollToProp is not null) - { - scrollToProp.SetValue(listView, (Action)((index, animate) => - { - var totalItems = cv.ItemsSource is System.Collections.ICollection c ? c.Count : 0; - // Use End position for the last item so it scrolls fully into view - var position = (totalItems > 0 && index >= totalItems - 1) - ? Microsoft.Maui.Controls.ScrollToPosition.End - : Microsoft.Maui.Controls.ScrollToPosition.MakeVisible; - cv.ScrollTo(index, position: position, animate: animate); - })); - } - } - } - - /// - /// Lightweight proxy that holds section/row indices for DataTemplate binding. - /// - sealed class CollectionViewItemProxy - { - public int Section { get; } - public int Row { get; } - - public CollectionViewItemProxy(int section, int row) - { - Section = section; - Row = row; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.iOS.cs b/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.iOS.cs deleted file mode 100644 index c1fdc3b95..000000000 --- a/src/Comet/src/Comet/Handlers/CollectionView/CollectionViewHandler.iOS.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System; -using CoreGraphics; -using UIKit; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; - -namespace Comet.Handlers -{ - public partial class CollectionViewHandler : ViewHandler - { - protected override CollectionViewContainer CreatePlatformView() => new CollectionViewContainer(); - - public static void MapListViewProperty(IElementHandler handler, IListView virtualView) - { - var cvHandler = (CollectionViewHandler)handler; - cvHandler._currentListViewRef = new WeakReference(virtualView); - - // Reuse existing MAUI CollectionView — only update changed properties. - // This preserves scroll position and selection state across body rebuilds. - if (cvHandler._mauiItemsView is Microsoft.Maui.Controls.CollectionView existingCv - && !IsCarouselView(virtualView)) - { - UpdateCollectionView(existingCv, virtualView); - return; - } - - if (IsCarouselView(virtualView)) - { - var carousel = new Microsoft.Maui.Controls.CarouselView(); - ConfigureMauiCarouselView(carousel, virtualView); - RefreshItemsSource(carousel, virtualView); - cvHandler._mauiItemsView = carousel; - } - else - { - var cv = new Microsoft.Maui.Controls.CollectionView(); - cvHandler.InitCollectionView(cv); - MapCometItemsLayout(cv, virtualView); - MapCometInfiniteScroll(cv, virtualView); - UpdateCollectionView(cv, virtualView); - cvHandler._mauiItemsView = cv; - } - cvHandler.EmbedMauiItemsView(); - } - -#nullable enable - public static void MapReloadData(CollectionViewHandler handler, IListView virtualView, object? value) -#nullable restore - { - if (handler._mauiItemsView is not null) - RefreshItemsSource(handler._mauiItemsView, virtualView); - } - - void EmbedMauiItemsView() - { - if (_mauiItemsView is null || MauiContext is null) - return; - - try - { - var platformView = _mauiItemsView.ToPlatform(MauiContext); - PlatformView.SetContent(platformView, _mauiItemsView); - - // MAUI's ItemsViewController forces ContentInsetAdjustmentBehavior = Never, - // which prevents the collection view from adjusting for the navigation bar. - // Override to Automatic so content starts below the nav bar and scrolls - // behind it with the standard iOS translucent glass effect. - SetAutomaticContentInsets(platformView); - } - catch (Exception ex) - { - Console.WriteLine($"[CollectionViewHandler] EmbedMauiItemsView failed: {ex.Message}"); - } - } - - static void SetAutomaticContentInsets(UIView view) - { - if (view is UIKit.UIScrollView scrollView) - { - scrollView.ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.Automatic; - return; - } - foreach (var subview in view.Subviews) - SetAutomaticContentInsets(subview); - } - - protected override void DisconnectHandler(CollectionViewContainer platformView) - { - platformView.ClearContent(); - if (_mauiItemsView?.Handler is IElementHandler hostedHandler) - { - hostedHandler.DisconnectHandler(); - if (hostedHandler is IDisposable disposable) - disposable.Dispose(); - } - _mauiItemsView = null; - base.DisconnectHandler(platformView); - } - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - if (_mauiItemsView is not null) - { - var platformView = _mauiItemsView.Handler?.PlatformView as UIView; - if (platformView is not null) - { - var fitting = platformView.SizeThatFits(new CoreGraphics.CGSize( - double.IsInfinity(widthConstraint) ? double.MaxValue : widthConstraint, - double.IsInfinity(heightConstraint) ? double.MaxValue : heightConstraint)); - var w = double.IsInfinity(widthConstraint) ? fitting.Width : widthConstraint; - var h = double.IsInfinity(heightConstraint) ? fitting.Height : heightConstraint; - if (w > 0 && h > 0) - return new Microsoft.Maui.Graphics.Size(w, h); - } - } - return new Microsoft.Maui.Graphics.Size( - double.IsInfinity(widthConstraint) ? 400 : widthConstraint, - double.IsInfinity(heightConstraint) ? 600 : heightConstraint); - } - - public class CollectionViewContainer : UIView - { - UIView _contentView; - IView _virtualView; - - public CollectionViewContainer() { ClipsToBounds = true; } - - public void SetContent(UIView platformView, IView virtualView) - { - _contentView?.RemoveFromSuperview(); - _contentView = platformView; - _virtualView = virtualView; - if (_contentView is not null) - { - _contentView.AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight; - AddSubview(_contentView); - SetNeedsLayout(); - } - } - - public void ClearContent() - { - _contentView?.RemoveFromSuperview(); - _contentView = null; - _virtualView = null; - } - - public override void LayoutSubviews() - { - base.LayoutSubviews(); - if (_contentView is null || Bounds.Width <= 0 || Bounds.Height <= 0) - return; - - _virtualView?.Measure(Bounds.Width, Bounds.Height); - _virtualView?.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, Bounds.Width, Bounds.Height)); - _contentView.Frame = Bounds; - } - - public override CGSize SizeThatFits(CGSize size) => size; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.Android.cs b/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.Android.cs deleted file mode 100644 index caebfdf9d..000000000 --- a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.Android.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System; -using Android.Views; -using Android.Widget; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.HotReload; -using Microsoft.Maui.Platform; - -namespace Comet.Handlers; - -/// -/// Android handler for CometHost. Creates a container FrameLayout that hosts -/// the Comet View's rendered body content (typically a MauiViewHost). -/// -public partial class CometHostHandler : ViewHandler -{ - public CometHostHandler() : base(CometHostMapper) { } - - protected override CometHostContainerView CreatePlatformView() - => new CometHostContainerView(Context); - - protected override void ConnectHandler(CometHostContainerView platformView) - { - base.ConnectHandler(platformView); - UpdateCometView(); - } - - protected override void DisconnectHandler(CometHostContainerView platformView) - { - platformView.ClearContent(); - base.DisconnectHandler(platformView); - } - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - if (VirtualView is IContentView contentView) - { - var size = contentView.CrossPlatformMeasure(widthConstraint, heightConstraint); - if (size.Width > 0 && size.Height > 0) - return size; - } - var w = double.IsInfinity(widthConstraint) ? 400 : widthConstraint; - var h = double.IsInfinity(heightConstraint) ? 800 : heightConstraint; - return new Microsoft.Maui.Graphics.Size(w, h); - } - - void UpdateCometView() - { - if (VirtualView?.CometView is null || MauiContext is null) - return; - - try - { - var cometView = VirtualView.CometView; - - // Give the container the context it needs to re-render on Reload() - PlatformView.MauiContext = MauiContext; - PlatformView.SetCometView(cometView); - - // Set the reload handler so Component.SetState → Reload() can - // notify the host to re-render the platform view tree. - if (cometView is Microsoft.Maui.HotReload.IHotReloadableView ihr) - { - ihr.ReloadHandler = PlatformView; - } - - // Get the render view (body content) to avoid CometViewHandler handler circularity - var renderView = cometView.GetView(); - IView viewToRender = (renderView is not null && renderView != cometView) ? renderView : cometView; - - var platformView = viewToRender.ToPlatform(MauiContext); - if (platformView is not null) - PlatformView.SetContent(platformView, viewToRender); - } - catch (Exception ex) - { - Console.WriteLine($"[CometHostHandler] UpdateCometView failed: {ex.Message}"); - } - } - - public class CometHostContainerView : FrameLayout, IReloadHandler - { - global::Android.Views.View _contentView; - IView _virtualView; - View _cometView; - - public CometHostContainerView(global::Android.Content.Context context) : base(context) { } - - internal IMauiContext MauiContext { get; set; } - - internal void SetCometView(View cometView) - { - _cometView = cometView; - } - - public void SetContent(global::Android.Views.View platformView, IView virtualView) - { - if (_contentView is not null) - RemoveView(_contentView); - _contentView = platformView; - _virtualView = virtualView; - if (_contentView is not null) - { - AddView(_contentView, new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)); - } - } - - public void ClearContent() - { - if (_contentView is not null) - RemoveView(_contentView); - _contentView = null; - _virtualView = null; - } - - /// - /// Called when the Comet view's state changes (Component.SetState → Reload). - /// After ResetView diffs the virtual tree and transfers handlers, we just - /// need to tell the platform to re-measure and re-layout with the updated - /// virtual view tree. We also update the virtual view reference so - /// measurement uses the current render output. - /// - public void Reload() - { - if (_cometView is null || MauiContext is null) return; - - var renderView = _cometView.GetView(); - IView viewToRender = (renderView is not null && renderView != _cometView) ? renderView : _cometView; - - // After ResetView's DiffUpdate, handlers have been transferred from - // old to new views. The virtual tree is correct but the platform - // ViewGroup children may be stale (pointing at old child instances). - // Do a top-down sync of the entire platform view tree to match - // the new virtual tree. - var existingHandler = viewToRender.Handler; - if (existingHandler?.PlatformView is global::Android.Views.View existingPlatformView) - { - if (existingPlatformView != _contentView) - { - SetContent(existingPlatformView, viewToRender); - } - else - { - _virtualView = viewToRender; - } - - // Recursively sync platform children to match virtual tree - SyncViewTreePlatformChildren(viewToRender); - - // Invalidate all measurements so the LayoutManager recalculates - InvalidateViewTreeMeasurements(viewToRender); - - // Immediately re-measure and re-arrange the virtual view tree - // so the LayoutManager assigns correct Frame positions BEFORE - // the platform layout pass runs. Without this, children retain - // stale positions from the previous render. - var density = Context?.Resources?.DisplayMetrics?.Density ?? 1; - var w = Width > 0 ? Width / density : Resources.DisplayMetrics.WidthPixels / density; - var h = Height > 0 ? Height / density : Resources.DisplayMetrics.HeightPixels / density; - viewToRender.Measure(w, h); - viewToRender.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, w, h)); - - // Now request the platform layout pass to apply the new positions - _contentView?.RequestLayout(); - RequestLayout(); - } - else - { - // No handler yet — need full platform view creation - var platformView = viewToRender.ToPlatform(MauiContext); - if (platformView is not null) - SetContent(platformView, viewToRender); - } - } - - /// - /// Recursively sync platform ViewGroup children to match the Comet - /// virtual view tree. Called once after the entire diff completes. - /// - static void SyncViewTreePlatformChildren(IView view) - { - if (view is not Comet.ContainerView container) return; - if (view.Handler is not ILayoutHandler layoutHandler) return; - - var virtualChildren = ((IContainerView)container).GetChildren(); - - // Use MAUI's ILayoutHandler API (not raw ViewGroup manipulation) - // so the handler's internal child list stays in sync with the - // platform ViewGroup. This ensures the layout pass applies - // correct positions to the correct platform children. - layoutHandler.Clear(); - foreach (var child in virtualChildren) - { - if (child is not null) - layoutHandler.Add(child); - } - - // Recurse into children - foreach (var child in virtualChildren) - { - if (child is not null) - SyncViewTreePlatformChildren(child); - } - } - - static void InvalidateViewTreeMeasurements(IView view) - { - if (view is Comet.View cometView) - cometView.InvalidateMeasurement(); - if (view is Comet.ContainerView container) - { - foreach (var child in ((IContainerView)container).GetChildren()) - { - if (child is not null) - InvalidateViewTreeMeasurements(child); - } - } - } - - protected override void OnLayout(bool changed, int left, int top, int right, int bottom) - { - base.OnLayout(changed, left, top, right, bottom); - if (_contentView is null) return; - - var width = right - left; - var height = bottom - top; - if (width > 0 && height > 0 && _virtualView is not null) - { - var density = Context?.Resources?.DisplayMetrics?.Density ?? 1; - var widthDp = width / density; - var heightDp = height / density; - _virtualView.Measure(widthDp, heightDp); - _virtualView.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, widthDp, heightDp)); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.MacOS.cs deleted file mode 100644 index 977181ebf..000000000 --- a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.MacOS.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using AppKit; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Comet.MacOS; - -namespace Comet.Handlers; - -public partial class CometHostHandler : ViewHandler -{ - public CometHostHandler() : base(CometHostMapper) { } - - protected override CometHostNSContainerView CreatePlatformView() - => new CometHostNSContainerView(); - - protected override void ConnectHandler(CometHostNSContainerView platformView) - { - base.ConnectHandler(platformView); - UpdateCometView(); - } - - protected override void DisconnectHandler(CometHostNSContainerView platformView) - { - platformView.ClearContent(); - base.DisconnectHandler(platformView); - } - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - var w = double.IsInfinity(widthConstraint) ? 400 : widthConstraint; - var h = double.IsInfinity(heightConstraint) ? 800 : heightConstraint; - return new Microsoft.Maui.Graphics.Size(w, h); - } - - void UpdateCometView() - { - if (VirtualView?.CometView is null || MauiContext is null) - return; - - try - { - var cometView = VirtualView.CometView; - var renderView = cometView.GetView(); - IView viewToRender = (renderView is not null && renderView != cometView) ? renderView : cometView; - - var platformView = viewToRender.ToMacOSPlatform(MauiContext); - if (platformView is not null) - PlatformView.SetContent(platformView, viewToRender); - } - catch (Exception ex) - { - Console.WriteLine($"[CometHostHandler.MacOS] UpdateCometView failed: {ex}"); - } - } - - public class CometHostNSContainerView : NSView - { - NSView _contentView; - IView _virtualView; - - public CometHostNSContainerView() - { - WantsLayer = true; - } - - public void SetContent(NSView platformView, IView virtualView) - { - _contentView?.RemoveFromSuperview(); - _contentView = platformView; - _virtualView = virtualView; - if (_contentView is not null) - { - _contentView.AutoresizingMask = NSViewResizingMask.WidthSizable | NSViewResizingMask.HeightSizable; - AddSubview(_contentView); - NeedsLayout = true; - } - } - - public void ClearContent() - { - _contentView?.RemoveFromSuperview(); - _contentView = null; - _virtualView = null; - } - - public override void Layout() - { - base.Layout(); - if (_contentView is null || Bounds.Width <= 0 || Bounds.Height <= 0) - return; - - _virtualView?.Measure(Bounds.Width, Bounds.Height); - _virtualView?.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, Bounds.Width, Bounds.Height)); - _contentView.Frame = Bounds; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.Windows.cs b/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.Windows.cs deleted file mode 100644 index 89e3b60e7..000000000 --- a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.Windows.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using WinSize = global::Windows.Foundation.Size; -using WinRect = global::Windows.Foundation.Rect; - -namespace Comet.Handlers; - -/// -/// Windows handler for CometHost. Creates a container that hosts -/// the Comet View's rendered body content. -/// -public partial class CometHostHandler : ViewHandler -{ - public CometHostHandler() : base(CometHostMapper) { } - - protected override CometHostContainerPanel CreatePlatformView() - => new CometHostContainerPanel(); - - protected override void ConnectHandler(CometHostContainerPanel platformView) - { - base.ConnectHandler(platformView); - UpdateCometView(); - } - - protected override void DisconnectHandler(CometHostContainerPanel platformView) - { - platformView.ClearContent(); - base.DisconnectHandler(platformView); - } - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - if (VirtualView is IContentView contentView) - { - var size = contentView.CrossPlatformMeasure(widthConstraint, heightConstraint); - if (size.Width > 0 && size.Height > 0) - return size; - } - var w = double.IsInfinity(widthConstraint) ? 400 : widthConstraint; - var h = double.IsInfinity(heightConstraint) ? 800 : heightConstraint; - return new Microsoft.Maui.Graphics.Size(w, h); - } - - void UpdateCometView() - { - if (VirtualView?.CometView is null || MauiContext is null) - return; - - try - { - var cometView = VirtualView.CometView; - - // Get the render view (body content) to avoid CometViewHandler handler circularity - var renderView = cometView.GetView(); - IView viewToRender = (renderView is not null && renderView != cometView) ? renderView : cometView; - - var platformView = viewToRender.ToPlatform(MauiContext); - if (platformView is FrameworkElement fe) - PlatformView.SetContent(fe, viewToRender); - } - catch (Exception ex) - { - Console.WriteLine($"[CometHostHandler] UpdateCometView failed: {ex.Message}"); - } - } - - public class CometHostContainerPanel : Canvas - { - FrameworkElement _contentElement; - IView _virtualView; - - public void SetContent(FrameworkElement element, IView virtualView) - { - if (_contentElement is not null) - Children.Remove(_contentElement); - _contentElement = element; - _virtualView = virtualView; - if (_contentElement is not null) - Children.Add(_contentElement); - } - - public void ClearContent() - { - if (_contentElement is not null) - Children.Remove(_contentElement); - _contentElement = null; - _virtualView = null; - } - - protected override WinSize ArrangeOverride(WinSize finalSize) - { - if (_contentElement is not null) - { - _virtualView?.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, finalSize.Width, finalSize.Height)); - _contentElement.Arrange(new WinRect(0, 0, finalSize.Width, finalSize.Height)); - } - return base.ArrangeOverride(finalSize); - } - - protected override WinSize MeasureOverride(WinSize availableSize) - { - if (_contentElement is not null) - { - _virtualView?.Measure(availableSize.Width, availableSize.Height); - _contentElement.Measure(availableSize); - } - return base.MeasureOverride(availableSize); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.cs b/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.cs deleted file mode 100644 index 22e12a023..000000000 --- a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers; - -/// -/// Cross-platform handler registration for CometHost. -/// Platform-specific implementations create a container view and -/// render the Comet View's platform representation inside it. -/// -public partial class CometHostHandler -{ - public static IPropertyMapper CometHostMapper = - new PropertyMapper(ViewHandler.ViewMapper); -} diff --git a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.iOS.cs b/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.iOS.cs deleted file mode 100644 index 3da69d084..000000000 --- a/src/Comet/src/Comet/Handlers/CometHost/CometHostHandler.iOS.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using CoreGraphics; -using UIKit; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; - -namespace Comet.Handlers; - -/// -/// iOS handler for CometHost. Renders the Comet View's body content (typically a MauiViewHost) -/// directly by calling GetView() then ToPlatform() on the rendered content. -/// -public partial class CometHostHandler : ViewHandler -{ - public CometHostHandler() : base(CometHostMapper) { } - - protected override CometHostContainerView CreatePlatformView() - => new CometHostContainerView(); - - protected override void ConnectHandler(CometHostContainerView platformView) - { - base.ConnectHandler(platformView); - UpdateCometView(); - } - - protected override void DisconnectHandler(CometHostContainerView platformView) - { - platformView.ClearContent(); - base.DisconnectHandler(platformView); - } - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - // Delegate to CometHost.CrossPlatformMeasure which measures the actual Comet content. - // This is critical for CollectionView cells which must auto-size to their content. - if (VirtualView is IContentView contentView) - { - var size = contentView.CrossPlatformMeasure(widthConstraint, heightConstraint); - if (size.Width > 0 && size.Height > 0) - return size; - } - var w = double.IsInfinity(widthConstraint) ? 400 : widthConstraint; - var h = double.IsInfinity(heightConstraint) ? 800 : heightConstraint; - return new Microsoft.Maui.Graphics.Size(w, h); - } - - void UpdateCometView() - { - if (VirtualView?.CometView is null || MauiContext is null) - return; - - try - { - var cometView = VirtualView.CometView; - - // Get the render view (body content) to avoid CometViewHandler handler circularity - var renderView = cometView.GetView(); - IView viewToRender = (renderView is not null && renderView != cometView) ? renderView : cometView; - - var platformView = viewToRender.ToPlatform(MauiContext); - if (platformView is not null) - PlatformView.SetContent(platformView, viewToRender, cometView, MauiContext); - } - catch (Exception ex) - { - Console.WriteLine($"[CometHostHandler] UpdateCometView failed: {ex.Message}"); - } - } - - public class CometHostContainerView : UIView - { - UIView _contentView; - IView _virtualView; - Comet.View _rootCometView; - IMauiContext _mauiContext; - - public CometHostContainerView() - { - ClipsToBounds = true; - } - - public void SetContent(UIView platformView, IView virtualView, Comet.View rootCometView = null, IMauiContext mauiContext = null) - { - _contentView?.RemoveFromSuperview(); - _contentView = platformView; - _virtualView = virtualView; - if (rootCometView is not null) - _rootCometView = rootCometView; - if (mauiContext is not null) - _mauiContext = mauiContext; - if (_contentView is not null) - { - _contentView.AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight; - AddSubview(_contentView); - SetNeedsLayout(); - } - } - - public void ClearContent() - { - _contentView?.RemoveFromSuperview(); - _contentView = null; - _virtualView = null; - _rootCometView = null; - _mauiContext = null; - } - - public override void LayoutSubviews() - { - base.LayoutSubviews(); - if (_contentView is null || Bounds.Width <= 0 || Bounds.Height <= 0) - return; - - // Re-resolve the virtual view from the root Comet View in case its - // body was rebuilt (state change). Without this, _virtualView references - // a disposed Grid and resize layout breaks. - if (_rootCometView is not null) - { - var currentView = _rootCometView.GetView(); - if (currentView is not null && currentView != _virtualView) - _virtualView = currentView; - } - - _virtualView?.Measure(Bounds.Width, Bounds.Height); - _virtualView?.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, Bounds.Width, Bounds.Height)); - _contentView.Frame = Bounds; - _contentView.SetNeedsLayout(); - _contentView.LayoutIfNeeded(); - } - - public override CGSize SizeThatFits(CGSize size) - { - if (_virtualView is not null) - { - var measured = _virtualView.Measure(size.Width, size.Height); - if (measured.Width > 0 && measured.Height > 0) - return new CGSize(measured.Width, measured.Height); - } - return size; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Android.cs b/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Android.cs deleted file mode 100644 index 74d85776e..000000000 --- a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Android.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using Android.Views; -using Android.Widget; -using AndroidX.RecyclerView.Widget; -using AView = Android.Views.View; -using Comet.Android.Controls; -using System.Drawing; -using Android.Content; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; - -namespace Comet.Handlers -{ - public partial class ListViewHandler : ViewHandler - { - - protected override CometRecyclerView CreatePlatformView() => new CometRecyclerView(MauiContext); - - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) => new Microsoft.Maui.Graphics.Size(widthConstraint, heightConstraint); - //public override SizeF GetIntrinsicSize(SizeF availableSize) - //{ - // //base.GetIntrinsicSize(availableSize); - // return Comet.View.UseAvailableWidthAndHeight; - //} - - public static void MapListViewProperty(IElementHandler viewHandler, IListView virtualView) - { - var nativeView = (CometRecyclerView)viewHandler.PlatformView; - nativeView.ListView = virtualView; - } - -#nullable enable - public static void MapReloadData(ListViewHandler viewHandler, IListView virtualView, object? value) -#nullable restore - { - var nativeView = (CometRecyclerView)viewHandler.PlatformView; - nativeView?.ReloadData(); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.MacOS.cs deleted file mode 100644 index 895ce2370..000000000 --- a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.MacOS.cs +++ /dev/null @@ -1,313 +0,0 @@ -using System; -using System.Collections.Generic; -using AppKit; -using CoreGraphics; -using Foundation; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Comet.MacOS; - -namespace Comet.Handlers -{ - public partial class ListViewHandler : ViewHandler - { - NSTableView _tableView; - ListViewDataSource _dataSource; - ListViewDelegate _delegate; - - public static void MapListViewProperty(IElementHandler viewHandler, IListView virtualView) - { - var handler = (ListViewHandler)viewHandler; - handler.UpdateListView(virtualView); - } - -#nullable enable - public static void MapReloadData(ListViewHandler viewHandler, IListView virtualView, object? value) -#nullable restore - { - viewHandler.UpdateListView(virtualView); - } - - void UpdateListView(IListView listView) - { - if (_dataSource is null || _delegate is null || _tableView is null) - return; - - _dataSource.ListView = listView; - _delegate.ListView = listView; - _tableView.ReloadData(); - } - - protected override NSScrollView CreatePlatformView() - { - _tableView = new NSTableView(); - - var column = new NSTableColumn("Content") { Title = "" }; - _tableView.AddColumn(column); - _tableView.HeaderView = null; - _tableView.UsesAlternatingRowBackgroundColors = false; - _tableView.SelectionHighlightStyle = NSTableViewSelectionHighlightStyle.Regular; - _tableView.ColumnAutoresizingStyle = NSTableViewColumnAutoresizingStyle.LastColumnOnly; - - _dataSource = new ListViewDataSource(); - _delegate = new ListViewDelegate(MauiContext, _dataSource); - - _tableView.DataSource = _dataSource; - _tableView.Delegate = _delegate; - _tableView.SizeLastColumnToFit(); - - var scrollView = new NSScrollView - { - DocumentView = _tableView, - HasVerticalScroller = true, - AutohidesScrollers = true, - }; - return scrollView; - } - - protected override void DisconnectHandler(NSScrollView platformView) - { - if (_tableView is not null) - { - _tableView.DataSource = null; - _tableView.Delegate = null; - } - _dataSource = null; - _delegate = null; - _tableView = null; - base.DisconnectHandler(platformView); - } - - public override Microsoft.Maui.Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - return new Microsoft.Maui.Graphics.Size( - double.IsInfinity(widthConstraint) ? 400 : widthConstraint, - double.IsInfinity(heightConstraint) ? 600 : heightConstraint); - } - - // NSTableView is flat — no native sections. We flatten (section, row) pairs - // into a single row index, interleaving section headers when present. - internal enum RowKind - { - Header, - Item, - } - - internal struct FlatRow - { - public RowKind Kind; - public int Section; - public int Index; - } - - static List BuildFlatRows(IListView listView) - { - var rows = new List(); - if (listView is null) - return rows; - - var sectionCount = listView.Sections(); - for (int s = 0; s < sectionCount; s++) - { - var header = listView.HeaderFor(s); - if (header is not null) - { - rows.Add(new FlatRow { Kind = RowKind.Header, Section = s, Index = -1 }); - } - - var rowCount = listView.Rows(s); - for (int r = 0; r < rowCount; r++) - { - rows.Add(new FlatRow { Kind = RowKind.Item, Section = s, Index = r }); - } - } - - return rows; - } - - class ListViewDataSource : NSTableViewDataSource - { - List _flatRows = new List(); - IListView _listView; - - public IListView ListView - { - get => _listView; - set - { - _listView = value; - _flatRows = BuildFlatRows(_listView); - } - } - - public List FlatRows => _flatRows; - - public override nint GetRowCount(NSTableView tableView) - { - return _flatRows.Count; - } - } - - class ListViewDelegate : NSTableViewDelegate - { - const string CellIdentifier = "CometListCell"; - const string HeaderIdentifier = "CometListHeader"; - static readonly nfloat DefaultRowHeight = 44; - - readonly IMauiContext _mauiContext; - readonly ListViewDataSource _dataSource; - IListView _listView; - - public ListViewDelegate(IMauiContext mauiContext, ListViewDataSource dataSource) - { - _mauiContext = mauiContext; - _dataSource = dataSource; - } - - public IListView ListView - { - get => _listView; - set => _listView = value; - } - - [Export("tableView:viewForTableColumn:row:")] - public NSView GetViewForTableColumn(NSTableView tableView, NSTableColumn tableColumn, nint row) - { - if (_listView is null || row < 0 || row >= _dataSource.FlatRows.Count) - return new NSView(); - - var flatRow = _dataSource.FlatRows[(int)row]; - - if (flatRow.Kind == RowKind.Header) - { - var headerView = _listView.HeaderFor(flatRow.Section); - if (headerView is null) - return new NSView(); - - var container = tableView.MakeView(HeaderIdentifier, this) as CometNSTableCellView; - if (container is null) - { - container = new CometNSTableCellView(_mauiContext) - { - Identifier = HeaderIdentifier, - }; - } - container.SetView(headerView); - return container; - } - - var view = _listView.ViewFor(flatRow.Section, flatRow.Index); - if (view is null) - return new NSView(); - - // Use content type hash for view recycling (same pattern as iOS) - var identifier = $"{CellIdentifier}_{view.GetContentTypeHashCode()}"; - var cellView = tableView.MakeView(identifier, this) as CometNSTableCellView; - if (cellView is null) - { - cellView = new CometNSTableCellView(_mauiContext) - { - Identifier = identifier, - }; - } - cellView.SetView(view); - return cellView; - } - - public override nfloat GetRowHeight(NSTableView tableView, nint row) - { - if (_listView is null || row < 0 || row >= _dataSource.FlatRows.Count) - return DefaultRowHeight; - - var flatRow = _dataSource.FlatRows[(int)row]; - - View view; - if (flatRow.Kind == RowKind.Header) - { - view = _listView.HeaderFor(flatRow.Section); - } - else - { - view = _listView.ViewFor(flatRow.Section, flatRow.Index); - } - - if (view is null) - return DefaultRowHeight; - - // Fast path: explicit frame constraints - var constraints = view.GetFrameConstraints(); - if (constraints?.Height is not null) - return (nfloat)constraints.Height; - - // Slow path: realize the view and measure - try - { - if (view.ToMacOSPlatform(_mauiContext) is not null) - { - var measure = view.Measure(tableView.Bounds.Width, double.PositiveInfinity); - if (measure.Height > 0) - return (nfloat)measure.Height; - } - } - catch - { - // Measurement can fail if handler isn't ready - } - - return DefaultRowHeight; - } - - public override void SelectionDidChange(NSNotification notification) - { - if (_listView is null) - return; - - var tableView = notification.Object as NSTableView; - if (tableView is null) - return; - - var selectedRow = tableView.SelectedRow; - if (selectedRow < 0 || selectedRow >= _dataSource.FlatRows.Count) - return; - - var flatRow = _dataSource.FlatRows[(int)selectedRow]; - if (flatRow.Kind == RowKind.Item) - { - _listView.OnSelected(flatRow.Section, flatRow.Index); - } - - // Deselect after callback, matching iOS behavior - tableView.DeselectRow(selectedRow); - } - } - - // Hosts a CometNSView inside an NSTableCellView for view recycling - class CometNSTableCellView : NSTableCellView - { - readonly CometNSView _cometView; - - public CometNSTableCellView(IMauiContext mauiContext) - { - _cometView = new CometNSView(mauiContext) - { - AutoresizingMask = NSViewResizingMask.WidthSizable | NSViewResizingMask.HeightSizable, - }; - AddSubview(_cometView); - } - - public void SetView(View view) - { - var previousView = _cometView.CurrentView as View; - _cometView.CurrentView = view; - previousView?.ViewDidDisappear(); - view?.ViewDidAppear(); - } - - public override void Layout() - { - base.Layout(); - _cometView.Frame = Bounds; - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Standard.cs b/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Standard.cs deleted file mode 100644 index 3d0aed737..000000000 --- a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Standard.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class ListViewHandler : ViewHandler - { - public static void MapListViewProperty(IElementHandler viewHandler, IListView virtualView) - { - } - - public static void MapReloadData(ListViewHandler viewHandler, IListView virtualView, object? value) - { - } - - protected override object CreatePlatformView() => throw new NotImplementedException(); - } -} diff --git a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Windows.cs b/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Windows.cs deleted file mode 100644 index c9dbdfda2..000000000 --- a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.Windows.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -using UWPListView = Microsoft.UI.Xaml.Controls.ListView; -using Microsoft.UI.Xaml.Controls; -using Comet.Platform.Windows; - -namespace Comet.Handlers -{ - public partial class ListViewHandler : ViewHandler, IElementHandler - { - - - public static void MapListViewProperty(IElementHandler viewHandler, IListView virtualView) - { - setupView(viewHandler as ListViewHandler, virtualView); - } - - static void setupView(ListViewHandler viewHandler, IListView virtualView) - { - var nativeView = (UWPListView)viewHandler.PlatformView; - var sections = virtualView?.Sections() ?? 0; - for (var s = 0; s < sections; s++) - { - var section = virtualView?.HeaderFor(s); - if (section is not null) - nativeView.Items?.Add(new ListViewHandlerItem((ListViewHandler)viewHandler, section)); - - var rows = virtualView.Rows(s); - for (var r = 0; r < rows; r++) - { - var v = virtualView.ViewFor(s, r); - nativeView.Items?.Add(new ListViewHandlerItem((ListViewHandler)viewHandler, v)); - } - var footer = virtualView?.FooterFor(s); - if (footer is not null) - nativeView.Items?.Add(new ListViewHandlerItem((ListViewHandler)viewHandler, footer)); - } - } - - public static void MapReloadData(IElementHandler viewHandler, IListView virtualView, object? value) - { - var nativeView = (UWPListView)viewHandler.PlatformView; - nativeView.Items.Clear(); - //setupView(viewHandler, virtualView); - } - - protected override UWPListView CreatePlatformView() - { - var listView = new UWPListView(); - listView.SelectionChanged += ListView_SelectionChanged; - return listView; - } - - private void ListView_SelectionChanged(object sender, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs e) - { - - VirtualView?.OnSelected(0, PlatformView.SelectedIndex); - } - public class ListViewHandlerItem : ListViewItem - { - public ListViewHandlerItem(ListViewHandler handler, View view) - { - RemoveViewHandlers(view); - var nativeView = new ListCell(view, handler.MauiContext); - Content = nativeView; - } - - private void RemoveViewHandlers(View view) - { - view.ViewHandler = null; - if (view is AbstractLayout layout) - foreach (var subview in layout) - RemoveViewHandlers(subview); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.cs b/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.cs deleted file mode 100644 index cd1eb172a..000000000 --- a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class ListViewHandler - { - public static readonly PropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) - { - ["ListView"] = MapListViewProperty, - - }; - public static readonly CommandMapper ActionMapper = new CommandMapper - { - [nameof(ListView.ReloadData)] = MapReloadData, - }; - - public ListViewHandler() : base(Mapper, ActionMapper) - { - - } - public ListViewHandler(PropertyMapper mapper) : base(mapper) - { - - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.iOS.cs b/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.iOS.cs deleted file mode 100644 index 1316ca5fc..000000000 --- a/src/Comet/src/Comet/Handlers/ListView/ListViewHandler.iOS.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using Foundation; -using UIKit; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -using Comet.iOS; - - -namespace Comet.Handlers -{ - public partial class ListViewHandler : ViewHandler - { - - - public static void MapListViewProperty(IElementHandler viewHandler, IListView virtualView) - { - var PlatformView = (CUITableView)viewHandler.PlatformView; - PlatformView.ListView = virtualView; - PlatformView.SizeToFit(); - } - -#nullable enable - public static void MapReloadData(ListViewHandler viewHandler, IListView virtualView, object? value) -#nullable restore - { - var PlatformView = (CUITableView)viewHandler.PlatformView; - PlatformView?.ReloadData(); - } - - protected override CUITableView CreatePlatformView() => new CUITableView(MauiContext); - } -} diff --git a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.Android.cs b/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.Android.cs deleted file mode 100644 index 543495871..000000000 --- a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.Android.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using AView = global::Android.Views.View; -using AFrameLayout = global::Android.Widget.FrameLayout; -using AViewGroup = global::Android.Views.ViewGroup; - -namespace Comet.Handlers -{ - /// - /// Android handler for MauiViewHost. Creates a container FrameLayout that hosts - /// the MAUI Controls platform view directly. - /// - public partial class MauiViewHostHandler : ViewHandler - { - public static IPropertyMapper Mapper = - new PropertyMapper(ViewHandler.ViewMapper); - - public MauiViewHostHandler() : base(Mapper) { } - - private AView _hostedPlatformView; - - protected override AFrameLayout CreatePlatformView() - => new AFrameLayout(Context); - - protected override void ConnectHandler(AFrameLayout platformView) - { - base.ConnectHandler(platformView); - UpdateHostedView(); - } - - protected override void DisconnectHandler(AFrameLayout platformView) - { - if (VirtualView?.HostedView?.Handler is IElementHandler hostedHandler) - { - hostedHandler.DisconnectHandler(); - if (hostedHandler is IDisposable disposableHandler) - disposableHandler.Dispose(); - } - if (_hostedPlatformView is not null) - { - platformView.RemoveView(_hostedPlatformView); - if (_hostedPlatformView is IDisposable disposable) - disposable.Dispose(); - _hostedPlatformView = null; - } - base.DisconnectHandler(platformView); - } - - void UpdateHostedView() - { - if (VirtualView?.HostedView is null || MauiContext is null) - return; - - if (_hostedPlatformView is not null) - PlatformView.RemoveView(_hostedPlatformView); - - try - { - _hostedPlatformView = VirtualView.HostedView.ToPlatform(MauiContext); - } - catch (Exception) - { - // Handler not found — try CometApp's MauiContext (has third-party handlers) - var fallbackCtx = CometApp.MauiContext; - if (fallbackCtx is not null && fallbackCtx != MauiContext) - { - try - { - _hostedPlatformView = VirtualView.HostedView.ToPlatform(fallbackCtx); - } - catch (Exception ex2) - { - System.Diagnostics.Debug.WriteLine( - $"[MauiViewHostHandler] All ToPlatform failed for {VirtualView.HostedView.GetType().Name}: {ex2.Message}"); - return; - } - } - else - { - return; - } - } - - if (_hostedPlatformView is not null) - { - PlatformView.AddView(_hostedPlatformView, - new AFrameLayout.LayoutParams( - AViewGroup.LayoutParams.MatchParent, - AViewGroup.LayoutParams.MatchParent)); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.MacOS.cs deleted file mode 100644 index b33e31b7f..000000000 --- a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.MacOS.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using AppKit; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Comet.MacOS; - -namespace Comet.Handlers -{ - public partial class MauiViewHostHandler : ViewHandler - { - public static IPropertyMapper Mapper = - new PropertyMapper(ViewHandler.ViewMapper); - - public MauiViewHostHandler() : base(Mapper) { } - - protected override MauiViewHostNSContainerView CreatePlatformView() - => new MauiViewHostNSContainerView(); - - protected override void ConnectHandler(MauiViewHostNSContainerView platformView) - { - base.ConnectHandler(platformView); - UpdateHostedView(); - } - - protected override void DisconnectHandler(MauiViewHostNSContainerView platformView) - { - if (VirtualView?.HostedView?.Handler is IElementHandler hostedHandler) - { - hostedHandler.DisconnectHandler(); - if (hostedHandler is IDisposable disposable) - disposable.Dispose(); - } - platformView.ClearHostedView(); - base.DisconnectHandler(platformView); - } - - void UpdateHostedView() - { - if (VirtualView?.HostedView is null || MauiContext is null) - return; - - try - { - var hostedPlatformView = VirtualView.HostedView.ToMacOSPlatform(MauiContext); - if (hostedPlatformView is not null) - PlatformView.SetHostedView(hostedPlatformView, VirtualView.HostedView); - } - catch (Exception ex) - { - Console.WriteLine($"[MauiViewHostHandler.MacOS] UpdateHostedView failed: {ex.Message}"); - } - } - - public class MauiViewHostNSContainerView : NSView - { - NSView _hostedPlatformView; - IView _hostedVirtualView; - - public MauiViewHostNSContainerView() { WantsLayer = true; } - - public void SetHostedView(NSView platformView, IView virtualView) - { - _hostedPlatformView?.RemoveFromSuperview(); - _hostedPlatformView = platformView; - _hostedVirtualView = virtualView; - if (_hostedPlatformView is not null) - { - AddSubview(_hostedPlatformView); - NeedsLayout = true; - } - } - - public void ClearHostedView() - { - _hostedPlatformView?.RemoveFromSuperview(); - _hostedPlatformView = null; - _hostedVirtualView = null; - } - - public override void Layout() - { - base.Layout(); - if (_hostedPlatformView is null || Bounds.Width <= 0 || Bounds.Height <= 0) - return; - - var bounds = new Microsoft.Maui.Graphics.Rect(0, 0, Bounds.Width, Bounds.Height); - _hostedVirtualView?.Measure(Bounds.Width, Bounds.Height); - _hostedVirtualView?.Arrange(bounds); - _hostedPlatformView.Frame = Bounds; - } - - public override CGSize IntrinsicContentSize - { - get - { - if (_hostedPlatformView is not null) - return _hostedPlatformView.IntrinsicContentSize; - return base.IntrinsicContentSize; - } - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.Windows.cs b/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.Windows.cs deleted file mode 100644 index 4b3ce83ce..000000000 --- a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.Windows.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using Microsoft.UI.Xaml.Controls; -using WPanel = Microsoft.UI.Xaml.Controls.Panel; -using WGrid = Microsoft.UI.Xaml.Controls.Grid; -using WFrameworkElement = Microsoft.UI.Xaml.FrameworkElement; - -namespace Comet.Handlers -{ - public partial class MauiViewHostHandler : ViewHandler - { - public static IPropertyMapper Mapper = - new PropertyMapper(ViewHandler.ViewMapper); - - public MauiViewHostHandler() : base(Mapper) { } - - private WFrameworkElement _hostedPlatformView; - - protected override WGrid CreatePlatformView() - => new WGrid(); - - protected override void ConnectHandler(WGrid platformView) - { - base.ConnectHandler(platformView); - UpdateHostedView(); - } - - protected override void DisconnectHandler(WGrid platformView) - { - if (VirtualView?.HostedView?.Handler is IElementHandler hostedHandler) - { - hostedHandler.DisconnectHandler(); - if (hostedHandler is IDisposable disposableHandler) - disposableHandler.Dispose(); - } - if (_hostedPlatformView is not null) - { - platformView.Children.Remove(_hostedPlatformView); - if (_hostedPlatformView is IDisposable disposable) - disposable.Dispose(); - _hostedPlatformView = null; - } - base.DisconnectHandler(platformView); - } - - void UpdateHostedView() - { - if (VirtualView?.HostedView is null || MauiContext is null) - return; - - if (_hostedPlatformView is not null) - PlatformView.Children.Remove(_hostedPlatformView); - - try - { - _hostedPlatformView = VirtualView.HostedView.ToPlatform(MauiContext) as WFrameworkElement; - } - catch (Exception) - { - // Handler not found — try CometApp's MauiContext (has third-party handlers) - var fallbackCtx = CometApp.MauiContext; - if (fallbackCtx is not null && fallbackCtx != MauiContext) - { - try - { - _hostedPlatformView = VirtualView.HostedView.ToPlatform(fallbackCtx) as WFrameworkElement; - } - catch (Exception ex2) - { - System.Diagnostics.Debug.WriteLine( - $"[MauiViewHostHandler] All ToPlatform failed for {VirtualView.HostedView.GetType().Name}: {ex2.Message}"); - return; - } - } - else - { - return; - } - } - - if (_hostedPlatformView is not null) - { - _hostedPlatformView.HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Stretch; - _hostedPlatformView.VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Stretch; - PlatformView.Children.Add(_hostedPlatformView); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.cs b/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.cs deleted file mode 100644 index 9594fb126..000000000 --- a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class MauiViewHostHandler - { - } -} diff --git a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.iOS.cs b/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.iOS.cs deleted file mode 100644 index 35722d42d..000000000 --- a/src/Comet/src/Comet/Handlers/MauiViewHost/MauiViewHostHandler.iOS.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using CoreGraphics; -using UIKit; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; - -namespace Comet.Handlers -{ -public partial class MauiViewHostHandler : ViewHandler -{ -public static IPropertyMapper Mapper = -new PropertyMapper(ViewHandler.ViewMapper); - -public MauiViewHostHandler() : base(Mapper) { } - -protected override MauiViewHostContainerView CreatePlatformView() -=> new MauiViewHostContainerView(); - -protected override void ConnectHandler(MauiViewHostContainerView platformView) -{ -base.ConnectHandler(platformView); -UpdateHostedView(); -} - -protected override void DisconnectHandler(MauiViewHostContainerView platformView) -{ -if (VirtualView?.HostedView?.Handler is IElementHandler hostedHandler) -{ -hostedHandler.DisconnectHandler(); -if (hostedHandler is IDisposable disposableHandler) -disposableHandler.Dispose(); -} -platformView.ClearHostedView(); -base.DisconnectHandler(platformView); -} - -void UpdateHostedView() -{ -if (VirtualView?.HostedView is null || MauiContext is null) -return; - -try -{ -UIView hostedPlatformView = null; - -// Try primary MauiContext first -try -{ -hostedPlatformView = VirtualView.HostedView.ToPlatform(MauiContext); -} -catch (Exception) -{ -// Handler not found in this MauiContext - try CometApp's context -// which has all registered handlers including third-party (Syncfusion, etc.) -var fallbackCtx = CometApp.MauiContext; -if (fallbackCtx is not null && fallbackCtx != MauiContext) -{ -try -{ -hostedPlatformView = VirtualView.HostedView.ToPlatform(fallbackCtx); -} -catch (Exception ex2) -{ -Console.WriteLine($"[MauiViewHostHandler] All ToPlatform failed for {VirtualView.HostedView.GetType().Name}: {ex2.Message}"); -} -} -} - -if (hostedPlatformView is not null) -PlatformView.SetHostedView(hostedPlatformView, VirtualView.HostedView); -} -catch (Exception ex) -{ -Console.WriteLine($"[MauiViewHostHandler] UpdateHostedView failed: {ex.Message}"); -} -} - -public class MauiViewHostContainerView : UIView -{ -UIView _hostedPlatformView; -IView _hostedVirtualView; - -public void SetHostedView(UIView platformView, IView virtualView) -{ -_hostedPlatformView?.RemoveFromSuperview(); -_hostedPlatformView = platformView; -_hostedVirtualView = virtualView; -if (_hostedPlatformView is not null) -{ -AddSubview(_hostedPlatformView); -SetNeedsLayout(); -} -} - -public void ClearHostedView() -{ -_hostedPlatformView?.RemoveFromSuperview(); -_hostedPlatformView = null; -_hostedVirtualView = null; -} - -public override void LayoutSubviews() -{ -base.LayoutSubviews(); -if (_hostedPlatformView is null || Bounds.Width <= 0 || Bounds.Height <= 0) -return; - -// Use MAUI's cross-platform layout to arrange children -var bounds = new Microsoft.Maui.Graphics.Rect(0, 0, Bounds.Width, Bounds.Height); -_hostedVirtualView?.Measure(Bounds.Width, Bounds.Height); -_hostedVirtualView?.Arrange(bounds); - -// Also set the platform frame directly as a fallback -_hostedPlatformView.Frame = Bounds; -} - -public override CGSize SizeThatFits(CGSize size) -{ -if (_hostedVirtualView is not null) -{ -var measured = _hostedVirtualView.Measure(size.Width, size.Height); -return new CGSize(measured.Width, measured.Height); -} -if (_hostedPlatformView is not null) -return _hostedPlatformView.SizeThatFits(size); -return base.SizeThatFits(size); -} - -public override CGSize IntrinsicContentSize -{ -get -{ -if (_hostedPlatformView is not null) -return _hostedPlatformView.IntrinsicContentSize; -return base.IntrinsicContentSize; -} -} -} -} -} diff --git a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.Android.cs b/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.Android.cs deleted file mode 100644 index f5f714a4e..000000000 --- a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.Android.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System; -using Android.Views; -using Android.Widget; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using AView = global::Android.Views.View; -using AViewGroup = global::Android.Views.ViewGroup; - -namespace Comet.Handlers -{ - public partial class NativeHostHandler : ViewHandler, INativeHostHandler - { - AView hostedNativeView; - object sourceToken; - bool ownsNativeView; - NativeHost connectedHost; - - public NativeHostHandler() : base(Mapper) - { - } - - protected override NativeHostContainerView CreatePlatformView() - => new NativeHostContainerView(Context); - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - UpdateHostedView(); - } - - protected override void ConnectHandler(NativeHostContainerView platformView) - { - base.ConnectHandler(platformView); - UpdateHostedView(); - } - - protected override void DisconnectHandler(NativeHostContainerView platformView) - { - TearDownHostedView(platformView); - base.DisconnectHandler(platformView); - } - - object INativeHostHandler.GetNativeView() => hostedNativeView; - - void INativeHostHandler.SyncNativeView() => UpdateHostedView(); - - Size INativeHostHandler.MeasureNativeView(Size availableSize) => MeasureHostedView(availableSize); - - void UpdateHostedView() - { - if (VirtualView is null || MauiContext is null || PlatformView is null) - return; - - var currentToken = VirtualView.SourceToken; - if (hostedNativeView is null || !Equals(sourceToken, currentToken)) - { - TearDownHostedView(PlatformView); - - var nativeView = VirtualView.GetOrCreateNativeView(MauiContext); - if (nativeView is not AView androidView) - throw new InvalidOperationException($"NativeHost requires an Android.Views.View on Android. Actual type: {nativeView?.GetType().FullName ?? "null"}"); - - hostedNativeView = androidView; - sourceToken = currentToken; - ownsNativeView = VirtualView.OwnsNativeView; - connectedHost = VirtualView; - PlatformView.SetHostedView(hostedNativeView); - connectedHost.ApplyConnected(hostedNativeView, MauiContext); - } - else - { - connectedHost = VirtualView; - connectedHost.ApplyUpdated(hostedNativeView, MauiContext); - } - - PlatformView.RequestLayout(); - } - - void TearDownHostedView(NativeHostContainerView platformView) - { - if (hostedNativeView is null) - return; - - var releasedView = hostedNativeView; - connectedHost?.ApplyDisconnected(releasedView); - platformView?.ClearHostedView(); - if (ownsNativeView) - releasedView.Dispose(); - connectedHost?.ReleaseNativeView(releasedView, ownsNativeView); - hostedNativeView = null; - sourceToken = null; - connectedHost = null; - ownsNativeView = false; - } - - Size MeasureHostedView(Size availableSize) - { - if (VirtualView is not null && VirtualView.TryMeasureOverride(availableSize, out var measured)) - return measured; - - if (hostedNativeView is null) - return availableSize; - - var density = Context?.Resources?.DisplayMetrics?.Density ?? 1f; - hostedNativeView.Measure( - CreateMeasureSpec(availableSize.Width, density), - CreateMeasureSpec(availableSize.Height, density)); - - var size = new Size(hostedNativeView.MeasuredWidth / density, hostedNativeView.MeasuredHeight / density); - if (size.Width <= 0 && !double.IsInfinity(availableSize.Width)) - size.Width = availableSize.Width; - if (size.Height <= 0 && !double.IsInfinity(availableSize.Height)) - size.Height = availableSize.Height; - if (size.Height <= 0) - size.Height = 44; - - return size; - } - - static int CreateMeasureSpec(double availableSize, float density) - { - if (double.IsInfinity(availableSize) || availableSize <= 0) - return AView.MeasureSpec.MakeMeasureSpec(0, MeasureSpecMode.Unspecified); - - return AView.MeasureSpec.MakeMeasureSpec((int)Math.Ceiling(availableSize * density), MeasureSpecMode.AtMost); - } - - public class NativeHostContainerView : FrameLayout - { - AView hostedView; - - public NativeHostContainerView(global::Android.Content.Context context) : base(context) - { - } - - public void SetHostedView(AView platformView) - { - if (hostedView == platformView) - return; - - ClearHostedView(); - hostedView = platformView; - if (hostedView.Parent is AViewGroup parent) - parent.RemoveView(hostedView); - AddView(hostedView, new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MatchParent, - ViewGroup.LayoutParams.MatchParent)); - } - - public void ClearHostedView() - { - if (hostedView is not null) - RemoveView(hostedView); - hostedView = null; - } - - protected override void OnLayout(bool changed, int left, int top, int right, int bottom) - { - base.OnLayout(changed, left, top, right, bottom); - hostedView?.Layout(0, 0, right - left, bottom - top); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.MacOS.cs deleted file mode 100644 index 2268b212c..000000000 --- a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.MacOS.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using AppKit; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class NativeHostHandler : ViewHandler, INativeHostHandler - { - NSView hostedNativeView; - object sourceToken; - bool ownsNativeView; - NativeHost connectedHost; - - public NativeHostHandler() : base(Mapper) - { - } - - protected override NativeHostNSContainerView CreatePlatformView() - => new NativeHostNSContainerView(); - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - UpdateHostedView(); - } - - protected override void ConnectHandler(NativeHostNSContainerView platformView) - { - base.ConnectHandler(platformView); - UpdateHostedView(); - } - - protected override void DisconnectHandler(NativeHostNSContainerView platformView) - { - TearDownHostedView(platformView); - base.DisconnectHandler(platformView); - } - - object INativeHostHandler.GetNativeView() => hostedNativeView; - void INativeHostHandler.SyncNativeView() => UpdateHostedView(); - Size INativeHostHandler.MeasureNativeView(Size availableSize) => MeasureHostedView(availableSize); - - void UpdateHostedView() - { - if (VirtualView is null || MauiContext is null || PlatformView is null) - return; - - var currentToken = VirtualView.SourceToken; - if (hostedNativeView is null || !Equals(sourceToken, currentToken)) - { - TearDownHostedView(PlatformView); - - var nativeView = VirtualView.GetOrCreateNativeView(MauiContext); - if (nativeView is not NSView nsView) - throw new InvalidOperationException( - $"NativeHost requires an NSView on macOS. Actual type: {nativeView?.GetType().FullName ?? "null"}"); - - hostedNativeView = nsView; - sourceToken = currentToken; - ownsNativeView = VirtualView.OwnsNativeView; - connectedHost = VirtualView; - PlatformView.SetHostedView(hostedNativeView); - connectedHost.ApplyConnected(hostedNativeView, MauiContext); - } - else - { - connectedHost = VirtualView; - connectedHost.ApplyUpdated(hostedNativeView, MauiContext); - } - - PlatformView.NeedsLayout = true; - PlatformView.InvalidateIntrinsicContentSize(); - } - - void TearDownHostedView(NativeHostNSContainerView platformView) - { - if (hostedNativeView is null) - return; - - var releasedView = hostedNativeView; - connectedHost?.ApplyDisconnected(releasedView); - platformView?.ClearHostedView(); - if (ownsNativeView) - releasedView.Dispose(); - connectedHost?.ReleaseNativeView(releasedView, ownsNativeView); - hostedNativeView = null; - sourceToken = null; - connectedHost = null; - ownsNativeView = false; - } - - Size MeasureHostedView(Size availableSize) - { - if (VirtualView is not null && VirtualView.TryMeasureOverride(availableSize, out var measured)) - return measured; - - if (hostedNativeView is null) - return availableSize; - - var intrinsic = hostedNativeView.IntrinsicContentSize; - var w = intrinsic.Width > 0 ? (double)intrinsic.Width : (double.IsInfinity(availableSize.Width) ? 400 : availableSize.Width); - var h = intrinsic.Height > 0 ? (double)intrinsic.Height : (double.IsInfinity(availableSize.Height) ? 44 : availableSize.Height); - - return new Size(w, h); - } - - public class NativeHostNSContainerView : NSView - { - NSView _hostedView; - - public NativeHostNSContainerView() { WantsLayer = true; } - - public void SetHostedView(NSView platformView) - { - if (_hostedView == platformView) - return; - - _hostedView?.RemoveFromSuperview(); - _hostedView = platformView; - if (_hostedView?.Superview is not null && _hostedView.Superview != this) - _hostedView.RemoveFromSuperview(); - if (_hostedView is not null) - { - _hostedView.AutoresizingMask = NSViewResizingMask.WidthSizable | NSViewResizingMask.HeightSizable; - AddSubview(_hostedView); - NeedsLayout = true; - } - } - - public void ClearHostedView() - { - _hostedView?.RemoveFromSuperview(); - _hostedView = null; - } - - public override void Layout() - { - base.Layout(); - if (_hostedView is not null) - _hostedView.Frame = Bounds; - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.Windows.cs b/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.Windows.cs deleted file mode 100644 index e6a808843..000000000 --- a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.Windows.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using WinGrid = Microsoft.UI.Xaml.Controls.Grid; - -namespace Comet.Handlers -{ - public partial class NativeHostHandler : ViewHandler, INativeHostHandler - { - FrameworkElement hostedNativeView; - object sourceToken; - bool ownsNativeView; - NativeHost connectedHost; - - public NativeHostHandler() : base(Mapper) - { - } - - protected override NativeHostContainerView CreatePlatformView() - => new NativeHostContainerView(); - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - UpdateHostedView(); - } - - protected override void ConnectHandler(NativeHostContainerView platformView) - { - base.ConnectHandler(platformView); - UpdateHostedView(); - } - - protected override void DisconnectHandler(NativeHostContainerView platformView) - { - TearDownHostedView(platformView); - base.DisconnectHandler(platformView); - } - - object INativeHostHandler.GetNativeView() => hostedNativeView; - - void INativeHostHandler.SyncNativeView() => UpdateHostedView(); - - Size INativeHostHandler.MeasureNativeView(Size availableSize) => MeasureHostedView(availableSize); - - void UpdateHostedView() - { - if (VirtualView is null || MauiContext is null || PlatformView is null) - return; - - var currentToken = VirtualView.SourceToken; - if (hostedNativeView is null || !Equals(sourceToken, currentToken)) - { - TearDownHostedView(PlatformView); - - var nativeView = VirtualView.GetOrCreateNativeView(MauiContext); - if (nativeView is not FrameworkElement frameworkElement) - throw new InvalidOperationException($"NativeHost requires a FrameworkElement on Windows. Actual type: {nativeView?.GetType().FullName ?? "null"}"); - - hostedNativeView = frameworkElement; - sourceToken = currentToken; - ownsNativeView = VirtualView.OwnsNativeView; - connectedHost = VirtualView; - PlatformView.SetHostedView(hostedNativeView); - connectedHost.ApplyConnected(hostedNativeView, MauiContext); - } - else - { - connectedHost = VirtualView; - connectedHost.ApplyUpdated(hostedNativeView, MauiContext); - } - - PlatformView.InvalidateMeasure(); - } - - void TearDownHostedView(NativeHostContainerView platformView) - { - if (hostedNativeView is null) - return; - - var releasedView = hostedNativeView; - connectedHost?.ApplyDisconnected(releasedView); - platformView?.ClearHostedView(); - if (ownsNativeView && releasedView is IDisposable disposable) - disposable.Dispose(); - connectedHost?.ReleaseNativeView(releasedView, ownsNativeView); - hostedNativeView = null; - sourceToken = null; - connectedHost = null; - ownsNativeView = false; - } - - Size MeasureHostedView(Size availableSize) - { - if (VirtualView is not null && VirtualView.TryMeasureOverride(availableSize, out var measured)) - return measured; - - if (hostedNativeView is null) - return availableSize; - - hostedNativeView.Measure(new global::Windows.Foundation.Size( - double.IsInfinity(availableSize.Width) ? double.PositiveInfinity : availableSize.Width, - double.IsInfinity(availableSize.Height) ? double.PositiveInfinity : availableSize.Height)); - - var desired = hostedNativeView.DesiredSize; - var size = new Size(desired.Width, desired.Height); - if (size.Width <= 0 && !double.IsInfinity(availableSize.Width)) - size.Width = availableSize.Width; - if (size.Height <= 0 && !double.IsInfinity(availableSize.Height)) - size.Height = availableSize.Height; - if (size.Height <= 0) - size.Height = 44; - - return size; - } - - public class NativeHostContainerView : WinGrid - { - FrameworkElement hostedView; - - public void SetHostedView(FrameworkElement platformView) - { - if (hostedView == platformView) - return; - - ClearHostedView(); - hostedView = platformView; - if (hostedView.Parent is Panel parent) - parent.Children.Remove(hostedView); - hostedView.HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Stretch; - hostedView.VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Stretch; - Children.Add(hostedView); - } - - public void ClearHostedView() - { - if (hostedView is not null) - Children.Remove(hostedView); - hostedView = null; - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.cs b/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.cs deleted file mode 100644 index c470b6164..000000000 --- a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - internal interface INativeHostHandler - { - object GetNativeView(); - void SyncNativeView(); - Size MeasureNativeView(Size availableSize); - } - - public partial class NativeHostHandler - { - public static IPropertyMapper Mapper = - new PropertyMapper(ViewHandler.ViewMapper); - } -} diff --git a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.iOS.cs b/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.iOS.cs deleted file mode 100644 index 98b77379c..000000000 --- a/src/Comet/src/Comet/Handlers/NativeHost/NativeHostHandler.iOS.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using UIKit; - -namespace Comet.Handlers -{ - public partial class NativeHostHandler : ViewHandler, INativeHostHandler - { - UIView hostedNativeView; - object sourceToken; - bool ownsNativeView; - NativeHost connectedHost; - - public NativeHostHandler() : base(Mapper) - { - } - - protected override NativeHostContainerView CreatePlatformView() - => new NativeHostContainerView(); - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - UpdateHostedView(); - } - - protected override void ConnectHandler(NativeHostContainerView platformView) - { - base.ConnectHandler(platformView); - UpdateHostedView(); - } - - protected override void DisconnectHandler(NativeHostContainerView platformView) - { - TearDownHostedView(platformView); - base.DisconnectHandler(platformView); - } - - object INativeHostHandler.GetNativeView() => hostedNativeView; - - void INativeHostHandler.SyncNativeView() => UpdateHostedView(); - - Size INativeHostHandler.MeasureNativeView(Size availableSize) => MeasureHostedView(availableSize); - - void UpdateHostedView() - { - if (VirtualView is null || MauiContext is null || PlatformView is null) - return; - - var currentToken = VirtualView.SourceToken; - if (hostedNativeView is null || !Equals(sourceToken, currentToken)) - { - TearDownHostedView(PlatformView); - - var nativeView = VirtualView.GetOrCreateNativeView(MauiContext); - if (nativeView is not UIView uiView) - throw new InvalidOperationException($"NativeHost requires a UIView on iOS/macCatalyst. Actual type: {nativeView?.GetType().FullName ?? "null"}"); - - hostedNativeView = uiView; - sourceToken = currentToken; - ownsNativeView = VirtualView.OwnsNativeView; - connectedHost = VirtualView; - PlatformView.SetHostedView(hostedNativeView); - connectedHost.ApplyConnected(hostedNativeView, MauiContext); - } - else - { - connectedHost = VirtualView; - connectedHost.ApplyUpdated(hostedNativeView, MauiContext); - } - - PlatformView.SetNeedsLayout(); - PlatformView.InvalidateIntrinsicContentSize(); - } - - void TearDownHostedView(NativeHostContainerView platformView) - { - if (hostedNativeView is null) - return; - - var releasedView = hostedNativeView; - connectedHost?.ApplyDisconnected(releasedView); - platformView?.ClearHostedView(); - if (ownsNativeView) - releasedView.Dispose(); - connectedHost?.ReleaseNativeView(releasedView, ownsNativeView); - hostedNativeView = null; - sourceToken = null; - connectedHost = null; - ownsNativeView = false; - } - - Size MeasureHostedView(Size availableSize) - { - if (VirtualView is not null && VirtualView.TryMeasureOverride(availableSize, out var measured)) - return measured; - - if (hostedNativeView is null) - return availableSize; - - var fitSize = hostedNativeView.SizeThatFits(new CGSize( - double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width, - double.IsInfinity(availableSize.Height) ? 0 : availableSize.Height)); - - var size = new Size(fitSize.Width, fitSize.Height); - var intrinsic = hostedNativeView.IntrinsicContentSize; - - if (size.Width <= 0 && intrinsic.Width > 0) - size.Width = intrinsic.Width; - if (size.Height <= 0 && intrinsic.Height > 0) - size.Height = intrinsic.Height; - if (size.Width <= 0 && !double.IsInfinity(availableSize.Width)) - size.Width = availableSize.Width; - if (size.Height <= 0 && !double.IsInfinity(availableSize.Height)) - size.Height = availableSize.Height; - if (size.Height <= 0) - size.Height = 44; - - return size; - } - - public class NativeHostContainerView : UIView - { - UIView hostedView; - - public void SetHostedView(UIView platformView) - { - if (hostedView == platformView) - return; - - hostedView?.RemoveFromSuperview(); - hostedView = platformView; - if (hostedView?.Superview is not null && hostedView.Superview != this) - hostedView.RemoveFromSuperview(); - if (hostedView is not null) - { - hostedView.AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight; - AddSubview(hostedView); - SetNeedsLayout(); - } - } - - public void ClearHostedView() - { - hostedView?.RemoveFromSuperview(); - hostedView = null; - } - - public override void LayoutSubviews() - { - base.LayoutSubviews(); - if (hostedView is not null) - hostedView.Frame = Bounds; - } - - public override CGSize SizeThatFits(CGSize size) - { - if (hostedView is not null) - return hostedView.SizeThatFits(size); - return base.SizeThatFits(size); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Android.cs b/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Android.cs deleted file mode 100644 index 5ee1097c6..000000000 --- a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Android.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using Comet.Android.Controls; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -//using Comet.iOS; -namespace Comet.Handlers -{ - public partial class NavigationViewHandler : ViewHandler, IPlatformViewHandler - { - protected override CometNavigationView CreatePlatformView() - => new CometNavigationView(MauiContext); - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - if (VirtualView is not null) - { - PlatformView.SetRoot(VirtualView.Content); - VirtualView?.SetPerformNavigate(PlatformView.NavigateTo); - VirtualView?.SetPerformPop(PlatformView.Pop); - } - } - protected override void DisconnectHandler(CometNavigationView nativeView) - { - base.DisconnectHandler(nativeView); - - if (VirtualView is not null) - { - VirtualView.SetPerformNavigate(action: null); - VirtualView.SetPerformPop(action: null); - } - - } - } -} diff --git a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.MacOS.cs deleted file mode 100644 index f5e237222..000000000 --- a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.MacOS.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using AppKit; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using Comet.MacOS; - -namespace Comet.Handlers -{ - public partial class NavigationViewHandler : ViewHandler - { - NSView _contentContainer; - NSView _currentPageView; - - protected override NSView CreatePlatformView() - { - _contentContainer = new NSView { WantsLayer = true }; - - var nav = VirtualView; - if (nav?.Content is not null) - { - var platformView = nav.Content.ToMacOSPlatform(MauiContext); - _contentContainer.AddSubview(platformView); - _currentPageView = platformView; - } - - nav?.SetPerformNavigate((toView) => - { - if (toView is NavigationView newNav) - { - newNav.SetPerformNavigate(nav); - newNav.SetPerformPop(nav); - } - toView.Navigation = nav; - ShowView(toView); - }); - nav?.SetPerformPop(() => - { - // Pop back to content root - ShowView(nav.Content); - }); - nav?.SetPerformContentReset((newContent) => - { - ShowView(newContent); - }); - - return _contentContainer; - } - - void ShowView(IView view) - { - if (view is null || MauiContext is null) - return; - - _currentPageView?.RemoveFromSuperview(); - var platformView = view.ToMacOSPlatform(MauiContext); - _contentContainer.AddSubview(platformView); - _currentPageView = platformView; - - if (_contentContainer.Bounds.Width > 0) - LayoutContent(_contentContainer.Bounds); - } - - void LayoutContent(CGRect bounds) - { - if (_currentPageView is null || bounds.Width <= 0 || bounds.Height <= 0) - return; - - _currentPageView.Frame = bounds; - var currentView = VirtualView?.Content; - if (currentView is IView iv) - { - iv.Measure((double)bounds.Width, (double)bounds.Height); - iv.Arrange(new Rect(0, 0, (double)bounds.Width, (double)bounds.Height)); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Standard.cs b/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Standard.cs deleted file mode 100644 index 3049d3e71..000000000 --- a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Standard.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -namespace Comet.Handlers -{ - public partial class NavigationViewHandler: ViewHandler - { - protected override object CreatePlatformView() => throw new NotImplementedException(); - - } -} diff --git a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Windows.cs b/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Windows.cs deleted file mode 100644 index 43b7a8d58..000000000 --- a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.Windows.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -using Microsoft.UI.Xaml.Controls; - -namespace Comet.Handlers -{ - public partial class NavigationViewHandler : ViewHandler - { - protected override Panel CreatePlatformView() => new LayoutPanel(); - - } -} diff --git a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.cs b/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.cs deleted file mode 100644 index 1273a2019..000000000 --- a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class NavigationViewHandler - { - public NavigationViewHandler() : base(ViewHandler.ViewMapper) - { - - } - } -} diff --git a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.iOS.cs b/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.iOS.cs deleted file mode 100644 index 0dc8401f8..000000000 --- a/src/Comet/src/Comet/Handlers/Navigation/NavigationViewHandler.iOS.cs +++ /dev/null @@ -1,137 +0,0 @@ -using Comet.iOS; -using CoreGraphics; -using Foundation; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using UIKit; - -namespace Comet.Handlers -{ - public partial class NavigationViewHandler : ViewHandler, IPlatformViewHandler - { - UIViewController viewController; - CometViewController rootViewController; - UIViewController IPlatformViewHandler.ViewController => viewController; - protected override UIView CreatePlatformView() - { - var vc = new Comet.iOS.CometViewController { MauiContext = MauiContext, CurrentView = VirtualView.Content }; - rootViewController = vc; - var nav = VirtualView; - - // Set title from NavigationView (content view may not carry the title) - var navTitle = nav.GetTitle(); - if (!string.IsNullOrEmpty(navTitle)) - vc.Title = navTitle; - - if (nav.Navigation is not null) - { - viewController = vc; - return viewController.View; - } - var navigationController = new CUINavigationController(); - viewController = navigationController; - - nav.SetPerformNavigate((toView) => { - if (toView is NavigationView newNav) - { - newNav.SetPerformNavigate(nav); - newNav.SetPerformPop(nav); - } - - toView.Navigation = nav; - var newVc = new Comet.iOS.CometViewController { MauiContext = MauiContext, CurrentView = toView }; - - // Store the NavigationView reference so CometViewController can - // apply toolbar items in ViewWillAppear (after Render has been called). - newVc.NavigationViewRef = nav; - navigationController.PushViewController(newVc, true); - }); - nav.SetPerformPop(() => navigationController.PopViewController(true)); - nav.SetPerformContentReset((newContent) => - { - navigationController.PopToRootViewController(false); - vc.CurrentView = newContent; - // Update title from the current NavigationView — the content view - // may not carry the title in its own environment. - var title = VirtualView?.GetTitle(); - if (!string.IsNullOrEmpty(title)) - vc.Title = title; - }); - // Use inline (compact) title by default; opt in to large titles - // when the NavigationView has NavigationPrefersLargeTitles=true. - var prefersLargeTitles = nav.GetNavigationPrefersLargeTitles(); - navigationController.NavigationBar.PrefersLargeTitles = prefersLargeTitles; - if (prefersLargeTitles) - vc.NavigationItem.LargeTitleDisplayMode = UINavigationItemLargeTitleDisplayMode.Always; - - navigationController.PushViewController(vc, true); - - // Add leading bar button (hamburger icon) if configured - if (nav.LeadingBarAction is not null) - { - var action = nav.LeadingBarAction; - vc.NavigationItem.LeftBarButtonItem = new UIBarButtonItem( - nav.LeadingBarIcon ?? "☰", - UIBarButtonItemStyle.Plain, - (s, e) => action()); - } - - // Toolbar items are resolved in CometViewController.ViewWillAppear - // (after the Component's Render has been called and BuiltView is available). - // Set NavigationViewRef so it can fall back to NavigationView-level items. - vc.NavigationViewRef = nav; - - return navigationController.View; - } - - protected override void ConnectHandler(UIView platformView) - { - base.ConnectHandler(platformView); - ApplyNavigationBarBackground(); - - // When the handler is transferred to a new NavigationView (during diff), - // the title must be refreshed from the current VirtualView. - if (rootViewController is not null) - { - var title = VirtualView?.GetTitle(); - if (!string.IsNullOrEmpty(title)) - rootViewController.Title = title; - } - } - - void ApplyNavigationBarBackground() - { - if (viewController is CUINavigationController navController) - { - // Let CometViewController.ApplyStyle() handle the full nav bar appearance. - // Resolve tint color dynamically from the view's environment so it - // updates on theme change (no hardcoded light-mode colors). - var textColor = VirtualView?.GetNavigationTextColor()?.ToPlatform(); - if (textColor is not null) - navController.NavigationBar.TintColor = textColor; - navController.NavigationBar.Translucent = true; - } - } - - internal static UIImage CreateFontIconImage(string glyph, string fontFamily, nfloat size) - { - var font = UIFont.FromName(fontFamily, size); - if (font is null) - return null; - - var text = new Foundation.NSString(glyph); - var attributes = new UIStringAttributes { Font = font }; - var textSize = text.GetSizeUsingAttributes(attributes); - if (textSize.Width <= 0 || textSize.Height <= 0) - return null; - - UIGraphics.BeginImageContextWithOptions(textSize, false, 0); - text.DrawString(CoreGraphics.CGPoint.Empty, attributes); - var image = UIGraphics.GetImageFromCurrentImageContext(); - UIGraphics.EndImageContext(); - - return image?.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Android.cs b/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Android.cs deleted file mode 100644 index ab13b43ee..000000000 --- a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Android.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using AndroidRadioButton = global::Android.Widget.RadioButton; -using AndroidCompoundButton = global::Android.Widget.CompoundButton; - -namespace Comet.Handlers -{ - public partial class RadioButtonHandler : ViewHandler - { - protected override AndroidRadioButton CreatePlatformView() - { - return new AndroidRadioButton(Context); - } - - protected override void ConnectHandler(AndroidRadioButton platformView) - { - base.ConnectHandler(platformView); - platformView.CheckedChange += OnPlatformCheckedChanged; - } - - protected override void DisconnectHandler(AndroidRadioButton platformView) - { - platformView.CheckedChange -= OnPlatformCheckedChanged; - base.DisconnectHandler(platformView); - } - - void OnPlatformCheckedChanged(object sender, AndroidCompoundButton.CheckedChangeEventArgs e) - { - if (VirtualView is IRadioButton radioButton) - { - radioButton.IsChecked = e.IsChecked; - } - } - - public static partial void MapIsChecked(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - handler.PlatformView.Checked = virtualView.Selected?.CurrentValue ?? false; - } - - public static partial void MapLabel(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - handler.PlatformView.Text = virtualView.Label?.CurrentValue ?? ""; - } - - public static partial void MapTextColor(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - var color = ((ITextStyle)virtualView).TextColor; - if (color is not null) - { - handler.PlatformView.SetTextColor(color.ToPlatform()); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.MacOS.cs deleted file mode 100644 index 3a8ab3218..000000000 --- a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.MacOS.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using AppKit; -using Comet.MacOS; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class RadioButtonHandler : ViewHandler - { - protected override CUIRadioNSButton CreatePlatformView() - { - var isChecked = VirtualView?.Selected?.CurrentValue ?? false; - return new CUIRadioNSButton(isChecked); - } - - public override Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - var platformView = PlatformView; - if (platformView is null) - return Size.Zero; - - var intrinsic = platformView.IntrinsicContentSize; - var w = intrinsic.Width > 0 ? intrinsic.Width : 24; - var h = intrinsic.Height > 0 ? intrinsic.Height : 24; - - return new Size( - Math.Min(widthConstraint, w), - Math.Min(heightConstraint, h)); - } - - protected override void ConnectHandler(CUIRadioNSButton platformView) - { - base.ConnectHandler(platformView); - platformView.IsCheckedChanged += OnPlatformIsCheckedChanged; - } - - protected override void DisconnectHandler(CUIRadioNSButton platformView) - { - platformView.IsCheckedChanged -= OnPlatformIsCheckedChanged; - base.DisconnectHandler(platformView); - } - - void OnPlatformIsCheckedChanged(object sender, EventArgs e) - { - if (VirtualView is IRadioButton radioButton) - { - radioButton.IsChecked = PlatformView.IsChecked; - } - } - - public static partial void MapIsChecked(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - handler.PlatformView.IsChecked = virtualView.Selected?.CurrentValue ?? false; - } - - public static partial void MapLabel(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - handler.PlatformView.SetLabel(virtualView.Label?.CurrentValue ?? ""); - virtualView.InvalidateMeasurement(); - } - - public static partial void MapTextColor(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - - var color = ((ITextStyle)virtualView).TextColor; - if (color is not null) - { - var nsColor = NSColor.FromRgba( - (nfloat)color.Red, - (nfloat)color.Green, - (nfloat)color.Blue, - (nfloat)color.Alpha); - handler.PlatformView.SetTextColor(nsColor); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Standard.cs b/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Standard.cs deleted file mode 100644 index 66a2eb131..000000000 --- a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Standard.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class RadioButtonHandler : ViewHandler - { - protected override object CreatePlatformView() => throw new NotSupportedException(); - - public static partial void MapIsChecked(RadioButtonHandler handler, RadioButton virtualView) { } - public static partial void MapLabel(RadioButtonHandler handler, RadioButton virtualView) { } - public static partial void MapTextColor(RadioButtonHandler handler, RadioButton virtualView) { } - } -} diff --git a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Windows.cs b/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Windows.cs deleted file mode 100644 index 2df7c30c9..000000000 --- a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.Windows.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using Microsoft.UI.Xaml.Controls; - -namespace Comet.Handlers -{ - public partial class RadioButtonHandler : ViewHandler - { - protected override Microsoft.UI.Xaml.Controls.RadioButton CreatePlatformView() - { - return new Microsoft.UI.Xaml.Controls.RadioButton(); - } - - protected override void ConnectHandler(Microsoft.UI.Xaml.Controls.RadioButton platformView) - { - base.ConnectHandler(platformView); - platformView.Checked += OnPlatformChecked; - platformView.Unchecked += OnPlatformUnchecked; - } - - protected override void DisconnectHandler(Microsoft.UI.Xaml.Controls.RadioButton platformView) - { - platformView.Checked -= OnPlatformChecked; - platformView.Unchecked -= OnPlatformUnchecked; - base.DisconnectHandler(platformView); - } - - void OnPlatformChecked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - if (VirtualView is IRadioButton radioButton) - radioButton.IsChecked = true; - } - - void OnPlatformUnchecked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - if (VirtualView is IRadioButton radioButton) - radioButton.IsChecked = false; - } - - public static partial void MapIsChecked(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - handler.PlatformView.IsChecked = virtualView.Selected?.CurrentValue ?? false; - } - - public static partial void MapLabel(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - handler.PlatformView.Content = virtualView.Label?.CurrentValue ?? ""; - } - - public static partial void MapTextColor(RadioButtonHandler handler, RadioButton virtualView) - { - // WinUI RadioButton uses theme foreground; custom color requires style override - } - } -} diff --git a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.cs b/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.cs deleted file mode 100644 index b11494ed6..000000000 --- a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class RadioButtonHandler - { - public static IPropertyMapper Mapper = - new PropertyMapper(ViewHandler.ViewMapper) - { - [nameof(IRadioButton.IsChecked)] = MapIsChecked, - [nameof(RadioButton.Label)] = MapLabel, - [nameof(ITextStyle.TextColor)] = MapTextColor, - }; - - public RadioButtonHandler() : base(Mapper) - { - } - - public static partial void MapIsChecked(RadioButtonHandler handler, RadioButton virtualView); - public static partial void MapLabel(RadioButtonHandler handler, RadioButton virtualView); - public static partial void MapTextColor(RadioButtonHandler handler, RadioButton virtualView); - } -} diff --git a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.iOS.cs b/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.iOS.cs deleted file mode 100644 index 6dd3a24c6..000000000 --- a/src/Comet/src/Comet/Handlers/RadioButton/RadioButtonHandler.iOS.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using Comet.iOS; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using UIKit; - -namespace Comet.Handlers -{ - public partial class RadioButtonHandler : ViewHandler - { - protected override CUIRadioButton CreatePlatformView() - { - var isChecked = VirtualView?.Selected?.CurrentValue ?? false; - return new CUIRadioButton(isChecked); - } - - // Override to directly measure CUIRadioButton via SizeThatFits, - // bypassing any IContentView.CrossPlatformMeasure routing that - // returns Size.Zero because PresentedContent is null. - public override Size GetDesiredSize(double widthConstraint, double heightConstraint) - { - var platformView = PlatformView; - if (platformView is null) - return Size.Zero; - - var widthC = widthConstraint < 0 || double.IsInfinity(widthConstraint) - ? double.MaxValue : widthConstraint; - var heightC = heightConstraint < 0 || double.IsInfinity(heightConstraint) - ? double.MaxValue : heightConstraint; - - var sizeThatFits = platformView.SizeThatFits(new CGSize(widthC, heightC)); - - // Guarantee a minimum size so the radio circle is always visible - var w = Math.Max(sizeThatFits.Width, 24); - var h = Math.Max(sizeThatFits.Height, 24); - - return new Size( - Math.Min(widthConstraint, w), - Math.Min(heightConstraint, h)); - } - - protected override void ConnectHandler(CUIRadioButton platformView) - { - base.ConnectHandler(platformView); - platformView.IsCheckedChanged += OnPlatformIsCheckedChanged; - } - - protected override void DisconnectHandler(CUIRadioButton platformView) - { - platformView.IsCheckedChanged -= OnPlatformIsCheckedChanged; - base.DisconnectHandler(platformView); - } - - void OnPlatformIsCheckedChanged(object sender, EventArgs e) - { - if (VirtualView is IRadioButton radioButton) - { - radioButton.IsChecked = PlatformView.IsChecked; - } - } - - public static partial void MapIsChecked(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - handler.PlatformView.IsChecked = virtualView.Selected?.CurrentValue ?? false; - } - - public static partial void MapLabel(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - handler.PlatformView.SetTitle(virtualView.Label?.CurrentValue ?? "", UIControlState.Normal); - virtualView.InvalidateMeasurement(); - } - - public static partial void MapTextColor(RadioButtonHandler handler, RadioButton virtualView) - { - if (handler.PlatformView is null) - return; - - var color = ((ITextStyle)virtualView).TextColor; - if (color is not null) - { - handler.PlatformView.SetTitleColor(color.ToPlatform(), UIControlState.Normal); - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Android.cs b/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Android.cs deleted file mode 100644 index 6912b9729..000000000 --- a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Android.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Android.Content; -using AView = Android.Views.View; -using AScrollView = Comet.Android.Controls.CometScrollView; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -using static Android.Views.ViewGroup; - -namespace Comet.Handlers -{ - public partial class ScrollViewHandler : ViewHandler - { - - private AView _content; - - protected override AScrollView CreatePlatformView() => new AScrollView(Context) - { - CrossPlatformArrange = Arange, - }; - - protected override void DisconnectHandler(AScrollView view) - { - if (_content is not null) - { - view.RemoveView(_content); - _content = null; - } - - base.DisconnectHandler(view); - } - - void Arange(Rect rect) - { - var sizeAllowed = this.VirtualView.Orientation == Orientation.Vertical ? new Size(rect.Width, double.PositiveInfinity) : new Size(double.PositiveInfinity, rect.Height); - var measuredSize = VirtualView?.Content?.Measure(sizeAllowed.Width, sizeAllowed.Height) ?? Size.Zero; - //Make sure we at least fit the scroll view - if (double.IsInfinity(measuredSize.Width)) - measuredSize.Width = rect.Width; - if (double.IsInfinity(measuredSize.Height)) - measuredSize.Height = rect.Height; - measuredSize.Width = Math.Max(measuredSize.Width, rect.Width); - measuredSize.Height = Math.Max(measuredSize.Height, rect.Height); - if (VirtualView?.Content is not null) - VirtualView.Content.Frame = new Rect(Point.Zero, measuredSize); - //PlatformView.v = measuredSize.ToCGSize(); - //_content.Frame = new CGRect(CGPoint.Empty, measuredSize); - } - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - PlatformView.SetVirtualView(VirtualView, MauiContext); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.MacOS.cs deleted file mode 100644 index 446bfcc49..000000000 --- a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.MacOS.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using AppKit; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using Comet.MacOS; - -namespace Comet.Handlers -{ - public partial class ScrollViewHandler : ViewHandler - { - NSView _documentView; - - protected override NSScrollView CreatePlatformView() - { - var scrollView = new NSScrollView - { - HasVerticalScroller = true, - HasHorizontalScroller = false, - AutohidesScrollers = true, - DrawsBackground = false, - }; - _documentView = new NSView { AutoresizesSubviews = false }; - scrollView.DocumentView = _documentView; - return scrollView; - } - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - - // Clear old content - foreach (var sub in _documentView.Subviews) - sub.RemoveFromSuperview(); - - var content = VirtualView?.Content?.ToMacOSPlatform(MauiContext); - if (content is not null) - _documentView.AddSubview(content); - - if (VirtualView.Orientation == Orientation.Horizontal) - { - PlatformView.HasVerticalScroller = false; - PlatformView.HasHorizontalScroller = true; - } - else - { - PlatformView.HasVerticalScroller = true; - PlatformView.HasHorizontalScroller = false; - } - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Standard.cs b/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Standard.cs deleted file mode 100644 index 434e93396..000000000 --- a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Standard.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -namespace Comet.Handlers -{ - public partial class ScrollViewHandler: ViewHandler - { - protected override object CreatePlatformView() => throw new NotImplementedException(); - - } -} diff --git a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Windows.cs b/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Windows.cs deleted file mode 100644 index 2ac42368b..000000000 --- a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.Windows.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -using UWPScrollView = Microsoft.UI.Xaml.Controls.ScrollViewer; - -namespace Comet.Handlers -{ - public partial class ScrollViewHandler : ViewHandler - { - protected override UWPScrollView CreatePlatformView() => new UWPScrollView(); - - } -} diff --git a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.cs b/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.cs deleted file mode 100644 index a84f73601..000000000 --- a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class ScrollViewHandler - { - public ScrollViewHandler() : base(ViewHandler.ViewMapper) - { - - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.iOS.cs deleted file mode 100644 index d23a3d38d..000000000 --- a/src/Comet/src/Comet/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using Foundation; -using UIKit; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -using Comet.iOS; -using Microsoft.Maui.Graphics; -using CoreGraphics; - -namespace Comet.Handlers -{ - public partial class ScrollViewHandler : ViewHandler - { - - private UIView _content; - - protected override CUIScrollView CreatePlatformView() => - new CUIScrollView { - ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.Automatic, - CrossPlatformArrange = Arange, - }; - - void Arange(Rect rect) - { - var isVertical = this.VirtualView.Orientation == Orientation.Vertical; - var sizeAllowed = isVertical - ? new Size(rect.Width, Double.PositiveInfinity) - : new Size(Double.PositiveInfinity, rect.Height); - - // Invalidate content measurement so it picks up the new width/height - // constraint (e.g. after rotation). - if (VirtualView?.Content is View contentView) - contentView.MeasurementValid = false; - - var measuredSize = VirtualView?.Content?.Measure(sizeAllowed.Width, sizeAllowed.Height) ?? Size.Zero; - - if (double.IsInfinity(measuredSize.Width)) - measuredSize.Width = rect.Width; - if (double.IsInfinity(measuredSize.Height)) - measuredSize.Height = rect.Height; - - // Clamp the non-scrolling dimension to the scroll view's bounds - // so a vertical ScrollView never scrolls horizontally and vice-versa. - if (isVertical) - { - measuredSize.Width = rect.Width; - measuredSize.Height = Math.Max(measuredSize.Height, rect.Height); - } - else - { - measuredSize.Width = Math.Max(measuredSize.Width, rect.Width); - measuredSize.Height = rect.Height; - } - - PlatformView.ContentSize = measuredSize.ToCGSize(); - _content.Frame = new CGRect(CGPoint.Empty, measuredSize); - } - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - - // ScrollView content must ignore safe area — the ScrollView itself - // handles safe area via ContentInsetAdjustmentBehavior. Without this, - // MAUI's LayoutView (MauiView) will double-apply safe area insets to - // the content's CrossPlatformArrange, pushing it below the nav bar. - if (VirtualView?.Content is View contentView) - contentView.SetEnvironment(EnvironmentKeys.Layout.IgnoreSafeArea, true, false); - - var oldContent = _content; - _content = VirtualView?.Content?.ToPlatform(MauiContext); - if(oldContent != _content) - oldContent?.RemoveFromSuperview(); - if (_content is not null) - { - PlatformView.Add(_content); - } - - if (VirtualView.Orientation == Orientation.Horizontal) - { - PlatformView.ShowsVerticalScrollIndicator = false; - PlatformView.ShowsHorizontalScrollIndicator = true; - } - else - { - PlatformView.ShowsVerticalScrollIndicator = true; - PlatformView.ShowsHorizontalScrollIndicator = false; - } - } - - } -} diff --git a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Android.cs b/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Android.cs deleted file mode 100644 index 3b7e5163f..000000000 --- a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Android.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -using Microsoft.Maui.Graphics.Platform; - -namespace Comet.Handlers -{ - public partial class ShapeViewHandler : ViewHandler - { - protected override PlatformGraphicsView CreatePlatformView() => new PlatformGraphicsView(MauiContext.Context); - - - public static void MapShapeProperty(IElementHandler viewHandler, ShapeView virtualView) - { - var nativeView = (PlatformGraphicsView)viewHandler.PlatformView; - nativeView.Drawable = virtualView; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.MacOS.cs deleted file mode 100644 index 5cdd01cec..000000000 --- a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.MacOS.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using AppKit; -using Comet.MacOS; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class ShapeViewHandler : ViewHandler - { - protected override CUIShapeNSView CreatePlatformView() => new CUIShapeNSView(); - - public static void MapShapeProperty(IElementHandler viewHandler, ShapeView virtualView) - { - var nativeView = (CUIShapeNSView)viewHandler.PlatformView; - var shape = virtualView.Shape?.CurrentValue; - nativeView.View = virtualView; - nativeView.Shape = shape; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Standard.cs b/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Standard.cs deleted file mode 100644 index 83fc5931c..000000000 --- a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Standard.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; -namespace Comet.Handlers -{ - public partial class ShapeViewHandler : ViewHandler - { - protected override object CreatePlatformView() => throw new NotImplementedException(); - - public static void MapShapeProperty(IElementHandler viewHandler, ShapeView virtualView) { } - } -} diff --git a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Windows.cs b/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Windows.cs deleted file mode 100644 index 62d9f7ee3..000000000 --- a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.Windows.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -//using Microsoft.Maui.Graphics.Platform; -using Microsoft.Maui; -using Microsoft.UI.Xaml.Controls; - -namespace Comet.Handlers -{ - public partial class ShapeViewHandler : ViewHandler - { - protected override Panel CreatePlatformView() => new LayoutPanel(); - - - public static void MapShapeProperty(IElementHandler viewHandler, ShapeView virtualView) - { - //var nativeView = (PlatformGraphicsView)viewHandler.PlatformView; - //nativeView.Drawable = virtualView; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.cs b/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.cs deleted file mode 100644 index 41ffc471e..000000000 --- a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class ShapeViewHandler - { - public static readonly PropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) - { - [nameof(ShapeView.Shape)] = MapShapeProperty - }; - - - - public ShapeViewHandler() : base(Mapper) - { - } - - public ShapeViewHandler(PropertyMapper mapper) : base(mapper) - { - } - } -} diff --git a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.iOS.cs b/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.iOS.cs deleted file mode 100644 index 62648fe39..000000000 --- a/src/Comet/src/Comet/Handlers/ShapeView/ShapeViewHandler.iOS.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Graphics.Platform; -using Microsoft.Maui; - -namespace Comet.Handlers -{ - public partial class ShapeViewHandler : ViewHandler - { - protected override PlatformGraphicsView CreatePlatformView() => new PlatformGraphicsView(); - - - public static void MapShapeProperty(IElementHandler viewHandler, ShapeView virtualView) - { - var nativeView = (PlatformGraphicsView)viewHandler.PlatformView; - nativeView.Drawable = virtualView; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/Spacer/SpacerHandler.cs b/src/Comet/src/Comet/Handlers/Spacer/SpacerHandler.cs deleted file mode 100644 index 8786d58ba..000000000 --- a/src/Comet/src/Comet/Handlers/Spacer/SpacerHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -#if __IOS__ - -using PlatformView = UIKit.UIView; -#elif ANDROID -using PlatformView = Android.Views.View; -#elif WINDOWS -using PlatformView = Microsoft.UI.Xaml.Controls.Panel; -#elif __MACOS__ -using PlatformView = AppKit.NSView; -#else -using PlatformView = System.Object; -#endif - - -namespace Comet.Handlers -{ - public partial class SpacerHandler : ViewHandler - { - public static readonly PropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper); - public SpacerHandler() : base(Mapper) - { - - } - - protected override PlatformView CreatePlatformView() => -#if ANDROID - new PlatformView(Context.ApplicationContext); -#elif WINDOWS - new LayoutPanel(); -#elif __MACOS__ - new PlatformView { WantsLayer = true }; -#else - new PlatformView(); -#endif - - } -} diff --git a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Android.cs b/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Android.cs deleted file mode 100644 index 1e94c3090..000000000 --- a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Android.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using Android.Views; -using Android.Widget; -using AView = Android.Views.View; -using Comet.Android.Controls; -using Android.Content; -using Android.Util; -using System.Linq; -using Microsoft.Maui.Handlers; -using Microsoft.Maui; - -namespace Comet.Handlers -{ - public partial class TabViewHandler : ViewHandler - { - //private AView _view; - protected override CometTabView CreatePlatformView() => new CometTabView(MauiContext); - - - protected override void ConnectHandler(CometTabView nativeView) - { - base.ConnectHandler(nativeView); - } - public override void SetVirtualView(IView view) { - base.SetVirtualView(view); - - PlatformView?.CreateTabs(this.VirtualView); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.MacOS.cs deleted file mode 100644 index 134b7de8e..000000000 --- a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.MacOS.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Linq; -using AppKit; -using Comet.MacOS; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -namespace Comet.Handlers -{ - public partial class TabViewHandler : ViewHandler - { - protected override CUITabNSView CreatePlatformView() => new CUITabNSView { Context = MauiContext }; - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - PlatformView?.Setup(this.VirtualView); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Standard.cs b/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Standard.cs deleted file mode 100644 index b4ee53490..000000000 --- a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Standard.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class TabViewHandler : ViewHandler - { - protected override object CreatePlatformView() => throw new NotImplementedException(); - } -} diff --git a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Windows.cs b/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Windows.cs deleted file mode 100644 index 2ecbf89d6..000000000 --- a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.Windows.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Linq; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; - - -namespace Comet.Handlers -{ - public partial class TabViewHandler : ViewHandler - { - protected override Panel CreatePlatformView() => PlatformView ?? new LayoutPanel { }; - - - //public override void SetVirtualView(IView view) - //{ - // base.SetVirtualView(view); - - // PlatformView?.Setup(this.VirtualView); - //} - } -} diff --git a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.cs b/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.cs deleted file mode 100644 index f778c6385..000000000 --- a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class TabViewHandler - { - - public TabViewHandler() : base(ViewHandler.ViewMapper) - { - - } - public TabViewHandler(PropertyMapper mapper) : base(mapper) - { - - } - } -} diff --git a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.iOS.cs b/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.iOS.cs deleted file mode 100644 index b512aac5d..000000000 --- a/src/Comet/src/Comet/Handlers/TabView/TabViewHandler.iOS.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Linq; -using Comet.iOS; -using Microsoft.Maui; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - public partial class TabViewHandler : ViewHandler - { - //public override bool IgnoreSafeArea => VirtualView?.GetIgnoreSafeArea(true) ?? true; - protected override CUITabView CreatePlatformView() - { - return new CUITabView { Context = MauiContext }; - } - - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - - PlatformView?.Setup(this.VirtualView); - PlatformView?.ApplyTabBarAppearance(this.VirtualView); - PlatformView?.ApplySelectedIndex(this.VirtualView.SelectedIndex); - } - - } -} diff --git a/src/Comet/src/Comet/Handlers/View/CometViewHandler.Android.cs b/src/Comet/src/Comet/Handlers/View/CometViewHandler.Android.cs deleted file mode 100644 index 0a1b487e9..000000000 --- a/src/Comet/src/Comet/Handlers/View/CometViewHandler.Android.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using Comet.Android; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -namespace Comet.Handlers -{ - public partial class CometViewHandler : ViewHandler - { - public static PropertyMapper CometViewMapper = new() - { - }; - - public CometViewHandler() : base(CometViewMapper) - { - } - - protected override CometView CreatePlatformView() => new CometView(MauiContext); - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - PlatformView.CurrentView = view; - } - } -} diff --git a/src/Comet/src/Comet/Handlers/View/CometViewHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/View/CometViewHandler.MacOS.cs deleted file mode 100644 index facf11830..000000000 --- a/src/Comet/src/Comet/Handlers/View/CometViewHandler.MacOS.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using Comet.MacOS; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using AppKit; - -namespace Comet.Handlers -{ - public partial class CometViewHandler : ViewHandler - { - public static PropertyMapper CometViewMapper = new() - { - [nameof(ITitledElement.Title)] = MapTitle, - [nameof(IView.Background)] = MapBackgroundColor, - }; - - public CometViewHandler() : base(CometViewMapper) - { - } - - protected override CometNSView CreatePlatformView() => new CometNSView(MauiContext); - - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - PlatformView.CurrentView = view; - } - - public static void MapTitle(CometViewHandler handler, View view) - { - // macOS title is managed at the window level, not per-view - } - - public static void MapBackgroundColor(CometViewHandler handler, View view) - { - handler.PlatformView?.UpdateBackground(view); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/View/CometViewHandler.Windows.cs b/src/Comet/src/Comet/Handlers/View/CometViewHandler.Windows.cs deleted file mode 100644 index 178779813..000000000 --- a/src/Comet/src/Comet/Handlers/View/CometViewHandler.Windows.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ -public partial class CometViewHandler : ViewHandler -{ -public static PropertyMapper CometViewMapper = new() -{ -[nameof(ITitledElement.Title)] = MapTitle, -[nameof(IView.Background)] = MapBackgroundColor, -}; - -public CometViewHandler() : base(CometViewMapper) -{ -} - -protected override Comet.Windows.CometView CreatePlatformView() => new(MauiContext); - -public override void SetVirtualView(IView view) -{ -base.SetVirtualView(view); -PlatformView.CurrentView = view; -} - -public static void MapTitle(CometViewHandler handler, View view) -{ -// Windows doesn't have a direct title mapping at the Grid level -// Title is typically set on the window or page, not individual views -// This is a no-op for grid-based views -} - -public static void MapBackgroundColor(CometViewHandler handler, View view) -{ -if (handler?.PlatformView is null) -return; - -var background = ((IView)view).Background; -if (background is SolidPaint solid && solid.Color is not null) -{ -var color = new global::Windows.UI.Color -{ -A = (byte)(solid.Color.Alpha * 255), -R = (byte)(solid.Color.Red * 255), -G = (byte)(solid.Color.Green * 255), -B = (byte)(solid.Color.Blue * 255) -}; -handler.PlatformView.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(color); -} -else if (background is null) -{ -handler.PlatformView.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush( -Microsoft.UI.Colors.White); -} -} -} -} diff --git a/src/Comet/src/Comet/Handlers/View/CometViewHandler.cs b/src/Comet/src/Comet/Handlers/View/CometViewHandler.cs deleted file mode 100644 index e9f859722..000000000 --- a/src/Comet/src/Comet/Handlers/View/CometViewHandler.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -namespace Comet.Handlers -{ - public partial class CometViewHandler - { - public static void AddGesture(IViewHandler viewHandler, IView view, object arg) - { - if (arg is not Gesture g) - return; - viewHandler.AddGesture(g); - } - public static void RemoveGesture(IViewHandler viewHandler, IView view, object arg) - { - if (arg is not Gesture g) - return; - viewHandler.RemoveGesture(g); - } - public static void AddGestures(IViewHandler viewHandler, IView view) - { - if (!(view is IGestureView ig)) - return; - var gestures = ig.Gestures; - if (!(gestures?.Any() ?? false)) - return; - foreach (var g in gestures) - viewHandler.AddGesture(g); - } - } -} diff --git a/src/Comet/src/Comet/Handlers/View/CometViewHandler.iOS.cs b/src/Comet/src/Comet/Handlers/View/CometViewHandler.iOS.cs deleted file mode 100644 index 586c32216..000000000 --- a/src/Comet/src/Comet/Handlers/View/CometViewHandler.iOS.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Comet.iOS; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using UIKit; - -namespace Comet.Handlers -{ - public partial class CometViewHandler : ViewHandler, IPlatformViewHandler - { - public static PropertyMapper CometViewMapper = new () - { - [nameof(ITitledElement.Title)] = MapTitle, - [nameof(IView.Background)] = MapBackgroundColor, - }; - - - public CometViewHandler() : base(CometViewMapper) - { - - } - CometViewController viewController; - UIViewController IPlatformViewHandler.ViewController => viewController ??= new CometViewController { ContainerView = this.PlatformView, MauiContext = MauiContext }; - protected override CometView CreatePlatformView() - { - return new CometView(MauiContext); - } - public override void SetVirtualView(IView view) - { - base.SetVirtualView(view); - PlatformView.CurrentView = view; - } - - - public static void MapTitle(CometViewHandler handler, View view) - { - var vc = handler?.viewController; - if (vc is null) - return; - vc.Title = view.GetTitle() ?? ""; - } - public static void MapBackgroundColor(CometViewHandler handler, View view) - { - var vc = handler?.viewController; - if (vc is null) - return; - vc.View.UpdateBackground(view); - } - - } -} diff --git a/src/Comet/src/Comet/Handlers/Window/CometWindowHandler.MacOS.cs b/src/Comet/src/Comet/Handlers/Window/CometWindowHandler.MacOS.cs deleted file mode 100644 index 8d7c4c9d3..000000000 --- a/src/Comet/src/Comet/Handlers/Window/CometWindowHandler.MacOS.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using AppKit; -using CoreGraphics; -using Comet.MacOS; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; - -namespace Comet.Handlers -{ - /// - /// macOS-specific window handler for CometWindow. Creates a real NSWindow - /// with AppKit chrome and hosts Comet view content via the handler chain. - /// - public partial class CometWindowHandler : ElementHandler - { - public static readonly IPropertyMapper Mapper = - new PropertyMapper(ElementMapper) - { - [nameof(IWindow.Title)] = MapTitle, - [nameof(IWindow.Content)] = MapContent, - }; - - CometWindowContentView _contentContainer; - - public CometWindowHandler() : base(Mapper) - { - } - - protected override NSWindow CreatePlatformElement() - { - var style = NSWindowStyle.Titled - | NSWindowStyle.Closable - | NSWindowStyle.Resizable - | NSWindowStyle.Miniaturizable; - - var window = new NSWindow( - new CGRect(0, 0, 1280, 720), - style, - NSBackingStore.Buffered, - false); - - window.Center(); - #pragma warning disable CS0618 // ReleasedWhenClosed is obsolete - window.ReleasedWhenClosed = false; - #pragma warning restore CS0618 - - _contentContainer = new CometWindowContentView(); - window.ContentView = _contentContainer; - - window.MakeKeyAndOrderFront(null); - - return window; - } - - public static void MapTitle(CometWindowHandler handler, IWindow window) - { - if (handler.PlatformView is not null) - handler.PlatformView.Title = window.Title ?? string.Empty; - } - - public static void MapContent(CometWindowHandler handler, IWindow window) - { - if (handler.MauiContext is null || window.Content is null) - return; - - var content = window.Content; - var contentView = content.ToMacOSPlatform(handler.MauiContext); - - if (handler._contentContainer is not null) - { - foreach (var subview in handler._contentContainer.Subviews) - subview.RemoveFromSuperview(); - - contentView.Frame = handler._contentContainer.Bounds; - contentView.AutoresizingMask = NSViewResizingMask.WidthSizable | NSViewResizingMask.HeightSizable; - handler._contentContainer.AddSubview(contentView); - handler._contentContainer.HostedContent = new WeakReference(content); - - var bounds = handler._contentContainer.Bounds; - if (bounds.Width > 0 && bounds.Height > 0) - { - content.Measure((double)bounds.Width, (double)bounds.Height); - content.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, (double)bounds.Width, (double)bounds.Height)); - } - } - } - - /// - /// Flipped NSView used as NSWindow.ContentView so MAUI's top-left - /// coordinate system works correctly with AppKit's bottom-left default. - /// - internal class CometWindowContentView : NSView - { - public CometWindowContentView() - { - WantsLayer = true; - PostsFrameChangedNotifications = true; - } - - public override bool IsFlipped => true; - - internal WeakReference HostedContent { get; set; } - - public override void SetFrameSize(CGSize newSize) - { - base.SetFrameSize(newSize); - RelayoutContent(); - } - - public override void Layout() - { - base.Layout(); - RelayoutContent(); - } - - void RelayoutContent() - { - var size = Bounds.Size; - if (size.Width <= 0 || size.Height <= 0) - return; - - try - { - if (HostedContent is not null && HostedContent.TryGetTarget(out var content)) - { - content.Measure((double)size.Width, (double)size.Height); - content.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, (double)size.Width, (double)size.Height)); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"[CometWindowContentView.RelayoutContent] {ex}"); - } - } - } - } -} diff --git a/src/Comet/src/Comet/Helpers/DatabindingExtensions.cs b/src/Comet/src/Comet/Helpers/DatabindingExtensions.cs index 9f2dda83e..2cd5b5527 100644 --- a/src/Comet/src/Comet/Helpers/DatabindingExtensions.cs +++ b/src/Comet/src/Comet/Helpers/DatabindingExtensions.cs @@ -166,10 +166,15 @@ static View TryMergeComponents(View newView, View oldView, out bool reusedOldIns if (ReferenceEquals(newView, oldView)) { - var replacement = CometHotReloadHelper.CreateReplacement(oldView); + // Reuse an already-installed replacement (AreSameType→GetView may have + // installed one moments earlier); creating a second instance would strand + // the state/backed node already transferred to the first. + var replacement = oldView.HotReloadReplacedView + ?? CometHotReloadHelper.CreateReplacement(oldView); if (replacement is not null && replacement != oldView) { - oldView.SetHotReloadReplacement(replacement); + if (!ReferenceEquals(oldView.HotReloadReplacedView, replacement)) + oldView.SetHotReloadReplacement(replacement); return replacement; } } @@ -277,6 +282,12 @@ static View DiffUpdate(this View newView, View oldView, bool checkRenderers) oldView = mergedComponent; } + // Node backend: a fresh hot-reload replacement has never rendered (BuiltView is + // null) and there is no lazy platform pull — patches are pushed at diff time — + // so build it now or the retained nodes never receive the replacement's output. + if (newView.BuiltView is null && oldView.BuiltView?.Node is not null) + _ = newView.GetView(); + // Always diff the built views (the result of Body/Render) // This is especially important for Components — their Render() output needs diffing if (newView.BuiltView is not null && oldView.BuiltView is not null) @@ -461,7 +472,12 @@ static View DiffUpdate(this View newView, View oldView, bool checkRenderers) // old handlers were already null by the time transfer ran). if (mergedComponent is null || !reusedOldComponentInstance) { - newView.UpdateFromOldView(oldView); + // checkRenderers is the hot-reload flag: ResetView passes isHotReload into + // Diff (View.cs), and the hot-reload replacement path passes true — every + // other diff (ordinary Component re-render) is false. Own-content nodes use + // it to preserve retained state on ordinary re-renders vs re-materialize on + // a real hot reload. + newView.UpdateFromOldView(oldView, checkRenderers); } return newView; diff --git a/src/Comet/src/Comet/Helpers/DrawingExtensions.cs b/src/Comet/src/Comet/Helpers/DrawingExtensions.cs index 34998e820..94641672b 100644 --- a/src/Comet/src/Comet/Helpers/DrawingExtensions.cs +++ b/src/Comet/src/Comet/Helpers/DrawingExtensions.cs @@ -220,6 +220,12 @@ public AsymmetricRoundedRectangle(float topLeft, float topRight, float bottomLef _bottomRight = bottomRight; } + // Exposed so the backend bridge can read the per-corner radii back off a ClipShape. + public float TopLeft => _topLeft; + public float TopRight => _topRight; + public float BottomLeft => _bottomLeft; + public float BottomRight => _bottomRight; + public override PathF PathForBounds(Rect bounds) { var path = new PathF(); diff --git a/src/Comet/src/Comet/Helpers/FontExtensions.cs b/src/Comet/src/Comet/Helpers/FontExtensions.cs index abc6090d1..9807c0ec1 100644 --- a/src/Comet/src/Comet/Helpers/FontExtensions.cs +++ b/src/Comet/src/Comet/Helpers/FontExtensions.cs @@ -24,6 +24,11 @@ public static T FontSize(this T view, double value) where T : View public static T FontSize(this T view, Func value) where T : View => view.FontSize(value()); + /// Sets an explicit line height (in sp/font units) for this text — mirrors a Compose + /// TextStyle.lineHeight. When unset, the backend uses a proportional default. + public static T LineHeight(this T view, double value) where T : View + => view.SetEnvironment(EnvironmentKeys.Fonts.LineHeight, (object)value, true); + public static T FontWeight(this T view, FontWeight value) where T : View => view.SetEnvironment(EnvironmentKeys.Fonts.Weight, (object)value, true); public static T FontWeight(this T view, Func value) where T : View diff --git a/src/Comet/src/Comet/Helpers/LayoutExtensions.cs b/src/Comet/src/Comet/Helpers/LayoutExtensions.cs index 548c48c6e..4c04fb3a6 100644 --- a/src/Comet/src/Comet/Helpers/LayoutExtensions.cs +++ b/src/Comet/src/Comet/Helpers/LayoutExtensions.cs @@ -272,6 +272,31 @@ public static FrameConstraints GetFrameConstraints(this View view, FrameConstrai return constraints ?? defaultContraints; } + /// Aligns this view to the shared text baseline of its row (like Compose's + /// Modifier.alignByBaseline()). In an HStack, opted-in text views are positioned so + /// their first text baselines coincide. No-op on the cross axis of a column. + public static T AlignBaseline(this T view) where T : View + { + view.SetEnvironment(EnvironmentKeys.Layout.BaselineAlign, true, false); + return view; + } + + public static bool GetBaselineAlign(this View view) => + view.GetEnvironment(view, EnvironmentKeys.Layout.BaselineAlign, false) == true; + + /// Lays this text out so its first baseline sits Dp from the + /// top of its box (like Jetchat's Modifier.baselineHeight) — the layout engine pads the + /// top by height − firstBaseline and grows the box to match. Gives baseline-anchored + /// vertical rhythm that's independent of font size. Only meaningful on a text-measuring leaf. + public static T BaselineHeight(this T view, double height) where T : View + { + view.SetEnvironment(EnvironmentKeys.Layout.BaselineHeight, height, false); + return view; + } + + public static double? GetBaselineHeight(this View view) => + view.GetEnvironment(view, EnvironmentKeys.Layout.BaselineHeight, false); + // AbsoluteLayout extensions public static T LayoutBounds(this T view, Rect bounds) where T : View { diff --git a/src/Comet/src/Comet/Helpers/LineBreakModeExtensions.cs b/src/Comet/src/Comet/Helpers/LineBreakModeExtensions.cs index f67dc7021..3504d945f 100644 --- a/src/Comet/src/Comet/Helpers/LineBreakModeExtensions.cs +++ b/src/Comet/src/Comet/Helpers/LineBreakModeExtensions.cs @@ -13,7 +13,31 @@ public static LineBreakMode GetLineBreakMode(this T view, LineBreakMode defau public static T LineBreakMode(this T view, LineBreakMode mode) where T : View => view.SetEnvironment(EnvironmentKeys.LineBreakMode.Mode, (object)mode); + /// Clamp the text to at most lines; overflow renders + /// an ellipsis (Compose Text maxLines + TextOverflow.Ellipsis). 0 = unlimited. + public static T MaxLines(this T view, int lines) where T : View => + view.SetEnvironment(EnvironmentKeys.Text.MaxLines, lines); + public static T LineBreakMode(this T view, Func mode) where T : View => view.LineBreakMode(mode()); + + /// Wrap STRATEGY (where soft breaks fall), distinct from LineBreakMode + /// (wrap vs truncate): Heading balances lines at phrase boundaries, Paragraph uses + /// high-quality (non-greedy) breaking — Compose's LineBreak presets. Android drives + /// the real Compose lineBreak; SwiftUI has no public wrap-strategy API, so iOS keeps + /// greedy wrapping (documented deviation). + public static T LineBreak(this T view, TextLineBreak strategy) where T : View => + view.SetEnvironment(EnvironmentKeys.Text.LineBreak, strategy); + } + + /// Soft-wrap strategy presets mirroring Compose's LineBreak. + public enum TextLineBreak + { + /// Greedy wrapping (the platform default). + Default = 0, + /// Balanced lines, phrase-boundary breaks — for titles. + Heading = 1, + /// High-quality non-greedy wrapping — for body copy. + Paragraph = 2, } } diff --git a/src/Comet/src/Comet/Helpers/SurfaceExtensions.cs b/src/Comet/src/Comet/Helpers/SurfaceExtensions.cs new file mode 100644 index 000000000..440a61f79 --- /dev/null +++ b/src/Comet/src/Comet/Helpers/SurfaceExtensions.cs @@ -0,0 +1,93 @@ +#nullable enable +using Microsoft.Maui.Graphics; + +namespace Comet +{ + /// Per-corner radii (Dp) for a rounded surface — the wire shape the backend nodes + /// consume (derived from a view's ClipShape). Uniform when all four are equal. + public readonly record struct CornerRadii(double TopLeft, double TopRight, double BottomRight, double BottomLeft) + { + public CornerRadii(double uniform) : this(uniform, uniform, uniform, uniform) { } + + public bool IsUniform => TopLeft == TopRight && TopRight == BottomRight && BottomRight == BottomLeft; + + public bool IsZero => TopLeft == 0 && TopRight == 0 && BottomRight == 0 && BottomLeft == 0; + } + + /// A stroke border the backend nodes consume (derived from a view's Border + /// shape's stroke): Dp of . + public readonly record struct BorderSpec(double Width, Color Color); + + /// + /// Ergonomic sugar for rounded corners, elevation and stroke borders. These write the SAME + /// canonical view environment (ClipShape / Shadow / Border) that Comet's + /// styling system already uses (.ClipShape, .Shadow, .RoundedBorder, + /// ButtonStyles, ViewModifier) — so a Material card / chat bubble is composable + /// and there is a single styling vocabulary the backend nodes read, not a parallel one. + /// + public static class SurfaceExtensions + { + /// Rounds this view's corners (uniform) — sets ClipShape(RoundedRectangle). + public static T CornerRadius(this T view, double radius) where T : View => + view.ClipShape(new RoundedRectangle((float)radius)); + + /// Rounds each corner independently (Dp), top-left, top-right, bottom-right, + /// bottom-left — e.g. the Jetchat bubble (4, 20, 20, 20). Sets + /// ClipShape(AsymmetricRoundedRectangle). + public static T CornerRadius(this T view, double topLeft, double topRight, double bottomRight, double bottomLeft) where T : View => + // AsymmetricRoundedRectangle's ctor order is (topLeft, topRight, bottomLeft, bottomRight). + view.ClipShape(new AsymmetricRoundedRectangle((float)topLeft, (float)topRight, (float)bottomLeft, (float)bottomRight)); + + /// Raises this view with a soft drop shadow of Dp — + /// sets the canonical Shadow (the backend reads its radius as the elevation). + public static T Elevation(this T view, double elevation) where T : View => + view.Shadow(Colors.Black, radius: (float)elevation, x: 0, y: (float)(elevation / 2)); + + /// Strokes this view's outline (following its corner radius) with + /// Dp of — sets the canonical + /// Border shape carrying the stroke. + public static T Border(this T view, double width, Color color) where T : View => + view.Border(new RoundedRectangle(0).Stroke(color, (float)width)); + + /// Renders this container as a Material Surface (its .Background color + + /// .CornerRadius shape) instead of a plain Box — matching the gold standard's + /// Surface(color, shape) chat bubble. A no-op on backends without a Surface widget. + /// A horizontal linear-gradient background (Compose + /// Brush.horizontalGradient / SwiftUI LinearGradient) — the Jetsnack + /// design system's gradient fills. Stops are spaced evenly; respects .CornerRadius(). + public static T BackgroundGradient(this T view, params Microsoft.Maui.Graphics.Color[] colors) where T : View + => view.BackgroundGradient(new GradientSpec(colors)); + + /// A linear-gradient background along — + /// vertical covers the Jetcaster artwork scrims (per-stop alpha rides in the ARGB). + public static T BackgroundGradient(this T view, GradientDirection direction, params Microsoft.Maui.Graphics.Color[] colors) where T : View + => view.BackgroundGradient(new GradientSpec(colors, direction)); + + /// The full gradient spec — extent/offset/mirror model the gold's + /// offsetGradientBackground parallax (see ). + public static T BackgroundGradient(this T view, GradientSpec spec) where T : View + => view.SetEnvironment("Comet.BackgroundGradient", spec, false); + + /// A 2dp diagonal linear-gradient border (the gold's + /// diagonalGradientBorder — Jetsnack chips and stepper circles). + /// Follows .CornerRadius(); stops spaced evenly corner-to-corner. + public static T BorderGradient(this T view, params Microsoft.Maui.Graphics.Color[] colors) where T : View + => view.BorderGradient(new GradientSpec(colors, GradientDirection.Diagonal)); + + /// Gradient border with an explicit spec (direction per stop layout). + public static T BorderGradient(this T view, GradientSpec spec) where T : View + => view.SetEnvironment("Comet.BorderGradient", spec, false); + + /// Render this container as the REAL Material 3 Card (self-themed + /// containerColor/elevation; clickable overload when the view has a tap gesture) — + /// the widget the gold standard uses for tappable cards. Shape from .CornerRadius(). + public static T AsCard(this T view) where T : View + => view.SetEnvironment("Comet.AsCard", true, false); + + public static T AsSurface(this T view) where T : View + { + view.SetEnvironment("Comet.AsSurface", true, false); + return view; + } + } +} diff --git a/src/Comet/src/Comet/Helpers/ViewExtensions.cs b/src/Comet/src/Comet/Helpers/ViewExtensions.cs index d905b3a03..4cf9deaf0 100644 --- a/src/Comet/src/Comet/Helpers/ViewExtensions.cs +++ b/src/Comet/src/Comet/Helpers/ViewExtensions.cs @@ -136,6 +136,12 @@ public static T OnLongPress(this T view, Action action) where T : View public static T OnPan(this T view, Action action) where T : View => view.AddGesture(new PanGesture(action)); + /// Press-and-hold voice-record gesture (gold Jetchat RecordButton). The handler + /// is invoked through the phases of a — Started on long-press, + /// Running on each drag, Completed on release, Canceled on a swipe-to-cancel. + public static T OnRecord(this T view, Action action) where T : View + => view.AddGesture(new RecordGesture(action)); + public static T OnPinch(this T view, Action action) where T : View => view.AddGesture(new PinchGesture(action)); @@ -196,11 +202,10 @@ public static T AutomationId(this T view, string automationId) where T : View /// public static IMauiContext GetMauiContext(this View view) { - //IF there is only one app, with one window, then there is only one context. - //Don't go hunting! - if (CometApp.CurrentApp.Windows.Count == 1) - return CometApp.MauiContext; - return view.FindParentOfType()?.MauiContext ?? CometApp.MauiContext; + // The MAUI host (CometApp) was deleted with the legacy render path (Phase 5); + // the node backends (Compose/SwiftUI) never run inside a MauiContext. Null lets + // callers (e.g. the animation-manager probe) fall through to Comet-owned services. + return null; } public static T Aspect(this T image, Aspect aspect) where T : Image => diff --git a/src/Comet/src/Comet/Layout/YogaMeasureBridge.cs b/src/Comet/src/Comet/Layout/YogaMeasureBridge.cs index 165eeff03..2f73756f9 100644 --- a/src/Comet/src/Comet/Layout/YogaMeasureBridge.cs +++ b/src/Comet/src/Comet/Layout/YogaMeasureBridge.cs @@ -127,7 +127,13 @@ public static void ApplyStyle( // Cross-axis: explicit FlexAlignSelf wins; otherwise an explicit cross-axis // LayoutAlignment maps to AlignSelf. If the child didn't set anything we leave // AlignSelf=Auto so the parent's AlignItems governs. - if (flexAlignSelf != YogaFlexAlign.Auto) + // Baseline opt-in (.AlignBaseline()) wins on a row's cross axis: align this child to the + // shared text baseline. Only meaningful in a row; a column ignores it. + if (parentIsRow && cometView?.GetBaselineAlign() == true) + { + node.AlignSelf = YogaFlexAlign.Baseline; + } + else if (flexAlignSelf != YogaFlexAlign.Auto) { node.AlignSelf = explicitAlignSelf; } diff --git a/src/Comet/src/Comet/Maui/CometApp.cs b/src/Comet/src/Comet/Maui/CometApp.cs deleted file mode 100644 index 856cea60e..000000000 --- a/src/Comet/src/Comet/Maui/CometApp.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Maui; -using Microsoft.Maui.ApplicationModel; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Hosting; - -namespace Comet -{ - - public class CometApp : View, IApplication, IMauiContextHolder - { - const string MauiWindowIdKey = "__MAUI_WINDOW_ID__"; - public CometApp() - { - CurrentApp = this; -#if __IOS__ - ModalView.PerformPresent = (o) => ThreadHelper.RunOnMainThread(()=> { - var vc = PresentingViewController; - vc?.PresentViewController(new Comet.iOS.CometViewController{MauiContext = o.GetMauiContext(),CurrentView = o}, true, null); - }); - ModalView.PerformDismiss = () => ThreadHelper.RunOnMainThread( ()=> PresentingViewController?.DismissViewController(true, null)); -#elif ANDROID - - ModalView.PerformPresent = Comet.Android.Controls.ModalManager.ShowModal; - ModalView.PerformDismiss = Comet.Android.Controls.ModalManager.DismisModal; -#endif - } - public static CometApp CurrentApp { get; protected set; } - public static CometWindow CurrentWindow { get; protected set; } - public static IMauiContext MauiContext => CometContext.Current ?? - CurrentWindow?.MauiContext ?? - (CurrentApp as IMauiContextHolder)?.MauiContext; - - public static float DisplayScale => CurrentWindow?.DisplayScale ?? 1; - List windows = new List(); - public IReadOnlyList Windows => windows; - - - IWindow IApplication.CreateWindow(IActivationState activationState) - { - ((IMauiContextHolder)this).MauiContext = activationState.Context; - - windows.Add(CurrentWindow = new CometWindow - { - MauiContext = activationState.Context, - Content = this, - }); - return CurrentWindow; - } - - void IApplication.ThemeChanged() - { - // System theme changes are observed here but the framework no longer - // forcibly swaps the active Comet theme. Apps that want to follow the - // platform light/dark setting should subscribe to - // Microsoft.Maui.ApplicationModel.AppInfo.Current.RequestedThemeChanged - // (or override this method) and call ThemeManager.SetTheme(...) with - // their own Theme instance. See STYLE_THEME_SPEC.md §6.5. - } - - IMauiContext IMauiContextHolder.MauiContext { get; set; } - - IReadOnlyList IApplication.Windows => windows; - void IApplication.OpenWindow(IWindow window) - { - if (window is CometWindow cwindow) - OpenWindow(cwindow); - } - - public virtual void OpenWindow(CometWindow window) - { - - - var state = new PersistedState - { - [MauiWindowIdKey] = window.Id - }; - - ViewHandler?.Invoke(nameof(IApplication.OpenWindow), new OpenWindowRequest(State: state)); - } - - void IApplication.CloseWindow(IWindow window) => ViewHandler?.Invoke(nameof(IApplication.CloseWindow), window); - - -#if __IOS__ - internal static UIKit.UIViewController PresentingViewController - { - get - { - var window = UIKit.UIApplication.SharedApplication.ConnectedScenes - .OfType() - .SelectMany(s => s.Windows) - .FirstOrDefault(w => w.IsKeyWindow); - var vc = window?.RootViewController; - while (vc?.PresentedViewController is not null) - vc = vc.PresentedViewController; - return vc; - } - } - -#endif - AppTheme IApplication.UserAppTheme => AppTheme.Unspecified; - } -} diff --git a/src/Comet/src/Comet/Maui/CometWindow.cs b/src/Comet/src/Comet/Maui/CometWindow.cs deleted file mode 100644 index 07c7f32b2..000000000 --- a/src/Comet/src/Comet/Maui/CometWindow.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; -using System.Collections.Generic; -using Comet.Internal; -using Microsoft.Maui; -using Microsoft.Maui.Platform; - -namespace Comet -{ - public class CometWindow : ContentView, IWindow, IToolbarElement - { - public float DisplayScale { get; private set; } = 1; - private IMauiContext mauiContext; - public IMauiContext MauiContext - { - get => mauiContext; - set - { - mauiContext = value; -#if __IOS__ - -#elif __ANDROID__ - DisplayScale = mauiContext?.Context?.Resources.DisplayMetrics.Density ?? 1; -#endif - } - } - IView IWindow.Content - { - get => this.Content; - } - - - public new string Title => this.Content.GetTitle(); - - HashSet _overlays = new HashSet(); - - IVisualDiagnosticsOverlay IWindow.VisualDiagnosticsOverlay { get; } - - IReadOnlyCollection IWindow.Overlays => _overlays; - - void IWindow.Created() - { - - } - void IWindow.Resumed() - { - } - void IWindow.Activated() - { - - } - void IWindow.Deactivated() - { - - } - void IWindow.Stopped() - { - - } - void IWindow.Destroying() - { - - } - - bool IWindow.BackButtonClicked() - { - var nav = this.Content.FindNavigation(); - nav.Pop(); - return true; - } - void IWindow.Backgrounding(IPersistedState state) - { - - } - - bool IWindow.AddOverlay(IWindowOverlay overlay) - { - if (overlay is IVisualDiagnosticsOverlay) - return false; - - // Add the overlay. If it's added, - // Initalize the native layer if it wasn't already, - // and call invalidate so it will be drawn. - var result = _overlays.Add(overlay); - if (result) - { - overlay.Initialize(); - overlay.Invalidate(); - } - - return result; - } - bool IWindow.RemoveOverlay(IWindowOverlay overlay) - { - if (overlay is IVisualDiagnosticsOverlay) - return false; - - var result = _overlays.Remove(overlay); - if (result) - overlay.Deinitialize(); - - return result; - - } - - void IWindow.DisplayDensityChanged(float displayDensity) => DisplayScale = displayDensity; - float IWindow.RequestDisplayDensity() => ViewHandler?.InvokeWithResult(nameof(IWindow.RequestDisplayDensity), new DisplayDensityRequest()) ?? DisplayScale; - void IWindow.FrameChanged(Rect frame) => this.Frame = frame; - - public static IToolbar Toolbar = new Toolbar(true, true); - IToolbar IToolbarElement.Toolbar => this.GetProperty(nameof(IToolbarElement.Toolbar), false) ?? Toolbar; - - FlowDirection IWindow.FlowDirection => this.GetEnvironment(nameof(IWindow.FlowDirection)); - - double IWindow.X => this.Frame.X; - - double IWindow.Y => this.Frame.Y; - - double IWindow.Width => this.Frame.Width; - - double IWindow.MinimumWidth => this.GetEnvironment(nameof(IWindow.MinimumWidth)); - - double IWindow.MaximumWidth => this.GetEnvironment(nameof(IWindow.MaximumWidth)) ?? double.MaxValue; - - double IWindow.Height => this.Frame.Height; - - double IWindow.MinimumHeight => this.GetEnvironment(nameof(IWindow.MinimumHeight)); - - double IWindow.MaximumHeight => this.GetEnvironment(nameof(IWindow.MaximumHeight)) ?? double.MaxValue; - } -} diff --git a/src/Comet/src/Comet/Maui/IMauiContextHolder.cs b/src/Comet/src/Comet/Maui/IMauiContextHolder.cs deleted file mode 100644 index 4e12cc1e8..000000000 --- a/src/Comet/src/Comet/Maui/IMauiContextHolder.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using Microsoft.Maui; - -namespace Comet -{ - public interface IMauiContextHolder - { - IMauiContext MauiContext {get;set;} - } -} diff --git a/src/Comet/src/Comet/PackAssets/Comet.targets b/src/Comet/src/Comet/PackAssets/Comet.targets new file mode 100644 index 000000000..cbbafaf24 --- /dev/null +++ b/src/Comet/src/Comet/PackAssets/Comet.targets @@ -0,0 +1,16 @@ + + + + + Framework + False + + + diff --git a/src/Comet/src/Comet/Platform/Android/CometFragment.cs b/src/Comet/src/Comet/Platform/Android/CometFragment.cs deleted file mode 100644 index 60945f4f6..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometFragment.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System; -using Android.OS; -using Android.Views; -using AView = Android.Views.View; -using Comet.Internal; -using AndroidX.Fragment.App; -using Microsoft.Maui; - -namespace Comet.Android.Controls -{ - public class CometFragment : Fragment - { - CometView containerView; - IView startingCurrentView; - - public IMauiContext MauiContext { get; set; } - - public CometFragment() - { - - } - - public CometFragment(IMauiContext mauiContext) - { - MauiContext = mauiContext; - } - - public CometFragment(View view, IMauiContext mauiContext) : this(mauiContext) - { - this.CurrentView = view; - } - - public string Title { get; set; } - - public IView CurrentView - { - get => containerView?.CurrentView ?? startingCurrentView; - set - { - if (containerView is not null) - containerView.CurrentView = value; - else - startingCurrentView = value; - var view = value as View; - Title = view?.GetEnvironment(EnvironmentKeys.View.Title) ?? view?.BuiltView?.GetEnvironment(EnvironmentKeys.View.Title) ?? ""; - } - } - - public override AView OnCreateView(LayoutInflater inflater, - ViewGroup container, - Bundle savedInstanceState) - { - if (CurrentView is null && savedInstanceState is not null) - { - var oldViewId = savedInstanceState.GetString(currentViewID); - var oldView = Comet.Internal.Extensions.FindViewById(null, oldViewId); - startingCurrentView = oldView; - MauiContext = oldView.ViewHandler?.MauiContext; - } - - containerView ??= new CometView(MauiContext); - containerView.CurrentView = startingCurrentView; - return containerView; - } - - const string currentViewID = nameof(currentViewID); - public override void OnSaveInstanceState(Bundle outState) - { - if (CurrentView is not null) - { - string viewId = (CurrentView as View)?.Id; - outState.PutString(currentViewID, viewId); - } - - base.OnSaveInstanceState(outState); - } - - public override void OnDestroy() - { - if (containerView is not null && containerView.CurrentView is not null) - { - //containerView.CurrentView.ViewHandler = null; - //containerView.CurrentView.Dispose(); - containerView.CurrentView = null; - } - - base.OnDestroy(); - this.Dispose(); - } - } -} diff --git a/src/Comet/src/Comet/Platform/Android/CometNavigationView.cs b/src/Comet/src/Comet/Platform/Android/CometNavigationView.cs deleted file mode 100644 index 521b0954a..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometNavigationView.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System; -using System.Collections.Generic; -using Android.Content; -using Android.Views; -using Android.Widget; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Platform; -using AndroidX.Activity; -using AView = Android.Views.View; - -namespace Comet.Android.Controls -{ -public class CometNavigationView : CustomFrameLayout -{ -IMauiContext MauiContext { get; set; } -readonly Stack viewStack = new(); -AView currentPlatformView; -IView currentVirtualView; -View currentView; - -public CometNavigationView(IMauiContext context) : base(context.Context) -{ -MauiContext = context; -} - -public void SetRoot(View view) -{ -if (!isAttached) -{ -contentView = view; -} -else -{ -viewStack.Clear(); -ShowView(view); -} -} - -void ShowView(View view) -{ -if (currentPlatformView is not null) -{ -RemoveView(currentPlatformView); -currentPlatformView = null; -currentVirtualView = null; -} - -currentView = view; - -var renderView = view.GetView(); -IView viewToRender = (renderView is not null && renderView != view) ? renderView : view; - -var platformView = viewToRender.ToPlatform(MauiContext); -if (platformView is not null) -{ -if (platformView.Parent is not null) -(platformView.Parent as ViewGroup)?.RemoveView(platformView); -currentPlatformView = platformView; -currentVirtualView = viewToRender; -AddView(currentPlatformView, new FrameLayout.LayoutParams( -LayoutParams.MatchParent, LayoutParams.MatchParent)); -ForceLayout(); -} -} - -void ForceLayout() -{ -RequestLayout(); -// Post a deferred layout to ensure the view tree is fully measured -Post(() => -{ -if (currentPlatformView is not null && Width > 0 && Height > 0) -{ -MeasureAndArrange(); -currentPlatformView.Layout(0, 0, Width, Height); -// Force a draw pass - without this, complex views added -// dynamically during navigation never get their initial draw -InvalidateViewTree(currentPlatformView); -} -}); -} - -static void InvalidateViewTree(AView view) -{ -view.Invalidate(); -if (view is ViewGroup vg) -{ -for (int i = 0; i < vg.ChildCount; i++) -InvalidateViewTree(vg.GetChildAt(i)); -} -} - -void MeasureAndArrange() -{ -if (currentVirtualView is null || Width <= 0 || Height <= 0) -return; -var density = Context?.Resources?.DisplayMetrics?.Density ?? 1; -var widthDp = Width / density; -var heightDp = Height / density; -currentVirtualView.Measure(widthDp, heightDp); -currentVirtualView.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, widthDp, heightDp)); -} - -public void NavigateTo(View view) -{ -if (currentView is not null) -viewStack.Push(currentView); -ShowView(view); -UpdateBackCallbackState(); -} - -public bool CanGoBack => viewStack.Count > 0; - -OnBackPressedCallback backCallback; - -bool isAttached = false; -View contentView; -protected override void OnAttachedToWindow() -{ -base.OnAttachedToWindow(); -isAttached = true; -if (contentView is not null) -{ -SetRoot(contentView); -contentView = null; -} -RegisterBackHandler(); -} - -protected override void OnDetachedFromWindow() -{ -base.OnDetachedFromWindow(); -backCallback?.Remove(); -backCallback = null; -isAttached = false; -} - -void RegisterBackHandler() -{ -if (Context is ComponentActivity activity) -{ -backCallback = new NavigationBackCallback(this); -activity.OnBackPressedDispatcher.AddCallback(backCallback); -UpdateBackCallbackState(); -} -} - -void UpdateBackCallbackState() -{ -if (backCallback is not null) -backCallback.Enabled = CanGoBack; -} - -class NavigationBackCallback : OnBackPressedCallback -{ -readonly CometNavigationView nav; -public NavigationBackCallback(CometNavigationView nav) : base(false) -{ -this.nav = nav; -} -public override void HandleOnBackPressed() -{ -nav.Pop(); -} -} - -protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) -{ -base.OnMeasure(widthMeasureSpec, heightMeasureSpec); -var widthSize = AView.MeasureSpec.GetSize(widthMeasureSpec); -var heightSize = AView.MeasureSpec.GetSize(heightMeasureSpec); -for (int i = 0; i < ChildCount; i++) -{ -var child = GetChildAt(i); -child?.Measure( -AView.MeasureSpec.MakeMeasureSpec(widthSize, global::Android.Views.MeasureSpecMode.Exactly), -AView.MeasureSpec.MakeMeasureSpec(heightSize, global::Android.Views.MeasureSpecMode.Exactly)); -} -} - -protected override void OnLayout(bool changed, int left, int top, int right, int bottom) -{ -var width = right - left; -var height = bottom - top; - -MeasureAndArrange(); - -for (int i = 0; i < ChildCount; i++) -{ -var child = GetChildAt(i); -child?.Layout(0, 0, width, height); -} -} - -public void Pop() -{ -if (viewStack.Count > 0) -{ -var previous = viewStack.Pop(); -ShowView(previous); -UpdateBackCallbackState(); -} -} -} -} diff --git a/src/Comet/src/Comet/Platform/Android/CometRecyclerView.cs b/src/Comet/src/Comet/Platform/Android/CometRecyclerView.cs deleted file mode 100644 index 2ce5d67c7..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometRecyclerView.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Android.Content; -using AndroidX.RecyclerView.Widget; -using Microsoft.Maui; - -namespace Comet.Android.Controls -{ - public class CometRecyclerView : RecyclerView - { - - //private ListView listView; - readonly CometRecyclerViewAdapter adapter; - public CometRecyclerView(IMauiContext mauiContext) : base(mauiContext.Context) - { - var layoutManager = new LinearLayoutManager(mauiContext.Context); - SetLayoutManager(layoutManager); - SetAdapter(adapter =new CometRecyclerViewAdapter() { MauiContext = mauiContext}); - AddItemDecoration(new DividerItemDecoration(mauiContext.Context, layoutManager.Orientation)); - } - - public IListView ListView - { - get => adapter.ListView; - set => adapter.ListView = value; - } - - public void ReloadData() => adapter.NotifyDataSetChanged(); - } -} \ No newline at end of file diff --git a/src/Comet/src/Comet/Platform/Android/CometRecyclerViewAdapter.cs b/src/Comet/src/Comet/Platform/Android/CometRecyclerViewAdapter.cs deleted file mode 100644 index 55e813d81..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometRecyclerViewAdapter.cs +++ /dev/null @@ -1,84 +0,0 @@ -using AndroidX.RecyclerView.Widget; -using Android.Views; -using Android.Widget; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; - -namespace Comet.Android.Controls -{ - public class CometRecyclerViewAdapter : RecyclerView.Adapter - { - public IMauiContext MauiContext{get;set;} - public IListView ListView { get; set; } - - //TODO: Account for Section - public override int ItemCount => ListView?.Rows(0) ?? 0; - - public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int position) - { - //TODO: Account for Section - var view = ListView?.ViewFor(0, position); - var cell = view?.ToPlatform(MauiContext); - - if (holder is CometRecyclerViewHolder rvh && cell is not null) - { - Logger.Debug($"OnBindViewHolder"); - - var parent = rvh.Parent; - var density = MauiContext.Context.Resources.DisplayMetrics.Density; - - // Use laid-out dimensions first, fall back to measured dimensions, - // then to screen dimensions as a last resort. - var parentWidth = parent.Width > 0 ? parent.Width - : parent.MeasuredWidth > 0 ? parent.MeasuredWidth - : MauiContext.Context.Resources.DisplayMetrics.WidthPixels; - var parentHeight = parent.Height > 0 ? parent.Height - : parent.MeasuredHeight > 0 ? parent.MeasuredHeight - : MauiContext.Context.Resources.DisplayMetrics.HeightPixels; - - var scaledSize = new Size(parentWidth / density, parentHeight / density); - var measuredSize = view.Measure(scaledSize, true); - view.MeasuredSize = measuredSize; - view.MeasurementValid = true; - - var itemHeight = (int)(measuredSize.Height * density); - if (itemHeight <= 0) itemHeight = (int)(48 * density); // fallback minimum height - - rvh.CometView.LayoutParameters = new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MatchParent, - itemHeight); - - // Add our view to the cell. - rvh.CometView.CurrentView = view; - - // Disable click/focus on child views so the item click listener fires - DisableChildClicks(rvh.CometView); - } - else - { - Logger.Warn("Should never happen."); - } - - } - - public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType) - { - return new CometRecyclerViewHolder(parent, ListView, MauiContext); - } - - static void DisableChildClicks(ViewGroup parent) - { - for (int i = 0; i < parent.ChildCount; i++) - { - var child = parent.GetChildAt(i); - if (child is not null) - { - child.Clickable = false; - child.Focusable = false; - if (child is ViewGroup vg) - DisableChildClicks(vg); - } - } - } - } -} \ No newline at end of file diff --git a/src/Comet/src/Comet/Platform/Android/CometRecyclerViewHolder.cs b/src/Comet/src/Comet/Platform/Android/CometRecyclerViewHolder.cs deleted file mode 100644 index 0ce2f6937..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometRecyclerViewHolder.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using AndroidX.RecyclerView.Widget; -using Android.Views; -using Android.Widget; -using Microsoft.Maui; -namespace Comet.Android.Controls -{ - public class CometRecyclerViewHolder : RecyclerView.ViewHolder, global::Android.Views.View.IOnClickListener - { - private readonly IListView listView; - public IMauiContext MauiContext {get;set;} - public ViewGroup Parent { get; } - public CometView CometView => (CometView)ItemView; - - public CometRecyclerViewHolder( - ViewGroup parent, - IListView listView, IMauiContext mauiContext) : base(new CometView(mauiContext)) - { - MauiContext = mauiContext; - Parent = parent; - this.listView = listView; - - ItemView.Clickable = true; - ItemView.SetOnClickListener(this); - } - - public void OnClick(global::Android.Views.View v) => listView?.OnSelected(0, BindingAdapterPosition); - } -} \ No newline at end of file diff --git a/src/Comet/src/Comet/Platform/Android/CometScrollView.cs b/src/Comet/src/Comet/Platform/Android/CometScrollView.cs deleted file mode 100644 index 9ea0815a1..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometScrollView.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Android.Content; -using AScrollView = Android.Widget.ScrollView; -using AView = Android.Views.View; -using AViewGroup = Android.Views.ViewGroup; -using HScrollView = Android.Widget.HorizontalScrollView; - -namespace Comet.Android.Controls -{ - public class CometScrollView : AScrollView - { - AViewGroup scrollView; - AView currentView; - Orientation currentOrientation; - public void SetVirtualView(ScrollView view, IMauiContext context) - { - var newContent = view.Content?.ToPlatform(context); - if(scrollView is null || currentOrientation != view.Orientation) - { - if (currentView is not null) - scrollView?.RemoveView(currentView); - currentView = null; - if (scrollView is not null) - this.RemoveView(scrollView); - - currentOrientation = view.Orientation; - AddView(scrollView = currentOrientation == Orientation.Horizontal ? new HScrollView(Context) : new AScrollView(Context)); - } - - if (newContent != currentView) { - if(currentView is not null) - scrollView?.RemoveView(currentView); - currentView = newContent; - scrollView.AddView(currentView); - } - - } - public CometScrollView(Context context) : base(context) - { - } - - internal Action CrossPlatformArrange { get; set; } - - protected override void OnLayout(bool changed, int left, int top, int right, int bottom) - { - scrollView?.Layout(0, 0, this.Width, this.Height); - if (CrossPlatformArrange is null || Context is null) - { - return; - } - var deviceIndependentLeft = Context.FromPixels(left); - var deviceIndependentTop = Context.FromPixels(top); - var deviceIndependentRight = Context.FromPixels(right); - var deviceIndependentBottom = Context.FromPixels(bottom); - - var destination = Rect.FromLTRB(0, 0, - deviceIndependentRight - deviceIndependentLeft, deviceIndependentBottom - deviceIndependentTop); - - CrossPlatformArrange(destination); - } - } -} diff --git a/src/Comet/src/Comet/Platform/Android/CometTabView.cs b/src/Comet/src/Comet/Platform/Android/CometTabView.cs deleted file mode 100644 index 478079ede..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometTabView.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Android.Content; -using Android.Util; -using Android.Views; -using Android.Widget; -using Google.Android.Material.BottomNavigation; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Platform; -using AView = Android.Views.View; - -namespace Comet.Android.Controls -{ -public class CometTabView : CustomFrameLayout -{ -readonly BottomNavigationView _bottomNavigationView; -readonly FrameLayout _contentContainer; -List _views; -AView _currentPlatformView; -IView _currentVirtualView; -int _selectedIndex = -1; - -public IMauiContext MauiContext { get; set; } - -public CometTabView(IMauiContext context) : base(context.Context) -{ -MauiContext = context; - -_contentContainer = new FrameLayout(context.Context) -{ -LayoutParameters = new LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent) -}; -AddView(_contentContainer); - -_bottomNavigationView = new BottomNavigationView(context.Context) -{ -LayoutParameters = new LayoutParams(LayoutParams.MatchParent, LayoutParams.WrapContent) -{ -Gravity = GravityFlags.Bottom -} -}; - -var val = new TypedValue(); -context.Context.Theme.ResolveAttribute(global::Android.Resource.Attribute.ColorBackground, val, true); -_bottomNavigationView.SetBackgroundColor(new global::Android.Graphics.Color(val.Data)); -_bottomNavigationView.ItemSelected += HandleNavigationItemSelected; -AddView(_bottomNavigationView); -} - -public void CreateTabs(IList views) -{ -_views = views?.ToList(); -_bottomNavigationView.Menu.Clear(); - -if (views is null) return; - -for (int i = 0; i < views.Count; i++) -{ -var view = views[i]; -var title = view.GetEnvironment(EnvironmentKeys.TabView.Title); -_bottomNavigationView.Menu.Add(0, i, i, title); -} -} - -protected override void OnAttachedToWindow() -{ -base.OnAttachedToWindow(); -if (_views is not null && _views.Count > 0 && _selectedIndex < 0) -ShowTab(0); -} - -void ShowTab(int index) -{ -if (_views is null || index < 0 || index >= _views.Count) -return; - -if (_currentPlatformView is not null) -{ -_contentContainer.RemoveView(_currentPlatformView); -_currentPlatformView = null; -_currentVirtualView = null; -} - -_selectedIndex = index; -var view = _views[index]; -var renderView = view.GetView(); -IView viewToRender = (renderView is not null && renderView != view) ? renderView : view; - -var platformView = viewToRender.ToPlatform(MauiContext); -if (platformView is not null) -{ -if (platformView.Parent is not null) -(platformView.Parent as ViewGroup)?.RemoveView(platformView); -_currentPlatformView = platformView; -_currentVirtualView = viewToRender; -_contentContainer.AddView(_currentPlatformView, new FrameLayout.LayoutParams( -LayoutParams.MatchParent, LayoutParams.MatchParent)); -RequestLayout(); -Post(() => -{ -// Use measured dimensions as fallback when layout hasn't completed yet -var containerWidth = _contentContainer.Width > 0 ? _contentContainer.Width : _contentContainer.MeasuredWidth; -var containerHeight = _contentContainer.Height > 0 ? _contentContainer.Height : _contentContainer.MeasuredHeight; -if (_currentPlatformView is not null && containerWidth > 0 && containerHeight > 0) -{ -MeasureAndArrangeContent(); -_currentPlatformView.Layout(0, 0, containerWidth, containerHeight); -InvalidateViewTree(_currentPlatformView); -} -}); -} -} - -void MeasureAndArrangeContent() -{ -if (_currentVirtualView is null) return; -var density = Context?.Resources?.DisplayMetrics?.Density ?? 1; -var containerWidth = _contentContainer.Width > 0 ? _contentContainer.Width : _contentContainer.MeasuredWidth; -var containerHeight = _contentContainer.Height > 0 ? _contentContainer.Height : _contentContainer.MeasuredHeight; -if (containerWidth <= 0 || containerHeight <= 0) return; -var widthDp = containerWidth / density; -var heightDp = containerHeight / density; -_currentVirtualView.Measure(widthDp, heightDp); -_currentVirtualView.Arrange(new Rect(0, 0, widthDp, heightDp)); -} - -static void InvalidateViewTree(AView view) -{ -view.Invalidate(); -if (view is ViewGroup vg) -{ -for (int i = 0; i < vg.ChildCount; i++) -InvalidateViewTree(vg.GetChildAt(i)); -} -} - -protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) -{ -var widthSize = AView.MeasureSpec.GetSize(widthMeasureSpec); -var heightSize = AView.MeasureSpec.GetSize(heightMeasureSpec); - -// Ensure we have valid dimensions -if (widthSize <= 0) widthSize = Resources.DisplayMetrics.WidthPixels; -if (heightSize <= 0) heightSize = Resources.DisplayMetrics.HeightPixels; - -// Measure bottom nav first -_bottomNavigationView.Measure( -AView.MeasureSpec.MakeMeasureSpec(widthSize, MeasureSpecMode.Exactly), -AView.MeasureSpec.MakeMeasureSpec(heightSize, MeasureSpecMode.AtMost)); -var navHeight = _bottomNavigationView.MeasuredHeight; - -// Content gets remaining height (never zero) -var contentHeight = Math.Max(1, heightSize - navHeight); -_contentContainer.Measure( -AView.MeasureSpec.MakeMeasureSpec(widthSize, MeasureSpecMode.Exactly), -AView.MeasureSpec.MakeMeasureSpec(contentHeight, MeasureSpecMode.Exactly)); - -// Explicitly measure the current content view so dynamically added -// children get proper dimensions (FrameLayout won't re-measure them). -if (_currentPlatformView is not null) -{ -_currentPlatformView.Measure( -AView.MeasureSpec.MakeMeasureSpec(widthSize, MeasureSpecMode.Exactly), -AView.MeasureSpec.MakeMeasureSpec(contentHeight, MeasureSpecMode.Exactly)); -} - -SetMeasuredDimension(widthSize, heightSize); -} - -protected override void OnLayout(bool changed, int left, int top, int right, int bottom) -{ -var width = right - left; -var height = bottom - top; -var navHeight = _bottomNavigationView.MeasuredHeight; -var contentHeight = height - navHeight; - -_contentContainer.Layout(0, 0, width, contentHeight); -_bottomNavigationView.Layout(0, contentHeight, width, height); - -// Explicitly layout the current content view within the container bounds -if (_currentPlatformView is not null) -{ -_currentPlatformView.Layout(0, 0, width, contentHeight); -} - -MeasureAndArrangeContent(); -} - -void HandleNavigationItemSelected(object sender, Google.Android.Material.Navigation.NavigationBarView.ItemSelectedEventArgs e) -{ -var index = e.Item.ItemId; -if (index != _selectedIndex) -ShowTab(index); -} -} -} diff --git a/src/Comet/src/Comet/Platform/Android/CometTouchGesterListener.cs b/src/Comet/src/Comet/Platform/Android/CometTouchGesterListener.cs deleted file mode 100644 index 07133d7de..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometTouchGesterListener.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Android.Views; -using AView = Android.Views.View; -namespace Comet.Android.Controls -{ - public class CometTouchGestureListener : Java.Lang.Object, AView.IOnTouchListener - { - class GestureDetectorListener : Java.Lang.Object, GestureDetector.IOnGestureListener, ScaleGestureDetector.IOnScaleGestureListener - { - readonly GestureDetector gestureDetector; - readonly ScaleGestureDetector scaleDetector; - public GestureDetectorListener(View view) - { - var context = view.GetMauiContext().Context; - gestureDetector = new GestureDetector(context, this); - scaleDetector = new ScaleGestureDetector(context, this); - } - - - public bool OnDown(MotionEvent e) => true; - - public bool OnFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) - { - if (dictionary.TryGetValue(e1, out var listener)) - { - float diffX = e2.GetX() - e1.GetX(); - float diffY = e2.GetY() - e1.GetY(); - - if (Math.Abs(diffX) > Math.Abs(diffY)) - { - var direction = diffX > 0 ? SwipeDirection.Right : SwipeDirection.Left; - listener.OnSwipe(direction); - } - else - { - var direction = diffY > 0 ? SwipeDirection.Down : SwipeDirection.Up; - listener.OnSwipe(direction); - } - } - return true; - } - - public void OnLongPress(MotionEvent e) - { - if (dictionary.TryGetValue(e, out var listener)) - listener.OnLongPress(); - } - - public bool OnScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) - { - if (dictionary.TryGetValue(e1, out var listener)) - { - float totalX = e2.GetX() - e1.GetX(); - float totalY = e2.GetY() - e1.GetY(); - var status = e2.Action == MotionEventActions.Up ? GestureStatus.Completed : GestureStatus.Running; - listener.OnPan(totalX, totalY, status); - } - return true; - } - - public void OnShowPress(MotionEvent e) - { - } - - public bool OnSingleTapUp(MotionEvent e) - { - if (dictionary.TryGetValue(e, out var listener)) - listener.OnTap(); - return true; - } - - // Scale gesture callbacks - public bool OnScale(ScaleGestureDetector detector) - { - if (currentListener is not null) - currentListener.OnPinch(detector.ScaleFactor, GestureStatus.Running); - return true; - } - - public bool OnScaleBegin(ScaleGestureDetector detector) - { - if (currentListener is not null) - currentListener.OnPinch(detector.ScaleFactor, GestureStatus.Started); - return true; - } - - public void OnScaleEnd(ScaleGestureDetector detector) - { - if (currentListener is not null) - currentListener.OnPinch(detector.ScaleFactor, GestureStatus.Completed); - } - - CometTouchGestureListener currentListener; - Dictionary dictionary = new Dictionary(); - public bool OnTouchEvent(CometTouchGestureListener v, MotionEvent e) - { - var isComplete = e.IsComplete(); - try - { - if (!isComplete) - { - dictionary[e] = v; - currentListener = v; - } - Logger.Debug($"Touch dictionary {dictionary.Count}"); - bool handled = scaleDetector.OnTouchEvent(e); - handled |= gestureDetector.OnTouchEvent(e); - return handled; - } - finally - { - if (isComplete) - { - dictionary.Remove(e); - currentListener = null; - } - } - } - } - - AView nativeView; - View view; - GestureDetectorListener _gestureDetector; - GestureDetectorListener gestureDetector => _gestureDetector ?? (_gestureDetector = new GestureDetectorListener(view)); - public CometTouchGestureListener(AView nativeView, View view) - { - this.view = view; - this.nativeView = nativeView; - nativeView.SetOnTouchListener(this); - } - List gestures = new List(); - public void AddGesture(Gesture gesture) => gestures.Add(gesture); - - public void RemoveGesture(Gesture gesture) => gestures.Remove(gesture); - - protected void OnTap() - { - foreach (var g in gestures.OfType()) - g.Invoke(); - } - - protected void OnLongPress() - { - foreach (var g in gestures.OfType()) - g.Invoke(); - } - - protected void OnPan(float totalX, float totalY, GestureStatus status) - { - foreach (var g in gestures.OfType()) - { - g.TotalX = totalX; - g.TotalY = totalY; - g.Status = status; - g.Invoke(); - } - } - - protected void OnPinch(float scale, GestureStatus status) - { - foreach (var g in gestures.OfType()) - { - g.Scale = scale; - g.Status = status; - g.Invoke(); - } - } - - protected void OnSwipe(SwipeDirection direction) - { - foreach (var g in gestures.OfType()) - { - if (g.Direction == direction) - g.Invoke(); - } - } - - public bool OnTouch(AView v, MotionEvent e) - { - return gestureDetector.OnTouchEvent(this, e); - } - protected override void Dispose(bool disposing) - { - nativeView.SetOnTouchListener(null); - base.Dispose(disposing); - } - - } -} diff --git a/src/Comet/src/Comet/Platform/Android/CometView.cs b/src/Comet/src/Comet/Platform/Android/CometView.cs deleted file mode 100644 index 77b441c9b..000000000 --- a/src/Comet/src/Comet/Platform/Android/CometView.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using AContext = Android.Content.Context; -using AView = Android.Views.View; -using AViewGroup = Android.Views.ViewGroup; -using Microsoft.Maui; -using Microsoft.Maui.HotReload; -using Microsoft.Maui.Graphics; - -namespace Comet.Android -{ - public class CometView : AViewGroup, IReloadHandler - { - IView _view; - IViewHandler currentHandler; - AView currentPlatformView; - private bool inLayout; - - IMauiContext MauiContext; - public CometView(IMauiContext mc) : base(mc.Context) - { - MauiContext = mc; - } - - public IView CurrentView - { - get => _view; - set => SetView(value); - } - - void SetView(IView view, bool forceRefresh = false) - { - if (view == _view && !forceRefresh) - return; - - _view = view; - - - if (_view is IHotReloadableView ihr) - { - ihr.ReloadHandler = this; - MauiHotReloadHelper.AddActiveView(ihr); - } - // Resolve views with a Body (e.g. Component) to their concrete view tree - var viewToRender = _view; - if (viewToRender is View cometView && cometView.Body is not null) - viewToRender = cometView.GetView(); - var newPlatformView = viewToRender?.ToPlatform(MauiContext); - - if (view is IReplaceableView ir) - currentHandler = ir.ReplacedView.Handler; - else - currentHandler = _view?.Handler; - if (currentPlatformView == newPlatformView) - return; - if (currentPlatformView is not null) - RemoveView(currentPlatformView); - if (_view is null) - return; - - currentPlatformView = currentHandler.PlatformView as AView ?? new AView(MauiContext.Context); - if (currentPlatformView.Parent == this) - return; - if (currentPlatformView.Parent is not null) - (currentPlatformView.Parent as AViewGroup).RemoveView(currentPlatformView); - AddView(currentPlatformView, new LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent)); - - } - - private void HandleNeedsLayout(object sender, EventArgs e) - { - RequestLayout(); - } - - protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) - { - var widthMode = AView.MeasureSpec.GetMode(widthMeasureSpec); - var heightMode = AView.MeasureSpec.GetMode(heightMeasureSpec); - var widthSize = AView.MeasureSpec.GetSize(widthMeasureSpec); - var heightSize = AView.MeasureSpec.GetSize(heightMeasureSpec); - - double nativeWidth = widthSize; - double nativeHeight = heightSize; - - if (CurrentView is not null) - { - var deviceIndependentWidth = widthMeasureSpec.ToDouble(Context); - var deviceIndependentHeight = heightMeasureSpec.ToDouble(Context); - var size = CurrentView.Measure(deviceIndependentWidth, deviceIndependentHeight); - nativeWidth = Context.ToPixels(size.Width); - nativeHeight = Context.ToPixels(size.Height); - } - - // Respect EXACTLY/AT_MOST constraints from parent - if (widthMode == global::Android.Views.MeasureSpecMode.Exactly) - nativeWidth = widthSize; - else if (widthMode == global::Android.Views.MeasureSpecMode.AtMost) - nativeWidth = Math.Min(nativeWidth, widthSize); - - if (heightMode == global::Android.Views.MeasureSpecMode.Exactly) - nativeHeight = heightSize; - else if (heightMode == global::Android.Views.MeasureSpecMode.AtMost) - nativeHeight = Math.Min(nativeHeight, heightSize); - - // Measure children with our resolved size - for (int i = 0; i < ChildCount; i++) - { - var child = GetChildAt(i); - child?.Measure( - AView.MeasureSpec.MakeMeasureSpec((int)nativeWidth, global::Android.Views.MeasureSpecMode.Exactly), - AView.MeasureSpec.MakeMeasureSpec((int)nativeHeight, global::Android.Views.MeasureSpecMode.Exactly)); - } - - SetMeasuredDimension((int)nativeWidth, (int)nativeHeight); - } - - protected override void OnLayout(bool changed, int left, int top, int right, int bottom) - { - if (currentPlatformView is null || inLayout) return; - - var displayScale = Context.Resources.DisplayMetrics.Density; - var width = (right - left) / displayScale; - var height = (bottom - top) / displayScale; - if (width > 0 && height > 0) - { - inLayout = true; - var rect = new Rect(0, 0, width, height); - CurrentView.Arrange(rect); - inLayout = false; - } - } - protected override void OnSizeChanged(int w, int h, int oldw, int oldh) - { - base.OnSizeChanged(w, h, oldw, oldh); - var displayScale = Context.Resources.DisplayMetrics.Density; - var width = w / displayScale; - var height = h / displayScale; - if (width > 0 && height > 0) - { - inLayout = true; - var rect = new Rect(0, 0, width, height); - CurrentView.Arrange(rect); - inLayout = false; - } - } - - protected override void Dispose(bool disposing) - { - //if (disposing) - // CurrentView?.Dispose(); - base.Dispose(disposing); - } - public void Reload() => SetView(CurrentView, true); - } -} \ No newline at end of file diff --git a/src/Comet/src/Comet/Platform/Android/CustomFrameLayout.cs b/src/Comet/src/Comet/Platform/Android/CustomFrameLayout.cs deleted file mode 100644 index 0a5b03b50..000000000 --- a/src/Comet/src/Comet/Platform/Android/CustomFrameLayout.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using Android.Content; -using Android.Runtime; -using Android.Util; -using Android.Views; -using Android.Widget; -using LP = Android.Views.ViewGroup.LayoutParams; -namespace Comet.Android.Controls -{ - public class CustomFrameLayout : FrameLayout - { - public CustomFrameLayout(Context context) : base(context) - { - LayoutParameters = new LP(LP.MatchParent, LP.MatchParent); - Id = global::Android.Views.View.GenerateViewId(); - } - - public CustomFrameLayout(Context context, IAttributeSet attrs) : base(context, attrs) - { - } - - public CustomFrameLayout(Context context, IAttributeSet attrs, int defStyleAttr) : base(context, attrs, defStyleAttr) - { - } - - public CustomFrameLayout(Context context, IAttributeSet attrs, int defStyleAttr, int defStyleRes) : base(context, attrs, defStyleAttr, defStyleRes) - { - } - - protected CustomFrameLayout(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) - { - } - - public override WindowInsets OnApplyWindowInsets(WindowInsets insets) - { - var leftPadding = PaddingLeft; - - var result = base.OnApplyWindowInsets(insets); - - SetPadding(leftPadding, PaddingTop, PaddingRight, PaddingBottom); - - return result; - } - } -} diff --git a/src/Comet/src/Comet/Platform/Android/HandlerExtensions.cs b/src/Comet/src/Comet/Platform/Android/HandlerExtensions.cs deleted file mode 100644 index c3a05c409..000000000 --- a/src/Comet/src/Comet/Platform/Android/HandlerExtensions.cs +++ /dev/null @@ -1,186 +0,0 @@ -using Microsoft.Maui.Handlers; -using Comet.Android.Controls; -using Microsoft.Maui.Graphics; -using Android.Content; -using AView = Android.Views.View; -using MotionEvent = Android.Views.MotionEvent; -using MotionEventActions = Android.Views.MotionEventActions; -using DragEvent = Android.Views.DragEvent; -using DragAction = Android.Views.DragAction; -namespace Comet; - -public static partial class HandlerExtensions -{ - public static void AddGesture(this IViewHandler handler, Gesture gesture) - { - if (gesture is DragGesture dragGesture) - { - var nativeView = handler.PlatformView as AView; - var view = handler.VirtualView as View; - if (nativeView is null) - return; - nativeView.LongClick += (s, e) => - { - if (!dragGesture.CanDrag) - return; - var data = dragGesture.DragStarting?.Invoke(view); - dragGesture.DragStartingCommand?.Execute(dragGesture.DragStartingCommandParameter); - - var text = data?.ToString() ?? string.Empty; - var clipData = ClipData.NewPlainText("drag", text); - var shadow = new AView.DragShadowBuilder(nativeView); - if (OperatingSystem.IsAndroidVersionAtLeast(24)) - nativeView.StartDragAndDrop(clipData, shadow, null, 0); - else -#pragma warning disable CA1422 - nativeView.StartDrag(clipData, shadow, null, 0); -#pragma warning restore CA1422 - }; - gesture.PlatformGesture = nativeView; - return; - } - - if (gesture is DropGesture dropGesture) - { - var nativeView = handler.PlatformView as AView; - var view = handler.VirtualView as View; - if (nativeView is null) - return; - nativeView.SetOnDragListener(new CometDragListener(dropGesture, view)); - gesture.PlatformGesture = nativeView; - return; - } - - if (gesture is PointerGesture pointerGesture) - { - var nativeView = handler.PlatformView as AView; - var view = handler.VirtualView as View; - if (nativeView is null) - return; - nativeView.Hover += (s, e) => - { - var point = new Point(e.Event.GetX(), e.Event.GetY()); - switch (e.Event.Action) - { - case MotionEventActions.HoverEnter: - pointerGesture.PointerEntered?.Invoke(view, point); - pointerGesture.PointerEnteredCommand?.Execute(point); - break; - case MotionEventActions.HoverMove: - pointerGesture.PointerMoved?.Invoke(view, point); - pointerGesture.PointerMovedCommand?.Execute(point); - break; - case MotionEventActions.HoverExit: - pointerGesture.PointerExited?.Invoke(view, point); - pointerGesture.PointerExitedCommand?.Execute(point); - break; - } - e.Handled = true; - }; - gesture.PlatformGesture = nativeView; - return; - } - - var gl = handler.GetGestureListener(true); - gl.AddGesture(gesture); - } - - public static void RemoveGesture(this IViewHandler handler, Gesture gesture) - { - var nativeView = handler.PlatformView as AView; - - if (gesture is DragGesture) - { - if (nativeView is not null) - nativeView.LongClickable = false; - return; - } - - if (gesture is PointerGesture) - { - // Hover events are cleared by removing all hover listeners - // The native view will stop receiving hover events when no longer registered - return; - } - - if (gesture is DropGesture) - { - nativeView?.SetOnDragListener(null); - return; - } - - var gl = handler.GetGestureListener(false); - gl?.RemoveGesture(gesture); - } - public static CometTouchGestureListener GetGestureListener(this IViewHandler handler, bool createIfNull) - { - var v = handler.VirtualView as View; - if (v is null) - return null; - var gl = v.GetEnvironment>(nameof(CometTouchGestureListener), false)?.Object; - if (gl is null && createIfNull) - v.SetEnvironment(nameof(CometTouchGestureListener), new ObjectWrapper { Object = gl = new CometTouchGestureListener(handler.PlatformView as AView, v) }, false); - return gl; - } - public static bool IsComplete(this MotionEvent e) - { - switch (e.Action) - { - case MotionEventActions.Cancel: - case MotionEventActions.Outside: - case MotionEventActions.PointerUp: - case MotionEventActions.Up: - return true; - default: - return false; - } - } - - internal class ObjectWrapper - { - public T Object { get; set; } - } - - class CometDragListener : Java.Lang.Object, AView.IOnDragListener - { - readonly DropGesture _gesture; - readonly View _view; - - public CometDragListener(DropGesture gesture, View view) - { - _gesture = gesture; - _view = view; - } - - public bool OnDrag(AView v, DragEvent e) - { - if (!_gesture.AllowDrop) - return false; - - switch (e.Action) - { - case DragAction.Entered: - var accepted = _gesture.DragOver?.Invoke(_view, null) ?? true; - _gesture.DragOverCommand?.Execute(null); - return accepted; - case DragAction.Location: - break; - case DragAction.Drop: - var clipData = e.ClipData; - object data = clipData?.ItemCount > 0 ? clipData.GetItemAt(0)?.Text : null; - _gesture.Drop?.Invoke(_view, data); - _gesture.DropCommand?.Execute(_gesture.DropCommandParameter ?? data); - return true; - case DragAction.Exited: - _gesture.DragLeave?.Invoke(_view); - _gesture.DragLeaveCommand?.Execute(null); - break; - case DragAction.Ended: - break; - } - return true; - } - } -} - - diff --git a/src/Comet/src/Comet/Platform/Android/ModalManager.cs b/src/Comet/src/Comet/Platform/Android/ModalManager.cs deleted file mode 100644 index 221d7776c..000000000 --- a/src/Comet/src/Comet/Platform/Android/ModalManager.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Android.OS; -using Android.Views; -using AndroidX.Fragment.App; -using AView = Android.Views.View; -using Microsoft.Maui.Platform; -using AndroidX.AppCompat.App; - -namespace Comet.Android.Controls -{ - public class ModalManager - { - static ViewModal currrentDialog; - static List currentDialogs = new List(); - public ModalManager() - { - } - static FragmentManager FragmentManager(View view) => (view.GetMauiContext().Context as AppCompatActivity)?.SupportFragmentManager; - public static void ShowModal(View view) - { - var transaction = FragmentManager(view).BeginTransaction(); - if (currrentDialog is not null) - transaction.Remove(currrentDialog); - transaction.AddToBackStack(null); - - var dialog = new ViewModal(view); - currentDialogs.Add(dialog.HView); - currrentDialog = dialog; - dialog.Show(transaction, "dialog"); - } - public static void DismisModal() => PerformDismiss(true); - static void PerformDismiss(bool removeCurrent = true) - { - if (currrentDialog is null) - return; - var transaction = FragmentManager(currrentDialog.HView).BeginTransaction(); - - if (removeCurrent) - { - transaction.Remove(currrentDialog); - transaction.AddToBackStack(null); - } - - currentDialogs.Remove(currrentDialog.HView); - currrentDialog = null; - var currentView = currentDialogs.LastOrDefault(); - if (currentView is null) - { - transaction.CommitAllowingStateLoss(); - return; - } - - currrentDialog = new ViewModal(currentView); - currrentDialog.Show(transaction, "dialog"); - } - - - class ViewModal : DialogFragment - { - public ViewModal(View view) - { - HView = view; - } - - public View HView { get; } - - AView currentBuiltView; - public override AView OnCreateView(LayoutInflater inflater, - ViewGroup container, - Bundle savedInstanceState) => currentBuiltView = HView.ToContainerView(HView.GetMauiContext()); - - public override void OnDestroy() - { - PerformDismiss(false); - if (HView is not null) - { - HView.ViewHandler = null; - } - if (currentBuiltView is not null) - { - currentBuiltView?.Dispose(); - currentBuiltView = null; - } - base.OnDestroy(); - this.Dispose(); - } - } - } -} diff --git a/src/Comet/src/Comet/Platform/Compose/ChoreographerTicker.cs b/src/Comet/src/Comet/Platform/Compose/ChoreographerTicker.cs new file mode 100644 index 000000000..b8581cbf7 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ChoreographerTicker.cs @@ -0,0 +1,84 @@ +#nullable enable +#if ANDROID +using System; +using System.Collections.Generic; +using Android.Views; + +namespace Comet.Platform.Compose +{ + /// + /// over Android's — + /// the vsync-aligned frame pump that drives Comet's animation engine on the node + /// backend. Keeps a single pending frame callback while anyone is subscribed. + /// + /// Choreographer is thread-affine: subscriptions must come from the main + /// thread (Comet's animation plumbing already marshals adds via ThreadHelper). + sealed class ChoreographerTicker : Java.Lang.Object, Backend.ICometFrameTicker, Choreographer.IFrameCallback + { + readonly List> _subscribers = new(); + bool _posted; + long _lastFrameNanos; + + public IDisposable Subscribe(Action onFrame) + { + lock (_subscribers) + { + _subscribers.Add(onFrame); + if (!_posted) + { + _posted = true; + _lastFrameNanos = 0; + Choreographer.Instance!.PostFrameCallback(this); + } + } + return new Subscription(this, onFrame); + } + + public void DoFrame(long frameTimeNanos) + { + Action[] subscribers; + lock (_subscribers) + { + if (_subscribers.Count == 0) + { + _posted = false; + return; + } + subscribers = _subscribers.ToArray(); + Choreographer.Instance!.PostFrameCallback(this); + } + + // 100ns ticks per nano ÷ 100; first frame after an idle gap reports zero elapsed. + var elapsed = _lastFrameNanos == 0 + ? TimeSpan.Zero + : TimeSpan.FromTicks((frameTimeNanos - _lastFrameNanos) / 100); + _lastFrameNanos = frameTimeNanos; + + foreach (var subscriber in subscribers) + subscriber(elapsed); + } + + sealed class Subscription : IDisposable + { + ChoreographerTicker? _owner; + readonly Action _callback; + + public Subscription(ChoreographerTicker owner, Action callback) + { + _owner = owner; + _callback = callback; + } + + public void Dispose() + { + var owner = _owner; + _owner = null; + if (owner is null) + return; + lock (owner._subscribers) + owner._subscribers.Remove(_callback); + } + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeAlertDialogNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeAlertDialogNode.cs new file mode 100644 index 000000000..fef1f3f6b --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeAlertDialogNode.cs @@ -0,0 +1,82 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose.Runtime; +using Comet.Backend; +using ComposeAlertDialog = AndroidX.Compose.AlertDialog; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet as the real Material 3 + /// AlertDialog widget (the same control the gold-standard Jetchat uses for its + /// FunctionalityNotAvailablePopup): a modal popup with its own overlay window + scrim, + /// composed only while the Comet Dialog_IsOpen signal is true. Owns its slot children + /// () so they're laid out by Material inside the dialog, + /// not in the parent Yoga tree, and the node measures to zero in its parent's layout. + sealed class ComposeAlertDialogNode : ComposeNode, IBackendManagesOwnContent + { + AlertDialog _dialog; + readonly BackendContext _context; + readonly AndroidX.Compose.MutableState _open = new(false); + ComposeNode? _text, _confirm, _title, _dismiss; + + public ComposeAlertDialogNode(AlertDialog dialog, BackendContext context) + { + _dialog = dialog; + _context = context; + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Dialog_IsOpen) + _open.Value = value.AsBool; + } + + /// Re-point at the new AlertDialog; only a hot reload re-materializes the slot + /// views (text/buttons/title) so the changed code renders. + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not AlertDialog dialog) + return; + _dialog = dialog; + if (!isHotReload) + return; + _text = _confirm = _title = _dismiss = null; + } + + void EnsureContent() + { + if (_text is not null) + return; + + // Materialize the slot views once. They carry no Yoga frame, so Material lays them out + // inside the dialog natively (intrinsic sizing), exactly like Compose's AlertDialog slots. + _text = (ComposeNode)CometBackendBridge.Materialize(_dialog.Text, _context); + _confirm = (ComposeNode)CometBackendBridge.Materialize(_dialog.ConfirmButton, _context); + if (_dialog.Title is not null) + _title = (ComposeNode)CometBackendBridge.Materialize(_dialog.Title, _context); + if (_dialog.DismissButton is not null) + _dismiss = (ComposeNode)CometBackendBridge.Materialize(_dialog.DismissButton, _context); + } + + public override void Render(IComposer composer) + { + // Closed → compose nothing, so the dialog window is absent (Compose tears it down). + if (!_open.Value) + return; + + EnsureContent(); + + var dialog = new ComposeAlertDialog( + onDismissRequest: () => Sink?.OnEvent(EventIds.DialogDismissed)) + { + Text = _text!, + ConfirmButton = _confirm!, + }; + if (_title is not null) dialog.Title = _title; + if (_dismiss is not null) dialog.DismissButton = _dismiss; + + dialog.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeBackendRoot.cs b/src/Comet/src/Comet/Platform/Compose/ComposeBackendRoot.cs new file mode 100644 index 000000000..52ed7ff03 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeBackendRoot.cs @@ -0,0 +1,171 @@ +#nullable enable +#if ANDROID +using System; +using Android.Content; +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Platform; +using Comet.Backend; + +namespace Comet.Platform.Compose +{ + /// + /// Hosts a Comet view tree as a single root Jetpack Compose composition. Builds the + /// retained tree from the Comet view via + /// , then drives it through one + /// set as the activity/root content. + /// + public sealed class ComposeBackendRoot + { + readonly BackendContext _context; + ComposeNode? _root; + View? _layoutRoot; + Microsoft.Maui.Graphics.Size _availableDp; + + /// The logical root view: the layout target is re-resolved from it on every + /// pass because a (hot) reload rebuilds the view tree — a captured built tree would + /// go stale and the reloaded views would never be laid out. + View? _logicalRoot; + + // IME handling: the view's laid-out size and the soft-keyboard inset are tracked + // separately — under Android 15's forced edge-to-edge the window never resizes for + // the IME (AdjustResize is a no-op), the keyboard just overlays, so the available + // height is the view height minus the reported IME inset. + Microsoft.Maui.Graphics.Size _viewDp; + double _imeInsetDp; + + void RecomputeAvailable() + { + if (_viewDp.Width <= 0 || _viewDp.Height <= 0) + return; + var avail = new Microsoft.Maui.Graphics.Size(_viewDp.Width, System.Math.Max(0, _viewDp.Height - _imeInsetDp)); + if (avail == ComposeNode.AvailableSize) + return; + ComposeNode.AvailableSize = avail; + _availableDp = avail; + Comet.Reactive.ReactiveScheduler.EnsureFlushScheduled(); + } + + internal void SetImeInsetDp(double dp) + { + if (System.Math.Abs(dp - _imeInsetDp) < 0.5) + return; + _imeInsetDp = dp; + RecomputeAvailable(); + } + + /// Observes the IME inset at the decor level via the AndroidX compat + /// dispatch — the ComposeView consumes child-level insets, so a listener on the + /// view itself never fires. + sealed class ImeInsetListener : Java.Lang.Object, AndroidX.Core.View.IOnApplyWindowInsetsListener + { + readonly ComposeBackendRoot _owner; + public ImeInsetListener(ComposeBackendRoot owner) => _owner = owner; + + public AndroidX.Core.View.WindowInsetsCompat OnApplyWindowInsets( + global::Android.Views.View v, AndroidX.Core.View.WindowInsetsCompat insets) + { + var ime = insets.GetInsets(AndroidX.Core.View.WindowInsetsCompat.Type.Ime()).Bottom; + _owner.SetImeInsetDp(ime / ComposeNode.Density); + + // Safe area (per-root reactive contract): system bars + display cutout — the + // insets edge-to-edge content must clear. Equality-gated, safe per dispatch. + var bars = insets.GetInsets( + AndroidX.Core.View.WindowInsetsCompat.Type.SystemBars() + | AndroidX.Core.View.WindowInsetsCompat.Type.DisplayCutout()); + float d = ComposeNode.Density; + Backend.CometWindowMetrics.Shared.UpdateSafeArea(new Microsoft.Maui.Thickness( + bars.Left / d, bars.Top / d, bars.Right / d, bars.Bottom / d)); + + return AndroidX.Core.View.ViewCompat.OnApplyWindowInsets(v, insets); + } + } + + public ComposeBackendRoot(IServiceProvider services) + => _context = new BackendContext(services); + + /// When true, the C# Yoga engine computes layout and the Compose nodes are + /// positioned absolutely from the computed frames. Default false (native layout). + public bool UseYogaLayout { get; set; } + + /// Optional hook to wrap the composed root — e.g. in a MaterialTheme carrying + /// the app's color scheme — so real Material controls (Button, Icon, ripples) pick up the + /// theme. The app supplies this since the theme/colors are app-specific, not backend policy. + public Func? WrapContent { get; set; } + + /// Materializes into a Compose tree and returns the + /// hosting to set as content. + public ComposeView CreateView(Context context, View view) + { + var metrics = context.Resources!.DisplayMetrics!; + ComposeNode.Density = metrics.Density; + + // Drive Comet's animation engine (view.Animate/FadeTo/…) from the vsync + // Choreographer — without this, animations silently no-op on the node backend. + Backend.CometAnimationDriver.Initialize(new ChoreographerTicker()); + + // A NEW root (activity re-creation) obsoletes every prior registration; without + // this the registry keeps ghost elements whose disposed views the agent can still + // resolve (semantic taps/long-presses then silently no-op on dead views). + Comet.DevTools.CometDevRegistry.Reset(); + _root = (ComposeNode)CometBackendBridge.Materialize(view, _context); + + if (UseYogaLayout) + { + _logicalRoot = view; + _availableDp = new Microsoft.Maui.Graphics.Size( + metrics.WidthPixels / metrics.Density, + metrics.HeightPixels / metrics.Density); + RunLayout(); + // Reflow after each reactive flush (content size changes re-measure + re-arrange). + Comet.Reactive.ReactiveScheduler.AfterFlush += RunLayout; + } + + var composeView = new ComposeView(context); + composeView.SetContent(_ => WrapContent is null ? _root : WrapContent(_root)); + + // Track the view's ACTUAL size (rotation, split-screen, or an OS that still + // resizes for the keyboard). A change recomputes the available size and + // schedules a flush so the backend root AND nested own-content hosts + // (NavigationView) re-lay-out to it. + composeView.LayoutChange += (_, e) => + { + var dp = new Microsoft.Maui.Graphics.Size( + (e.Right - e.Left) / ComposeNode.Density, + (e.Bottom - e.Top) / ComposeNode.Density); + if (dp.Width <= 0 || dp.Height <= 0 || dp == _viewDp) + return; + _viewDp = dp; + // Window metrics track the RAW view size (per-root reactive contract for + // adaptive size-class UI) — deliberately not the IME-shrunk available size: + // a soft keyboard must not flip Medium→Compact chrome. + Backend.CometWindowMetrics.Shared.Update(dp); + RecomputeAvailable(); + }; + + // IME inset via the decor: shrinks the available height so the composer/footer + // lifts above the soft keyboard (P7). The window itself must do NOTHING for the + // IME — without AdjustNothing the system falls back to adjustPan and pans the + // whole window up (pushing the top bar off-screen) on top of our reflow. + if (context is global::Android.App.Activity activity && activity.Window is { } window) + { + window.SetSoftInputMode(global::Android.Views.SoftInput.AdjustNothing); + if (window.DecorView is { } decor) + AndroidX.Core.View.ViewCompat.SetOnApplyWindowInsetsListener(decor, new ImeInsetListener(this)); + } + + return composeView; + } + + void RunLayout() + { + // Re-resolve the layout target: a (hot) reload rebuilds the view tree, so a + // captured built tree goes stale. Read-only (BuiltView, never GetView) — a + // flush can run off the main thread and building views there is not safe. + _layoutRoot = _logicalRoot?.BuiltView ?? _logicalRoot; + if (_layoutRoot is not null) + CometBackendLayoutEngine.Layout(_layoutRoot, _availableDp); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeContentSwitcherNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeContentSwitcherNode.cs new file mode 100644 index 000000000..c689951e6 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeContentSwitcherNode.cs @@ -0,0 +1,114 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet : composes only the active + /// view, swapped by MutableState. Views materialize LAZILY on first show and stay + /// materialized (state survives route switches — the gold keeps route state too). Active + /// content is Yoga-laid to this node's arranged frame (window metrics standalone), + /// re-flowed per flush like the other own-content hosts. + sealed class ComposeContentSwitcherNode : ComposeNode, IBackendManagesOwnContent + { + ContentSwitcher _switcher; + readonly BackendContext _context; + readonly MutableState _index = new(0); + readonly MutableState _contentVersion = new(0); + ComposeNode?[] _nodes = System.Array.Empty(); + int _indexValue; + + public ComposeContentSwitcherNode(ContentSwitcher switcher, BackendContext context) + { + _switcher = switcher; + _context = context; + Comet.Reactive.ReactiveScheduler.AfterFlush += ReflowContent; + } + + public override void Dispose() + => Comet.Reactive.ReactiveScheduler.AfterFlush -= ReflowContent; + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.ContentSwitcher_Index) + { + _indexValue = value.AsInt; + _index.Value = value.AsInt; + // Materialize + lay the newly active view before it composes. + EnsureActive(); + } + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not ContentSwitcher switcher) + return; + _switcher = switcher; + if (!isHotReload) + return; + _nodes = System.Array.Empty(); + _contentVersion.Value++; + } + + Size BoundsDp() + { + if (FrameWidth > 0 && FrameHeight > 0) + return new Size(FrameWidth, FrameHeight); + var size = _switcher.GetWindowMetrics().SizeDp.Peek(); + return size.Width > 0 && size.Height > 0 ? size : ScreenSizeDp(); + } + + void EnsureActive() + { + var views = _switcher.Views; + if (_nodes.Length != views.Count) + { + var resized = new ComposeNode?[views.Count]; + System.Array.Copy(_nodes, resized, System.Math.Min(_nodes.Length, views.Count)); + _nodes = resized; + } + int i = _indexValue; + if (i < 0 || i >= views.Count) + return; + _nodes[i] ??= (ComposeNode)CometBackendBridge.Materialize(views[i], _context); + LayoutActive(); + } + + void LayoutActive() + { + int i = _indexValue; + if (i < 0 || i >= _switcher.Views.Count || _nodes.Length <= i || _nodes[i] is null) + return; + var bounds = BoundsDp(); + if (bounds.Width <= 0) + return; + CometBackendLayoutEngine.Layout(_switcher.Views[i], bounds); + } + + void ReflowContent() => LayoutActive(); + + public override Size Measure(double widthConstraint, double heightConstraint) + { + double w = double.IsFinite(widthConstraint) && widthConstraint > 0 ? widthConstraint : ScreenSizeDp().Width; + double h = double.IsFinite(heightConstraint) && heightConstraint > 0 ? heightConstraint : ScreenSizeDp().Height; + return new Size(w, h); + } + + public override void Render(IComposer composer) + { + _ = _contentVersion.Value; + int index = _index.Value; + EnsureActive(); + + var box = new Box(); + ((ComposableNode)box).Modifier = BuildNodeModifier() ?? Modifier.Companion.FillMaxSize(); + if (index >= 0 && index < _nodes.Length && _nodes[index] is { } active) + box.Add(active); + box.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeDevAgentHost.cs b/src/Comet/src/Comet/Platform/Compose/ComposeDevAgentHost.cs new file mode 100644 index 000000000..f474b3d96 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeDevAgentHost.cs @@ -0,0 +1,195 @@ +#nullable enable +#if ANDROID +using System; +using System.Diagnostics; +using System.Threading; +using Android.OS; +using Android.Views; +using Comet.DevTools; + +namespace Comet.Platform.Compose +{ + /// + /// Hosts the on Android so the same DevFlow-compatible + /// surface that drives the SwiftUI probe (tree/elements + semantic tap/fill/clear/ + /// focus/back) drives Compose apps too — plus REAL coordinate drags: synthesized + /// streams dispatched to the decor view, exercising the + /// actual Compose gesture pipeline (pull-to-refresh, pager swipes, swipe-to-dismiss, + /// flings — velocity falls out of the event timing, exactly as for a finger). + /// + /// + /// The agent binds localhost, so external tooling reaches it via + /// adb forward tcp:9223 tcp:9223 (a FIXED port a smoke script can rely on, + /// unlike the broker-assigned MAUI-agent port that changes each launch). Screenshots + /// are not served on Android — use adb screencap (the deployed smoke-script + /// convention) or the probe's MAUI-agent PixelCopy route. + /// + public static class ComposeDevAgentHost + { + static CometDevAgent? _agent; + + /// The running agent (null before ); its + /// is the port that actually bound. + public static CometDevAgent? Agent => _agent; + + /// + /// Registers the drag injector and starts the agent. Call BEFORE materializing the + /// root view — enables + /// tracking, and views materialized while it is disabled never enter the tree. + /// Safe to call again on activity re-creation (the injector rebinds to the new + /// activity; the agent itself starts once). + /// + public static void Start(global::Android.App.Activity activity, int port = CometDevAgent.DevFlowPort) + { + CometDevRegistry.DragInjector = (x1, y1, x2, y2, durationMs) => + InjectDrag(activity, x1, y1, x2, y2, durationMs); + + if (_agent is not null) + return; + + var agent = new CometDevAgent(port, a => activity.RunOnUiThread(a)); + agent.Start(); + _agent = agent; + } + + /// + /// Synthesizes a touch drag as a Down → Move… → Up stream on + /// the decor view, spacing the moves in real time so gesture-velocity trackers see an + /// authentic finger. Blocks the calling (agent worker) thread for the gesture duration; + /// each event is dispatched on the UI thread. Coordinates are physical pixels. + /// + internal static bool InjectDrag(global::Android.App.Activity activity, + float x1, float y1, float x2, float y2, int durationMs) + { + var decor = activity.Window?.DecorView; + if (decor is null) + return false; + if (Looper.MyLooper() == Looper.MainLooper) + throw new InvalidOperationException("InjectDrag must not run on the UI thread (it blocks while dispatching to it)"); + + // ~80Hz move stream: dense enough for Compose's velocity tracker even on + // short flings, without flooding the main looper. + int steps = Math.Max(2, durationMs / 12); + long downTime = 0; + + bool Dispatch( + MotionEventActions action, + float x, + float y, + int timeoutMs, + bool cancelIfQueued, + bool waitIfStarted) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var dispatchState = 0; // 0 = queued, 1 = running, 2 = canceled + activity.RunOnUiThread(() => + { + if (Interlocked.CompareExchange(ref dispatchState, 1, 0) != 0) + { + completion.TrySetResult(false); + return; + } + + var dispatched = true; + try + { + long now = SystemClock.UptimeMillis(); + if (action == MotionEventActions.Down) + downTime = now; + var ev = MotionEvent.Obtain(downTime, now, action, x, y, 0); + if (ev is null) { dispatched = false; return; } + try + { + ev.SetSource(InputSourceType.Touchscreen); + if (!decor.DispatchTouchEvent(ev)) + dispatched = action != MotionEventActions.Down; // nobody claimed the down → nothing will track the drag + } + finally { ev.Recycle(); } + } + catch { dispatched = false; } + finally { completion.TrySetResult(dispatched); } + }); + + if (completion.Task.Wait(timeoutMs)) + return completion.Task.Result; + + if (!cancelIfQueued) + return false; + + if (Interlocked.CompareExchange(ref dispatchState, 2, 0) == 0) + return false; + + if (!waitIfStarted) + return false; + + // A started Down must finish so the caller knows whether it needs a terminal event. + completion.Task.Wait(); + return completion.Task.Result; + } + + if (!Dispatch( + MotionEventActions.Down, + x1, + y1, + timeoutMs: 2000, + cancelIfQueued: true, + waitIfStarted: true)) + return false; + + var timer = Stopwatch.StartNew(); + var deadlineMs = durationMs + 2000L; + var ok = true; + var lastX = x1; + var lastY = y1; + for (int i = 1; i <= steps; i++) + { + var targetElapsedMs = durationMs * i / steps; + var delayMs = targetElapsedMs - timer.ElapsedMilliseconds; + if (delayMs > 0) + Thread.Sleep((int)delayMs); + if (timer.ElapsedMilliseconds > deadlineMs) + { + ok = false; + break; + } + + float t = i / (float)steps; + var x = x1 + (x2 - x1) * t; + var y = y1 + (y2 - y1) * t; + var remainingBudgetMs = deadlineMs - timer.ElapsedMilliseconds; + if (remainingBudgetMs <= 0 || + !Dispatch( + MotionEventActions.Move, + x, + y, + timeoutMs: (int)Math.Min(2000L, remainingBudgetMs), + cancelIfQueued: true, + waitIfStarted: false) || + timer.ElapsedMilliseconds > deadlineMs) + { + ok = false; + break; + } + lastX = x; + lastY = y; + } + + // Once Down is delivered, the terminal event cannot be canceled while queued. + // It must close the native gesture when the UI thread becomes responsive. + var terminalAction = ok ? MotionEventActions.Up : MotionEventActions.Cancel; + var terminalX = ok ? x2 : lastX; + var terminalY = ok ? y2 : lastY; + var terminalBudgetMs = (int)Math.Max(0L, deadlineMs - timer.ElapsedMilliseconds); + var terminated = Dispatch( + terminalAction, + terminalX, + terminalY, + timeoutMs: terminalBudgetMs, + cancelIfQueued: false, + waitIfStarted: false); + return ok && terminated; + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeDrawerNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeDrawerNode.cs new file mode 100644 index 000000000..6e967152c --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeDrawerNode.cs @@ -0,0 +1,137 @@ +#nullable enable +#if ANDROID +using System.Threading; +using System.Threading.Tasks; +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet as the real Material + /// ModalNavigationDrawer widget (the same control the gold-standard Jetchat uses): the + /// slides over the content with the standard scrim + edge-swipe + /// gesture, driven by a . The Comet Drawer_IsOpen signal is + /// synced to the drawer's animated state both ways. Owns its two children + /// (), laying the content out at full screen and the sheet + /// at the standard 360dp width with the shared Yoga engine. + sealed class ComposeDrawerNode : ComposeNode, IBackendManagesOwnContent + { + // Material 3 ModalDrawerSheet is 360dp wide. + const float SheetWidthDp = 360f; + + Drawer _drawer; + readonly BackendContext _context; + readonly MutableState _open = new(false); + readonly MutableState _contentVersion = new(0); + readonly DrawerStateHolder _holder = new(AndroidX.Compose.Material3.DrawerValue.Closed); + ComposeNode? _sideNode, _contentNode; + + public ComposeDrawerNode(Drawer drawer, BackendContext context) + { + _drawer = drawer; + _context = context; + + // Re-lay the hosted content/side after every reactive flush so an IME-driven size + // change (or rotation) reflows them — this own-content node is a leaf to the engine, + // so the top-level RunLayout can't reach inside it (mirrors ComposeNavigationNode). + Comet.Reactive.ReactiveScheduler.AfterFlush += ReflowContent; + } + + /// The node was transferred to a new Drawer. Re-point always; only a hot reload + /// re-materializes the side/content subtrees (the code changed) — an ordinary re-render + /// keeps the materialized content and the open/closed state. + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not Drawer drawer) + return; + _drawer = drawer; + if (!isHotReload) + return; + _sideNode = null; + _contentNode = null; + _contentVersion.Value++; + } + + public override void Dispose() + => Comet.Reactive.ReactiveScheduler.AfterFlush -= ReflowContent; + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Drawer_IsOpen) + _open.Value = value.AsBool; + } + + void EnsureContent() + { + if (_contentNode is not null) + return; + + _sideNode = (ComposeNode)CometBackendBridge.Materialize(_drawer.Side, _context); + _contentNode = (ComposeNode)CometBackendBridge.Materialize(_drawer.Content, _context); + LayoutContent(); + } + + // Lay the content out full-viewport and the sheet at the standard width. Re-run on every + // flush so a keyboard-driven AvailableSize change (or rotation) reflows the hosted content + // instead of leaving it sized to whatever the viewport was when it was first materialized. + void LayoutContent() + { + if (_contentNode is null) + return; + var size = ScreenSizeDp(); + CometBackendLayoutEngine.Layout(_drawer.Content, size); + CometBackendLayoutEngine.Layout(_drawer.Side, new Microsoft.Maui.Graphics.Size(SheetWidthDp, size.Height)); + } + + void ReflowContent() + { + if (_contentNode is not null) + LayoutContent(); + } + + public override void Render(IComposer composer) + { + _ = _contentVersion.Value; // subscribe: a reload re-materializes content + EnsureContent(); + + bool open = _open.Value; + + // Drive the real drawer's animated state from the Comet open-signal. Effects run after + // composition, so the holder is already bound to its live peer when these fire. + composer.LaunchedEffect(open, async ct => + { + if (open && _holder.IsClosed) + await _holder.OpenAsync(); + else if (!open && _holder.IsOpen) + await _holder.CloseAsync(); + }); + + // Reading CurrentValue subscribes this scope, so a user gesture (edge-swipe / scrim tap) + // that closes the drawer recomposes here and reports it back so the Comet signal clears. + var current = _holder.CurrentValue; + composer.LaunchedEffect(current, ct => + { + if (_holder.IsClosed && _open.Value) + Sink?.OnEvent(EventIds.DrawerClosed); + // Symmetric: an edge-swipe OPEN must reflect back too, or the signal + // desyncs and (being equality-gated) swallows the next programmatic close. + else if (_holder.IsOpen && !_open.Value) + Sink?.OnEvent(EventIds.DrawerOpened); + return Task.CompletedTask; + }); + + var sheet = new ModalDrawerSheet(); + sheet.Add(_sideNode!); + + var drawer = new ModalNavigationDrawer(drawerState: _holder) + { + Drawer = sheet, + Content = _contentNode!, + }; + ((ComposableNode)drawer).Modifier = Modifier.Companion.FillMaxSize(); + drawer.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeFabNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeFabNode.cs new file mode 100644 index 000000000..848a6e10f --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeFabNode.cs @@ -0,0 +1,134 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet as the REAL Material 3 + /// ExtendedFloatingActionButton — never a styled pill. Uses + /// to call the Kotlin bridge with + /// ComposableLambda-wrapped slot content directly, bypassing the generated + /// slot-property wiring that had an empty-render bug. The FAB is a self-sizing native overlay + /// (Option C): reports its intrinsic size (the real content measured + /// by the engine + the FAB's documented insets) so the parent's Yoga layout can corner-pin + /// it, then positions it at that offset and lets the native FAB size + + /// lay out its own content. Owns its content (). + sealed class ComposeFabNode : ComposeNode, IBackendManagesOwnContent + { + // Material extended-FAB content insets (dp): start 16, icon→text gap 12, end 20. + const float PadStart = 16f, Gap = 12f, PadEnd = 20f, MinWidth = 48f; + + Fab _fab; + readonly BackendContext _context; + readonly MutableState _extended; + ComposeNode? _icon, _label; + bool _built; + + public ComposeFabNode(Fab fab, BackendContext context) + { + _fab = fab; + _context = context; + _extended = new MutableState(fab.Extended); + + // Subscribe to the reactive extended signal so Compose recomposes when it changes. + if (fab.ExtendedSignal is { } sig) + { + _extended.Value = sig.Peek(); + sig.PropertyChanged += (_, __) => + { + bool v = sig.Peek(); + Comet.ThreadHelper.RunOnMainThread(() => _extended.Value = v); + }; + } + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) { } + + /// Re-point at the new Fab; only a hot reload rebuilds the icon/label slots + /// (an ordinary re-render keeps them). + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not Fab fab) + return; + _fab = fab; + if (!isHotReload) + return; + _built = false; + _icon = null; + _label = null; + } + + void EnsureContent() + { + if (_built) + return; + _built = true; + // Always build both slot nodes — ExtendedFloatingActionButton uses them in both + // expanded and contracted states (icon shows in both; label only shows when expanded). + _icon = (ComposeNode)CometBackendBridge.Materialize(_fab.IconView, _context); + _label = (ComposeNode)CometBackendBridge.Materialize(_fab.LabelView, _context); + // The icon/label inherit Opacity=0 from the parent FAB's environment (the FAB starts + // hidden via .Opacity(0)). Their visibility is driven by the FAB's own alpha modifier + // on the ExtendedFloatingActionButton composable — reset slot content to full opacity. + var fullOpacity = PropertyValue.From(1.0); + _icon.ApplyProperty(PropertyIds.Opacity, in fullOpacity); + _label.ApplyProperty(PropertyIds.Opacity, in fullOpacity); + } + + // Intrinsic size for the parent's Yoga layout: the real content's measured width + the + // FAB's content insets. Always returns the extended (full) width so Yoga can corner-pin + // the FAB; the FAB self-sizes when contracted (the parent layout is unaffected since it's + // an overlay, not in flow). Height is the gold's pinned value. + public override Size Measure(double widthConstraint, double heightConstraint) + { + EnsureContent(); + var icon = CometBackendLayoutEngine.Measure(_fab.IconView); + var label = CometBackendLayoutEngine.Measure(_fab.LabelView); + double width = System.Math.Max(MinWidth, PadStart + icon.Width + Gap + label.Width + PadEnd); + return new Size(width, _fab.Height); + } + + public override void Render(IComposer composer) + { + EnsureContent(); + + // Subscribe to _extended so Compose recomposes when the extended state changes. + bool fabExtended = _extended.Value; + + // Reactive visibility: keep the FAB COMPOSED even when hidden — leaving/re-entering + // composition discards the slot content's state and it renders empty on return — so + // apply alpha + push off-screen to keep it alive but invisible and non-interactive. + float alpha = SubscribeAndGetAlpha(); + float offsetY = alpha <= 0f ? FrameY + 2000f : FrameY; + + // When contracted, Compose shrinks the FAB to a square of _fab.Height dp. + // Yoga computed FrameX for the extended (wide) FAB; shift right by the width difference + // so the RIGHT edge stays pinned (BottomEnd alignment is preserved in both states). + float adjustedX = fabExtended ? FrameX : FrameX + FrameWidth - (float)_fab.Height; + var modifier = Modifier.Companion.AbsoluteOffset(new Dp(adjustedX), new Dp(offsetY)); + if (alpha < 1f) + modifier = modifier.Alpha(alpha); + + void OnClick() => Sink?.OnEvent(EventIds.Clicked); + + // Drive the real ExtendedFloatingActionButton via the direct bridge path. The + // `expanded` parameter animates between extended (icon+label pill) and contracted + // (icon-only rounded square) states — matching AnimatingFabContent's behaviour in + // the gold. Both FABs use this path: ProfileFab (reactive extended) and + // JumpToBottom (always extended = always showing icon + label). + ExtendedFloatingActionButton.RenderDirect( + icon: _icon!, + text: _label!, + onClick: OnClick, + expanded: fabExtended, + containerColor: _fab.ContainerColor is { } cc ? ToComposeColor(cc) : null, + contentColor: _fab.ContentColor is { } fc ? ToComposeColor(fc) : null, + modifier: modifier, + composer: composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeFilterChipNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeFilterChipNode.cs new file mode 100644 index 000000000..c0575f93b --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeFilterChipNode.cs @@ -0,0 +1,75 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; + +namespace Comet.Platform.Compose +{ + /// Renders Comet as the REAL Material 3 + /// FilterChip (the Jetcaster category tabs): selected state + label/leading + /// slots; the widget self-themes fill/outline/check. + // IBackendManagesOwnContent: the slots are materialized by THIS node into the + // widget's lambdas — without it the bridge also auto-materializes the children. + sealed class ComposeFilterChipNode : ComposeNode, IBackendManagesOwnContent + { + const float HeightDp = 32f; // M3 chip container height + + FilterChip _control; + readonly BackendContext _context; + readonly MutableState _selected = new(false); + ComposeNode? _labelNode; + ComposeNode? _leadingNode; + + public ComposeFilterChipNode(FilterChip control, BackendContext context) + { + _control = control; + _context = context; + _selected.Value = control.IsSelected; + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not FilterChip control) + return; + _control = control; + _selected.Value = control.IsSelected; + _labelNode = null; // slot views were rebuilt with the owner + _leadingNode = null; + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Toggle_IsOn) + _selected.Value = value.AsBool; + } + + public override Size Measure(double widthConstraint, double heightConstraint) + { + // The label view only measures once its backend node exists — materialize the + // slot first (idempotent; Render reuses the same node). + _labelNode ??= (ComposeNode)CometBackendBridge.Materialize(_control.LabelView, _context, _control); + // Label width + M3 chip paddings (16dp label-only sides, +check room selected). + var label = CometBackendLayoutEngine.Measure(_control.LabelView); + double lead = _control.LeadingIconView is null ? 0 : 26; + double check = _selected.Value ? 26 : 0; + return new(label.Width + 32 + lead + check, HeightDp); + } + + public override void Render(IComposer composer) + { + _labelNode ??= (ComposeNode)CometBackendBridge.Materialize(_control.LabelView, _context, _control); + if (_control.LeadingIconView is { } leading) + _leadingNode ??= (ComposeNode)CometBackendBridge.Materialize(leading, _context, _control); + + var chip = new AndroidX.Compose.FilterChip(_selected.Value, () => _control.OnClick()) + { + Label = _labelNode, + LeadingIcon = _leadingNode, + }; + ((ComposableNode)chip).Modifier = BuildNodeModifier(); + chip.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeFontRegistry.cs b/src/Comet/src/Comet/Platform/Compose/ComposeFontRegistry.cs new file mode 100644 index 000000000..95e2325be --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeFontRegistry.cs @@ -0,0 +1,115 @@ +#nullable enable +#if ANDROID +using System; +using System.Collections.Generic; +using Android.Graphics; +using AndroidX.Compose; + +namespace Comet.Platform.Compose +{ + /// + /// App-populated map from a font-family name to loaded s, so the + /// Compose backend can render custom fonts (and icon fonts) that the vendored facade can't + /// construct on its own. The app registers either a single base typeface OR — preferred for + /// fidelity — one typeface per weight (the real per-weight TTFs carry distinct glyph outlines; + /// synthesizing weights from a single base with Typeface.Create produces thin, pale + /// intermediate weights). picks the registered weight closest to the + /// requested one (falling back to synthesizing from the base), and wraps it as a Compose + /// , cached per (family, weight). + /// + public static class ComposeFontRegistry + { + static readonly Dictionary Bases = new(StringComparer.OrdinalIgnoreCase); + // Per-family weight → typeface, when the app registers real per-weight files. + static readonly Dictionary> Weights = new(StringComparer.OrdinalIgnoreCase); + static readonly Dictionary<(string, int, bool), (Typeface Typeface, FontFamily Family)?> Cache = new(); + + /// Registers a single base typeface under a family name (e.g. "Montserrat"). + /// Used when no per-weight files are available; synthesizes weights. + public static void Register(string family, Typeface? typeface) + { + if (!string.IsNullOrEmpty(family) && typeface is not null) + Bases[family] = typeface; + } + + /// Registers a real per-weight typeface (e.g. Montserrat at 500 from + /// montserrat_medium.ttf). Preferred over the single-base overload — distinct weight + /// files render true, unlike synthesized intermediate weights. Also seeds the base from the + /// first weight registered so single-base lookups still resolve. + public static void Register(string family, int weight, Typeface? typeface) + { + if (string.IsNullOrEmpty(family) || typeface is null) + return; + + if (!Weights.TryGetValue(family, out var byWeight)) + Weights[family] = byWeight = new SortedDictionary(); + byWeight[weight] = typeface; + Bases.TryAdd(family, typeface); + } + + /// Resolves a registered family at the given weight to a (Typeface, FontFamily) + /// pair — the typeface drives native measurement, the family the composed Text. Prefers the + /// registered weight nearest the request; if a family has only a single base, synthesizes the + /// weight. Returns null when unregistered or if wrapping fails (so text falls back to the + /// default font). + public static (Typeface Typeface, FontFamily Family)? Resolve(string? family, int weight, bool italic = false) + { + if (string.IsNullOrEmpty(family)) + return null; + + var key = (family, weight, italic); + if (Cache.TryGetValue(key, out var cached)) + return cached; + + (Typeface, FontFamily)? result; + try + { + var typeface = PickTypeface(family, weight); + // A Typeface-backed FontFamily bypasses Compose's font matching, so a + // TextStyle fontStyle=Italic never synthesizes a slant — bake the italic + // into the typeface itself (same as the measurement path). + if (italic && typeface is not null && OperatingSystem.IsAndroidVersionAtLeast(28)) + typeface = Typeface.Create(typeface, weight <= 0 ? 400 : weight, true); + result = typeface is null ? null : ((Typeface, FontFamily)?)(typeface, FontFamily.FromTypeface(typeface)); + } + catch (Exception ex) + { + // The custom-font JNI path failed — degrade to the default font, never crash. + global::Android.Util.Log.Warn("CometFont", $"resolve {family}@{weight} failed: {ex.Message}"); + result = null; + } + + Cache[key] = result; + return result; + } + + // Picks the best typeface for a weight: an exact registered weight, else the nearest + // registered weight, else the synthesized base (Typeface.Create), else null. + static Typeface? PickTypeface(string family, int weight) + { + if (Weights.TryGetValue(family, out var byWeight) && byWeight.Count > 0) + { + int want = weight <= 0 ? 400 : weight; + if (byWeight.TryGetValue(want, out var exact)) + return exact; + + Typeface? best = null; + int bestDelta = int.MaxValue; + foreach (var pair in byWeight) + { + int delta = Math.Abs(pair.Key - want); + if (delta < bestDelta) { bestDelta = delta; best = pair.Value; } + } + return best; + } + + if (!Bases.TryGetValue(family, out var baseFace)) + return null; + + return (weight > 0 && OperatingSystem.IsAndroidVersionAtLeast(28)) + ? Typeface.Create(baseFace, weight, false)! + : baseFace; + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeFormattedTextNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeFormattedTextNode.cs new file mode 100644 index 000000000..924019ca1 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeFormattedTextNode.cs @@ -0,0 +1,123 @@ +#nullable enable +#if ANDROID +using System.Collections.Generic; +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using ComposeAnnotatedText = AndroidX.Compose.AnnotatedText; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet as a Compose + /// AnnotatedText: one wrapping paragraph of styled runs (color / monospace "code" / + /// background / underline). Measured with a native SpannableString carrying the same monospace + /// spans so the wrapped height matches the rendered glyph metrics. + sealed class ComposeFormattedTextNode : ComposeNode + { + protected override bool PadsOwnContent => true; + + IReadOnlyList _runs = System.Array.Empty(); + int _fontSize; + int _lineHeight; + int _lineBreak; + string? _fontFamily; + readonly MutableState _version = new(0); + + int EffectiveLineHeightSp() => _lineHeight > 0 ? _lineHeight : TextMeasure.LineHeightSp(_fontSize > 0 ? _fontSize : 16f); + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Text_Runs) + { + _runs = value.AsObject as IReadOnlyList ?? System.Array.Empty(); + _version.Value++; + } + else if (id == PropertyIds.Text_FontSize) + { + _fontSize = (int)System.Math.Round(value.AsDouble); + _version.Value++; + } + else if (id == PropertyIds.Text_LineHeight) + { + _lineHeight = (int)System.Math.Round(value.AsDouble); + _version.Value++; + } + else if (id == PropertyIds.Text_LineBreak) + { + _lineBreak = value.AsInt; + _version.Value++; + } + else if (id == PropertyIds.Text_FontFamily) + { + _fontFamily = value.AsString; + _version.Value++; + } + } + + public override Size Measure(double widthConstraint, double heightConstraint) + { + int sp = _fontSize > 0 ? _fontSize : 16; + var baseTypeface = ComposeFontRegistry.Resolve(_fontFamily, 400)?.Typeface; + return TextMeasure.MeasureRuns(_runs, sp, widthConstraint, baseTypeface, EffectiveLineHeightSp(), _lineBreak); + } + + public override void Render(IComposer composer) + { + _ = _version.Value; + + var builder = new AnnotatedStringBuilder(); + foreach (var run in _runs) + { + var style = new SpanStyle(); + if (run.Color is { } c) + style.Color = ToComposeColor(c); + if (run.Monospace) + style.FontFamily = FontFamily.Monospace; + if (run.Background is { } bg) + style.Background = ToComposeColor(bg); + if (run.Bold) + style.FontWeight = AndroidX.Compose.FontWeight.Bold; + if (run.Italic) + style.FontStyle = AndroidX.Compose.FontStyle.Italic; + // Strikethrough and underline are mutually exclusive in practice; strike wins. + if (run.Strikethrough) + style.Decoration = TextDecoration.LineThrough; + else if (run.Underline) + style.Decoration = TextDecoration.Underline; + if (run.FontSize is { } fsz) + style.FontSize = new AndroidX.Compose.Sp((int)System.Math.Round(fsz)); + + // A run with a tap handler becomes a clickable LinkAnnotation (the SpanStyle rides + // along as the link's range style) so Compose's Text fires the click — mirrors the + // gold's ClickableText @mention/link annotations. Otherwise a plain styled span. + if (run.OnTap is { } onTap) + { + var link = LinkAnnotation.Clickable(string.Empty, _ => onTap(), style); + int li = builder.PushLink(link); + builder.Append(run.Text); + builder.Pop(li); + } + else + { + int idx = builder.PushStyle(style); + builder.Append(run.Text); + builder.Pop(idx); + } + } + + var text = new ComposeAnnotatedText(builder.ToAnnotatedString()) { Modifier = BuildNodeModifier() }; + text.LetterSpacing = AndroidX.Compose.Sp.Zero; + if (_fontSize > 0) + text.FontSize = new AndroidX.Compose.Sp(_fontSize); + text.LineHeight = new AndroidX.Compose.Sp(EffectiveLineHeightSp()); + // Base (non-code) runs use the body family; code runs override to monospace above. + if (ComposeFontRegistry.Resolve(_fontFamily, 400) is { } r) + text.FontFamily = r.Family; + // Wrap strategy via the cached base TextStyle (see ComposeTextNode) — explicit params still win. + if (_lineBreak != 0) + text.Style = TextMeasure.LineBreakStyleFor(_lineBreak); + text.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeIconButtonNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeIconButtonNode.cs new file mode 100644 index 000000000..dfc10985c --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeIconButtonNode.cs @@ -0,0 +1,55 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose.Runtime; +using Comet.Backend; + +namespace Comet.Platform.Compose +{ + /// Renders Comet as the REAL Material 3 + /// IconButton: 48dp state-layer target, bounded ripple, button semantics — + /// the widget behind the gold's icon actions (Jetcaster queue-add/overflow). + // IBackendManagesOwnContent: the icon slot is materialized by THIS node into the + // widget's content lambda (same contract as ComposeIconToggleNode). + sealed class ComposeIconButtonNode : ComposeNode, IBackendManagesOwnContent + { + const float TargetDp = 48f; // M3 icon-button minimum touch target + + IconButton _control; + readonly BackendContext _context; + ComposeNode? _iconNode; + + public ComposeIconButtonNode(IconButton control, BackendContext context) + { + _control = control; + _context = context; + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not IconButton control) + return; + _control = control; + _iconNode = null; // the icon slot view was rebuilt with the owner + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + // No control-specific typed patches — the action rides the ctor callback. + } + + public override Size Measure(double widthConstraint, double heightConstraint) + => new(TargetDp, TargetDp); + + public override void Render(IComposer composer) + { + _iconNode ??= (ComposeNode)CometBackendBridge.Materialize(_control.IconView, _context, _control); + var button = new AndroidX.Compose.IconButton(() => _control.OnClick()) + { + _iconNode, + }; + ((AndroidX.Compose.ComposableNode)button).Modifier = BuildNodeModifier(); + button.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeIconNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeIconNode.cs new file mode 100644 index 000000000..381dd0cfd --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeIconNode.cs @@ -0,0 +1,171 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Graphics.Vector; +using Comet.Backend; +using ComposeIcon = AndroidX.Compose.Icon; +using ComposeText = AndroidX.Compose.Text; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet as a real Material Icon + /// (ImageVector from the Icons set), tinted and sized — not a glyph in a label. + sealed class ComposeIconNode : ComposeNode + { + protected override bool PadsOwnContent => true; + + readonly MutableState _symbol = new(string.Empty); + readonly MutableState _glyph = new(string.Empty); // icon-font codepoint, if any + readonly MutableState _glyphFont = new(string.Empty); // icon-font family + Microsoft.Maui.Graphics.Color? _tint; + float _size = 24f; + bool _fillFrame; + readonly MutableState _iconVersion = new(0); + + // Brand logos that ship their own colors — rendered as an Image (untinted) so + // painterResource preserves them. Every other bundled "ic_" is tinted. + static readonly System.Collections.Generic.HashSet MulticolorAssets = new() { "jetchat", "jetnews_badge" }; + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Icon_Symbol) + _symbol.Value = value.AsString ?? string.Empty; + else if (id == PropertyIds.Icon_Glyph) + _glyph.Value = value.AsString ?? string.Empty; + else if (id == PropertyIds.Icon_FontFamily) + _glyphFont.Value = value.AsString ?? string.Empty; + else if (id == PropertyIds.Icon_Tint) + { + _tint = value.AsColor; + _iconVersion.Value++; + } + else if (id == PropertyIds.Icon_Size) + _size = (float)value.AsDouble; + else if (id == PropertyIds.Icon_FillFrame) + _fillFrame = value.AsBool; + } + + public override Size Measure(double widthConstraint, double heightConstraint) + => new Size(_size, _size); + + public override void Render(IComposer composer) + { + _ = _iconVersion.Value; + + // Honor IconSize even when the engine hasn't given this node a frame (e.g. an icon inside a + // native control's slot — a FAB — which lays its own content out). With a frame, the size + // already comes from the frame; without one, pin it here so the glyph isn't the default 24dp. + // A frame LARGER than the icon size (a 24dp star in a 40dp circle) centers the glyph via + // symmetric padding — nothing else does (the glyph otherwise draws at the box origin). + Modifier IconModifier() + { + var m = BuildNodeModifier() ?? Modifier.Companion; + if (!HasFrame) + return m.Size(new Dp(_size), new Dp(_size)); + // Non-square asset: draw at the frame's size, no slack centering. + if (_fillFrame) + return m; + // Remaining slack after any explicit leaf padding (BuildNodeModifier applied it). + float padH = (float)((FrameWidth - Padding.Left - Padding.Right - _size) / 2.0); + float padV = (float)((FrameHeight - Padding.Top - Padding.Bottom - _size) / 2.0); + if (padH > 0.5f || padV > 0.5f) + m = m.Padding(System.Math.Max(0f, padH), System.Math.Max(0f, padV), + System.Math.Max(0f, padH), System.Math.Max(0f, padV)); + return m; + } + + // An icon-font glyph (e.g. Material Icons) renders as a tinted, sized character in the + // registered font — the SAME glyph the iOS backend draws, so the icon set is identical + // cross-platform. Wins over the native ImageVector/drawable path when a glyph is set. + // AnnotatedText (not plain Text) so the LINE box can be pinned to the icon size and + // centered: the default line height exceeds the glyph box and pushed the ink below + // center (the Reply back-arrow rendered low + clipped by its circular clip). + if (!string.IsNullOrEmpty(_glyph.Value)) + { + var glyph = new AndroidX.Compose.AnnotatedText(new AndroidX.Compose.AnnotatedString(_glyph.Value)) + { + FontSize = new AndroidX.Compose.Sp((int)_size), + LineHeight = new AndroidX.Compose.Sp((int)_size), + Align = AndroidX.Compose.TextAlign.Center, + SoftWrap = false, + }; + if (_tint is { } gc) + glyph.Color = ToComposeColor(gc); + if (ComposeFontRegistry.Resolve(_glyphFont.Value, 400) is { } r) + glyph.FontFamily = r.Family; + ((ComposableNode)glyph).Modifier = IconModifier(); + glyph.Render(composer); + return; + } + + // A bundled vector drawable named "ic_" wins over the built-in ImageVector set, + // so apps can ship exact assets (e.g. Jetchat's own footer icons). Multicolor brand + // logos (jetchat) render as an Image so painterResource keeps their own colors; + // everything else renders through the tinted Material Icon (painter overload), so the + // requested .Color() recolors the glyph just like a built-in icon. + var ctx = global::Android.App.Application.Context; + int resId = ctx.Resources!.GetIdentifier("ic_" + _symbol.Value, "drawable", ctx.PackageName); + if (resId != 0) + { + // A multicolor brand logo renders as an Image to keep its own colors — UNLESS an + // explicit .Color() asks for a tint, in which case it routes through the tinted Icon + // path and renders monochrome (e.g. the drawer's chat-row logo, tinted onSurfaceVariant). + if (MulticolorAssets.Contains(_symbol.Value) && _tint is null) + { + var image = new AndroidX.Compose.Image(resId, _symbol.Value); + ((ComposableNode)image).Modifier = IconModifier(); + image.Render(composer); + return; + } + + var bundled = new ComposeIcon(resId, _symbol.Value); + if (_tint is { } bt) + bundled.Tint = ToComposeColor(bt); + ((ComposableNode)bundled).Modifier = IconModifier(); + bundled.Render(composer); + return; + } + + var icon = new ComposeIcon(Resolve(_symbol.Value), _symbol.Value); + if (_tint is { } t) + icon.Tint = ToComposeColor(t); + ((ComposableNode)icon).Modifier = IconModifier(); + icon.Render(composer); + } + + // Cross-platform symbol name → Material ImageVector. Core set (material-icons-core); + // a few footer icons fall back to the nearest available until the facade exposes the + // extended set (mood/alternate_email/photo/duo). + internal static ImageVector ResolveSymbol(string s) => Resolve(s); + + static ImageVector Resolve(string s) => s switch + { + "search" => Icons.Filled.Search, + "info" => Icons.Outlined.Info, + "menu" => Icons.Filled.Menu, + "send" => Icons.AutoMirrored.Default.Send, + "place" or "location" => Icons.Filled.Place, + "person" => Icons.Filled.Person, + "people" => Icons.Filled.AccountCircle, // jetchat logo stand-in (core set has no "groups") + "account" => Icons.Filled.AccountCircle, + "call" or "phone" => Icons.Filled.Call, + "email" or "mail" => Icons.Filled.Email, + "close" => Icons.Filled.Close, + "settings" => Icons.Filled.Settings, + "share" => Icons.Filled.Share, + "back" => Icons.AutoMirrored.Default.ArrowBack, + "arrow_down" or "arrow_downward" or "expand_more" => Icons.Filled.KeyboardArrowDown, + "add" => Icons.Filled.Add, + "edit" => Icons.Filled.Edit, + // Nearest-available stand-ins for Jetchat's extended footer icons: + "mood" or "emoji" => Icons.Filled.Face, + "at" => Icons.Filled.Email, + "photo" or "image" => Icons.Filled.AccountBox, + "video" or "duo" => Icons.Filled.Call, + "mic" or "microphone" => Icons.Filled.Phone, // core set has no Mic glyph (stand-in) + _ => Icons.Filled.Star, + }; + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeIconToggleNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeIconToggleNode.cs new file mode 100644 index 000000000..f25cadc5c --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeIconToggleNode.cs @@ -0,0 +1,61 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; + +namespace Comet.Platform.Compose +{ + /// Renders Comet as the REAL Material 3 + /// IconToggleButton (the gold bookmark control): 48dp state-layer target, checked + /// state + onCheckedChange routed back to the control's handler. + // IBackendManagesOwnContent: the icon slot is materialized by THIS node into the + // widget's content lambda — without it the bridge ALSO auto-materializes the + // IContainerView child, double-wiring the same Icon view. + sealed class ComposeIconToggleNode : ComposeNode, IBackendManagesOwnContent + { + const float TargetDp = 48f; // M3 icon-button minimum touch target + + IconToggleButton _control; + readonly BackendContext _context; + readonly MutableState _checked = new(false); + ComposeNode? _iconNode; + + public ComposeIconToggleNode(IconToggleButton control, BackendContext context) + { + _control = control; + _context = context; + _checked.Value = control.IsChecked; + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not IconToggleButton control) + return; + _control = control; + _checked.Value = control.IsChecked; + _iconNode = null; // the icon slot view was rebuilt with the owner + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Toggle_IsOn) + _checked.Value = value.AsBool; + } + + public override Size Measure(double widthConstraint, double heightConstraint) + => new(TargetDp, TargetDp); + + public override void Render(IComposer composer) + { + _iconNode ??= (ComposeNode)CometBackendBridge.Materialize(_control.IconView, _context, _control); + var button = new AndroidX.Compose.IconToggleButton(_checked.Value, v => _control.OnChange(v)) + { + _iconNode, + }; + ((ComposableNode)button).Modifier = BuildNodeModifier(); + button.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeInputNodes.cs b/src/Comet/src/Comet/Platform/Compose/ComposeInputNodes.cs new file mode 100644 index 000000000..5eeda0557 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeInputNodes.cs @@ -0,0 +1,268 @@ +#nullable enable +#if ANDROID +using System; +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using ComposeTextField = AndroidX.Compose.TextField; +using ComposeSwitch = AndroidX.Compose.Switch; +using ComposeSlider = AndroidX.Compose.Slider; + +namespace Comet.Platform.Compose +{ + /// Renders Comet TextField as a Material 3 TextField, routing + /// edits back through the event sink (TextChanged payload). + sealed class ComposeTextFieldNode : ComposeNode + { + readonly MutableState _text = new(string.Empty); + readonly MutableState _placeholder = new(string.Empty); + readonly MutableState _borderless = new(false); + Microsoft.Maui.Graphics.Color? _textColor; + Comet.ReturnType _returnType = Comet.ReturnType.Default; + + // TextFieldValue state (text + caret) for the borderless path: user edits hand back the + // full value, and programmatic edits (insert-at-cursor) can place the caret. + MutableState? _tfv; + + public ComposeTextFieldNode(Comet.TextField field) + => field.RegisterTextInserter(InsertAtCursor); + + /// A diff transferred this node to a new TextField instance: re-register the + /// caret-aware inserter on it, or emoji-insert-at-cursor silently degrades to append + /// (the new field's inserter would be null). Fires on ordinary re-renders and hot reload. + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is Comet.TextField field) + field.RegisterTextInserter(InsertAtCursor); + } + + static long PackCaret(int start, int end) => ((long)start << 32) | (uint)end; + + MutableState Tfv + => _tfv ??= new(ComposeExtensions.NewTextFieldValue( + _text.Value, PackCaret(_text.Value.Length, _text.Value.Length))); + + /// Inserts at the caret (replacing any selection), caret lands after the + /// insert. The classic emoji-picker edit — mid-string, not append. + void InsertAtCursor(string insert) + { + var current = Tfv.Value!; + var text = current.Text ?? string.Empty; + // Selection is the packed TextRange inline value: start in the high 32 bits, end low. + // A TextRange can be reversed (start > end, a right-to-left drag), so normalize with + // min/max before slicing — otherwise the selected span isn't replaced and the insert + // lands at the wrong index. + long sel = current.Selection; + int a = (int)(sel >> 32); + int b = (int)(sel & 0xFFFFFFFF); + int start = System.Math.Clamp(System.Math.Min(a, b), 0, text.Length); + int end = System.Math.Clamp(System.Math.Max(a, b), start, text.Length); + var newText = text.Substring(0, start) + insert + text.Substring(end); + int caret = start + insert.Length; + Tfv.Value = ComposeExtensions.NewTextFieldValue(newText, PackCaret(caret, caret)); + _text.Value = newText; + Sink?.OnEvent(EventIds.TextChanged, newText); + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.TextField_Text) + { + var s = value.AsString ?? string.Empty; + _text.Value = s; + // Programmatic text change (e.g. clear-on-send): rebuild the TextFieldValue with + // the caret at the end. The user-typing echo arrives with IDENTICAL text — skip + // it so the live caret position isn't reset mid-typing. + if (_tfv is not null && (_tfv.Value?.Text ?? string.Empty) != s) + _tfv.Value = ComposeExtensions.NewTextFieldValue(s, PackCaret(s.Length, s.Length)); + } + else if (id == PropertyIds.TextField_Placeholder) + _placeholder.Value = value.AsString ?? string.Empty; + else if (id == PropertyIds.TextField_Outlined) + _outlined.Value = value.AsBool; + else if (id == PropertyIds.TextField_LeadingIcon) + _leadingIcon.Value = value.AsString ?? string.Empty; + else if (id == PropertyIds.TextField_Borderless) + _borderless.Value = value.AsBool; + else if (id == PropertyIds.TextField_TextColor) + _textColor = value.AsColor; + else if (id == PropertyIds.TextField_ReturnType) + _returnType = (Comet.ReturnType)value.AsInt; + } + + // Map Comet's ReturnType (the soft-keyboard action key) to a Compose ImeAction int. + static int MapImeAction(Comet.ReturnType rt) => rt switch + { + Comet.ReturnType.Send => AndroidX.Compose.ImeAction.Send, + Comet.ReturnType.Done => AndroidX.Compose.ImeAction.Done, + Comet.ReturnType.Go => AndroidX.Compose.ImeAction.Go, + Comet.ReturnType.Next => AndroidX.Compose.ImeAction.Next, + Comet.ReturnType.Search => AndroidX.Compose.ImeAction.Search, + _ => AndroidX.Compose.ImeAction.Default, + }; + + // A Material TextField fills the available width and is ~56dp tall; give Yoga that intrinsic + // size so it doesn't collapse to zero height. A borderless field is sized to its text plus + // the requested vertical padding (no Material container chrome). + public override Size Measure(double widthConstraint, double heightConstraint) + { + double width = double.IsInfinity(widthConstraint) ? 0 : widthConstraint; + if (_borderless.Value) + return new Size(width, 22); // content only — Yoga adds the leaf's padding + return new Size(width, 56); + } + + const int BorderlessFontSp = 16; + + readonly MutableState _outlined = new(false); + readonly MutableState _leadingIcon = new(string.Empty); + + public override void Render(IComposer composer) + { + if (_borderless.Value) + { + RenderBorderless(composer); + return; + } + + var placeholder = _placeholder.Value; + + // The REAL Material OutlinedTextField (gold HomeSearch) — self-themed outline, + // optional leading icon from the shared cross-platform symbol set. + if (_outlined.Value) + { + var outlined = new AndroidX.Compose.OutlinedTextField( + value: _text.Value, + onValueChange: s => Sink?.OnEvent(EventIds.TextChanged, s)); + if (!string.IsNullOrEmpty(placeholder)) + outlined.Placeholder = new AndroidX.Compose.Text(placeholder); + if (_leadingIcon.Value is { Length: > 0 } leading) + outlined.LeadingIcon = new AndroidX.Compose.Icon(ComposeIconNode.ResolveSymbol(leading), leading); + ((ComposableNode)outlined).Modifier = BuildNodeModifier(); + outlined.Render(composer); + return; + } + + var field = new ComposeTextField( + value: _text.Value, + onValueChange: s => Sink?.OnEvent(EventIds.TextChanged, s)); + + if (!string.IsNullOrEmpty(placeholder)) + field.Placeholder = new AndroidX.Compose.Text(placeholder); + + // Position + size the field from its Yoga frame so it doesn't render at the origin. + ((ComposableNode)field).Modifier = BuildNodeModifier(); + field.Render(composer); + } + + // A foundation BasicTextField (no container/indicator) overlaid with the placeholder when + // empty — blends into the surrounding surface. Content padding is applied here because the + // Yoga engine doesn't inset leaf content. + void RenderBorderless(IComposer composer) + { + var p = Padding; + var contentMod = Modifier.Companion + .FillMaxWidth() + .Padding(new Dp((float)p.Left), new Dp((float)p.Top), new Dp((float)p.Right), new Dp((float)p.Bottom)); + + var textColor = _textColor is { } tc ? ToComposeColor(tc) : AndroidX.Compose.Color.Black; + + var box = new AndroidX.Compose.Box(); + ((ComposableNode)box).Modifier = BuildNodeModifier(); // Yoga frame (+ any background) + + var field = new AndroidX.Compose.BasicTextField( + Tfv.Value!, tfv => + { + Tfv.Value = tfv; + var s = tfv.Text ?? string.Empty; + if (_text.Value != s) + { + _text.Value = s; + Sink?.OnEvent(EventIds.TextChanged, s); + } + }) + { + // Report focus GAINED so the host can react (gold onTextFieldFocused — e.g. close an open + // input-selector panel so the keyboard doesn't overlay it). + Modifier = contentMod.OnFocusChanged(fs => { if (fs.IsFocused) Sink?.OnEvent(EventIds.Focused); }), + SingleLine = true, + TextStyle = new AndroidX.Compose.TextStyle { Color = textColor, FontSize = new AndroidX.Compose.Sp(BorderlessFontSp) }, + }; + + // Soft-keyboard action key (e.g. Send): set the ImeAction and fire Completed when it's pressed + // (the gold's KeyboardActions { onMessageSent }). All action callbacks route to Completed — + // only the configured action's key is shown by the IME, so just one can fire. + if (_returnType != Comet.ReturnType.Default) + { + // Copy the default options overriding only imeAction. The binding strips Kotlin defaults, + // so pass all slots: capitalization None(0), autoCorrect default, keyboardType Text, the + // mapped action; the trailing platformImeOptions / showKeyboardOnFocus / hintLocales = null. + field.KeyboardOptions = AndroidX.Compose.KeyboardOptionsCompanion.Default.Copy( + 0, null, AndroidX.Compose.KeyboardType.Text, MapImeAction(_returnType), null, null, null); + void Fire() => Sink?.OnEvent(EventIds.Completed); + field.KeyboardActions = AndroidX.Compose.KeyboardActionsHelper.Create( + onDone: Fire, onGo: Fire, onNext: Fire, onSearch: Fire, onSend: Fire); + } + + // Field FIRST so it keeps a stable position (index 0) across text changes. The placeholder is + // ALWAYS present (blanked once there's input) and overlaid on top — adding/removing it instead + // would shift the field's index and make Compose drop focus + dismiss the keyboard mid-typing. + box.Add(field); + var hint = string.IsNullOrEmpty(_text.Value) ? (_placeholder.Value ?? string.Empty) : string.Empty; + box.Add(new AndroidX.Compose.Text(hint) + { + Modifier = contentMod, + FontSize = new AndroidX.Compose.Sp(BorderlessFontSp), + // Dim the hint (≈60% alpha) — reads like onSurfaceVariant. + Color = _textColor is { } c + ? ToComposeColor(new Microsoft.Maui.Graphics.Color(c.Red, c.Green, c.Blue, 0.6f)) + : AndroidX.Compose.Color.Gray, + }); + + ((ComposableNode)box).Render(composer); + } + } + + /// Renders Comet Slider as a Material 3 Slider (default 0..1 + /// range), routing drags back through the event sink (ValueChanged double payload). + sealed class ComposeSliderNode : ComposeNode + { + readonly MutableState _value = new(0f); + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Slider_Value) + _value.Value = (float)value.AsDouble; + } + + public override void Render(IComposer composer) + { + new ComposeSlider( + value: _value.Value, + onValueChange: v => Sink?.OnEvent(EventIds.ValueChanged, (double)v)) + .Render(composer); + } + } + + /// Renders Comet Toggle as a Material 3 Switch, routing flips + /// back through the event sink (Toggled bool payload). + sealed class ComposeToggleNode : ComposeNode + { + readonly MutableState _isOn = new(false); + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Toggle_IsOn) + _isOn.Value = value.AsBool; + } + + public override void Render(IComposer composer) + { + new ComposeSwitch( + @checked: _isOn.Value, + onCheckedChange: b => Sink?.OnEvent(EventIds.Toggled, b)) + .Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeLeafNodes.cs b/src/Comet/src/Comet/Platform/Compose/ComposeLeafNodes.cs new file mode 100644 index 000000000..a04a9cfff --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeLeafNodes.cs @@ -0,0 +1,489 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +// Comet has its own Text/Button controls; alias the vendored Compose composables. +using ComposeText = AndroidX.Compose.Text; +using ComposeButton = AndroidX.Compose.Button; + +namespace Comet.Platform.Compose +{ + /// Renders Comet Text as a Material 3 Text composable. + sealed class ComposeTextNode : ComposeNode + { + protected override bool PadsOwnContent => true; + + readonly MutableState _text = new(string.Empty); + Microsoft.Maui.Graphics.Color? _color; + int _maxLines; + int _fontSize; + int _lineHeight; + int _lineBreak; // (int)TextLineBreak — wrap strategy, threaded to render AND measure + bool _italic; + int _fontWeight; + string? _fontFamily; + readonly MutableState _colorVersion = new(0); + + // The line height to pin (sp): an explicit .LineHeight() value (Compose TextStyle.lineHeight), + // else the proportional heuristic. Used identically by render, measure, and baseline so the + // frame, the drawn text, and the reported baseline all agree. + int EffectiveLineHeightSp() => _lineHeight > 0 ? _lineHeight : TextMeasure.LineHeightSp(_fontSize > 0 ? _fontSize : 16f); + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Text_Value) + _text.Value = value.AsString ?? string.Empty; + else if (id == PropertyIds.Text_Color) + { + _color = value.AsColor; + _colorVersion.Value++; + } + else if (id == PropertyIds.Text_FontSize) + { + _fontSize = (int)System.Math.Round(value.AsDouble); + _colorVersion.Value++; + } + else if (id == PropertyIds.Text_LineHeight) + { + _lineHeight = (int)System.Math.Round(value.AsDouble); + _colorVersion.Value++; + } + else if (id == PropertyIds.Text_LineBreak) + { + _lineBreak = value.AsInt; + _colorVersion.Value++; + } + else if (id == PropertyIds.Text_Italic) + { + _italic = value.AsBool; + _colorVersion.Value++; + } + else if (id == PropertyIds.Text_FontWeight) + { + _fontWeight = value.AsInt; // emitted as From((int)weight) — read as Int, not Double + _colorVersion.Value++; + } + else if (id == PropertyIds.Text_MaxLines) + { + _maxLines = value.AsInt; + _colorVersion.Value++; + } + else if (id == PropertyIds.Text_FontFamily) + { + _fontFamily = value.AsString; + _colorVersion.Value++; + } + } + + // Intrinsic size for the Yoga engine: measure the text wrapped to the available width + // with StaticLayout (synchronous, no composition needed). Uses the resolved custom typeface + // so measurement matches the rendered (custom-font) glyphs. 16sp ~ Material bodyLarge. + public override Size Measure(double widthConstraint, double heightConstraint) + => TextMeasure.MeasureWrapped(_text.Value, _fontSize > 0 ? _fontSize : 16f, widthConstraint, MeasureTypeface(), EffectiveLineHeightSp(), _maxLines, _lineBreak); + + // First-baseline offset (Dp) measured by Compose's OWN layout (TextMeasurer) so the reported + // baseline equals the drawn one (the RN single-engine model). Only invoked for baseline-aligned + // rows, and the list caches materialized rows so it runs once per row, not per scroll frame. + public override double? MeasureBaseline(double width, double height) + { + int sp = _fontSize > 0 ? _fontSize : 16; + var resolved = ComposeFontRegistry.Resolve(_fontFamily, _fontWeight, _italic); + // Mirror Render: a resolved custom family carries the weight (no FontWeight); otherwise + // apply the weight to the default family. + AndroidX.Compose.FontFamily? family = resolved?.Family; + AndroidX.Compose.FontWeight? weight = resolved is null && _fontWeight > 0 ? MapWeight(_fontWeight) : null; + return AndroidX.Compose.ComposeTextMeasure.FirstBaselineDp( + global::Android.App.Application.Context!, ComposeNode.Density, _text.Value, + new AndroidX.Compose.Sp(sp), family, weight, new AndroidX.Compose.Sp(EffectiveLineHeightSp()), width); + } + + // The typeface to measure with: the resolved custom font (which carries the weight), or the + // default font at the requested weight — so bold default-font text isn't measured at Regular + // width (which clips the heavier rendered glyphs). + global::Android.Graphics.Typeface? MeasureTypeface() + { + // Measure == render: slant (and weight) must reach the measuring typeface too, + // or italic glyphs render wider than the upright-measured frame and clip. + // The registry bakes the slant into the resolved typeface itself. + if (ComposeFontRegistry.Resolve(_fontFamily, _fontWeight, _italic)?.Typeface is { } custom) + return custom; + if ((_fontWeight >= 500 || _italic) && System.OperatingSystem.IsAndroidVersionAtLeast(28)) + return global::Android.Graphics.Typeface.Create(global::Android.Graphics.Typeface.Default, + _fontWeight > 0 ? _fontWeight : 400, _italic); + return null; + } + + public override void Render(IComposer composer) + { + // Reading _text.Value inside composition subscribes this scope, so a later + // ApplyProperty -> setValue recomposes just this Text. + _ = _colorVersion.Value; // subscribe so a color/size/weight/family change recomposes + var text = new ComposeText(_text.Value) { Modifier = BuildNodeModifier() }; + // Zero letter-spacing so the rendered width matches the Paint measurement above (the + // MaterialTheme default bodyLarge adds 0.5sp, which otherwise overflows the frame). + text.LetterSpacing = AndroidX.Compose.Sp.Zero; + if (_color is { } c) + text.Color = ToComposeColor(c); + if (_fontSize > 0) + text.FontSize = new AndroidX.Compose.Sp(_fontSize); + // Custom font: the resolved typeface already carries the weight, so set the family and + // DON'T also set FontWeight (which would synthesize bold on top). Falls back to the + // weight when no custom family is registered. + var resolved = ComposeFontRegistry.Resolve(_fontFamily, _fontWeight, _italic); + if (resolved is { } r) + text.FontFamily = r.Family; + else if (_fontWeight > 0) + text.FontWeight = MapWeight(_fontWeight); + if (_italic) + text.FontStyle = AndroidX.Compose.FontStyle.Italic; + // Pin the rendered line-height to exactly what the measurement used (instead of the + // MaterialTheme bodyLarge default of 24sp) so multi-line text doesn't clip, a single + // line isn't an over-tall box (which threw off vertical centering + the title/subtitle + // gap), and the frame height matches the render line-for-line. + text.LineHeight = new AndroidX.Compose.Sp(EffectiveLineHeightSp()); + if (_maxLines > 0) + { + text.MaxLines = _maxLines; + text.Overflow = AndroidX.Compose.TextOverflow.Ellipsis; + } + // Wrap strategy rides in on a base TextStyle (Compose has no direct lineBreak + // param); the explicit params above still win over the style, so only lineBreak + // changes. Measurement mirrors it via StaticLayout break-strategy (below). + if (_lineBreak != 0) + text.Style = TextMeasure.LineBreakStyleFor(_lineBreak); + text.Render(composer); + } + + static AndroidX.Compose.FontWeight MapWeight(int w) => + w >= 700 ? AndroidX.Compose.FontWeight.Bold + : w >= 600 ? AndroidX.Compose.FontWeight.SemiBold + : w >= 500 ? AndroidX.Compose.FontWeight.Medium + : AndroidX.Compose.FontWeight.Normal; + } + + /// Synchronous native text measurement for the Yoga layout engine. + static class TextMeasure + { + // A comfortable line-height multiple (close to Roboto's natural ~1.17 plus a little), used + // BOTH to set the rendered Text's lineHeight and to compute the measured frame height — so + // measure == render and nothing clips. Integer sp so the two sides match exactly. + const float LineMult = 1.3f; + public static int LineHeightSp(float sp) => (int)System.Math.Round(sp * LineMult); + + // Measures formatted runs by building a native SpannableString with the same monospace + // ("code") spans the renderer uses, so wrapping/height match the composed AnnotatedText. + // Mirror the Compose LineBreak presets in StaticLayout so measured line count matches + // the render: Heading = balanced + phrase word breaks (33+), Paragraph = high quality. + // The immutable LineBreakConfig is cached — Measure is the layout hot path and this + // otherwise allocates two JNI objects per text per pass. + static global::Android.Graphics.Text.LineBreakConfig? s_phraseConfig; + + static void ApplyBreakStrategy(global::Android.Text.StaticLayout.Builder builder, int lineBreak) + { + if (lineBreak == 1) + { + builder.SetBreakStrategy(global::Android.Text.BreakStrategy.Balanced); + if (System.OperatingSystem.IsAndroidVersionAtLeast(33)) + builder.SetLineBreakConfig(s_phraseConfig ??= new global::Android.Graphics.Text.LineBreakConfig.Builder() + .SetLineBreakWordStyle((int)global::Android.Graphics.Text.LineBreakWordStyle.Phrase) + .Build()); + } + else if (lineBreak == 2) + builder.SetBreakStrategy(global::Android.Text.BreakStrategy.HighQuality); + } + + public static AndroidX.Compose.UI.Text.TextStyle LineBreakStyleFor(int lineBreak) + => LineBreakStyles.For(lineBreak); + + /// The two wrap-strategy base styles, built ONCE — Render otherwise crosses + /// JNI for a ~23-slot TextStyle copy on every recomposition of every LineBreak text. + static class LineBreakStyles + { + static AndroidX.Compose.UI.Text.TextStyle? s_heading, s_paragraph; + + public static AndroidX.Compose.UI.Text.TextStyle For(int lineBreak) => lineBreak == 1 + ? s_heading ??= new AndroidX.Compose.TextStyle { LineBreak = LineBreakValues.Heading }.Build() + : s_paragraph ??= new AndroidX.Compose.TextStyle { LineBreak = LineBreakValues.Paragraph }.Build(); + } + + public static Size MeasureRuns(System.Collections.Generic.IReadOnlyList runs, + float sp, double maxWidthDp, global::Android.Graphics.Typeface? baseTypeface, int lineHeightSp = 0, int lineBreak = 0) + { + var density = ComposeNode.Density; + int widthPx = (maxWidthDp > 0 && !double.IsInfinity(maxWidthDp)) + ? (int)System.Math.Ceiling(maxWidthDp * density) + : global::Android.Content.Res.Resources.System!.DisplayMetrics!.WidthPixels; + + var sb = new global::Android.Text.SpannableStringBuilder(); + foreach (var run in runs) + { + int start = sb.Length(); + sb.Append(run.Text ?? string.Empty); + if (run.Monospace) + sb.SetSpan(new global::Android.Text.Style.TypefaceSpan("monospace"), + start, sb.Length(), global::Android.Text.SpanTypes.InclusiveExclusive); + } + + using var paint = new global::Android.Text.TextPaint { TextSize = sp * density }; + if (baseTypeface is not null) + paint.SetTypeface(baseTypeface); + var runsBuilder = global::Android.Text.StaticLayout.Builder + .Obtain(sb, 0, sb.Length(), paint, widthPx); + ApplyBreakStrategy(runsBuilder, lineBreak); + using var layout = runsBuilder.Build(); + + float used = 0f; + for (int i = 0; i < layout.LineCount; i++) + used = System.Math.Max(used, layout.GetLineWidth(i)); + double heightDp = layout.LineCount * (lineHeightSp > 0 ? lineHeightSp : LineHeightSp(sp)); + return new Size((System.Math.Ceiling(used) + 2) / density, heightDp); + } + + // First-baseline offset (Dp) from the top of a line whose height is pinned to LineHeightSp. + // Compose distributes the extra leading (lineHeight − natural ascent/descent) PROPORTIONALLY + // to the ascent:descent ratio (the default when no LineHeightStyle is set), so the baseline + // sits at topLeading + ascent. Matching this exactly is what makes a baseline-aligned row + // line up to the pixel instead of ~2px off. + public static double FirstBaselineDp(float sp, global::Android.Graphics.Typeface? typeface) + { + var density = ComposeNode.Density; + using var paint = new global::Android.Graphics.Paint { TextSize = sp * density }; + if (typeface is not null) + paint.SetTypeface(typeface); + var fm = paint.GetFontMetrics(); + float ascent = -fm.Ascent; // px above the baseline + float descent = fm.Descent; // px below the baseline + float naturalPx = ascent + descent; + float lineHeightPx = LineHeightSp(sp) * density; + float extraPx = System.Math.Max(0f, lineHeightPx - naturalPx); + float topLeadingPx = naturalPx > 0 ? extraPx * ascent / naturalPx : 0f; + return (topLeadingPx + ascent) / density; + } + + // Single-line width (e.g. a button label). + public static Size SingleLine(string? text, float sp) + { + var density = ComposeNode.Density; + using var paint = new global::Android.Graphics.Paint { TextSize = sp * density }; + float wPx = paint.MeasureText(text ?? string.Empty); + var fm = paint.GetFontMetrics(); + return new Size(wPx / density, (fm.Descent - fm.Ascent) / density); + } + + // Wrapped to the available width: StaticLayout lays the text out to widthPx and reports + // the multi-line height (the Compose analog of iOS TextKit boundingRect). When a custom + // typeface is supplied it is used so the measurement matches the rendered glyph metrics. + public static Size MeasureWrapped(string? text, float sp, double maxWidthDp, global::Android.Graphics.Typeface? typeface = null, int lineHeightSp = 0, int maxLines = 0, int lineBreak = 0) + { + var density = ComposeNode.Density; + var s = text ?? string.Empty; + int widthPx = (maxWidthDp > 0 && !double.IsInfinity(maxWidthDp)) + ? (int)System.Math.Ceiling(maxWidthDp * density) + : global::Android.Content.Res.Resources.System!.DisplayMetrics!.WidthPixels; + using var paint = new global::Android.Text.TextPaint { TextSize = sp * density }; + if (typeface is not null) + paint.SetTypeface(typeface); + var builder = global::Android.Text.StaticLayout.Builder + .Obtain(s, 0, s.Length, paint, widthPx); + ApplyBreakStrategy(builder, lineBreak); + using var layout = builder.Build(); + + // Report the ACTUAL used width (the widest laid-out line), not the constraint — so a + // short label hugs its text (and the flex row packs it tight) while a long one still + // wraps to widthPx. Returning the constraint width made every Text fill its column. + float used = 0f; + for (int i = 0; i < layout.LineCount; i++) + used = System.Math.Max(used, layout.GetLineWidth(i)); + // Width: +2px absorbs hinting differences so a one-line label never clips. + // Height: lineCount × the SAME line-height the renderer pins (explicit if supplied, else + // the heuristic), so the frame is exactly as tall as the composed Text — no clipping. + int lines = maxLines > 0 ? System.Math.Min(layout.LineCount, maxLines) : layout.LineCount; + double heightDp = lines * (lineHeightSp > 0 ? lineHeightSp : LineHeightSp(sp)); + return new Size((System.Math.Ceiling(used) + 2) / density, heightDp); + } + } + + /// Renders Comet Button as a Material 3 filled Button, + /// routing clicks back through the event sink. + sealed class ComposeButtonNode : ComposeNode + { + readonly MutableState _text = new(string.Empty); + Microsoft.Maui.Graphics.Color? _textColor; + bool _outlined; + bool _textButton; + readonly MutableState _styleVersion = new(0); + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Button_Text) + _text.Value = value.AsString ?? string.Empty; + else if (id == PropertyIds.Button_TextColor) + { + _textColor = value.AsColor; + _styleVersion.Value++; + } + else if (id == PropertyIds.Button_Outlined) + { + _outlined = value.AsBool; + _styleVersion.Value++; + } + else if (id == PropertyIds.Button_TextButton) + { + _textButton = value.AsBool; + _styleVersion.Value++; + } + } + + // A Material button: label plus default content padding, min 48dp tall. + public override Size Measure(double widthConstraint, double heightConstraint) + { + var label = TextMeasure.SingleLine(_text.Value, 14f); + return new Size(label.Width + 48, System.Math.Max(label.Height + 20, 48)); + } + + public override void Render(IComposer composer) + { + _ = _styleVersion.Value; + + // A real Material Button — filled by default, or OutlinedButton (bordered, no fill) when + // asked. Content color + corner shape come from the Comet view's .Color()/.CornerRadius(). + var label = new ComposeText(_text.Value); + void OnClick() => Sink?.OnEvent(EventIds.Clicked); + + if (_textButton) + { + // Material TextButton: no fill, no border — the gold's dialog/confirm button. The + // container is transparent (TextButton's default); .Color() drives the content color. + var button = new AndroidX.Compose.TextButton(OnClick); + if (_textColor is { } tc) + button.Colors = composer.ButtonColors( + containerColor: (long)ToComposeColor(Microsoft.Maui.Graphics.Colors.Transparent), + contentColor: (long)ToComposeColor(tc)); + if (HasRoundedCorners) + button.Shape = CornerShape(); + ((ComposableNode)button).Modifier = BuildNodeModifier(); + button.Add(label); + button.Render(composer); + } + else if (_outlined) + { + var button = new AndroidX.Compose.OutlinedButton(OnClick); + if (_textColor is { } tc) + button.Colors = composer.ButtonColors(contentColor: (long)ToComposeColor(tc)); + if (HasRoundedCorners) + button.Shape = CornerShape(); + ((ComposableNode)button).Modifier = BuildNodeModifier(); + button.Add(label); + button.Render(composer); + } + else + { + var button = new ComposeButton(OnClick); + if (_textColor is { } tc) + button.Colors = composer.ButtonColors(contentColor: (long)ToComposeColor(tc)); + if (HasRoundedCorners) + button.Shape = CornerShape(); + ((ComposableNode)button).Modifier = BuildNodeModifier(); + button.Add(label); + button.Render(composer); + } + } + } + + /// Renders Comet Image by hosting a native ImageView (via Compose + /// AndroidView) and loading the URL source asynchronously. Images carry no intrinsic layout + /// size, so callers give them an explicit Frame; the Yoga engine positions/sizes the node. + sealed class ComposeImageNode : ComposeNode + { + static readonly System.Net.Http.HttpClient Http = new(); + readonly MutableState _url = new(string.Empty); + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Image_Source) + _url.Value = value.AsString ?? string.Empty; + } + + public override void Render(IComposer composer) + { + var url = _url.Value; // subscribe so a source change recomposes + + // A bundled drawable renders through the real Compose Image widget (painterResource), + // cropped + clipped by the node's modifier — exactly the gold standard's + // Image(painterResource(...), contentScale = Crop, modifier = …clip(CircleShape)). + if (!string.IsNullOrEmpty(url) && !url.StartsWith("http", System.StringComparison.OrdinalIgnoreCase)) + { + var ctx = global::Android.App.Application.Context; + int resId = ctx.Resources!.GetIdentifier(url, "drawable", ctx.PackageName); + if (resId != 0) + { + var image = new AndroidX.Compose.Image(resId) + { + ContentScale = AndroidX.Compose.ContentScale.Crop, + Modifier = BuildNodeModifier(), + }; + image.Render(composer); + return; + } + } + + // Remote URL: there's no Bitmap→Painter bridge, so host a native ImageView and clip it + // with a hardware outline (Compose's Modifier.clip doesn't reliably round an AndroidView). + float radiusPx = CornerRadiusDp * ComposeNode.Density; + bool rounded = HasRoundedCorners; + var view = new AndroidView(factory: ctx => + { + var iv = new global::Android.Widget.ImageView(ctx); + iv.SetScaleType(global::Android.Widget.ImageView.ScaleType.CenterCrop); + if (rounded) + { + iv.OutlineProvider = new RoundedOutlineProvider(radiusPx); + iv.ClipToOutline = true; + } + Load(iv, url); + return iv; + }); + ((ComposableNode)view).Modifier = BuildNodeModifier(); + view.Render(composer); + } + + // Clips a hosted ImageView to a circle (radius ≥ half the size) or a rounded rect, using the + // view's own px bounds so the cropped bitmap fills a true circle with no width distortion. + sealed class RoundedOutlineProvider : global::Android.Views.ViewOutlineProvider + { + readonly float _radiusPx; + public RoundedOutlineProvider(float radiusPx) => _radiusPx = radiusPx; + public override void GetOutline(global::Android.Views.View view, global::Android.Graphics.Outline outline) + { + int w = view.Width, h = view.Height; + if (w <= 0 || h <= 0) + return; + if (_radiusPx * 2f >= System.Math.Min(w, h)) + outline.SetOval(0, 0, w, h); + else + outline.SetRoundRect(0, 0, w, h, _radiusPx); + } + } + + // Async network fetch for a remote (http) source; bundled drawables take the Compose Image + // path in Render instead. + static void Load(global::Android.Widget.ImageView iv, string url) + { + if (string.IsNullOrEmpty(url)) + return; + _ = System.Threading.Tasks.Task.Run(async () => + { + try + { + var bytes = await Http.GetByteArrayAsync(url).ConfigureAwait(false); + var bmp = global::Android.Graphics.BitmapFactory.DecodeByteArray(bytes, 0, bytes.Length); + if (bmp is not null) + iv.Post(() => iv.SetImageBitmap(bmp)); + } + catch { /* leave the empty image view */ } + }); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeListDetailNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeListDetailNode.cs new file mode 100644 index 000000000..d4f4b9775 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeListDetailNode.cs @@ -0,0 +1,190 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet : two panes 50/50 with a 16dp gap + /// at expanded widths (≥ 840dp, the gold TwoPane), a single pane below that where the + /// IsDetailOpen state swaps list ↔ detail with a closing the + /// compact detail (raised as ; the control writes the + /// signal back). Same own-content shape as : + /// MutableState drives the swap, both panes materialize once, panes are Yoga-laid to their + /// slot and re-flowed on window-metrics changes and after every reactive flush. + sealed class ComposeListDetailNode : ComposeNode, IBackendManagesOwnContent + { + ListDetail _listDetail; + readonly BackendContext _context; + readonly MutableState _detailOpen = new(false); + readonly MutableState _twoPane = new(false); + readonly MutableState _geometry = new(0); + readonly MutableState _contentVersion = new(0); + ComposeNode? _listNode, _detailNode; + Size _windowDp; + bool _twoPaneValue, _detailOpenValue; // plain mirrors for reads outside composition + bool _built, _metricsHooked; + + public ComposeListDetailNode(ListDetail listDetail, BackendContext context) + { + _listDetail = listDetail; + _context = context; + Comet.Reactive.ReactiveScheduler.AfterFlush += ReflowContent; + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.ListDetail_IsDetailOpen) + { + _detailOpenValue = value.AsBool; + _detailOpen.Value = value.AsBool; + } + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not ListDetail listDetail) + return; + _listDetail = listDetail; + if (!isHotReload) + return; + _built = false; + _listNode = _detailNode = null; + _contentVersion.Value++; + } + + void EnsureContent() + { + if (_built) + return; + _built = true; + _listNode = (ComposeNode)CometBackendBridge.Materialize(_listDetail.List, _context); + _detailNode = (ComposeNode)CometBackendBridge.Materialize(_listDetail.Detail, _context); + HookMetrics(); + UpdateFromMetrics(); + } + + System.ComponentModel.PropertyChangedEventHandler? _metricsHandler; + Backend.CometWindowMetrics? _hookedMetrics; + + void HookMetrics() + { + if (_metricsHooked) + return; + _metricsHooked = true; + _hookedMetrics = _listDetail.GetWindowMetrics(); + _metricsHandler = (_, __) => Comet.ThreadHelper.RunOnMainThread(UpdateFromMetrics); + _hookedMetrics.SizeDp.PropertyChanged += _metricsHandler; + } + + public override void Dispose() + { + Comet.Reactive.ReactiveScheduler.AfterFlush -= ReflowContent; + if (_hookedMetrics is not null && _metricsHandler is not null) + _hookedMetrics.SizeDp.PropertyChanged -= _metricsHandler; + } + + /// This node usually fills a NavigationSuite content slot, so its bounds are + /// the window minus the active chrome — the Yoga-arranged frame, not the raw window. + /// Metrics changes still drive the update (the suite re-lays this node first), and the + /// frame is the fallback the first time through. + Size BoundsDp() + { + if (FrameWidth > 0 && FrameHeight > 0) + return new Size(FrameWidth, FrameHeight); + var size = _listDetail.GetWindowMetrics().SizeDp.Peek(); + if (size.Width <= 0 || size.Height <= 0) + size = ScreenSizeDp(); + return size; + } + + void UpdateFromMetrics() + { + var size = BoundsDp(); + bool twoPane = ListDetail.TwoPaneFor(size.Width); + if (size == _windowDp && twoPane == _twoPaneValue) + return; + _windowDp = size; + _twoPaneValue = twoPane; + _twoPane.Value = twoPane; + _geometry.Value++; + LayoutContent(); + Comet.Reactive.ReactiveScheduler.EnsureFlushScheduled(); + } + + void LayoutContent() + { + if (_listNode is null || _windowDp.Width <= 0) + return; + if (_twoPaneValue) + { + // Split per the control's list fraction (Reply 0.5; JetNews ~⅓), gap between. + var usable = _windowDp.Width - ListDetail.GapDp; + var listW = usable * _listDetail.ListFraction; + CometBackendLayoutEngine.Layout(_listDetail.List, new Size(listW, _windowDp.Height)); + CometBackendLayoutEngine.Layout(_listDetail.Detail, new Size(usable - listW, _windowDp.Height)); + } + else + { + CometBackendLayoutEngine.Layout(_listDetail.List, _windowDp); + CometBackendLayoutEngine.Layout(_listDetail.Detail, _windowDp); + } + } + + void ReflowContent() + { + if (_listNode is not null) + { + // Re-check bounds too: the suite re-arranges this node's frame on chrome swaps. + UpdateFromMetrics(); + LayoutContent(); + } + } + + public override Size Measure(double widthConstraint, double heightConstraint) + { + double w = double.IsFinite(widthConstraint) && widthConstraint > 0 ? widthConstraint : ScreenSizeDp().Width; + double h = double.IsFinite(heightConstraint) && heightConstraint > 0 ? heightConstraint : ScreenSizeDp().Height; + return new Size(w, h); + } + + public override void Render(IComposer composer) + { + _ = _contentVersion.Value; + EnsureContent(); + _ = _geometry.Value; + bool twoPane = _twoPane.Value; + bool detailOpen = _detailOpen.Value; + float w = (float)_windowDp.Width; + + var box = new Box(); + ((ComposableNode)box).Modifier = BuildNodeModifier() ?? Modifier.Companion.FillMaxSize(); + + if (twoPane) + { + var detailHost = new Box(); + ((ComposableNode)detailHost).Modifier = Modifier.Companion + .AbsoluteOffset(new Dp((float)((w - ListDetail.GapDp) * _listDetail.ListFraction + ListDetail.GapDp)), new Dp(0)); + detailHost.Add(_detailNode!); + box.Add(_listNode!); + box.Add(detailHost); + } + else if (detailOpen) + { + box.Add(_detailNode!); + // Gold ReplySinglePaneContent: BackHandler { closeDetailScreen() } while the + // full-screen detail shows. + box.Add(new BackHandler(() => Sink?.OnEvent(EventIds.DetailClosed), enabled: true)); + } + else + { + box.Add(_listNode!); + } + + box.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeListNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeListNode.cs new file mode 100644 index 000000000..cfbdf7cd2 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeListNode.cs @@ -0,0 +1,260 @@ +#nullable enable +#if ANDROID +using System.Collections.Generic; +using System.Linq; +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using ComposeLazyColumn = AndroidX.Compose.LazyColumn; + +namespace Comet.Platform.Compose +{ + /// + /// Renders a Comet ListView/CollectionView as a Compose + /// LazyColumn — genuinely virtualized: each row's template + /// is materialized into a backend node only when Compose + /// composes that row (i.e. when it scrolls into view). + /// + /// + /// Single-section lists (the common case) are flattened to a flat row index. + /// Data changes bump a version via + /// so the LazyColumn recomposes against the new rows. + /// + sealed class ComposeListNode : ComposeNode + { + IListView _list; + readonly BackendContext _context; + readonly MutableState _version = new(0); + + // Compose re-invokes a LazyColumn item's content on every recomposition (and the list + // recomposes per scroll frame), so materializing + Yoga-laying-out the row in the item + // lambda re-did that work for every visible row every frame. Cache the materialized node per + // row so a recomposition is O(1); invalidate when the data version or the row width changes. + readonly System.Collections.Generic.Dictionary _rowCache = new(); + int _cachedVersion = -1; + double _cachedWidth = -1; + + // Scroll-state bridge: a remembered LazyListState surfaces scroll position to C#. A + // LaunchedEffect + snapshotFlow watches CanScrollBackward outside the untracked root + // composable scope, so ScrolledAway fires correctly on scroll; a registered scroller + // animates to index 0 (newest = top of a newest-first list) on demand. + bool _scrollerRegistered; + + public ComposeListNode(IListView list, BackendContext context) + { + _list = list; + _context = context; + } + + /// The node was transferred to a new ListView (ordinary re-render or hot reload). + /// Re-point at the new list and re-bind the JumpToBottom scroller + ScrolledAway signal to + /// it; bump the version so the new list's data is read (Render's version check drops the + /// stale row cache). Scroll position is preserved either way — the LazyListState persists + /// via Remember and the one-shot seed is composition-keyed, so it does not re-fire here. + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not IListView list) + return; + _list = list; + _scrollerRegistered = false; + _version.Value++; + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.List_Version) + { + // Drop the previous rows from the dev registry before the recompose pulls the + // new ones (the iOS list node's Rebuild does the same) — stale row elements + // otherwise accumulate and the agent resolves dead views. + if (_list is View listView) + Comet.DevTools.CometDevRegistry.UnregisterSubtree(listView, includeRoot: false); + _version.Value++; // recompose against the latest rows + } + } + + public override void Render(IComposer composer) + { + int version = _version.Value; // subscribe so data changes recompose the list + + // Container flavor resolves FIRST so every later decision (state bridge, row + // width, container) agrees: Horizontal wins (grid is a vertical concept), then + // adaptive grid, then the plain LazyColumn. + bool horizontal = _list.Horizontal; + double gridMin = horizontal ? 0 : _list.GridAdaptiveMinWidth; + + // The scroll-state bridge (ScrolledAway/ScrolledFromTop/LastScrolledBackward, + // AnchorBottom, ScrollToBottom) attaches a LazyListState, which only the + // LazyColumn accepts — the grid needs a rememberLazyGridState binding + // (backlogged) and the row/carousel paths have never carried it. Skip the + // observers entirely for those flavors instead of watching a detached state + // that never changes. + bool scrollBridge = !horizontal && gridMin <= 0; + + // Remembered scroll state (survives recomposition). The first time, register a scroller + // that animates to the last item (newest = bottom of a forward-order list). + var listState = composer.RememberLazyListState(); + if (scrollBridge && !_scrollerRegistered) + { + _scrollerRegistered = true; + var captured = listState; + _list.RegisterScroller(() => + { + int lastIndex = _list.Sections() > 0 ? System.Math.Max(0, _list.Rows(0) - 1) : 0; + _ = captured.AnimateScrollToItemAsync(lastIndex); + }); + } + + // snapshotFlow tracks canScrollForward: true when the newest messages are below the + // viewport (user has scrolled up). LaunchedEffect(true) starts once and cancels when + // the list leaves the composition. + var capturedList = _list; + var capturedState = listState; + if (scrollBridge) + { + composer.LaunchedEffect(true, async ct => + { + // AnchorBottom (chat log): open at the newest message — the gold Jetchat's + // reverseLayout initial position; instant so launch doesn't visibly scroll. + // Ordinary lists (inbox) open at the top. + int lastIndex = capturedList.Sections() > 0 ? System.Math.Max(0, capturedList.Rows(0) - 1) : 0; + if (capturedList.AnchorBottom && lastIndex > 0) + await capturedState.ScrollToItemAsync(lastIndex); + + await foreach (var away in ComposeExtensions.SnapshotFlow(() => capturedState.CanScrollForward) + .WithCancellation(ct)) + { + var signal = _list.ScrolledAway; // re-read: owner re-points _list on re-render + Comet.ThreadHelper.RunOnMainThread(() => signal.Value = away); + } + }); + + // Top-relative twin: canScrollBackward = content above the viewport. Drives + // Reply's ExtendedFAB (expanded at the top, contracted once scrolled). + composer.LaunchedEffect(2, async ct => + { + await foreach (var away in ComposeExtensions.SnapshotFlow(() => capturedState.CanScrollBackward) + .WithCancellation(ct)) + { + var signal = _list.ScrolledFromTop; // re-read: owner re-points _list on re-render + Comet.ThreadHelper.RunOnMainThread(() => signal.Value = away); + } + }); + + // Scroll DIRECTION: lastScrolledBackward — the gold FAB re-expands on any upward + // scroll (ReplyListContent.kt:124-125), not only at the very top. + composer.LaunchedEffect(3, async ct => + { + await foreach (var backward in ComposeExtensions.SnapshotFlow(() => capturedState.LastScrolledBackward) + .WithCancellation(ct)) + { + var signal = _list.LastScrolledBackward; // re-read: owner re-points _list on re-render + Comet.ThreadHelper.RunOnMainThread(() => signal.Value = backward); + } + }); + } + + // Single-section (the common case); multi-section flattening is a follow-up. + int count = _list.Sections() > 0 ? _list.Rows(0) : 0; + var indices = Enumerable.Range(0, count).ToList(); + + // Under Yoga, lay each row out to the list's arranged width so rows render identically + // to the rest of the tree (and to iOS). FrameWidth is 0 until the engine arranges this + // list, so fall back to the screen width. + bool yoga = HasFrame; + double rowWidth = FrameWidth > 0 + ? FrameWidth + : global::Android.Content.Res.Resources.System!.DisplayMetrics!.WidthPixels / ComposeNode.Density; + + // Drop the cache when the rows or the width change (otherwise we'd render stale layout). + if (version != _cachedVersion || rowWidth != _cachedWidth) + { + _rowCache.Clear(); + _cachedVersion = version; + _cachedWidth = rowWidth; + } + + // Adaptive grid (Jetcaster Home): cells share the row width across the column + // count Compose's GridCells.Adaptive will compute — mirror that math so the + // Yoga-laid cell content matches the rendered cell box. (gridMin is already 0 + // for horizontal lists — the flavor precedence is resolved once, up top.) + int gridColumns = gridMin > 0 ? System.Math.Max(1, (int)(rowWidth / gridMin)) : 0; + + // Carousel item width: the stated Dp, or (when the caller forgot it) the + // FIRST item's intrinsic width — a 0 here would Yoga-lay every item at zero + // and hand the M3 carousel a 0dp itemWidth (an invisible carousel). + double carouselWidth = 0; + if (horizontal && _list.Carousel != ListCarousel.None && count > 0) + { + carouselWidth = _list.CarouselItemWidth > 0 + ? _list.CarouselItemWidth + : CometBackendLayoutEngine.Measure(_list.ViewFor(0, 0)).Width; + } + + ComposableNode BuildRow(int i) + { + if (_rowCache.TryGetValue(i, out var cached)) + return cached; + + // First time this row is needed: build, materialize, and Yoga-lay-out once, then cache. + var view = _list.ViewFor(0, i); + // Parent the row under the ListView in the dev registry (like the iOS list + // node) so UnregisterSubtree(list) can prune stale rows on reload — parentless + // rows were registry ROOTS and survived every rebuild as ghost elements. + var node = (ComposableNode)CometBackendBridge.Materialize(view, _context, _list as View); + if (yoga) + { + // Vertical rows fill the list's width; grid cells the computed column + // width; carousel items their (resolved) item width; LazyRow items + // their own intrinsic width (a fixed-size card). + double w = horizontal && carouselWidth > 0 ? carouselWidth + : horizontal ? CometBackendLayoutEngine.Measure(view).Width + : gridColumns > 0 ? rowWidth / gridColumns + : rowWidth; + CometBackendLayoutEngine.LayoutContent(view, w); + } + _rowCache[i] = node; + return node; + } + + // Horizontal: the REAL Compose LazyRow, or the real M3 carousel the gold + // uses (Jetcaster followed-podcasts / top-podcasts rows). + if (horizontal) + { + ComposableNode row = _list.Carousel switch + { + ListCarousel.MultiBrowse => new AndroidX.Compose.HorizontalMultiBrowseCarousel( + indices, (float)carouselWidth, BuildRow), + ListCarousel.Uncontained => new AndroidX.Compose.HorizontalUncontainedCarousel( + indices, (float)carouselWidth, BuildRow), + _ => new AndroidX.Compose.LazyRow(indices, BuildRow), + }; + row.Modifier = BuildNodeModifier(); + row.Render(composer); + return; + } + + // Adaptive grid: the REAL LazyVerticalGrid (full-span header rows are a + // follow-up — Jetcaster's headers land as ordinary cells until spans bind). + if (gridColumns > 0) + { + var grid = new AndroidX.Compose.LazyVerticalGrid( + AndroidX.Compose.GridCells.Adaptive((float)gridMin), indices, BuildRow); + ((ComposableNode)grid).Modifier = BuildNodeModifier(); + grid.Render(composer); + return; + } + + var lazy = new ComposeLazyColumn(indices, BuildRow) + { + State = listState, + }; + + // Position + size the list from its Yoga frame (offset below the top bar, sized to the + // remaining height) so it scrolls within its slot rather than laying out at the origin. + ((ComposableNode)lazy).Modifier = BuildNodeModifier(); + lazy.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeNavChromeNodes.cs b/src/Comet/src/Comet/Platform/Compose/ComposeNavChromeNodes.cs new file mode 100644 index 000000000..9624f245d --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeNavChromeNodes.cs @@ -0,0 +1,183 @@ +#nullable enable +#if ANDROID +using System.Collections.Generic; +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.Compose +{ + /// Shared core for the Material 3 nav chrome nodes: owns the per-item icon/label + /// slot nodes ( — the real item widgets lay out their + /// own slots), tracks the reactive selected index, and routes an item tap through the Comet + /// control's SelectItem (signal write + OnSelect) so selection state has one source + /// of truth. + abstract class ComposeNavChromeNode : ComposeNode, IBackendManagesOwnContent + { + protected readonly BackendContext Context; + protected readonly MutableState Selected = new(0); + protected readonly MutableState ContentVersion = new(0); + protected (ComposeNode icon, ComposeNode? label)[] ItemNodes = + System.Array.Empty<(ComposeNode, ComposeNode?)>(); + bool _built; + + protected ComposeNavChromeNode(BackendContext context) => Context = context; + + protected abstract IReadOnlyList Items { get; } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Nav_SelectedIndex) + Selected.Value = value.AsInt; + } + + /// Only a hot reload re-materializes the slot content (the code changed); + /// an ordinary re-render keeps the materialized nodes — the view diff patches them. + protected void OnChromeViewChanged(bool isHotReload) + { + if (!isHotReload) + return; + _built = false; + ItemNodes = System.Array.Empty<(ComposeNode, ComposeNode?)>(); + ContentVersion.Value++; + } + + protected void EnsureContent() + { + if (_built) + return; + _built = true; + var items = Items; + ItemNodes = new (ComposeNode, ComposeNode?)[items.Count]; + for (int i = 0; i < items.Count; i++) + ItemNodes[i] = ( + (ComposeNode)CometBackendBridge.Materialize(items[i].IconView, Context), + items[i].LabelView is { } label + ? (ComposeNode)CometBackendBridge.Materialize(label, Context) + : null); + } + } + + /// Renders a Comet as the REAL Material 3 + /// NavigationBar + NavigationBarItem widgets. + sealed class ComposeNavigationBarNode : ComposeNavChromeNode + { + // Material 3 NavigationBar container height (dp), before window insets — bottom + // system-inset handling is the safe-area automation follow-up. + const float BarHeightDp = 80f; + + Comet.NavigationBar _bar; + + public ComposeNavigationBarNode(Comet.NavigationBar bar, BackendContext context) + : base(context) => _bar = bar; + + protected override IReadOnlyList Items => _bar.Items; + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not Comet.NavigationBar bar) + return; + _bar = bar; + OnChromeViewChanged(isHotReload); + } + + public override Size Measure(double widthConstraint, double heightConstraint) + { + double width = double.IsFinite(widthConstraint) && widthConstraint > 0 + ? widthConstraint : ScreenSizeDp().Width; + return new Size(width, BarHeightDp); + } + + public override void Render(IComposer composer) + { + _ = ContentVersion.Value; + EnsureContent(); + int selected = Selected.Value; + + // TODO(fidelity): surface the bridge's containerColor param (facade Phase-6) so + // _bar.ContainerColor reaches the widget; until then the M3 default renders. + var bar = new AndroidX.Compose.NavigationBar(); + for (int i = 0; i < ItemNodes.Length; i++) + { + int index = i; + var item = new AndroidX.Compose.NavigationBarItem( + selected: index == selected, + onClick: () => _bar.SelectItem(index)) + { + Icon = ItemNodes[i].icon, + }; + if (ItemNodes[i].label is { } label) + item.Label = label; + bar.Add(item); + } + ((ComposableNode)bar).Modifier = BuildNodeModifier(); + bar.Render(composer); + } + } + + /// Renders a Comet as the REAL Material 3 + /// NavigationRail + NavigationRailItem widgets, with the optional Comet + /// header view (menu + FAB in the gold Reply) rendered first in the rail's content column. + sealed class ComposeNavigationRailNode : ComposeNavChromeNode + { + // Material 3 NavigationRail container width (dp). + const float RailWidthDp = 80f; + + Comet.NavigationRail _rail; + ComposeNode? _headerNode; + + public ComposeNavigationRailNode(Comet.NavigationRail rail, BackendContext context) + : base(context) => _rail = rail; + + protected override IReadOnlyList Items => _rail.Items; + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not Comet.NavigationRail rail) + return; + _rail = rail; + if (isHotReload) + _headerNode = null; + OnChromeViewChanged(isHotReload); + } + + public override Size Measure(double widthConstraint, double heightConstraint) + { + double height = double.IsFinite(heightConstraint) && heightConstraint > 0 + ? heightConstraint : ScreenSizeDp().Height; + return new Size(RailWidthDp, height); + } + + public override void Render(IComposer composer) + { + _ = ContentVersion.Value; + EnsureContent(); + if (_headerNode is null && _rail.HeaderView is { } header) + _headerNode = (ComposeNode)CometBackendBridge.Materialize(header, Context); + int selected = Selected.Value; + + // TODO(fidelity): surface containerColor (gold rail uses inverseOnSurface) — same + // facade Phase-6 follow-up as the bar. + var rail = new AndroidX.Compose.NavigationRail(); + if (_headerNode is not null) + rail.Add(_headerNode); + for (int i = 0; i < ItemNodes.Length; i++) + { + int index = i; + var item = new AndroidX.Compose.NavigationRailItem( + selected: index == selected, + onClick: () => _rail.SelectItem(index)) + { + Icon = ItemNodes[i].icon, + }; + if (ItemNodes[i].label is { } label) + item.Label = label; + rail.Add(item); + } + ((ComposableNode)rail).Modifier = BuildNodeModifier(); + rail.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeNavigationNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeNavigationNode.cs new file mode 100644 index 000000000..0bfebcb88 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeNavigationNode.cs @@ -0,0 +1,112 @@ +#nullable enable +#if ANDROID +using System.Collections.Generic; +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; + +namespace Comet.Platform.Compose +{ + /// + /// Renders a Comet NavigationView as a Compose navigation stack: the top screen + /// of the stack is composed, and Comet's Navigate/Pop push/pop and + /// recompose. The node owns the stack (so it implements + /// — the bridge doesn't materialize the + /// NavigationView's content as a static child). + /// + sealed class ComposeNavigationNode : ComposeNode, IBackendManagesOwnContent + { + NavigationView _nav; + readonly BackendContext _context; + readonly List _stack = new(); + // Each screen is materialized + laid out once and kept while it's on the stack, so pushing + // a screen doesn't re-materialize the ones beneath it (and popping back preserves their state). + readonly Dictionary _screens = new(); + readonly MutableState _version = new(0); + + public ComposeNavigationNode(NavigationView nav, BackendContext context) + { + _nav = nav; + _context = context; + + // Root screen = the NavigationView's content (already has Navigation/Parent set). + if (nav.Content is { } root) + _stack.Add(root); + + // Comet's Navigate/Pop drive the stack; bump the version to recompose. + nav.SetPerformNavigate(view => + { + _stack.Add(view); + _version.Value++; + }); + nav.SetPerformPop(() => + { + if (_stack.Count > 1) + { + var popped = _stack[_stack.Count - 1]; + _stack.RemoveAt(_stack.Count - 1); + _screens.Remove(popped); // it's gone — don't keep it cached + _version.Value++; + } + }); + + // Re-lay-out the current screen after every reactive flush so a hosted view whose intrinsic + // size changed (e.g. the input-selector panel expanding) reflows — the top-level RunLayout + // can't reach here because this node is own-content (a leaf to the engine), so the screen + // it hosts must drive its own reflow. Arrange only recomposes the nodes that actually moved. + Comet.Reactive.ReactiveScheduler.AfterFlush += ReflowTopScreen; + } + + public override void Dispose() + => Comet.Reactive.ReactiveScheduler.AfterFlush -= ReflowTopScreen; + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) { } + + /// The node was transferred to a new NavigationView. Re-point always; only a hot + /// reload resets the stack to the new content and drops materialized screens (the code + /// changed). An ordinary re-render preserves the user's navigation position — the pushed + /// screens live in _stack, driven by the Navigate/Pop delegates that + /// UpdateFromOldView carried over. + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not NavigationView nav) + return; + _nav = nav; + if (!isHotReload) + return; + _stack.Clear(); + _screens.Clear(); + if (nav.Content is { } root) + _stack.Add(root); + _version.Value++; + } + + void ReflowTopScreen() + { + if (_stack.Count == 0) + return; + var top = _stack[_stack.Count - 1]; + if (_screens.ContainsKey(top)) // only once it's been materialized + first-laid-out + CometBackendLayoutEngine.Layout(top, ScreenSizeDp()); + } + + public override void Render(IComposer composer) + { + _ = _version.Value; // subscribe so push/pop recomposes + if (_stack.Count == 0) + return; + + var top = _stack[_stack.Count - 1]; + if (!_screens.TryGetValue(top, out var node)) + { + // Materialize the screen, then lay it out full-screen with the Yoga engine (the + // pushed screen owns the whole viewport, just like the root did). + node = (ComposableNode)CometBackendBridge.Materialize(top, _context); + CometBackendLayoutEngine.Layout(top, ScreenSizeDp()); + _screens[top] = node; + } + node.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeNavigationSuiteNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeNavigationSuiteNode.cs new file mode 100644 index 000000000..c89fc5835 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeNavigationSuiteNode.cs @@ -0,0 +1,363 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet : hosts the app content and + /// swaps the REAL M3 chrome widget (bottom NavigationBar / NavigationRail / + /// PermanentDrawerSheet + NavigationDrawerItems) as the window crosses the + /// gold breakpoints. The swap happens INSIDE this node via + /// (the Drawer/SelectorPanel idiom) because a body-level container-type swap does not + /// reach the retained node tree. Item icon/label slot nodes are materialized ONCE and + /// shared across variants — only one variant composes at a time. Owns its content + /// (): the content subtree is laid out with the + /// shared Yoga engine to the window minus the active chrome, re-flowed on every reactive + /// flush and on every window-metrics change. + sealed class ComposeNavigationSuiteNode : ComposeNode, IBackendManagesOwnContent + { + // M3 container sizes (dp): bar height, rail width; drawer sheet = the gold's max + // (PermanentNavigationDrawerContent sizeIn(minWidth 200, maxWidth 300)). + const float BarHeightDp = 80f, RailWidthDp = 80f, DrawerWidthDp = 300f; + + NavigationSuite _suite; + readonly BackendContext _context; + readonly MutableState _selected = new(0); + readonly MutableState _variant = new((int)NavigationSuiteVariant.BottomBar); + readonly MutableState _geometry = new(0); // bumped when the window size changes + readonly MutableState _contentVersion = new(0); + (ComposeNode icon, ComposeNode? label)[] _items = + System.Array.Empty<(ComposeNode, ComposeNode?)>(); + ComposeNode? _contentNode, _railHeaderNode, _drawerHeaderNode; + Size _windowDp; + Microsoft.Maui.Thickness _safeDp; + // Plain mirror of _variant for reads OUTSIDE composition (Measure/Layout — the + // SelectorPanel _selectorValue idiom); the MutableState drives recomposition. + NavigationSuiteVariant _variantValue = NavigationSuiteVariant.BottomBar; + bool _built, _metricsHooked; + // Modal drawer (gold wraps the whole app): Comet signal ↔ animated drawer state, + // the ComposeDrawerNode sync idiom. + readonly MutableState _drawerOpen = new(false); + readonly DrawerStateHolder _drawerHolder = new(AndroidX.Compose.Material3.DrawerValue.Closed); + + public ComposeNavigationSuiteNode(NavigationSuite suite, BackendContext context) + { + _suite = suite; + _context = context; + // Reflow the hosted content after every reactive flush (IME, rotation, content + // growth) — this own-content node is a leaf to the engine, so the top-level + // RunLayout can't reach inside it. Mirrors ComposeDrawerNode. + Comet.Reactive.ReactiveScheduler.AfterFlush += ReflowContent; + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Nav_SelectedIndex) + _selected.Value = value.AsInt; + else if (id == PropertyIds.Drawer_IsOpen) + _drawerOpen.Value = value.AsBool; + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not NavigationSuite suite) + return; + _suite = suite; + if (!isHotReload) + return; + _built = false; + _items = System.Array.Empty<(ComposeNode, ComposeNode?)>(); + _contentNode = _railHeaderNode = _drawerHeaderNode = null; + _contentVersion.Value++; + } + + void EnsureContent() + { + if (_built) + return; + _built = true; + + var items = _suite.Items; + _items = new (ComposeNode, ComposeNode?)[items.Count]; + for (int i = 0; i < items.Count; i++) + _items[i] = ( + (ComposeNode)CometBackendBridge.Materialize(items[i].IconView, _context), + items[i].LabelView is { } label + ? (ComposeNode)CometBackendBridge.Materialize(label, _context) + : null); + if (_suite.RailHeaderView is { } rail) + _railHeaderNode = (ComposeNode)CometBackendBridge.Materialize(rail, _context); + if (_suite.DrawerHeaderView is { } drawer) + _drawerHeaderNode = (ComposeNode)CometBackendBridge.Materialize(drawer, _context); + _contentNode = (ComposeNode)CometBackendBridge.Materialize(_suite.Content, _context); + + HookMetrics(); + UpdateFromMetrics(); + } + + /// Follow the per-root reactive window contract: a size change recomputes the + /// variant (recomposes the chrome swap) and re-lays the content to the new bounds. + System.ComponentModel.PropertyChangedEventHandler? _metricsHandler; + Backend.CometWindowMetrics? _hookedMetrics; + + void HookMetrics() + { + if (_metricsHooked) + return; + _metricsHooked = true; + _hookedMetrics = _suite.GetWindowMetrics(); + _metricsHandler = (_, __) => Comet.ThreadHelper.RunOnMainThread(UpdateFromMetrics); + _hookedMetrics.SizeDp.PropertyChanged += _metricsHandler; + _hookedMetrics.SafeAreaDp.PropertyChanged += _metricsHandler; + } + + public override void Dispose() + { + Comet.Reactive.ReactiveScheduler.AfterFlush -= ReflowContent; + if (_hookedMetrics is not null && _metricsHandler is not null) + { + _hookedMetrics.SizeDp.PropertyChanged -= _metricsHandler; + _hookedMetrics.SafeAreaDp.PropertyChanged -= _metricsHandler; + } + } + + void UpdateFromMetrics() + { + // Per-flush path: reuse the hooked metrics object — GetWindowMetrics walks the + // environment parent chain, too hot for every reactive flush. + var metrics = _hookedMetrics ?? _suite.GetWindowMetrics(); + var size = metrics.SizeDp.Peek(); + if (size.Width <= 0 || size.Height <= 0) + size = ScreenSizeDp(); + var safe = metrics.SafeAreaDp.Peek(); + if (size == _windowDp && safe == _safeDp) + return; + _windowDp = size; + _safeDp = safe; + _variantValue = _suite.VariantForWindow(size.Width, size.Height); + if (_suite.VariantSignal is { } vs) + vs.Value = (int)_variantValue; + _variant.Value = (int)_variantValue; + _geometry.Value++; + LayoutContent(); + Comet.Reactive.ReactiveScheduler.EnsureFlushScheduled(); + } + + // The content slot clears the safe area automatically: the status-bar/cutout strip at + // the top always, plus the bottom system-bar strip on variants where the content runs + // to the bottom edge (rail/drawer — the M3 bottom NavigationBar already covers it). + // The chrome widgets themselves keep the M3 default internal inset handling. + Size ContentSizeDp() => _variantValue switch + { + NavigationSuiteVariant.Rail => new Size( + _windowDp.Width - RailWidthDp, + _windowDp.Height - _safeDp.Top - _safeDp.Bottom), + NavigationSuiteVariant.PermanentDrawer => new Size( + _windowDp.Width - DrawerWidthDp, + _windowDp.Height - _safeDp.Top - _safeDp.Bottom), + NavigationSuiteVariant.None => new Size( + _windowDp.Width, + _windowDp.Height - _safeDp.Top - _safeDp.Bottom), + _ => new Size( + _windowDp.Width, + _windowDp.Height - _safeDp.Top - BarHeightDp), + }; + + void LayoutContent() + { + if (_contentNode is null || _windowDp.Width <= 0) + return; + CometBackendLayoutEngine.Layout(_suite.Content, ContentSizeDp()); + // Headers lay out to their INTRINSIC height (they're column entries inside the + // chrome widget — a window-height frame would push the destination items off-screen). + if (_suite.RailHeaderView is { } rail) + CometBackendLayoutEngine.Layout(rail, + new Size(RailWidthDp, CometBackendLayoutEngine.Measure(rail).Height)); + if (_suite.DrawerHeaderView is { } drawer) + CometBackendLayoutEngine.Layout(drawer, + new Size(DrawerWidthDp, CometBackendLayoutEngine.Measure(drawer).Height)); + } + + void ReflowContent() + { + if (_contentNode is not null) + { + // Re-check bounds too (the ComposeListDetailNode idiom): a resize that + // recreates the activity can land before the metrics hook, so per-flush + // recheck is what keeps the variant honest. + UpdateFromMetrics(); + LayoutContent(); + } + } + + public override Size Measure(double widthConstraint, double heightConstraint) + { + double w = double.IsFinite(widthConstraint) && widthConstraint > 0 ? widthConstraint : ScreenSizeDp().Width; + double h = double.IsFinite(heightConstraint) && heightConstraint > 0 ? heightConstraint : ScreenSizeDp().Height; + return new Size(w, h); + } + + public override void Render(IComposer composer) + { + _ = _contentVersion.Value; + EnsureContent(); + _ = _geometry.Value; // subscribe: a resize recomposes the offsets below + int selected = _selected.Value; + var variant = (NavigationSuiteVariant)_variant.Value; + float w = (float)_windowDp.Width, h = (float)_windowDp.Height; + + var box = new Box(); + ((ComposableNode)box).Modifier = BuildNodeModifier() ?? Modifier.Companion.FillMaxSize(); + + // Content first (chrome paints over it at the shared edge), shifted below the + // status-bar/cutout strip — the layout math in ContentSizeDp shrank it to match. + float safeTop = (float)_safeDp.Top; + var contentHost = new Box(); + ((ComposableNode)contentHost).Modifier = variant switch + { + NavigationSuiteVariant.Rail => + Modifier.Companion.AbsoluteOffset(new Dp(RailWidthDp), new Dp(safeTop)), + NavigationSuiteVariant.PermanentDrawer => + Modifier.Companion.AbsoluteOffset(new Dp(DrawerWidthDp), new Dp(safeTop)), + _ => Modifier.Companion.AbsoluteOffset(new Dp(0), new Dp(safeTop)), + }; + contentHost.Add(_contentNode!); + box.Add(contentHost); + + switch (variant) + { + case NavigationSuiteVariant.BottomBar: + { + var bar = new AndroidX.Compose.NavigationBar(); + for (int i = 0; i < _items.Length; i++) + { + int index = i; + // Gold bar items are icon-only (ReplyNavigationComponents.kt:240-249). + bar.Add(new AndroidX.Compose.NavigationBarItem( + selected: index == selected, + onClick: () => _suite.SelectItem(index)) + { Icon = _items[i].icon }); + } + ((ComposableNode)bar).Modifier = Modifier.Companion + .AbsoluteOffset(new Dp(0), new Dp(h - BarHeightDp)) + .Width(new Dp(w)); + box.Add(bar); + break; + } + case NavigationSuiteVariant.Rail: + { + var rail = new AndroidX.Compose.NavigationRail(); + if (_railHeaderNode is not null) + rail.Add(_railHeaderNode); + for (int i = 0; i < _items.Length; i++) + { + int index = i; + var railItem = new AndroidX.Compose.NavigationRailItem( + selected: index == selected, + onClick: () => _suite.SelectItem(index)) + { Icon = _items[i].icon }; + // JetNews rail: label under the icon only while selected (gold + // alwaysShowLabel=false). Reply stays icon-only. + if (_suite.RailShowsSelectedLabel && _items[i].label is { } railLabel) + { + railItem.Label = railLabel; + railItem.AlwaysShowLabel = false; + } + rail.Add(railItem); + } + ((ComposableNode)rail).Modifier = Modifier.Companion.Height(new Dp(h)); + box.Add(rail); + break; + } + case NavigationSuiteVariant.PermanentDrawer: + { + var column = new Column(); + if (_drawerHeaderNode is not null) + column.Add(_drawerHeaderNode); + for (int i = 0; i < _items.Length; i++) + { + int index = i; + var item = new AndroidX.Compose.NavigationDrawerItem( + selected: index == selected, + onClick: () => _suite.SelectItem(index)) + { Icon = _items[i].icon }; + // The drawer variant shows labels (gold :309-331); icon-only items + // still need the required Label slot — reuse the icon as a stand-in. + item.Label = _items[i].label ?? _items[i].icon; + column.Add(item); + } + var sheet = new PermanentDrawerSheet(); + sheet.Add(column); + ((ComposableNode)sheet).Modifier = Modifier.Companion + .Width(new Dp(DrawerWidthDp)).Height(new Dp(h)); + box.Add(sheet); + break; + } + } + + // Modal drawer wrap (gold ReplyNavigationComponents.kt:122-137): only when the + // app supplied a DrawerOpen signal. The sheet reuses the drawer header + labeled + // item nodes (rendering the same slot nodes in two composition sites is safe — + // they're render-emitters over shared MutableState). + if (_suite.DrawerOpen is null) + { + box.Render(composer); + return; + } + + bool open = _drawerOpen.Value; + composer.LaunchedEffect(open, async ct => + { + if (open && _drawerHolder.IsClosed) + await _drawerHolder.OpenAsync(); + else if (!open && _drawerHolder.IsOpen) + await _drawerHolder.CloseAsync(); + }); + // Reading CurrentValue subscribes this scope: a gesture/scrim dismissal + // recomposes here and reports back so the Comet signal clears. + var current = _drawerHolder.CurrentValue; + composer.LaunchedEffect(current, ct => + { + if (_drawerHolder.IsClosed && _drawerOpen.Value) + Sink?.OnEvent(EventIds.DrawerClosed); + // Symmetric gesture-open report — see ComposeDrawerNode (signal desync + // otherwise swallows the next programmatic close). + else if (_drawerHolder.IsOpen && !_drawerOpen.Value) + Sink?.OnEvent(EventIds.DrawerOpened); + return System.Threading.Tasks.Task.CompletedTask; + }); + + var modalColumn = new Column(); + if (_drawerHeaderNode is not null) + modalColumn.Add(_drawerHeaderNode); + for (int i = 0; i < _items.Length; i++) + { + int index = i; + var item = new AndroidX.Compose.NavigationDrawerItem( + selected: index == selected, + onClick: () => + { + _suite.SelectItem(index); + Sink?.OnEvent(EventIds.DrawerClosed); // gold closes on selection + }) + { Icon = _items[i].icon }; + item.Label = _items[i].label ?? _items[i].icon; + modalColumn.Add(item); + } + var modalSheet = new ModalDrawerSheet(); + modalSheet.Add(modalColumn); + + var drawer = new ModalNavigationDrawer(drawerState: _drawerHolder) + { + Drawer = modalSheet, + Content = box, + }; + ((ComposableNode)drawer).Modifier = Modifier.Companion.FillMaxSize(); + drawer.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeNode.cs new file mode 100644 index 000000000..cc6a09a87 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeNode.cs @@ -0,0 +1,442 @@ +#nullable enable +#if ANDROID +using System.Collections.Generic; +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.Compose +{ + /// + /// A retained backend node that bridges Comet's imperative diff + /// () to Jetpack Compose's declarative + /// recomposition (the vendored ). + /// + /// + /// The node is created once when a Comet view mounts and lives across + /// recompositions. Each renderable property is a retained Compose + /// : writes + /// .Value (the single steady-state JNI call — setValue), and + /// reads it inside composition so Compose + /// recomposes only the narrowest scope. Structural changes bump + /// , which container renders read so a child + /// insert/remove/move recomposes the container. + /// + abstract class ComposeNode : ComposableNode, ICometBackendNode + { + protected readonly List Children = new(); + readonly MutableState _childVersion = new(0); + ICometEventSink? _sink; + readonly MutableState _hasTap = new(false); + + /// A widget-rendering branch (e.g. the clickable Card) that takes over the + /// tap sets this so BuildNodeModifier doesn't ALSO add a clickable (double ripple). + protected bool GesturesHandledByWidget; + + /// Whether the view carries a tap gesture (for widget branches that choose + /// a clickable overload). + protected bool HasTapGesture => _hasTap.Value; + + /// Fire the Comet tap event — for widgets that own the click (Card.onClick). + protected void FireTapEvent() => Sink?.OnGesture(GestureKind.Tap, new GestureData(GestureState.Ended, default)); + readonly MutableState _hasLongPress = new(false); + readonly MutableState _hasRecord = new(false); + // Color/Thickness aren't Java-boxable, so they can't live in a Compose MutableState. + // They're held as plain fields and a style-version state drives recomposition. + Microsoft.Maui.Graphics.Color? _background; + Microsoft.Maui.Thickness _padding; + CornerRadii _corners; + float _elevation; + float _borderWidth; + Microsoft.Maui.Graphics.Color? _borderColor; + // Reactive translation (Dp): a render-time offset (no parent reflow) for transforms such as the + // profile-photo parallax. Folded into the Yoga frame offset in BuildNodeModifier. + float _translationX, _translationY; + // Reactive visibility: opacity (1 = opaque) and IsVisible. A fully-transparent or + // invisible node fades out AND stops receiving taps, so a hidden overlay (e.g. the + // JumpToBottom FAB) doesn't intercept touches. Driven by a reactive property push. + float _opacity = 1f; + bool _isVisible = true; + readonly MutableState _styleVersion = new(0); + + // Yoga-driven layout (when the engine runs): parent-relative frame in Dp + a version + // state so a re-arrange recomposes. Until arranged, Compose lays the node out natively. + float _fx, _fy, _fw, _fh; + bool _hasFrame; + float _contentTopInset; + readonly MutableState _frameVersion = new(0); + + /// Display density (px per Dp), set by the backend root; used to convert native + /// pixel measurements (e.g. text) into the Dp space Yoga computes in. + public static float Density { get; set; } = 1f; + + /// The app's current available size in Dp — the ComposeView's actual laid-out + /// size, kept current by (shrinks when the soft + /// keyboard resizes the window under AdjustResize, changes on rotation). Zero until + /// the first layout; consumers fall back to DisplayMetrics then. + public static Microsoft.Maui.Graphics.Size AvailableSize { get; set; } + + /// The current viewport in Dp: the live (shrinks for the + /// soft keyboard) once known, else the raw display metrics. The single source own-content + /// hosts (navigation, drawer, scroll) use to lay their content out full-viewport. + public static Microsoft.Maui.Graphics.Size ScreenSizeDp() + { + if (AvailableSize is { Width: > 0, Height: > 0 } avail) + return avail; + var m = global::Android.Content.Res.Resources.System!.DisplayMetrics!; + return new Microsoft.Maui.Graphics.Size(m.WidthPixels / Density, m.HeightPixels / Density); + } + + protected bool HasFrame => _hasFrame; + + /// True for LEAF nodes that render their own content (text, icon glyphs): + /// Yoga includes their padding in the arranged frame, so the node itself insets the + /// content at render (see ). Containers stay false — + /// their children's frames already carry the inset. + protected virtual bool PadsOwnContent => false; + + /// The Yoga-arranged size of this node in Dp (0 until arranged). Own-content + /// nodes (list, scroll, list-detail) use it to lay their own content out to the host's + /// bounds. + protected float FrameWidth => _fw; + protected float FrameHeight => _fh; + + /// The Yoga-arranged parent-relative position in Dp (0 until arranged). A self-sizing + /// native control (e.g. a FAB overlay) positions itself at this offset WITHOUT applying the + /// Yoga size, so the real control measures + lays out its own content (Option C / overlays). + protected float FrameX => _fx; + protected float FrameY => _fy; + + /// For nodes that build their own modifier (bypassing ): + /// subscribe to style + frame changes so a reactive opacity/visibility/position update + /// recomposes, and return the effective alpha (IsVisible ? Opacity : 0). A fully + /// transparent node should not be rendered at all (so it doesn't intercept input). + protected float SubscribeAndGetAlpha() + { + _ = _styleVersion.Value; + _ = _frameVersion.Value; + return _isVisible ? _opacity : 0f; + } + + /// True when a non-zero corner radius was set (so a subclass can apply + /// to a composable that takes an explicit shape, e.g. a Button). + protected bool HasRoundedCorners => !_corners.IsZero; + + /// The top-left corner radius in Dp (avatars/clips use a uniform radius). Lets a + /// subclass clip a hosted native View to the same outline Compose would (e.g. ImageView). + protected float CornerRadiusDp => (float)_corners.TopLeft; + + /// The Comet padding (Dp). The Yoga engine doesn't inset leaf content, so a leaf + /// that needs interior padding (e.g. a borderless text field) applies this itself. + protected Microsoft.Maui.Thickness Padding => _padding; + + GradientSpec? _gradient; + AndroidX.Compose.UI.Graphics.Brush? _cachedBrush; + GradientSpec? _borderGradient; + AndroidX.Compose.UI.Graphics.Brush? _cachedBorderBrush; + + // Builds the REAL Compose gradient brush for a spec: direction picks the factory, + // extent/offset/mirror (the gold's offsetGradientBackground parallax) ride on the + // horizontal overload's startX/endX + TileMode. + static AndroidX.Compose.UI.Graphics.Brush BuildGradientBrush(GradientSpec spec) + { + var stops = new AndroidX.Compose.Color[spec.Stops.Length]; + for (int i = 0; i < spec.Stops.Length; i++) + stops[i] = ToComposeColor(spec.Stops[i]); + var tile = spec.Mirror ? AndroidX.Compose.TileMode.Mirror : AndroidX.Compose.TileMode.Clamp; + return spec.Direction switch + { + GradientDirection.Vertical when spec.ExtentDp is { } vExtent => + AndroidX.Compose.Brush.VerticalGradient( + stops, -spec.OffsetDp * Density, (vExtent - spec.OffsetDp) * Density, tile), + GradientDirection.Vertical => AndroidX.Compose.Brush.VerticalGradient(stops), + GradientDirection.Diagonal => AndroidX.Compose.Brush.LinearGradient(stops), + GradientDirection.Radial => AndroidX.Compose.Brush.RadialGradient(stops), + _ when spec.ExtentDp is { } extent => AndroidX.Compose.Brush.HorizontalGradient( + stops, -spec.OffsetDp * Density, (extent - spec.OffsetDp) * Density, tile), + _ => AndroidX.Compose.Brush.HorizontalGradient(stops), + }; + } + + /// Whether the view carries a long-press gesture (widget branches that own + /// the click must NOT take over when a long-press also needs the combinedClickable). + protected bool HasLongPress => _hasLongPress.Value; + + /// The background color set via .Background(), if any. Lets a container render + /// as a Material Surface (color + shape) rather than a plain Box. + protected Microsoft.Maui.Graphics.Color? Background => _background; + + protected ICometEventSink? Sink => _sink; + + // ICometBackendNode ---------------------------------------------------- + + public void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.HasTapGesture) + _hasTap.Value = value.AsBool; + else if (id == PropertyIds.HasLongPressGesture) + _hasLongPress.Value = value.AsBool; + else if (id == PropertyIds.HasRecordGesture) + _hasRecord.Value = value.AsBool; + else if (id == PropertyIds.Opacity) + { + _opacity = (float)value.AsDouble; + _styleVersion.Value++; + } + else if (id == PropertyIds.IsVisible) + { + _isVisible = value.AsBool; + _styleVersion.Value++; + } + else if (id == PropertyIds.GradientBackground) + { + _gradient = value.AsObject as GradientSpec; + _cachedBrush = null; // spec changed — rebuild on next render + _styleVersion.Value++; + } + else if (id == PropertyIds.GradientBorder) + { + _borderGradient = value.AsObject as GradientSpec; + _cachedBorderBrush = null; + _styleVersion.Value++; + } + else if (id == PropertyIds.BackgroundColor) + { + _background = value.AsColor; + _styleVersion.Value++; + } + else if (id == PropertyIds.Padding) + { + _padding = value.AsObject is Microsoft.Maui.Thickness t ? t : default; + _styleVersion.Value++; + } + else if (id == PropertyIds.CornerRadius) + { + _corners = value.AsObject is CornerRadii c ? c : default; + _styleVersion.Value++; + } + else if (id == PropertyIds.Shadow) + { + _elevation = (float)value.AsDouble; + _styleVersion.Value++; + } + else if (id == PropertyIds.Border) + { + if (value.AsObject is BorderSpec b) + { + _borderWidth = (float)b.Width; + _borderColor = b.Color; + } + _styleVersion.Value++; + } + else if (id == PropertyIds.TranslationX) + { + _translationX = (float)value.AsDouble; + _styleVersion.Value++; + } + else if (id == PropertyIds.TranslationY) + { + _translationY = (float)value.AsDouble; + _styleVersion.Value++; + } + else + ApplyControlProperty(id, in value); + } + + /// Applies a control-specific property. Common (View-level) properties are + /// handled by before this is called. + protected abstract void ApplyControlProperty(PropertyId id, in PropertyValue value); + + /// Builds the modifier chain this node applies to its composable: background, + /// padding, and a clickable when the Comet view has a tap gesture. Reads run inside + /// composition (via MutableState) so changes recompose. Returns null when none apply. + protected Modifier? BuildNodeModifier() + { + _ = _styleVersion.Value; // subscribe so background/padding changes recompose + _ = _frameVersion.Value; // subscribe so a re-arrange recomposes + + Modifier? m = null; + + // Yoga-positioned: place + size this node absolutely within its (Box) parent. Applied + // first so background/clickable cover the arranged frame. A reactive translation shifts the + // render position without a parent reflow (the photo parallax). + if (_hasFrame) + m = Modifier.Companion + .AbsoluteOffset(new Dp(_fx + _translationX), new Dp(_fy + _translationY)) + .Size(new Dp(_fw), new Dp(_fh)); + else if (_translationX != 0f || _translationY != 0f) + m = Modifier.Companion.AbsoluteOffset(new Dp(_translationX), new Dp(_translationY)); + + // Reactive visibility: fade the whole node (background + content) — applied early so + // everything painted below inherits the alpha. An invisible node collapses to alpha 0. + float alpha = _isVisible ? _opacity : 0f; + if (alpha < 1f) + m = (m ?? Modifier.Companion).Alpha(alpha); + + // baselineHeight: inset the content down within the (already-grown) box so the text's + // first baseline lands at the requested offset. Applied after Size so it insets inside. + if (_contentTopInset > 0) + m = (m ?? Modifier.Companion).Padding(0f, _contentTopInset, 0f, 0f); + + // Card surface / chat bubble: raise (shadow) then round the corners, so the shadow + // follows the rounded outline and the background/content below are clipped to it. + bool rounded = !_corners.IsZero; + if (_elevation > 0) + m = (m ?? Modifier.Companion).Shadow(new Dp(_elevation), rounded ? CornerShape() : null); + + if (rounded) + m = (m ?? Modifier.Companion).Clip(CornerShape()); + + // Gradient fill wins over a solid background (Jetsnack's design-system fills) — + // the REAL Compose Brush.horizontalGradient. The BoundBrush is CACHED per node: + // rebuilding it costs ~stops+2 JNI crossings and Render runs per recomposition + // (a fling over the gradient-heavy feed would churn hundreds of Java objects). + if (_gradient is { Stops.Length: > 1 } gradientSpec) + { + _cachedBrush ??= BuildGradientBrush(gradientSpec); + m = (m ?? Modifier.Companion).Background(_cachedBrush); + } + else if (_background is { } bg) + m = (m ?? Modifier.Companion).Background(ToComposeColor(bg)); + + // Stroke (e.g. avatar ring) following the same rounded outline, drawn over the fill. + // A gradient stroke (the gold's diagonalGradientBorder — Jetsnack chips/steppers) + // wins over a solid one; the diagonal Brush.linearGradient is cached per node. + if (_borderGradient is { Stops.Length: > 1 } borderSpec) + { + _cachedBorderBrush ??= BuildGradientBrush(borderSpec); + m = (m ?? Modifier.Companion).Border(new Dp(2f), _cachedBorderBrush, + rounded ? CornerShape() : null); + } + else if (_borderWidth > 0 && _borderColor is { } bc) + m = (m ?? Modifier.Companion).Border(new Dp(_borderWidth), ToComposeColor(bc), + rounded ? CornerShape() : null); + + // Padding: under native Compose layout always; under Yoga only for LEAF nodes + // (PadsOwnContent) — Yoga grows a leaf's frame by its padding but the node renders + // its content at the frame origin, so the inset must be applied here. Containers + // must NOT re-apply it (their children's absolute frames already include it — + // doubling would push content off the edge). + var p = _padding; + if ((!_hasFrame || PadsOwnContent) && (p.Left != 0 || p.Top != 0 || p.Right != 0 || p.Bottom != 0)) + m = (m is null ? Modifier.Companion : m) + .Padding((float)p.Left, (float)p.Top, (float)p.Right, (float)p.Bottom); + + // Skip the clickable when faded out / invisible so a hidden overlay doesn't eat taps. + // With a long-press present, use combinedClickable (the gold's combinedClickable + // onClick/onLongClick — Reply row selection); otherwise the plain clickable. + if (!GesturesHandledByWidget && (_hasTap.Value || _hasLongPress.Value) && alpha > 0f) + { + void FireTap() => Sink?.OnGesture(GestureKind.Tap, new GestureData(GestureState.Ended, default)); + var clickable = _hasLongPress.Value + ? Modifier.CombinedClickable( + onClick: FireTap, + onLongClick: () => Sink?.OnGesture(GestureKind.LongPress, new GestureData(GestureState.Ended, default))) + : Modifier.Clickable(FireTap); + m = m is null ? clickable : m.Then(clickable); + } + + // Press-and-hold voice-record drag (gold RecordButton's detectDragGesturesAfterLongPress): + // long-press engages, per-frame deltas (converted px→dp) flow back as Pan phases, and the + // Comet RecordGesture accumulates the offset + applies the swipe-to-cancel threshold. + if (_hasRecord.Value && alpha > 0f) + { + float density = Density; + var record = Modifier.Companion.DetectDragGesturesAfterLongPress( + onDrag: d => Sink?.OnGesture(GestureKind.Pan, new GestureData( + GestureState.Changed, default, new Point(d.X / density, d.Y / density))), + onDragStart: _ => Sink?.OnGesture(GestureKind.Pan, new GestureData(GestureState.Began, default)), + onDragEnd: () => Sink?.OnGesture(GestureKind.Pan, new GestureData(GestureState.Ended, default)), + onDragCancel: () => Sink?.OnGesture(GestureKind.Pan, new GestureData(GestureState.Cancelled, default))); + m = m is null ? record : m.Then(record); + } + + return m; + } + + // The clip/shadow outline. Uniform corners use the single-radius factory; otherwise + // per-corner (LTR: TopLeft→topStart, TopRight→topEnd, BottomRight→bottomEnd, BottomLeft→bottomStart). + protected AndroidX.Compose.Shape CornerShape() => _corners.IsUniform + ? AndroidX.Compose.Shape.RoundedCorners(new Dp((float)_corners.TopLeft)) + : AndroidX.Compose.Shape.RoundedCorners( + new Dp((float)_corners.TopLeft), new Dp((float)_corners.TopRight), + new Dp((float)_corners.BottomRight), new Dp((float)_corners.BottomLeft)); + + protected static AndroidX.Compose.Color ToComposeColor(Microsoft.Maui.Graphics.Color c) => AndroidX.Compose.Color.FromArgb( + (byte)(c.Alpha * 255), (byte)(c.Red * 255), (byte)(c.Green * 255), (byte)(c.Blue * 255)); + + public void InsertChild(int index, ICometBackendNode child) + { + Children.Insert(index, (ComposeNode)child); + _childVersion.Value++; + } + + public void RemoveChildAt(int index) + { + Children.RemoveAt(index); + _childVersion.Value++; + } + + public void MoveChild(int fromIndex, int toIndex) + { + var node = Children[fromIndex]; + Children.RemoveAt(fromIndex); + Children.Insert(toIndex, node); + _childVersion.Value++; + } + + // Leaf intrinsic size for the Yoga engine (overridden by measurable leaves, e.g. text). + // Container nodes defer to Yoga and need no intrinsic size. + public virtual Size Measure(double widthConstraint, double heightConstraint) => Size.Zero; + + // First text baseline (Dp from the top), for baseline-aligned rows; null = no text baseline. + public virtual double? MeasureBaseline(double width, double height) => null; + + // baselineHeight content inset (Dp): the engine grew the box by this and we pad the top to match. + public void SetContentTopInset(double dp) + { + _contentTopInset = (float)dp; + _frameVersion.Value++; + } + + // Yoga-computed parent-relative frame (in Dp); stored + recomposed so BuildNodeModifier + // positions this node absolutely. Only bump the version when the frame actually changes, so a + // full-tree reflow (run after every reactive flush) recomposes just the nodes that moved/resized + // — not the whole subtree on every keystroke. + public void Arrange(Rect frame) + { + float nx = (float)frame.X, ny = (float)frame.Y, nw = (float)frame.Width, nh = (float)frame.Height; + bool changed = !_hasFrame || nx != _fx || ny != _fy || nw != _fw || nh != _fh; + _fx = nx; _fy = ny; _fw = nw; _fh = nh; + _hasFrame = true; + if (changed) + _frameVersion.Value++; + } + + public void SetEventSink(ICometEventSink? sink) => _sink = sink; + + /// A (hot) reload adopted this retained node onto a rebuilt view. Own-content + /// subclasses re-point their view reference and invalidate materialized content. + public virtual void OnOwnerViewChanged(View newView, bool isHotReload) { } + + /// Called when the owning view is disposed (diff replacement, reload). + /// Own-content nodes override to unhook static events (AfterFlush, window metrics) — + /// without this every discarded node stays rooted for the process lifetime and keeps + /// re-running its per-flush layout. + public virtual void Dispose() { } + + // Composition helpers -------------------------------------------------- + + /// Adds this node's children to a freshly-built vendored container, + /// subscribing to structural changes so the container recomposes on + /// insert/remove/move. + protected void AddChildrenTo(ComposableContainer container) + { + _ = _childVersion.Value; // subscribe + foreach (var child in Children) + container.Add(child); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeScrollNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeScrollNode.cs new file mode 100644 index 000000000..0c1025058 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeScrollNode.cs @@ -0,0 +1,110 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; + +namespace Comet.Platform.Compose +{ + /// + /// Renders a Comet vertical ScrollView as a Compose Box with a + /// verticalScroll modifier. The scroll owns its content (so it implements + /// ): it lays the single content view out with the + /// shared Yoga engine — width pinned to the viewport, height wrapped to the content — then + /// hosts that taller-than-viewport content in the scrollable box. This is the non-virtualized + /// counterpart of , for screens that scroll as one piece + /// (settings, profile, article detail) rather than a lazy row list. + /// + sealed class ComposeScrollNode : ComposeNode, IBackendManagesOwnContent + { + IContainerView _scroll; + readonly BackendContext _context; + readonly ScrollState _scrollState = new(); + ComposeNode? _content; + View? _contentView; + + public ComposeScrollNode(IContainerView scroll, BackendContext context) + { + _scroll = scroll; + _context = context; + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) { } + + /// Re-point at the new ScrollView; only a hot reload drops the materialized + /// content so the changed code re-renders (an ordinary re-render keeps it + scroll state). + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not IContainerView scroll) + return; + _scroll = scroll; + if (!isHotReload) + return; + _content = null; + _contentView = null; + } + + ComposeNode? EnsureContent() + { + if (_content is not null) + return _content; + + var children = _scroll.GetChildren(); + _contentView = children is { Count: > 0 } ? children[0] : null; + if (_contentView is null) + return null; + + _content = (ComposeNode)CometBackendBridge.Materialize(_contentView, _context); + return _content; + } + + public override void Render(IComposer composer) + { + var content = EnsureContent(); + if (content is null) + return; + + // Width pinned to the arranged viewport (fall back to the screen until arranged). + double width = FrameWidth > 0 + ? FrameWidth + : global::Android.Content.Res.Resources.System!.DisplayMetrics!.WidthPixels / ComposeNode.Density; + + // Lay the content out taller-than-viewport; its children carry absolute frames and + // self-position, so the box below just needs to host it and scroll. + if (HasFrame) + CometBackendLayoutEngine.LayoutContent(_contentView!, width); + + // Viewport frame (offset+size+background) then verticalScroll; the content box inside + // is sized to its natural (taller) height by its own frame, so it scrolls. + var box = new Box(); + var modifier = (BuildNodeModifier() ?? Modifier.Companion).VerticalScroll(_scrollState); + ((ComposableNode)box).Modifier = modifier; + box.Add(content); + ((ComposableNode)box).Render(composer); + + // Continuously marshal the scroll offset (Dp) + at-top flag to the ScrollView's signals: a + // parallax header translates with the offset, and overlays (ProfileFab) extend/contract from + // at-top. SnapshotFlow tracks the live scrollState.value (px); LaunchedEffect(true) starts once + // and cancels when the scroll leaves the composition. Mirrors ComposeListNode's scroll bridge. + if (_scroll is Comet.ScrollView sv) + { + var captured = _scrollState; + var offsetSignal = sv.ScrollOffset; + var atTopSignal = sv.AtTop; + composer.LaunchedEffect(true, async ct => + { + await foreach (var px in ComposeExtensions.SnapshotFlow(() => captured.Value).WithCancellation(ct)) + { + double dp = px / ComposeNode.Density; + Comet.ThreadHelper.RunOnMainThread(() => + { + offsetSignal.Value = dp; + atTopSignal.Value = px == 0; + }); + } + }); + } + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeSearchBarNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeSearchBarNode.cs new file mode 100644 index 000000000..982fb6fff --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeSearchBarNode.cs @@ -0,0 +1,146 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet as the REAL M3 state-based + /// search pair: the collapsed at the node's + /// Yoga frame plus the popup, sharing one + /// / (expansion on + /// focus, back-collapse — all inside the widget). Slot + content views are owned + /// (); typed text streams into the control's + /// Query signal via a snapshot flow. + sealed class ComposeSearchBarNode : ComposeNode, IBackendManagesOwnContent + { + // M3 search bar container height (dp). + const float BarHeightDp = 56f; + + Comet.SearchBar _bar; + readonly BackendContext _context; + readonly SearchBarState _searchState = new(); + readonly SearchBarTextFieldState _textState = new(); + readonly MutableState _contentVersion = new(0); + ComposeNode? _placeholder, _leading, _trailing, _content; + bool _built; + + public ComposeSearchBarNode(Comet.SearchBar bar, BackendContext context) + { + _bar = bar; + _context = context; + Comet.Reactive.ReactiveScheduler.AfterFlush += ReflowContent; + } + + public override void Dispose() + => Comet.Reactive.ReactiveScheduler.AfterFlush -= ReflowContent; + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) { } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not Comet.SearchBar bar) + return; + _bar = bar; + if (!isHotReload) + return; + _built = false; + _placeholder = _leading = _trailing = _content = null; + _contentVersion.Value++; + } + + void EnsureContent() + { + if (_built) + return; + _built = true; + _placeholder = (ComposeNode)CometBackendBridge.Materialize(_bar.PlaceholderView, _context); + _content = (ComposeNode)CometBackendBridge.Materialize(_bar.ContentView, _context); + if (_bar.LeadingView is { } leading) + _leading = (ComposeNode)CometBackendBridge.Materialize(leading, _context); + if (_bar.TrailingView is { } trailing) + _trailing = (ComposeNode)CometBackendBridge.Materialize(trailing, _context); + LayoutContent(); + } + + // The popup content wraps to its natural height at the bar's width; re-laid per + // flush so query-driven result changes reflow. + void LayoutContent() + { + if (_content is null) + return; + double width = FrameWidth > 0 ? FrameWidth : ScreenSizeDp().Width - 32; + CometBackendLayoutEngine.LayoutContent(_bar.ContentView, width); + } + + void ReflowContent() + { + if (_content is not null) + LayoutContent(); + } + + // In-flow footprint: the collapsed bar. The expanded popup overlays (widget-managed). + public override Size Measure(double widthConstraint, double heightConstraint) + { + double w = double.IsFinite(widthConstraint) && widthConstraint > 0 + ? widthConstraint : ScreenSizeDp().Width; + return new Size(w, BarHeightDp); + } + + SearchBarInputField MakeInputField() => new(_textState, _searchState) + { + Placeholder = _placeholder, + LeadingIcon = _leading, + TrailingIcon = _trailing, + OnSearch = q => _bar.OnSearch?.Invoke(q), + }; + + public override void Render(IComposer composer) + { + _ = _contentVersion.Value; + EnsureContent(); + + // Stream every edit into the CURRENT control's Query signal (equality-gated + // there; _bar re-points on owner re-render, so don't capture it). + var capturedText = _textState; + composer.LaunchedEffect(true, async ct => + { + await foreach (var text in ComposeExtensions.SnapshotFlow(() => capturedText.Text) + .WithCancellation(ct)) + { + Comet.ThreadHelper.RunOnMainThread(() => + { + _bar.Query.Value = text; + Comet.Reactive.ReactiveScheduler.EnsureFlushScheduled(); + }); + } + }); + + var box = new Box(); + ((ComposableNode)box).Modifier = BuildNodeModifier() ?? Modifier.Companion; + + // Pin the INPUT FIELD (the visible pill) to the Yoga-arranged width — it otherwise + // renders at its intrinsic minimum (SearchBarMinWidth = 360dp) and leaves the + // right margin short of the gutter the sample asked for. + SearchBarInputField SizedInputField() + { + var field = MakeInputField(); + if (FrameWidth > 0) + ((ComposableNode)field).Modifier = Modifier.Companion.RequiredWidth(new Dp(FrameWidth)); + return field; + } + + var bar = new AndroidX.Compose.SearchBar(_searchState) { InputField = SizedInputField() }; + box.Add(bar); + + var expanded = new ExpandedDockedSearchBar(_searchState) { InputField = SizedInputField() }; + expanded.Add(_content!); + box.Add(expanded); + + box.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeSelectorPanelNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeSelectorPanelNode.cs new file mode 100644 index 000000000..8fbc0539b --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeSelectorPanelNode.cs @@ -0,0 +1,120 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.Compose +{ + /// Renders a Comet as the gold-standard Jetchat + /// SelectorExpanded: a single Material Surface that swaps its content by the active + /// selector index (or composes nothing when collapsed). The direct generalization of + /// — a index instead of a bool, one of + /// N materialized children instead of two slots. Owns its children + /// (): the Yoga engine treats this as a measured leaf, so the + /// active panel's height grows the footer (and shrinks the sibling message list) on the next reactive + /// reflow — the lighter, layout-driven equivalent of the gold's bottom-anchored + /// Surface(tonalElevation = 8.dp). While a panel is open a routes the + /// system back press to a dismiss event (the control writes the index back to 0). + sealed class ComposeSelectorPanelNode : ComposeNode, IBackendManagesOwnContent + { + SelectorPanel _panel; + readonly BackendContext _context; + readonly MutableState _selector = new(0); // drives Render (content swap) + int _selectorValue; // drives Measure (layout reflow) + ComposeNode?[] _nodes = System.Array.Empty(); + bool _initialized; + + public ComposeSelectorPanelNode(SelectorPanel panel, BackendContext context) + { + _panel = panel; + _context = context; + } + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.SelectorPanel_Index) + { + _selectorValue = value.AsInt; // plain field, read by Measure outside composition + _selector.Value = value.AsInt; // MutableState, read by Render to recompose + } + } + + /// Re-point at the new SelectorPanel; only a hot reload re-materializes the + /// per-index panel views so the changed code renders. + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is not SelectorPanel panel) + return; + _panel = panel; + if (!isHotReload) + return; + _initialized = false; + _nodes = System.Array.Empty(); + } + + void EnsureContent() + { + if (_initialized) + return; + _initialized = true; + + var panels = _panel.Panels; + _nodes = new ComposeNode?[panels.Count]; + for (int i = 0; i < panels.Count; i++) + if (panels[i] is { } v) + _nodes[i] = (ComposeNode)CometBackendBridge.Materialize(v, _context); + } + + View? ActiveView() + { + var panels = _panel.Panels; + return _selectorValue > 0 && _selectorValue < panels.Count ? panels[_selectorValue] : null; + } + + // Own-content leaf: report the active panel's natural height so the Yoga engine grows the footer + // (and shrinks the message list). Returns zero when collapsed, so the slot disappears. Re-run by + // ComposeBackendRoot after every reactive flush, so a selector change reflows the layout. + public override Size Measure(double widthConstraint, double heightConstraint) + { + EnsureContent(); + + var view = ActiveView(); + if (view is null) + return Size.Zero; + + double width = double.IsInfinity(widthConstraint) || widthConstraint <= 0 + ? global::Android.Content.Res.Resources.System!.DisplayMetrics!.WidthPixels / Density + : widthConstraint; + + // Lay the active panel out to the host width (wrapping its height) AND push the frames onto + // its node subtree, so Render below can draw it positioned. Mirrors the list's per-row layout. + return CometBackendLayoutEngine.LayoutContent(view, width); + } + + public override void Render(IComposer composer) + { + EnsureContent(); + + int s = _selector.Value; // subscribe so a selector change recomposes this scope + ComposeNode? node = s > 0 && s < _nodes.Length ? _nodes[s] : null; + if (node is null) + return; // collapsed (NONE) or a dialog-handled selector (DM) → compose nothing + + // The expandable panel = Surface(tonalElevation = 8.dp). The facade Surface doesn't expose + // tonalElevation, so the app supplies the M3 elevation-8 surface color via .Background(); a + // real Surface painted with it is pixel-identical (the same approach the footer uses for the + // 2.dp bar). The node frame (BuildNodeModifier) positions + sizes the Surface in the footer. + var surface = new AndroidX.Compose.Surface { Modifier = BuildNodeModifier() }; + if (Background is { } bg) + surface.Color = ToComposeColor(bg); + + // Gold UserInput.kt: `BackHandler(onBack = dismissKeyboard)` while a selector is visible. + surface.Add(new BackHandler(() => Sink?.OnEvent(EventIds.SelectorPanelDismissed), enabled: true)); + surface.Add(node); + ((ComposableNode)surface).Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeStackNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeStackNode.cs new file mode 100644 index 000000000..8c28b8ac4 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeStackNode.cs @@ -0,0 +1,107 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; + +namespace Comet.Platform.Compose +{ + /// Vertical / horizontal stack orientation for . + enum StackAxis { Vertical, Horizontal, Depth } + + /// Renders Comet VStack/HStack/ZStack as a Compose + /// Column/Row/Box, hosting the child nodes. + sealed class ComposeStackNode : ComposeNode + { + readonly StackAxis _axis; + readonly MutableState _spacing = new(0); + readonly MutableState _asSurface = new(false); + + public ComposeStackNode(StackAxis axis) => _axis = axis; + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Stack_Spacing) + _spacing.Value = (int)value.AsDouble; + else if (id == PropertyIds.Container_Surface) + _asSurface.Value = value.AsBool; + else if (id == PropertyIds.Container_Card) + _asCard.Value = value.AsBool; + } + + readonly MutableState _asCard = new(false); + + public override void Render(IComposer composer) + { + // A fully transparent container composes NOTHING (the SubscribeAndGetAlpha + // contract): children carry their own alpha=1 clickables and would otherwise + // keep intercepting input under an invisible overlay (the Jetsnack filters + // scrim swallowed the feed's taps after closing). + if (SubscribeAndGetAlpha() <= 0f) + return; + + int spacing = _spacing.Value; + + // Opt-in: the REAL Material 3 Card (gold PostCardPopular et al). Self-themed + // containerColor/elevation; the clickable overload owns the tap (ripple + + // semantics), so the node modifier must not add a second clickable. + if (_asCard.Value) + { + // The clickable Card owns the tap ONLY when there's no long-press — Card.onClick + // can't carry onLongClick, so a long-press view keeps the combinedClickable. + bool widgetOwnsClick = HasTapGesture && !HasLongPress; + GesturesHandledByWidget = widgetOwnsClick; + var shape = HasRoundedCorners ? CornerShape() : null; + ComposableContainer card = widgetOwnsClick + ? new AndroidX.Compose.ClickableCard(FireTapEvent) { Shape = shape } + : new AndroidX.Compose.Card { Shape = shape }; + ((ComposableNode)card).Modifier = BuildNodeModifier(); + // Card's content slot is a ColumnScope — Yoga children carry ABSOLUTE offsets, + // so they must sit in a Box or the column stacking double-offsets them. + var content = new Box(); + AddChildrenTo(content); + card.Add(content); + ((ComposableNode)card).Render(composer); + return; + } + + // Opt-in: a real Material Surface (color + shape) — the widget the gold standard draws + // chat bubbles with. The Surface owns the fill + clip, so children sit inside it. + if (_asSurface.Value && Background is { } bgColor) + { + var surface = new AndroidX.Compose.Surface + { + Color = ToComposeColor(bgColor), + Shape = HasRoundedCorners ? CornerShape() : null, + Modifier = BuildNodeModifier(), + }; + AddChildrenTo(surface); + ((ComposableNode)surface).Render(composer); + return; + } + + // When Yoga has arranged this stack, render a Box so children position themselves + // absolutely (each child carries its own offset+size); Yoga owns spacing/axis then. + ComposableContainer container; + if (HasFrame) + { + container = new Box(); + } + else + { + var arrangement = spacing > 0 ? Arrangement.SpacedBy(spacing) : null; + container = _axis switch + { + StackAxis.Horizontal => new Row(arrangement), + StackAxis.Depth => new Box(), + _ => new Column(arrangement), + }; + } + // background / padding / tap gesture (+ Yoga frame) become the stack's modifier chain. + ((ComposableNode)container).Modifier = BuildNodeModifier(); + AddChildrenTo(container); + ((ComposableNode)container).Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ComposeTabRowNode.cs b/src/Comet/src/Comet/Platform/Compose/ComposeTabRowNode.cs new file mode 100644 index 000000000..771f99f5d --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ComposeTabRowNode.cs @@ -0,0 +1,61 @@ +#nullable enable +#if ANDROID +using AndroidX.Compose; +using AndroidX.Compose.Runtime; +using Comet.Backend; +using ComposeText = AndroidX.Compose.Text; + +namespace Comet.Platform.Compose +{ + /// Renders Comet as the REAL Material 3 + /// PrimaryTabRow with Tab children (text labels) — the gold + /// Interests switcher. Selection flows signal → MutableState (indicator slides), + /// and taps route back through . + sealed class ComposeTabRowNode : ComposeNode + { + const float RowHeight = 48f; // M3 text-tab container height + + TabBar _bar; + readonly MutableState _selected = new(0); + + public ComposeTabRowNode(TabBar bar) => _bar = bar; + + protected override void ApplyControlProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Nav_SelectedIndex) + _selected.Value = value.AsInt; + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is TabBar bar) + _bar = bar; + } + + public override Size Measure(double widthConstraint, double heightConstraint) + => new(double.IsInfinity(widthConstraint) ? ScreenSizeDp().Width : widthConstraint, RowHeight); + + public override void Render(IComposer composer) + { + int selected = _selected.Value; // subscribe: selection recomposes the row + var row = new PrimaryTabRow(selected) { Modifier = BuildNodeModifier() }; + for (int i = 0; i < _bar.Titles.Count; i++) + { + int index = i; + var label = new ComposeText(_bar.Titles[i]); + label.LetterSpacing = AndroidX.Compose.Sp.Zero; + label.FontSize = new AndroidX.Compose.Sp((int)System.Math.Round(_bar.FontSize)); + var color = index == selected ? _bar.SelectedColor : _bar.UnselectedColor; + if (color is { } c) + label.Color = ToComposeColor(c); + if (ComposeFontRegistry.Resolve(_bar.FontFamily, _bar.FontWeight) is { } r) + label.FontFamily = r.Family; + else if (_bar.FontWeight >= 500) + label.FontWeight = AndroidX.Compose.FontWeight.Medium; + row.Add(new Tab(index == selected, () => _bar.SelectItem(index)) { Text = label }); + } + row.Render(composer); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Compose/ControlBackendNodes.Android.cs b/src/Comet/src/Comet/Platform/Compose/ControlBackendNodes.Android.cs new file mode 100644 index 000000000..3bf440586 --- /dev/null +++ b/src/Comet/src/Comet/Platform/Compose/ControlBackendNodes.Android.cs @@ -0,0 +1,180 @@ +#nullable enable +#if ANDROID +using Comet.Backend; +using Comet.Platform.Compose; + +namespace Comet +{ + // CreateBackendNode overrides wiring each wave-1 control to its Compose node. + // These are the ONLY references to the concrete node types, so a control the app + // never uses trims away together with its node. + + public partial class Image + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeImageNode(); + } + + public partial class Icon + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeIconNode(); + } + + public partial class Drawer + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeDrawerNode(this, context); + } + + public partial class AlertDialog + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeAlertDialogNode(this, context); + } + + public partial class SelectorPanel + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeSelectorPanelNode(this, context); + } + + public partial class Fab + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeFabNode(this, context); + } + + public partial class NavigationBar + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeNavigationBarNode(this, context); + } + + public partial class NavigationRail + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeNavigationRailNode(this, context); + } + + public partial class NavigationSuite + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeNavigationSuiteNode(this, context); + } + + public partial class ListDetail + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeListDetailNode(this, context); + } + + public partial class ContentSwitcher + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeContentSwitcherNode(this, context); + } + + public partial class TabBar + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeTabRowNode(this); + } + + public partial class IconToggleButton + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeIconToggleNode(this, context); + } + + public partial class FilterChip + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeFilterChipNode(this, context); + } + + public partial class IconButton + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeIconButtonNode(this, context); + } + + public partial class SearchBar + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeSearchBarNode(this, context); + } + + public partial class Text + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeTextNode(); + } + + public partial class FormattedText + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeFormattedTextNode(); + } + + public partial class Button + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeButtonNode(); + } + + public partial class VStack + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeStackNode(StackAxis.Vertical); + } + + public partial class HStack + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeStackNode(StackAxis.Horizontal); + } + + public partial class ZStack + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeStackNode(StackAxis.Depth); + } + + public partial class TextField + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeTextFieldNode(this); + } + + public partial class Toggle + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeToggleNode(); + } + + public partial class Slider + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeSliderNode(); + } + + public partial class ListView + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeListNode(this, context); + } + + public partial class ScrollView + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeScrollNode(this, context); + } + + public partial class NavigationView + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new ComposeNavigationNode(this, context); + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/MacOS/CUIRadioNSButton.cs b/src/Comet/src/Comet/Platform/MacOS/CUIRadioNSButton.cs deleted file mode 100644 index f474e9a1d..000000000 --- a/src/Comet/src/Comet/Platform/MacOS/CUIRadioNSButton.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using AppKit; -using Foundation; -using ObjCRuntime; - -namespace Comet.MacOS -{ - public class CUIRadioNSButton : NSButton - { - public event EventHandler IsCheckedChanged; - - private bool _isChecked; - - public CUIRadioNSButton(bool isChecked = false) - { - SetButtonType(NSButtonType.Radio); - BezelStyle = NSBezelStyle.Rounded; - Title = ""; - _isChecked = isChecked; - State = _isChecked ? NSCellStateValue.On : NSCellStateValue.Off; - Target = this; - Action = new Selector("onClicked:"); - } - - [Export("onClicked:")] - void OnClicked(NSObject sender) - { - var newValue = State == NSCellStateValue.On; - if (_isChecked != newValue) - { - _isChecked = newValue; - IsCheckedChanged?.Invoke(this, EventArgs.Empty); - } - } - - public bool IsChecked - { - get => _isChecked; - set - { - if (_isChecked != value) - { - _isChecked = value; - State = _isChecked ? NSCellStateValue.On : NSCellStateValue.Off; - IsCheckedChanged?.Invoke(this, EventArgs.Empty); - } - } - } - - public void SetLabel(string label) - { - Title = label ?? ""; - InvalidateIntrinsicContentSize(); - } - - public void SetTextColor(NSColor color) - { - if (color is null || string.IsNullOrEmpty(Title)) - return; - - var attributes = new NSDictionary( - NSStringAttributeKey.ForegroundColor, color); - AttributedTitle = new NSAttributedString(Title, attributes); - } - } -} diff --git a/src/Comet/src/Comet/Platform/MacOS/CUIShapeNSView.cs b/src/Comet/src/Comet/Platform/MacOS/CUIShapeNSView.cs deleted file mode 100644 index 5b608e502..000000000 --- a/src/Comet/src/Comet/Platform/MacOS/CUIShapeNSView.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System; -using AppKit; -using CoreGraphics; -using Comet.Graphics; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Graphics.Platform; - -namespace Comet.MacOS -{ - public class CUIShapeNSView : NSView - { - private static readonly CGColorSpace ColorSpace = CGColorSpace.CreateDeviceRGB(); - - private Shape _shape; - - public CUIShapeNSView() - { - WantsLayer = true; - } - - public Shape Shape - { - get => _shape; - set - { - _shape = value; - NeedsDisplay = true; - } - } - - public View View { get; set; } - - public override bool IsFlipped => true; - - public override void DrawRect(CGRect dirtyRect) - { - var context = NSGraphicsContext.CurrentContext?.CGContext; - if (context is null || Shape is null) - return; - - var drawingStyle = Shape.GetDrawingStyle(View, DrawingStyle.StrokeFill); - - var lineWidth = Shape.GetLineWidth(View, 1); - var strokeColor = Colors.Black; - object fill = null; - - if (drawingStyle == DrawingStyle.Fill || drawingStyle == DrawingStyle.StrokeFill) - { - fill = Shape.GetFill(View); - } - - var shapeBounds = new RectF( - (float)dirtyRect.X + (lineWidth / 2), - (float)dirtyRect.Y + (lineWidth / 2), - (float)dirtyRect.Width - lineWidth, - (float)dirtyRect.Height - lineWidth); - - var path = Shape.PathForBounds(shapeBounds).AsCGPath(); - - if (fill is not null) - { - if (fill is Color color) - { - context.SetFillColor(ColorToCG(color)); - context.AddPath(path); - context.FillPath(); - } - else if (fill is Gradient gradient) - { - context.SaveState(); - context.AddPath(path); - context.Clip(); - - var gradientColors = new nfloat[gradient.Stops.Length * 4]; - var offsets = new nfloat[gradient.Stops.Length]; - - int g = 0; - for (int i = 0; i < gradient.Stops.Length; i++) - { - var stopColor = gradient.Stops[i].Color; - offsets[i] = gradient.Stops[i].Offset; - - if (stopColor is null) stopColor = Colors.White; - - gradientColors[g++] = stopColor.Red; - gradientColors[g++] = stopColor.Green; - gradientColors[g++] = stopColor.Blue; - gradientColors[g++] = stopColor.Alpha; - } - - var cgGradient = new CGGradient(CGColorSpace.CreateDeviceRGB(), gradientColors, offsets); - - if (gradient is LinearGradient linearGradient) - { - var gradientStart = new CGPoint( - (float)dirtyRect.X + dirtyRect.Width * linearGradient.StartPoint.X, - (float)dirtyRect.Y + dirtyRect.Height * linearGradient.StartPoint.Y); - - var gradientEnd = new CGPoint( - (float)dirtyRect.X + dirtyRect.Width * linearGradient.EndPoint.X, - (float)dirtyRect.Y + dirtyRect.Height * linearGradient.EndPoint.Y); - - context.DrawLinearGradient( - cgGradient, - gradientStart, - gradientEnd, - CGGradientDrawingOptions.DrawsAfterEndLocation | CGGradientDrawingOptions.DrawsBeforeStartLocation); - } - else if (gradient is RadialGradient radialGradient) - { - var radialFocalPoint = new CGPoint( - (float)dirtyRect.X + dirtyRect.Width * radialGradient.Center.X, - (float)dirtyRect.Y + dirtyRect.Height * radialGradient.Center.Y); - - context.DrawRadialGradient( - cgGradient, - radialFocalPoint, - radialGradient.StartRadius, - radialFocalPoint, - radialGradient.EndRadius, - CGGradientDrawingOptions.DrawsBeforeStartLocation | CGGradientDrawingOptions.DrawsAfterEndLocation); - } - - cgGradient.Dispose(); - context.RestoreState(); - } - } - - if (drawingStyle == DrawingStyle.Stroke || drawingStyle == DrawingStyle.StrokeFill) - { - strokeColor = Shape.GetStrokeColor(View, Colors.Black); - - context.SetLineWidth(lineWidth); - context.SetStrokeColor(ColorToCG(strokeColor)); - - context.AddPath(path); - context.StrokePath(); - } - } - static CGColor ColorToCG(Color color) - => CGColor.CreateSrgb( - (nfloat)color.Red, (nfloat)color.Green, - (nfloat)color.Blue, (nfloat)color.Alpha); - } -} diff --git a/src/Comet/src/Comet/Platform/MacOS/CUITabNSView.cs b/src/Comet/src/Comet/Platform/MacOS/CUITabNSView.cs deleted file mode 100644 index b754ba324..000000000 --- a/src/Comet/src/Comet/Platform/MacOS/CUITabNSView.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AppKit; -using CoreGraphics; -using Microsoft.Maui; -namespace Comet.MacOS -{ - public class CUITabNSView : NSView - { - readonly NSSegmentedControl _tabBar; - readonly NSView _contentArea; - NSView _currentPageView; - TabView _tabView; - readonly List _tabs = new(); - - public IMauiContext Context { get; set; } - - public CUITabNSView() - { - WantsLayer = true; - - _tabBar = new NSSegmentedControl - { - SegmentStyle = NSSegmentStyle.Automatic, - TrackingMode = NSSegmentSwitchTracking.SelectOne, - }; - _tabBar.Activated += OnTabSelected; - - _contentArea = new NSView { WantsLayer = true }; - - AddSubview(_tabBar); - AddSubview(_contentArea); - } - - public void Setup(TabView tabView) - { - _tabView = tabView; - _tabs.Clear(); - - if (tabView is null) - return; - - foreach (var tab in tabView) - { - _tabs.Add(tab); - } - - _tabBar.SegmentCount = _tabs.Count; - for (int i = 0; i < _tabs.Count; i++) - { - var title = (_tabs[i] as View)?.GetTitle() ?? $"Tab {i}"; - _tabBar.SetLabel(title, i); - _tabBar.SetWidth(0, i); - } - - if (_tabs.Count > 0) - SelectTab(0); - } - - void OnTabSelected(object sender, EventArgs e) - { - SelectTab((int)_tabBar.SelectedSegment); - } - - void SelectTab(int index) - { - if (index < 0 || index >= _tabs.Count || Context is null) - return; - - _tabBar.SelectedSegment = index; - _currentPageView?.RemoveFromSuperview(); - - var tab = _tabs[index]; - var platformView = tab.ToMacOSPlatform(Context); - _currentPageView = platformView; - _contentArea.AddSubview(platformView); - NeedsLayout = true; - } - - public override void Layout() - { - base.Layout(); - var bounds = Bounds; - if (bounds.Width <= 0 || bounds.Height <= 0) - return; - - var tabBarHeight = (nfloat)30; - var padding = (nfloat)8; - - var tabSize = _tabBar.FittingSize; - var tabX = (bounds.Width - tabSize.Width) / 2; - _tabBar.Frame = new CGRect(tabX, padding, tabSize.Width, tabBarHeight); - - var contentTop = padding + tabBarHeight + padding; - _contentArea.Frame = new CGRect(0, contentTop, bounds.Width, bounds.Height - contentTop); - - if (_currentPageView is not null) - { - _currentPageView.Frame = _contentArea.Bounds; - var tabIndex = (int)_tabBar.SelectedSegment; - if (tabIndex >= 0 && tabIndex < _tabs.Count) - { - var tab = _tabs[tabIndex]; - tab.Measure( - (double)_contentArea.Bounds.Width, - (double)_contentArea.Bounds.Height); - tab.Arrange(new Microsoft.Maui.Graphics.Rect( - 0, 0, - (double)_contentArea.Bounds.Width, - (double)_contentArea.Bounds.Height)); - } - } - } - } -} diff --git a/src/Comet/src/Comet/Platform/MacOS/CometNSView.cs b/src/Comet/src/Comet/Platform/MacOS/CometNSView.cs deleted file mode 100644 index f841a0a04..000000000 --- a/src/Comet/src/Comet/Platform/MacOS/CometNSView.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using AppKit; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.HotReload; -namespace Comet.MacOS -{ - public class CometNSView : NSView, IReloadHandler - { - public CometNSView(IMauiContext mauiContext) - { - MauiContext = mauiContext; - WantsLayer = true; - } - - IView _view; - public IView CurrentView - { - get => _view; - set => SetView(value); - } - - public IMauiContext MauiContext { get; internal set; } - - NSView currentPlatformView; - IViewHandler currentHandler; - - void SetView(IView view, bool forceRefresh = false) - { - if (view == _view && !forceRefresh) - return; - - // Reuse handlers when content type matches - if (view is View v && _view is View pv && - v.GetContentTypeHashCode() == pv.GetContentTypeHashCode() - && currentHandler is not null) - { - _view = view; - v.ViewHandler = currentHandler; - if (_view is IHotReloadableView ihr1) - { - ihr1.ReloadHandler = this; - MauiHotReloadHelper.AddActiveView(ihr1); - } - return; - } - - _view = view; - - if (_view is IHotReloadableView ihr) - { - ihr.ReloadHandler = this; - MauiHotReloadHelper.AddActiveView(ihr); - } - - // Resolve views with a Body to their concrete tree to avoid - // circular CometViewHandler→CometNSView loops - var viewToRender = _view; - if (viewToRender is View cometView && cometView.Body is not null) - viewToRender = cometView.GetView(); - - var newPlatformView = viewToRender?.ToMacOSPlatform(MauiContext); - currentHandler = _view?.Handler; - if (currentPlatformView == newPlatformView) - return; - - currentPlatformView?.RemoveFromSuperview(); - if (newPlatformView != this && newPlatformView is not null) - { - AddSubview(newPlatformView); - currentPlatformView = newPlatformView; - } - } - - public override void Layout() - { - base.Layout(); - if (currentPlatformView is null) - return; - _view?.Measure(Bounds.Width, Bounds.Height); - currentPlatformView.Frame = Bounds; - } - - public void UpdateBackground(IView view) - { - WantsLayer = true; - if (Layer is null) - return; - - if (view.Background is Microsoft.Maui.Graphics.SolidPaint solid && solid.Color is not null) - { - var color = solid.Color; - Layer.BackgroundColor = CoreGraphics.CGColor.CreateSrgb( - (nfloat)color.Red, (nfloat)color.Green, (nfloat)color.Blue, (nfloat)color.Alpha); - } - } - - public void Reload() => SetView(CurrentView, true); - } -} diff --git a/src/Comet/src/Comet/Platform/MacOS/HandlerExtensions.cs b/src/Comet/src/Comet/Platform/MacOS/HandlerExtensions.cs deleted file mode 100644 index 7c8d8cf0c..000000000 --- a/src/Comet/src/Comet/Platform/MacOS/HandlerExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using AppKit; -using Microsoft.Maui.Handlers; - -namespace Comet; - -public static partial class HandlerExtensions -{ - public static void AddGesture(this IViewHandler handler, Gesture gesture) - { - // TODO: Implement AppKit gesture support (NSClickGestureRecognizer, NSPanGestureRecognizer, etc.) - } - - public static void RemoveGesture(this IViewHandler handler, Gesture gesture) - { - // TODO: Implement AppKit gesture removal - } -} diff --git a/src/Comet/src/Comet/Platform/MacOS/MacOSViewExtensions.cs b/src/Comet/src/Comet/Platform/MacOS/MacOSViewExtensions.cs deleted file mode 100644 index e5b25c82b..000000000 --- a/src/Comet/src/Comet/Platform/MacOS/MacOSViewExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using AppKit; -using Microsoft.Maui; - -namespace Comet.MacOS -{ - /// - /// Extension methods for converting IView to NSView on macOS. - /// This is Comet's local equivalent of Platform.Maui.MacOS.ViewExtensions. - /// When Platform.Maui.MacOS is added as a dependency, these can delegate - /// to or be replaced by the upstream implementations. - /// - public static class MacOSViewExtensions - { - public static NSView ToMacOSPlatform(this IView view, IMauiContext context) - { - var handler = view.ToHandler(context); - if (handler.PlatformView is NSView nsView) - return nsView; - - throw new InvalidOperationException( - $"Unable to convert handler platform view ({handler.PlatformView?.GetType().Name}) to NSView"); - } - } -} diff --git a/src/Comet/src/Comet/Platform/Standard/HandlerExtensions.cs b/src/Comet/src/Comet/Platform/Standard/HandlerExtensions.cs deleted file mode 100644 index 9f38160c5..000000000 --- a/src/Comet/src/Comet/Platform/Standard/HandlerExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.Maui.Handlers; - -namespace Comet; - -public static partial class HandlerExtensions -{ - public static void AddGesture(this IViewHandler handler, Gesture gesture) - { - - } - - public static void RemoveGesture(this IViewHandler handler, Gesture gesture) - { - - } - -} - - diff --git a/src/Comet/src/Comet/Platform/SwiftUI/CometSwiftUIKeyboard.cs b/src/Comet/src/Comet/Platform/SwiftUI/CometSwiftUIKeyboard.cs new file mode 100644 index 000000000..bff5606e3 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/CometSwiftUIKeyboard.cs @@ -0,0 +1,49 @@ +#nullable enable +#if IOS +using System; +using Foundation; +using UIKit; + +namespace Comet.Platform.SwiftUI +{ + /// + /// Tracks how much the software keyboard overlaps the screen so the absolute-positioned + /// SwiftUI backend can shrink its laid-out height (SwiftUI's automatic keyboard avoidance + /// doesn't apply to our offset-positioned nodes). Own-content nodes that lay out a full-height + /// screen (the navigation screen) read and re-run their layout on + /// , so the footer rises above the keyboard instead of hiding behind it. + /// + static class CometSwiftUIKeyboard + { + static NSObject? _change, _hide; + + /// Points of the screen currently covered by the keyboard (0 when hidden). + public static nfloat Inset { get; private set; } + + /// Raised (on the main thread) whenever changes. + public static event Action? Changed; + + /// Idempotently starts observing keyboard frame changes. + public static void EnsureStarted() + { + if (_change is not null) + return; + + _change = UIKeyboard.Notifications.ObserveWillChangeFrame((_, e) => + { + var screen = UIScreen.MainScreen.Bounds; + SetInset((nfloat)Math.Max(0, screen.Bottom - e.FrameEnd.Top)); + }); + _hide = UIKeyboard.Notifications.ObserveWillHide((_, _) => SetInset(0)); + } + + static void SetInset(nfloat value) + { + if (Math.Abs(value - Inset) < 0.5) + return; + Inset = value; + Changed?.Invoke(); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/ControlBackendNodes.iOS.cs b/src/Comet/src/Comet/Platform/SwiftUI/ControlBackendNodes.iOS.cs new file mode 100644 index 000000000..a6c65bb05 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/ControlBackendNodes.iOS.cs @@ -0,0 +1,190 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Comet.Platform.SwiftUI; + +namespace Comet +{ + // CreateBackendNode overrides mapping wave-1 controls to their SwiftUI node kind. + // The only references to SwiftUINode, so unused controls trim with their nodes. + + public partial class Text + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("text"); + } + + public partial class FormattedText + { + // iOS renders the concatenated text (per-run styling is a follow-up); the node folds runs + // into the plain "text" value. + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("text"); + } + + public partial class Button + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("button"); + } + + public partial class AlertDialog + { + // Native SwiftUI .alert (the dialog's Text/ConfirmButton flatten to message + button label). + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIAlertDialogNode(this); + } + + public partial class SelectorPanel + { + // Android-first: the input-selector panel never expands on iOS (no-op twin). + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUISelectorPanelNode(this, context); + } + + public partial class Fab + { + // Native SwiftUI composition (iOS has no Material FAB); pending simulator verification. + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIFabNode(this, context); + } + + public partial class VStack + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("vstack"); + } + + public partial class HStack + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("hstack"); + } + + public partial class ZStack + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("zstack"); + } + + public partial class TextField + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("textfield"); + } + + public partial class Toggle + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("toggle"); + } + + public partial class Image + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("image"); + } + + public partial class Icon + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("icon"); + } + + public partial class Drawer + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIDrawerNode(this, context); + } + + public partial class Slider + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINode("slider"); + } + + public partial class ListView + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIListNode(this, context); + } + + public partial class ScrollView + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIScrollNode(this, context); + } + + public partial class NavigationView + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINavigationNode(this, context); + } + + // Adaptive primitives (M1 Reply): hosted-composition twins — structure/interaction + // parity composed from Comet views (see SwiftUIAdaptiveNodes.cs). + + public partial class ContentSwitcher + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIContentSwitcherNode(this, context); + } + + public partial class TabBar + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUITabRowNode(this, context); + } + + public partial class IconToggleButton + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIIconToggleNode(this, context); + } + + public partial class FilterChip + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIFilterChipNode(this, context); + } + + public partial class IconButton + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIIconButtonNode(this, context); + } + + public partial class ListDetail + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUIListDetailNode(this, context); + } + + public partial class NavigationSuite + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINavigationSuiteNode(this, context); + } + + public partial class SearchBar + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUISearchBarNode(this, context); + } + + public partial class NavigationBar + { + // Standalone bar: host a one-variant suite-style row (rare outside the suite). + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINavigationSuiteNode( + new NavigationSuite(SelectedIndex, Items, new VStack()), context); + } + + public partial class NavigationRail + { + protected internal override ICometBackendNode CreateBackendNode(BackendContext context) + => new SwiftUINavigationSuiteNode( + new NavigationSuite(SelectedIndex, Items, new VStack(), railHeader: HeaderView), context); + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/DisplayLinkTicker.cs b/src/Comet/src/Comet/Platform/SwiftUI/DisplayLinkTicker.cs new file mode 100644 index 000000000..5c5a20d7c --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/DisplayLinkTicker.cs @@ -0,0 +1,84 @@ +#nullable enable +#if IOS +using System; +using System.Collections.Generic; +using CoreAnimation; +using Foundation; + +namespace Comet.Platform.SwiftUI +{ + /// + /// over — the + /// vsync frame pump that drives Comet's animation engine on the SwiftUI backend + /// (the iOS twin of the Compose ChoreographerTicker). The link is paused while no + /// one is subscribed. + /// + sealed class DisplayLinkTicker : Backend.ICometFrameTicker + { + readonly List> _subscribers = new(); + CADisplayLink? _link; + double _lastTimestamp; + + public IDisposable Subscribe(Action onFrame) + { + lock (_subscribers) + { + _subscribers.Add(onFrame); + if (_link is null) + { + _lastTimestamp = 0; + _link = CADisplayLink.Create(OnFrame); + _link.AddToRunLoop(NSRunLoop.Main, NSRunLoopMode.Common); + } + } + return new Subscription(this, onFrame); + } + + void OnFrame() + { + Action[] subscribers; + double timestamp = _link?.Timestamp ?? 0; + lock (_subscribers) + { + if (_subscribers.Count == 0) + { + _link?.Invalidate(); + _link = null; + return; + } + subscribers = _subscribers.ToArray(); + } + + var elapsed = _lastTimestamp == 0 + ? TimeSpan.Zero + : TimeSpan.FromSeconds(timestamp - _lastTimestamp); + _lastTimestamp = timestamp; + + foreach (var subscriber in subscribers) + subscriber(elapsed); + } + + sealed class Subscription : IDisposable + { + DisplayLinkTicker? _owner; + readonly Action _callback; + + public Subscription(DisplayLinkTicker owner, Action callback) + { + _owner = owner; + _callback = callback; + } + + public void Dispose() + { + var owner = _owner; + _owner = null; + if (owner is null) + return; + lock (owner._subscribers) + owner._subscribers.Remove(_callback); + } + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIAdaptiveNodes.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIAdaptiveNodes.cs new file mode 100644 index 000000000..24fdf661a --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIAdaptiveNodes.cs @@ -0,0 +1,535 @@ +#nullable enable +#if IOS +using System; +using System.Collections.Generic; +using Comet.Backend; +using Comet.Reactive; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.SwiftUI +{ + /// + /// Shared base for the iOS twins of the adaptive primitives: an own-content node that + /// hosts ONE Comet view composed from the control's current state and swaps the whole + /// subtree when that state changes — the ShowTop + /// idiom generalized. Per the iOS gate bar this is structure/values/interaction parity + /// (SwiftUI-native look), not cross-OS pixel identity: chrome is composed from existing + /// Comet views instead of binding new native widgets. + /// + abstract class SwiftUIHostedCompositionNode : ICometBackendNode, IBackendManagesOwnContent, ISwiftUINativeNode + { + protected readonly BackendContext Context; + readonly CometNode _native = CometSwiftUIHost.MakeNode("navigation"); + View? _shown; + Size _frameDp; + protected ICometEventSink? Sink; + + public CometNode Native => _native; + + protected SwiftUIHostedCompositionNode(BackendContext context) + { + Context = context; + // Reflow after every flush so nested content that changes measured size reflows + // (mirrors the Compose own-content nodes); unhooked in Dispose. + ReactiveScheduler.AfterFlush += Relayout; + } + + /// Compose the full subtree for the CURRENT control state. + protected abstract View BuildContent(); + + /// Re-point the node at a re-built owner view (hot reload / ancestor + /// refresh). Virtual so the interface member dispatches into subclasses — a plain + /// method on a subclass never gets called (the ICometBackendNode default no-op wins). + public virtual void OnOwnerViewChanged(View newView, bool isHotReload) { } + + /// Nodes materialized for the CURRENT hosted subtree; disposed on the next + /// swap so stale generations release their static hooks (AfterFlush, signals, metrics). + List? _generation; + + /// Rebuild + swap the hosted subtree (call from state patches). Flushes are + /// held for the duration: materializing the new subtree writes the environment, and an + /// inline flush's AfterFlush layout pass would re-arrange this node mid-build and + /// re-enter (double-built generations / the Reply detail-swap overflow class). + protected void Refresh() + { + using var hold = ReactiveScheduler.HoldFlushes(); + if (_shown is { } prev) + { + Comet.DevTools.CometDevRegistry.UnregisterSubtree(prev, includeRoot: true); + _shown = null; + } + DisposeGeneration(); + CometSwiftUIHost.ClearChildren(_native); + var view = BuildContent(); + var generation = new List(); + ISwiftUINativeNode node; + using (CometBackendBridge.CollectNodes(generation)) + node = (ISwiftUINativeNode)CometBackendBridge.Materialize(view, Context); + _generation = generation; + CometSwiftUIHost.InsertChild(_native, 0, node.Native); + _shown = view; + Relayout(); + } + + void DisposeGeneration() + { + if (_generation is not { } nodes) + return; + _generation = null; + foreach (var n in nodes) + n.Dispose(); + } + + /// Whether the hosted subtree has been built (Arrange builds lazily). + protected bool IsBuilt => _shown is not null; + + /// Build the hosted subtree if it doesn't exist yet. Nodes whose + /// reads a slot view's size must call this first — Yoga + /// measures BEFORE Arrange, and an un-materialized slot measures Size.Zero. + protected void EnsureContent() + { + if (_shown is null) + Refresh(); + } + + protected void Relayout() + { + if (_shown is not { } v) + return; + var size = _frameDp.Width > 0 ? _frameDp : ScreenDp(); + CometBackendLayoutEngine.Layout(v, size); + } + + protected static Size ScreenDp() + { + var b = UIKit.UIScreen.MainScreen.Bounds; + return new Size(b.Width, b.Height); + } + + public virtual void ApplyProperty(PropertyId id, in PropertyValue value) + { + // Honour the control's .Background(): the suite's safe-area strips paint with it + // (without this the strips showed the bare window — white above the content). + if (id == PropertyIds.BackgroundColor && value.AsColor is { } c) + CometSwiftUIHost.SetColor(_native, "background", + ((uint)(c.Alpha * 255) << 24) | ((uint)(c.Red * 255) << 16) | + ((uint)(c.Green * 255) << 8) | (uint)(c.Blue * 255)); + } + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + + public virtual Size Measure(double widthConstraint, double heightConstraint) + { + var screen = ScreenDp(); + double w = double.IsFinite(widthConstraint) && widthConstraint > 0 ? widthConstraint : screen.Width; + double h = double.IsFinite(heightConstraint) && heightConstraint > 0 ? heightConstraint : screen.Height; + return new Size(w, h); + } + + public void Arrange(Rect frame) + { + CometSwiftUIHost.SetFrame(_native, frame.X, frame.Y, frame.Width, frame.Height); + _frameDp = new Size(frame.Width, frame.Height); + if (_shown is null) + Refresh(); // controls that push no properties still need an initial build + else + Relayout(); + } + + public void SetEventSink(ICometEventSink? sink) => Sink = sink; + + public virtual void Dispose() + { + ReactiveScheduler.AfterFlush -= Relayout; + // Drop this generation's views from the dev registry — a disposed host's hosted + // subtree would otherwise linger as ghost elements the agent can still query. + if (_shown is { } shown) + { + Comet.DevTools.CometDevRegistry.UnregisterSubtree(shown, includeRoot: true); + _shown = null; + } + DisposeGeneration(); // cascades: a disposed host releases its current subtree too + } + } + + /// iOS ContentSwitcher: swap the active route subtree on index patches. + sealed class SwiftUIContentSwitcherNode : SwiftUIHostedCompositionNode + { + ContentSwitcher _switcher; + int _index; + + public SwiftUIContentSwitcherNode(ContentSwitcher switcher, BackendContext context) + : base(context) => _switcher = switcher; + + protected override View BuildContent() + { + var views = _switcher.Views; + return _index >= 0 && _index < views.Count ? views[_index] : new VStack(); + } + + bool _applied; + + public override void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.ContentSwitcher_Index) + { + base.ApplyProperty(id, in value); + return; + } + if (_applied && value.AsInt == _index) + return; // re-pushed unchanged (a re-materialize) — keep the built subtree + _applied = true; + _index = value.AsInt; + Refresh(); + } + } + + /// iOS ListDetail: two panes ≥840dp (side-by-side), else IsDetailOpen swaps + /// list ↔ detail (the detail supplies its own back affordance — no system BackHandler + /// on iOS). + sealed class SwiftUIListDetailNode : SwiftUIHostedCompositionNode + { + ListDetail _listDetail; + bool _open; + + public SwiftUIListDetailNode(ListDetail listDetail, BackendContext context) + : base(context) => _listDetail = listDetail; + + protected override View BuildContent() + { + bool twoPane = ListDetail.TwoPaneFor( + _listDetail.GetWindowMetrics().SizeDp.Peek() is { Width: > 0 } s ? s.Width : ScreenDp().Width); + if (twoPane) + // Split per the control's list fraction (flexGrow ratios) — keeps the iOS + // twin on the SAME split as ComposeListDetailNode (Reply 0.5, JetNews ~1/3). + return new HStack(spacing: (float)ListDetail.GapDp) + { + _listDetail.List.FlexGrow((float)_listDetail.ListFraction).FlexBasis(0), + _listDetail.Detail.FlexGrow((float)(1 - _listDetail.ListFraction)).FlexBasis(0), + }; + return _open ? _listDetail.Detail : _listDetail.List; + } + + bool _applied; + + public override void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.ListDetail_IsDetailOpen) + { + base.ApplyProperty(id, in value); + return; + } + if (_applied && value.AsBool == _open) + return; + _applied = true; + _open = value.AsBool; + Refresh(); + } + } + + /// iOS NavigationSuite: compact bottom bar (iPhone) / rail (wide) composed from + /// Comet views, safe-area aware; selection routes through . + /// The modal drawer and permanent-drawer variants are Android-first (the gold opens the + /// modal only from the rail; the drawer variant needs ≥1200dp). + sealed class SwiftUINavigationSuiteNode : SwiftUIHostedCompositionNode + { + const double BarHeight = 64; + NavigationSuite _suite; + int _selected; + + System.ComponentModel.PropertyChangedEventHandler? _metricsHandler; + Backend.CometWindowMetrics? _hookedMetrics; + Microsoft.Maui.Thickness _builtSafe; + Size _builtSize; + + public SwiftUINavigationSuiteNode(NavigationSuite suite, BackendContext context) + : base(context) + { + _suite = suite; + // The first BuildContent can run before the root's layout pass publishes the + // window size/safe area — refresh when they land (equality-gated on what the + // last build actually used). + _hookedMetrics = suite.GetWindowMetrics(); + _metricsHandler = (_, __) => ThreadHelper.RunOnMainThread(() => + { + var size = _hookedMetrics!.SizeDp.Peek(); + var safe = _hookedMetrics.SafeAreaDp.Peek(); + if (size != _builtSize || safe != _builtSafe) + Refresh(); + }); + _hookedMetrics.SizeDp.PropertyChanged += _metricsHandler; + _hookedMetrics.SafeAreaDp.PropertyChanged += _metricsHandler; + } + + public override void Dispose() + { + base.Dispose(); + if (_hookedMetrics is not null && _metricsHandler is not null) + { + _hookedMetrics.SizeDp.PropertyChanged -= _metricsHandler; + _hookedMetrics.SafeAreaDp.PropertyChanged -= _metricsHandler; + } + } + + protected override View BuildContent() + { + var metrics = _suite.GetWindowMetrics(); + var size = metrics.SizeDp.Peek() is { Width: > 0 } s ? s : ScreenDp(); + var safe = metrics.SafeAreaDp.Peek(); + _builtSize = metrics.SizeDp.Peek(); + _builtSafe = safe; + // The first build can run before the window exists (KeyWindow null → no inset + // dispatch yet); fall back to typical iPhone insets — the metrics hook refreshes + // with the real values the moment they publish. + if (safe.Top == 0 && safe.Bottom == 0) + safe = new Microsoft.Maui.Thickness(0, 59, 0, 34); + var variant = _suite.VariantForWindow(size.Width, size.Height); + // Publish the active variant (equality-gated) so content can adapt — + // mirrors ComposeNavigationSuiteNode.UpdateFromMetrics. + if (_suite.VariantSignal is { } vs && vs.Peek() != (int)variant) + vs.Value = (int)variant; + + View chromed; + if (variant == NavigationSuiteVariant.BottomBar) + chromed = new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)safe.Top).FlexShrink(0), + _suite.Content.FlexGrow(1).FlexBasis(0), + BottomBar((float)safe.Bottom).FlexShrink(0), + }; + else if (variant == NavigationSuiteVariant.None) + // Chromeless (JetNews compact): content fills; navigation via the modal drawer. + chromed = new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)safe.Top).FlexShrink(0), + _suite.Content.FlexGrow(1).FlexBasis(0), + }; + else + // Rail (and, until a dedicated variant lands, the ≥1200dp case too): left rail + // with the header + items, content beside it. + chromed = new HStack(spacing: 0f) + { + Rail((float)safe.Top).Frame(width: 80).FlexShrink(0), + new VStack(spacing: 0f) + { + new HStack().Frame(height: (float)safe.Top).FlexShrink(0), + _suite.Content.FlexGrow(1).FlexBasis(0), + }.FlexGrow(1).FlexBasis(0), + }; + + // Modal drawer wrap — chromeless variant only: the item views compose into the + // sheet here, and a view materializes into exactly ONE node per generation, so + // the sheet and a bar/rail can't share them. (The bar/rail variants never show + // a drawer in the golds this twin serves.) + if (variant == NavigationSuiteVariant.None && _suite.DrawerOpen is { } open) + chromed = new Drawer(open, DrawerSheet((float)safe.Top), chromed); + return chromed; + } + + // Hand-composed to the M3 NavigationDrawerItem metrics (56dp pill, r28, icon 24 + + // 12 gap, labelLarge) — the Android side renders the real widget. + View DrawerSheet(float safeTop) + { + var col = new VStack(spacing: 0f) { new HStack().Frame(height: safeTop).FlexShrink(0) }; + if (_suite.DrawerHeaderView is { } header) + col.Add(header.FlexShrink(0)); + for (int i = 0; i < _suite.Items.Count; i++) + { + int index = i; + var item = _suite.Items[i]; + var row = new HStack(spacing: 0f) + { + item.IconView.Margin(new Microsoft.Maui.Thickness(16, 0, 12, 0)) + .VerticalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Center) + .FlexShrink(0), + }; + if (item.LabelView is { } label) + row.Add(label.VerticalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Center)); + col.Add(row + .Frame(height: 56) + .Background(index == _selected ? PillColor : null) + .CornerRadius(28) + .Margin(new Microsoft.Maui.Thickness(12, index == 0 ? 0 : 4, 12, 0)) + .HorizontalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Fill) + .OnTap(_ => + { + _suite.SelectItem(index); + if (_suite.DrawerOpen is { } o) + o.Value = false; // gold closes on selection + })); + } + return col + .VerticalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Fill) + .Background(_suite.ContainerColor ?? Colors.White); + } + + // M3 chrome tokens from the control (surfaceContainer / secondaryContainer in the + // gold); neutral-overlay fallbacks when the sample didn't pass them. + Color BarColor => _suite.ContainerColor ?? Color.FromArgb("#14000000"); + Color PillColor => _suite.IndicatorColor ?? Color.FromArgb("#33000000"); + + View BottomBar(float safeBottom) + { + var row = new HStack(spacing: 0f); + for (int i = 0; i < _suite.Items.Count; i++) + { + int index = i; + var item = _suite.Items[i]; + var cell = new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + new HStack(spacing: 0f) + { + new HStack().FlexGrow(1), + // Center on the pill row's cross axis (default flex-start pinned + // the icon to the pill's top edge). + item.IconView.VerticalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .Frame(width: 64, height: 32) + .Background(index == _selected ? PillColor : null) + .CornerRadius(16) + // Center the pill in its quarter cell (a fixed-width child of a column + // otherwise sits at flex-start — the bar read flush-left on iOS). + .HorizontalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .FlexGrow(1).FlexBasis(0) + .OnTap(_ => _suite.SelectItem(index)); + row.Add(cell); + } + return new VStack(spacing: 0f) + { + row.Frame(height: (float)BarHeight), + new HStack().Frame(height: safeBottom), + }.Background(BarColor); + } + + View Rail(float safeTop) + { + var col = new VStack(spacing: 12f) { new HStack().Frame(height: safeTop) }; + if (_suite.RailHeaderView is { } header) + col.Add(header); + for (int i = 0; i < _suite.Items.Count; i++) + { + int index = i; + var pill = new HStack(spacing: 0f) + { + new HStack().FlexGrow(1), + _suite.Items[i].IconView.VerticalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .Frame(width: 56, height: 32) + .Background(index == _selected ? PillColor : null) + .CornerRadius(16) + .HorizontalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Center); + // M3 alwaysShowLabel=false: label under the icon only while selected (JetNews). + if (_suite.RailShowsSelectedLabel && index == _selected && _suite.Items[i].LabelView is { } label) + col.Add(new VStack(spacing: 4f) + { + pill, + label.HorizontalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Center), + }.OnTap(_ => _suite.SelectItem(index))); + else + col.Add(pill.OnTap(_ => _suite.SelectItem(index))); + } + return col.Background(BarColor); + } + + bool _applied; + + public override void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.Nav_SelectedIndex) + { + base.ApplyProperty(id, in value); + return; + } + if (_applied && value.AsInt == _selected) + return; + _applied = true; + _selected = value.AsInt; + Refresh(); + } + } + + /// iOS SearchBar: collapsed pill (placeholder + slots); tapping swaps to an + /// expanded pane — a real TextField bound to the Query signal above the results content; + /// its close affordance collapses. Values parity with the Android popup. + sealed class SwiftUISearchBarNode : SwiftUIHostedCompositionNode + { + Comet.SearchBar _bar; + System.ComponentModel.PropertyChangedEventHandler? _expandedHandler; + + public SwiftUISearchBarNode(Comet.SearchBar bar, BackendContext context) + : base(context) + { + _bar = bar; + // Expansion state lives on the CONTROL (survives node re-materialization from + // ancestor refreshes); the node just re-renders when it changes. + _expandedHandler = (_, __) => ThreadHelper.RunOnMainThread(Refresh); + _bar.Expanded.PropertyChanged += _expandedHandler; + } + + public override void Dispose() + { + base.Dispose(); + if (_expandedHandler is not null) + _bar.Expanded.PropertyChanged -= _expandedHandler; + } + + // In-flow footprint: the collapsed pill (56dp) or the expanded pane — NOT the base's + // fill size (a fill-measured bar starves its flex siblings of height). + public override Size Measure(double widthConstraint, double heightConstraint) + { + double w = double.IsFinite(widthConstraint) && widthConstraint > 0 ? widthConstraint : ScreenDp().Width; + return new Size(w, _bar.Expanded.Peek() ? 480 : 56); + } + + protected override View BuildContent() + { + // The M3 SearchBar container token (Android's widget themes itself); fall back to + // the old neutral overlay when the sample didn't pass one. + var container = _bar.ContainerColor ?? Color.FromArgb("#14000000"); + + if (!_bar.Expanded.Peek()) + { + // Children CENTER on the row's cross axis (Yoga's default is flex-start — + // the icon/placeholder/avatar sat pinned to the pill's top edge; the M3 + // input field centers its slots). + var center = Microsoft.Maui.Primitives.LayoutAlignment.Center; + var pill = new HStack(spacing: 12f) { }; + if (_bar.LeadingView is { } leading) + pill.Add(leading.FlexShrink(0).VerticalLayoutAlignment(center)); + pill.Add(_bar.PlaceholderView.FlexGrow(1).FlexBasis(0).VerticalLayoutAlignment(center)); + if (_bar.TrailingView is { } trailing) + pill.Add(trailing.FlexShrink(0).VerticalLayoutAlignment(center)); + return pill + .Padding(new Microsoft.Maui.Thickness(16, 0)) + .Frame(height: 56) + .Background(container) + .CornerRadius(28) + .OnTap(_ => _bar.Expanded.Value = true); + } + + var centerV = Microsoft.Maui.Primitives.LayoutAlignment.Center; + var field = SignalExtensions.TextField(_bar.Query, placeholder: "Search"); + return new VStack(spacing: 8f) + { + new HStack(spacing: 8f) + { + field.FlexGrow(1).FlexBasis(0).VerticalLayoutAlignment(centerV), + new Text("Close").FontSize(14) + .OnTap(_ => { _bar.Query.Value = ""; _bar.Expanded.Value = false; }) + .FlexShrink(0).VerticalLayoutAlignment(centerV), + }.Frame(height: 56), + _bar.ContentView.FlexGrow(1).FlexBasis(0), + } + .Background(container) + .CornerRadius(16); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIAlertDialogNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIAlertDialogNode.cs new file mode 100644 index 000000000..4b3b7ea26 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIAlertDialogNode.cs @@ -0,0 +1,47 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.SwiftUI +{ + /// Renders a Comet as a native SwiftUI .alert — + /// the iOS counterpart of Compose's Material AlertDialog. SwiftUI's .alert takes a + /// message string + simple buttons (not view slots), so the dialog's Text / first + /// ConfirmButton are flattened to the alert's message + button label. IsOpen drives + /// presentation; the button / scrim dismiss routes back as DialogDismissed. + sealed class SwiftUIAlertDialogNode : ICometBackendNode, IBackendManagesOwnContent, ISwiftUINativeNode + { + readonly CometNode _native; + ICometEventSink? _sink; + + public CometNode Native => _native; + + public SwiftUIAlertDialogNode(AlertDialog dialog) + { + _native = CometSwiftUIHost.MakeNode("alert"); + CometSwiftUIHost.SetString(_native, "dialogmessage", MessageOf(dialog.Text)); + CometSwiftUIHost.SetString(_native, "dialogbutton", LabelOf(dialog.ConfirmButton)); + CometSwiftUIHost.SetDialogDismissHandler(_native, () => _sink?.OnEvent(EventIds.DialogDismissed)); + } + + static string MessageOf(View view) => (view as Text)?.Value?.CurrentValue ?? string.Empty; + static string LabelOf(View view) => (view as Button)?.Text?.CurrentValue ?? "OK"; + + public void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Dialog_IsOpen) + CometSwiftUIHost.SetBool(_native, "dialogopen", value.AsBool); + } + + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + public Size Measure(double widthConstraint, double heightConstraint) => Size.Zero; + public void Arrange(Rect frame) { } + public void SetEventSink(ICometEventSink? sink) => _sink = sink; + public void Dispose() { } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIBackendRoot.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIBackendRoot.cs new file mode 100644 index 000000000..e220a6e98 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIBackendRoot.cs @@ -0,0 +1,78 @@ +#nullable enable +#if IOS +using System; +using Comet.Backend; +using Comet.SwiftUI.Interop; +using UIKit; + +namespace Comet.Platform.SwiftUI +{ + /// + /// Hosts a Comet view tree as a SwiftUI composition: materializes the view into the + /// retained tree and returns the hosting + /// (a UIHostingController) to set as the window root. + /// + public sealed class SwiftUIBackendRoot + { + readonly BackendContext _context; + View? _layoutRoot; + + public SwiftUIBackendRoot(IServiceProvider services) + => _context = new BackendContext(services); + + /// When true, the C# Yoga engine computes layout and the SwiftUI nodes are + /// positioned absolutely from the computed frames. Default false (native layout). + public bool UseYogaLayout { get; set; } + + public UIViewController CreateController(View view) + { + // Let the dev agent's /ui/screenshot endpoint snapshot the rendered SwiftUI window. + Comet.DevTools.CometDevRegistry.ScreenshotProvider = () => CometSwiftUIHost.ScreenshotPng()?.ToArray(); + + // Drive Comet's animation engine (view.Animate/FadeTo/…) from CADisplayLink — + // the iOS twin of the Compose backend's ChoreographerTicker. + Backend.CometAnimationDriver.Initialize(new DisplayLinkTicker()); + + var root = (ISwiftUINativeNode)CometBackendBridge.Materialize(view, _context); + var controller = CometSwiftUIHost.HostController(root.Native); + + if (UseYogaLayout) + { + _logicalRoot = view; + RunLayout(); + // Reflow after each reactive flush: content whose size changed (e.g. a bound + // label) is re-measured and the tree re-arranged. + Comet.Reactive.ReactiveScheduler.AfterFlush += RunLayout; + } + + return controller; + } + + /// The logical root: the layout target is re-resolved per pass because a + /// (hot) reload rebuilds the view tree (read-only BuiltView — building views on a + /// background flush thread is not safe). The iOS twin of the ComposeBackendRoot fix. + View? _logicalRoot; + + void RunLayout() + { + _layoutRoot = _logicalRoot?.BuiltView ?? _logicalRoot; + if (_layoutRoot is null) + return; + var bounds = UIScreen.MainScreen.Bounds; + var size = new Microsoft.Maui.Graphics.Size(bounds.Width, bounds.Height); + // Per-root reactive window contract (adaptive size-class UI). Screen bounds are + // only re-read per layout pass — real resize observation (viewWillTransition / + // scene geometry) lands with the first adaptive iOS sample. + CometWindowMetrics.Shared.Update(size); + // Safe area from the key window (notch / home indicator), same per-pass cadence. + if (UIApplication.SharedApplication.KeyWindow is { } window) + { + var sa = window.SafeAreaInsets; + CometWindowMetrics.Shared.UpdateSafeArea(new Microsoft.Maui.Thickness( + sa.Left, sa.Top, sa.Right, sa.Bottom)); + } + CometBackendLayoutEngine.Layout(_layoutRoot, size); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIDrawerNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIDrawerNode.cs new file mode 100644 index 000000000..58013a676 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIDrawerNode.cs @@ -0,0 +1,66 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; +using UIKit; + +namespace Comet.Platform.SwiftUI +{ + /// Renders a Comet as a SwiftUI sliding panel (the iOS + /// counterpart of ; SwiftUI has no built-in modal nav drawer, so + /// the shim composes content + scrim + a left-edge panel). Owns its two children + /// () and lays each out with the shared Yoga engine — + /// content at full screen, the panel at the sheet width. + sealed class SwiftUIDrawerNode : ICometBackendNode, IBackendManagesOwnContent, ISwiftUINativeNode + { + readonly Drawer _drawer; + readonly BackendContext _context; + readonly CometNode _native; + ICometEventSink? _sink; + + public CometNode Native => _native; + + public SwiftUIDrawerNode(Drawer drawer, BackendContext context) + { + _drawer = drawer; + _context = context; + _native = CometSwiftUIHost.MakeNode("drawer"); + CometSwiftUIHost.SetTapHandler(_native, OnDismiss); // scrim tap closes the drawer + BuildContent(); + } + + void OnDismiss() => _sink?.OnEvent(EventIds.DrawerClosed); + + void BuildContent() + { + var content = (ISwiftUINativeNode)CometBackendBridge.Materialize(_drawer.Content, _context, _drawer); + var side = (ISwiftUINativeNode)CometBackendBridge.Materialize(_drawer.Side, _context, _drawer); + CometSwiftUIHost.InsertChild(_native, 0, content.Native); + CometSwiftUIHost.InsertChild(_native, 1, side.Native); + + var b = UIScreen.MainScreen.Bounds; + double w = b.Width, h = b.Height; + double panel = System.Math.Min(320, w * 0.85); + CometBackendLayoutEngine.Layout(_drawer.Content, new Size(w, h)); + CometBackendLayoutEngine.Layout(_drawer.Side, new Size(panel, h)); + } + + public void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Drawer_IsOpen) + CometSwiftUIHost.SetBool(_native, "draweropen", value.AsBool); + } + + public void SetEventSink(ICometEventSink? sink) => _sink = sink; + + // Content is managed internally. + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + public Size Measure(double widthConstraint, double heightConstraint) => Size.Zero; + public void Arrange(Rect frame) { } + public void Dispose() { } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIFabNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIFabNode.cs new file mode 100644 index 000000000..b4eeab8d4 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIFabNode.cs @@ -0,0 +1,157 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.SwiftUI +{ + /// iOS has no Material FAB, so the native idiom for a floating action button is an + /// icon + label row with a capsule background and a tap, composed from native SwiftUI primitives + /// (the "fab" shim kind). Owns its content; positions + sizes itself from the Yoga frame + /// ( reports the content's intrinsic size so the parent can corner-pin it, + /// pushes the frame down). Content colour is applied to the icon/label + /// directly since SwiftUI has no LocalContentColor inheritance from the capsule. + /// drives fabExtended on the native node so the + /// shim can animate label show/hide. + sealed class SwiftUIFabNode : ICometBackendNode, IBackendManagesOwnContent, ISwiftUINativeNode + { + // FAB content insets (matches the "fab" shim render: .padding(.horizontal, 16) + HStack spacing 8). + const double PadH = 16, Gap = 8; + + readonly Fab _fab; + readonly BackendContext _context; + readonly CometNode _native; + ICometEventSink? _sink; + + public CometNode Native => _native; + + bool _extended; + Rect _frame; + + public SwiftUIFabNode(Fab fab, BackendContext context) + { + _fab = fab; + _context = context; + _native = CometSwiftUIHost.MakeNode("fab"); + CometSwiftUIHost.SetTapHandler(_native, () => _sink?.OnEvent(EventIds.Clicked)); + BuildContent(); + + // Subscribe to the reactive extended signal so the shim can animate the label — + // AND contract the frame itself (see ApplyExtendedFrame): the shim only hides the + // label; the capsule keeps whatever frame we set. + if (fab.ExtendedSignal is { } sig) + { + _extended = sig.Peek(); + CometSwiftUIHost.SetBool(_native, "fabextended", _extended); + sig.PropertyChanged += (_, __) => + { + _extended = sig.Peek(); + ThreadHelper.RunOnMainThread(() => + { + CometSwiftUIHost.SetBool(_native, "fabextended", _extended); + ApplyExtendedFrame(); + }); + }; + } + else + { + // No reactive signal → use the static extended value. + _extended = fab.Extended; + CometSwiftUIHost.SetBool(_native, "fabextended", _extended); + } + } + + void BuildContent() + { + var icon = (ISwiftUINativeNode)CometBackendBridge.Materialize(_fab.IconView, _context, _fab); + var label = (ISwiftUINativeNode)CometBackendBridge.Materialize(_fab.LabelView, _context, _fab); + CometSwiftUIHost.InsertChild(_native, 0, icon.Native); + CometSwiftUIHost.InsertChild(_native, 1, label.Native); + + // The slots materialize INHERITING the FAB's env opacity — and the JumpToBottom FAB starts at + // Opacity(0) (hidden until scrolled away), so without this the icon/label stay at opacity 0 even + // after the FAB reactively fades in: the capsule shows but the label is invisible. Reset them to + // fully opaque — the FAB container's own opacity controls visibility. The iOS twin of the + // ComposeFabNode slot-opacity-inheritance fix. (Now reachable because iOS honours Opacity.) + CometSwiftUIHost.SetDouble(icon.Native, "opacity", 1.0); + CometSwiftUIHost.SetDouble(label.Native, "opacity", 1.0); + + if (_fab.ContainerColor is { } c) + CometSwiftUIHost.SetColor(_native, "background", ToArgb(c)); + + // The icon/label inherit the FAB content colour (no SwiftUI LocalContentColor): tint both. + if (_fab.ContentColor is { } fc) + { + CometSwiftUIHost.SetColor(icon.Native, "textcolor", ToArgb(fc)); + CometSwiftUIHost.SetColor(label.Native, "textcolor", ToArgb(fc)); + } + + // M3 extended-FAB shape: CornerLarge = 16dp rounded rect (NOT a capsule) — what the + // real ExtendedFloatingActionButton renders on Android, in both states. + const float r = 16f; + CometSwiftUIHost.SetDouble(_native, "corner.tl", r); + CometSwiftUIHost.SetDouble(_native, "corner.tr", r); + CometSwiftUIHost.SetDouble(_native, "corner.br", r); + CometSwiftUIHost.SetDouble(_native, "corner.bl", r); + CometSwiftUIHost.SetDouble(_native, "padding", PadH); + } + + static uint ToArgb(Color c) => + ((uint)(c.Alpha * 255) << 24) | ((uint)(c.Red * 255) << 16) | + ((uint)(c.Green * 255) << 8) | (uint)(c.Blue * 255); + + public void ApplyProperty(PropertyId id, in PropertyValue value) + { + // Honour Opacity / IsVisible so the JumpToBottom FAB can actually hide: its visibility is + // driven by fab.Opacity(0/1) off the list's ScrolledAway signal. Without this the capsule's + // own opacity is never applied and the FAB stays permanently visible. + if (id == PropertyIds.Opacity) + CometSwiftUIHost.SetDouble(_native, "opacity", value.AsDouble); + else if (id == PropertyIds.IsVisible) + CometSwiftUIHost.SetBool(_native, "isvisible", value.AsBool); + } + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + + // Intrinsic size for the parent's Yoga layout: the real content (icon/label measured via + // SwiftUI sizeThatFits) + the FAB's content insets. Height is the gold's pinned value. + // Always measures the full extended width so the parent's Yoga frame can corner-pin the + // FAB; the FAB visually contracts on iOS by hiding the label. + public Size Measure(double widthConstraint, double heightConstraint) + { + var icon = CometBackendLayoutEngine.Measure(_fab.IconView); + var label = CometBackendLayoutEngine.Measure(_fab.LabelView); + // Round the content width up: the shim renders the label single-line (.fixedSize), so any + // sub-pixel shortfall here would clip the last glyph against the capsule. + var width = System.Math.Ceiling(PadH * 2 + icon.Width + Gap + label.Width); + return new Size(width, _fab.Height); + } + + public void Arrange(Rect frame) + { + _frame = frame; + ApplyExtendedFrame(); + } + + // Yoga computed the frame for the EXTENDED (wide) FAB; when contracted, shrink to a + // square of Height dp and shift right by the width difference so the RIGHT edge stays + // pinned — the iOS twin of ComposeFabNode's adjustedX (BottomEnd alignment preserved + // in both states; the real M3 widget self-sizes the same way on Android). + void ApplyExtendedFrame() + { + if (_frame.Width <= 0) + return; + if (_extended) + CometSwiftUIHost.SetFrame(_native, _frame.X, _frame.Y, _frame.Width, _frame.Height); + else + CometSwiftUIHost.SetFrame(_native, + _frame.X + _frame.Width - _fab.Height, _frame.Y, _fab.Height, _fab.Height); + } + + public void SetEventSink(ICometEventSink? sink) => _sink = sink; + public void Dispose() { } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIFilterChipNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIFilterChipNode.cs new file mode 100644 index 000000000..d6f9c351b --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIFilterChipNode.cs @@ -0,0 +1,88 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Microsoft.Maui; +using Microsoft.Maui.Primitives; + +namespace Comet.Platform.SwiftUI +{ + /// The iOS twin of : Comet + /// hand-composed to the M3 chip metrics (32dp pill, + /// selected = filled + check, unselected = outlined) — Android drives the real + /// FilterChip, which self-themes; the twin takes the M3 defaults. + sealed class SwiftUIFilterChipNode : SwiftUIHostedCompositionNode + { + const float HeightDp = 32f; + + FilterChip _control; + bool _selected; + bool _applied; + + public SwiftUIFilterChipNode(FilterChip control, BackendContext context) + : base(context) + { + _control = control; + _selected = control.IsSelected; + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is FilterChip control) + { + _control = control; + _selected = control.IsSelected; + if (IsBuilt) + Refresh(); // slot views were rebuilt with the owner — swap the subtree + } + } + + public override Size Measure(double widthConstraint, double heightConstraint) + { + // Yoga measures before Arrange builds the subtree — an un-materialized label + // measures Size.Zero (the chips collapsed to bare 32dp pills without this). + EnsureContent(); + var label = CometBackendLayoutEngine.Measure(_control.LabelView); + // Mirror BuildContent: the check replaces the leading icon when selected. + double lead = _selected || _control.LeadingIconView is not null ? 26 : 0; + return new(label.Width + 32 + lead, HeightDp); + } + + protected override View BuildContent() + { + var row = new HStack(spacing: 8f); + if (_selected) + row.Add(new Icon("check").IconSize(18) + .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0)); + else if (_control.LeadingIconView is { } leading) + row.Add(leading.VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0)); + row.Add(_control.LabelView.VerticalLayoutAlignment(LayoutAlignment.Center)); + + var pill = row + .Frame(height: HeightDp) + .Padding(new Thickness(16, 0, 16, 0)) + .CornerRadius(8) + .OnTap(_ => _control.OnClick()); + return _selected + ? pill + : pill.Border(1, Microsoft.Maui.Graphics.Colors.Gray); + } + + public override void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.Toggle_IsOn) + { + base.ApplyProperty(id, in value); + return; + } + if (_applied && value.AsBool == _selected) + return; + _applied = true; + _selected = value.AsBool; + // Rebuild the hosted subtree — the fill/check/outline all branch on _selected, + // and only Refresh() re-runs BuildContent (the sibling hosted nodes' contract). + if (IsBuilt) + Refresh(); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIIconButtonNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIIconButtonNode.cs new file mode 100644 index 000000000..4e01af118 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIIconButtonNode.cs @@ -0,0 +1,47 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Microsoft.Maui; +using Microsoft.Maui.Primitives; + +namespace Comet.Platform.SwiftUI +{ + /// The iOS twin of : Comet + /// hand-composed to the M3 metrics (48dp tap + /// target, centered icon slot) — Android drives the real IconButton. + sealed class SwiftUIIconButtonNode : SwiftUIHostedCompositionNode + { + const float TargetDp = 48f; + + IconButton _control; + + public SwiftUIIconButtonNode(IconButton control, BackendContext context) + : base(context) + { + _control = control; + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is IconButton control) + { + _control = control; + if (IsBuilt) + Refresh(); // the icon slot view was rebuilt with the owner + } + } + + public override Size Measure(double widthConstraint, double heightConstraint) + => new(TargetDp, TargetDp); + + protected override View BuildContent() => new HStack(spacing: 0f) + { + new HStack().FlexGrow(1), + _control.IconView.VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .Frame(width: TargetDp, height: TargetDp) + .OnTap(_ => _control.OnClick()); + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIIconToggleNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIIconToggleNode.cs new file mode 100644 index 000000000..94493ca55 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIIconToggleNode.cs @@ -0,0 +1,62 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Microsoft.Maui; +using Microsoft.Maui.Primitives; + +namespace Comet.Platform.SwiftUI +{ + /// The iOS twin of : Comet + /// hand-composed to the M3 metrics (48dp tap + /// target, centered icon slot) — Android drives the real IconToggleButton. + sealed class SwiftUIIconToggleNode : SwiftUIHostedCompositionNode + { + const float TargetDp = 48f; + + IconToggleButton _control; + bool _checked; + bool _applied; + + public SwiftUIIconToggleNode(IconToggleButton control, BackendContext context) + : base(context) + { + _control = control; + _checked = control.IsChecked; + } + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is IconToggleButton control) + { + _control = control; + _checked = control.IsChecked; + } + } + + public override Size Measure(double widthConstraint, double heightConstraint) + => new(TargetDp, TargetDp); + + protected override View BuildContent() => new HStack(spacing: 0f) + { + new HStack().FlexGrow(1), + _control.IconView.VerticalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + } + .Frame(width: TargetDp, height: TargetDp) + .OnTap(_ => _control.OnChange(!_checked)); + + public override void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.Toggle_IsOn) + { + base.ApplyProperty(id, in value); + return; + } + if (_applied && value.AsBool == _checked) + return; + _applied = true; + _checked = value.AsBool; + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIListNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIListNode.cs new file mode 100644 index 000000000..fddd3ce45 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIListNode.cs @@ -0,0 +1,216 @@ +#nullable enable +#if IOS +using System.Collections.Generic; +using Comet.Backend; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.SwiftUI +{ + /// + /// Renders a Comet ListView as a SwiftUI List (the iOS counterpart of + /// ComposeListNode). Owns its rows (so it implements + /// ): on a data-version change it materializes + /// each row's template view into a child node of the native "list" node, which the + /// shim renders via List { ForEach … } (SwiftUI lazily realizes row views). + /// + sealed class SwiftUIListNode : ICometBackendNode, IBackendManagesOwnContent, ISwiftUINativeNode + { + readonly IListView _list; + readonly BackendContext _context; + readonly CometNode _native; + readonly List _rows = new(); + double _width; + + public CometNode Native => _native; + + public SwiftUIListNode(IListView list, BackendContext context) + { + _list = list; + _context = context; + _native = CometSwiftUIHost.MakeNode("list"); + if (_list.Horizontal) + CometSwiftUIHost.SetBool(_native, "horizontal", true); + + // Drive ScrollToBottom (JumpToBottom FAB / after-send) through the native + // ScrollViewReader — the iOS counterpart of the Compose LazyListState scroller. + _list.RegisterScroller(() => CometSwiftUIHost.ScrollToBottom(_native)); + + // The shim reports last-row visibility (0 = newest on screen / at bottom, 1 = scrolled away); + // mirror it onto IListView.ScrolledAway so the JumpToBottom FAB shows/hides on scroll — the + // iOS counterpart of ComposeListNode's snapshotFlow(CanScrollBackward). Also remember the + // latest value so the initial seed can stop as soon as it lands (and not fight a user scroll). + CometSwiftUIHost.SetScrollHandler(_native, away => + { + _lastAway = away; + _list.ScrolledAway.Value = away > 0.5; + }); + + // Top-relative twin (first-row visibility) → ScrolledFromTop, the iOS counterpart + // of ComposeListNode's CanScrollBackward flow (Reply's ExtendedFAB collapse). + CometSwiftUIHost.SetScrollTopHandler(_native, away => + _list.ScrolledFromTop.Value = away > 0.5); + + // Scroll DIRECTION → LastScrolledBackward (the gold FAB re-expands on any upward + // scroll). The shim reports each row's (index, visible) as rows realize; the + // minimum visible index moving DOWN means the viewport moved toward the start. + CometSwiftUIHost.SetRowVisibilityHandler(_native, (index, visible) => + { + int i = (int)index; + if (visible > 0.5) _visibleRows.Add(i); else _visibleRows.Remove(i); + if (_visibleRows.Count == 0) + return; + int min = int.MaxValue; + foreach (var r in _visibleRows) + if (r < min) min = r; + if (min != _lastMinVisible) + { + // Equal-min churn (rows realizing at the bottom) leaves the signal alone. + _list.LastScrolledBackward.Value = min < _lastMinVisible; + _lastMinVisible = min; + } + }); + } + + public void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.List_Version) + Rebuild(); + } + + // Nodes materialized for the current row set; disposed on the next rebuild so stale + // row generations release any static hooks. + List? _rowGeneration; + + void Rebuild() + { + // Hold flushes: row templates carry modifiers (environment writes) — an inline + // flush mid-rebuild re-arranges ancestors around a half-built row set. + using var hold = Comet.Reactive.ReactiveScheduler.HoldFlushes(); + // Drop the previous rows from the dev tree (they register under the ListView). + if (_list is View listView) + Comet.DevTools.CometDevRegistry.UnregisterSubtree(listView, includeRoot: false); + + if (_rowGeneration is { } stale) + { + _rowGeneration = null; + foreach (var n in stale) + n.Dispose(); + } + + CometSwiftUIHost.ClearChildren(_native); + _rows.Clear(); + _visibleRows.Clear(); + _lastMinVisible = int.MaxValue; + int count = _list.Sections() > 0 ? _list.Rows(0) : 0; + var generation = new List(); + using (var scope = CometBackendBridge.CollectNodes(generation)) + { + for (int i = 0; i < count; i++) + { + var view = _list.ViewFor(0, i); + var node = (ISwiftUINativeNode)CometBackendBridge.Materialize(view, _context, _list as View); + CometSwiftUIHost.InsertChild(_native, i, node.Native); + _rows.Add(view); + LayoutRow(view); // no-op until the list has been arranged (width known) + } + } + _rowGeneration = generation; + } + + // Lay each row out to the list's arranged width with the shared Yoga engine, height-wrapped, + // so rows render identically to the Compose backend (avatar + author + wrapping body). Each + // row's nodes self-position from the frames this pushes; the row root self-sizes for the List. + void LayoutRow(View row) + { + if (_width <= 0) + return; + // Horizontal rows lay at their intrinsic width (the ComposeListNode LazyRow + // branch does the same); vertical rows fill the list width. + if (_list.Horizontal) + CometBackendLayoutEngine.LayoutContent(row, CometBackendLayoutEngine.Measure(row).Width); + else + CometBackendLayoutEngine.LayoutContent(row, _width); + } + + // The node manages its own rows; the generic child API is unused. + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + public Size Measure(double widthConstraint, double heightConstraint) => Size.Zero; + + public void Arrange(Rect frame) + { + // Position + size the List from its Yoga frame (below the top bar, filling the rest). + CometSwiftUIHost.SetFrame(_native, frame.X, frame.Y, frame.Width, frame.Height); + + // First time we learn our width (or it changed), (re)lay the rows out to it. + if (frame.Width > 0 && System.Math.Abs(frame.Width - _width) > 0.5) + { + _width = frame.Width; + foreach (var row in _rows) + LayoutRow(row); + + // AnchorBottom (chat log): open at the newest message — the iOS twin of + // ComposeListNode's one-shot ScrollToItem(last) seed. Nudged a few times + // because a ScrollViewReader scroll to a far target undershoots while rows + // are still realizing. Ordinary lists (inbox) open at the top. + if (_list.AnchorBottom && !_seededToNewest && _rows.Count > 0) + { + _seededToNewest = true; + SeedToNewest(); + } + } + } + + bool _seededToNewest; + bool _disposed; + readonly HashSet _visibleRows = new(); + int _lastMinVisible = int.MaxValue; + double _lastAway = 1; // 1 = not at bottom yet + + // Seed the list at the newest message (iOS twin of ComposeListNode's one-shot + // ScrollToItem(last)). A ScrollViewReader scroll to a far target undershoots while rows + // are still realizing, so nudge a few times — but STOP as soon as the shim reports we've + // landed at the bottom (_lastAway low) or the node is torn down, so it doesn't re-scroll + // redundantly or yank a user who scrolled up during the window. + async void SeedToNewest() + { + try + { + foreach (var delay in new[] { 350, 900, 1700 }) + { + await System.Threading.Tasks.Task.Delay(delay); + if (_disposed || _lastAway <= 0.5) + return; + ThreadHelper.RunOnMainThread(() => + { + if (!_disposed) + CometSwiftUIHost.ScrollToBottom(_native); + }); + } + } + catch (System.Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[SwiftUIListNode] seed-scroll failed: {ex.Message}"); + } + } + + public void SetEventSink(ICometEventSink? sink) { } + public void Dispose() + { + _disposed = true; + // Drop this node's rows from the dev registry (runs BEFORE a successor list node + // re-registers fresh rows, so only the stale generation is pruned). + if (_list is View listView) + Comet.DevTools.CometDevRegistry.UnregisterSubtree(listView, includeRoot: false); + if (_rowGeneration is { } rows) + { + _rowGeneration = null; + foreach (var n in rows) + n.Dispose(); + } + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUINavigationNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUINavigationNode.cs new file mode 100644 index 000000000..2ec457817 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUINavigationNode.cs @@ -0,0 +1,139 @@ +#nullable enable +#if IOS +using System.Collections.Generic; +using Comet.Backend; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; +using UIKit; + +namespace Comet.Platform.SwiftUI +{ + /// + /// Renders a Comet NavigationView as a SwiftUI navigation stack (the iOS + /// counterpart of ComposeNavigationNode): the C# side owns the screen stack, and + /// the top screen is materialized as this node's single child, which the shim's + /// "navigation" kind renders. Navigate pushes and Pop pops, re-rendering. + /// + sealed class SwiftUINavigationNode : ICometBackendNode, IBackendManagesOwnContent, ISwiftUINativeNode + { + readonly BackendContext _context; + readonly NavigationView _nav; + readonly CometNode _native; + readonly List _stack = new(); + View? _shown; + + public CometNode Native => _native; + + public SwiftUINavigationNode(NavigationView nav, BackendContext context) + { + _context = context; + _nav = nav; + _native = CometSwiftUIHost.MakeNode("navigation"); + + if (nav.Content is { } root) + _stack.Add(root); + + nav.SetPerformNavigate(view => + { + _stack.Add(view); + ShowTop(); + }); + nav.SetPerformPop(() => + { + if (_stack.Count > 1) + { + _stack.RemoveAt(_stack.Count - 1); + ShowTop(); + } + }); + + // Shrink the laid-out height when the keyboard is up so the footer rises above it + // (SwiftUI's auto-avoidance doesn't reach our absolute-positioned nodes). + CometSwiftUIKeyboard.EnsureStarted(); + CometSwiftUIKeyboard.Changed += RelayoutTop; + + // Re-lay-out the top screen after every reactive flush, so a nested own-content node that + // changes its measured height (e.g. the input-selector panel growing the footer / shrinking the + // message list) reflows. The global root reflow only re-lays the top-level layout root, not this + // nav's owned subtree — mirrors ComposeNavigationNode's AfterFlush hook (the P3 discovery). + Comet.Reactive.ReactiveScheduler.AfterFlush += RelayoutTop; + + ShowTop(); + } + + // Lay the current top screen out to the keyboard-adjusted height (full width, height minus + // whatever the keyboard covers) — the single place the screen's available height is decided. + void RelayoutTop() + { + if (_shown is not { } top) + return; + var b = UIScreen.MainScreen.Bounds; + CometBackendLayoutEngine.Layout(top, new Size(b.Width, b.Height - CometSwiftUIKeyboard.Inset)); + } + + // Nodes materialized for the current top screen; disposed on the next swap so a + // popped/replaced screen's nodes release their static hooks. + List? _generation; + + void ShowTop() + { + // Hold flushes so the screen swap is atomic (see SwiftUIHostedCompositionNode.Refresh). + using var hold = Comet.Reactive.ReactiveScheduler.HoldFlushes(); + // Drop the previous screen's nodes from the dev tree before swapping it out. + if (_shown is { } prev) + { + Comet.DevTools.CometDevRegistry.UnregisterSubtree(prev, includeRoot: true); + _shown = null; + } + if (_generation is { } stale) + { + _generation = null; + foreach (var n in stale) + n.Dispose(); + } + + CometSwiftUIHost.ClearChildren(_native); + if (_stack.Count == 0) + return; + var top = _stack[_stack.Count - 1]; + // Register the screen under the NavigationView so the dev tree nests correctly. + var generation = new List(); + ISwiftUINativeNode node; + using (CometBackendBridge.CollectNodes(generation)) + node = (ISwiftUINativeNode)CometBackendBridge.Materialize(top, _context, _nav); + _generation = generation; + CometSwiftUIHost.InsertChild(_native, 0, node.Native); + _shown = top; + + // Lay the screen out with the shared Yoga engine (mirrors ComposeNavigationNode): without + // this the screen's whole subtree has no Yoga frame and falls back to native SwiftUI layout + // (which centers on the cross axis). Height is keyboard-adjusted so the footer stays visible. + RelayoutTop(); + } + + public void ApplyProperty(PropertyId id, in PropertyValue value) { } + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + public Size Measure(double widthConstraint, double heightConstraint) => Size.Zero; + public void Arrange(Rect frame) { } + public void SetEventSink(ICometEventSink? sink) { } + public void Dispose() + { + Comet.Reactive.ReactiveScheduler.AfterFlush -= RelayoutTop; + CometSwiftUIKeyboard.Changed -= RelayoutTop; + if (_shown is { } shown) + { + Comet.DevTools.CometDevRegistry.UnregisterSubtree(shown, includeRoot: true); + _shown = null; + } + if (_generation is { } nodes) + { + _generation = null; + foreach (var n in nodes) + n.Dispose(); + } + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUINode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUINode.cs new file mode 100644 index 000000000..c49e42384 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUINode.cs @@ -0,0 +1,238 @@ +#nullable enable +#if IOS +using System.Collections.Generic; +using Comet.Backend; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.SwiftUI +{ + /// A backend node backed by a Swift . Lets nodes of + /// different C# types (generic nodes, the list node) nest and expose their native handle. + interface ISwiftUINativeNode + { + CometNode Native { get; } + } + + /// + /// A retained backend node that bridges Comet's diff to a Swift + /// in the SwiftUI tree (the iOS counterpart of the Compose ComposeNode). Unlike + /// the Compose backend — which needs a distinct class per control — the SwiftUI shim is + /// kind-driven, so one node type parameterized by a kind string suffices. + /// + sealed class SwiftUINode : ICometBackendNode, ISwiftUINativeNode + { + readonly CometNode _native; + readonly List _children = new(); + readonly string _kind; + ICometEventSink? _sink; + + // Tracked so MeasureBaseline can compute the first-baseline offset from UIFont + // metrics (the iOS counterpart of ComposeTextNode's TextMeasurer baseline). + double? _fontSize; + string? _fontFamily; + + public CometNode Native => _native; + + public SwiftUINode(string kind) + { + _kind = kind; + _native = CometSwiftUIHost.MakeNode(kind); + // Route native events back through the event sink. Harmless for kinds that + // don't raise a given event (their handler simply never fires). + CometSwiftUIHost.SetTapHandler(_native, OnNativeTap); + CometSwiftUIHost.SetTapGestureHandler(_native, OnNativeTapGesture); + CometSwiftUIHost.SetLongPressGestureHandler(_native, OnNativeLongPress); + CometSwiftUIHost.SetStringChangeHandler(_native, OnNativeTextChanged); + CometSwiftUIHost.SetBoolChangeHandler(_native, OnNativeToggled); + CometSwiftUIHost.SetDoubleChangeHandler(_native, OnNativeValueChanged); + CometSwiftUIHost.SetFocusHandler(_native, OnNativeFocused); + } + + void OnNativeTap() => _sink?.OnEvent(EventIds.Clicked); + void OnNativeTapGesture() => _sink?.OnGesture(GestureKind.Tap, new GestureData(GestureState.Ended, default)); + void OnNativeLongPress() => _sink?.OnGesture(GestureKind.LongPress, new GestureData(GestureState.Ended, default)); + void OnNativeTextChanged(string s) => _sink?.OnEvent(EventIds.TextChanged, s); + // TextField gained focus (gold onTextFieldFocused) → e.g. the composer closes an open selector panel. + void OnNativeFocused() => _sink?.OnEvent(EventIds.Focused); + void OnNativeToggled(bool b) => _sink?.OnEvent(EventIds.Toggled, b); + void OnNativeValueChanged(double d) => _sink?.OnEvent(EventIds.ValueChanged, d); + + public void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.Text_Value || id == PropertyIds.Button_Text || id == PropertyIds.TextField_Text) + CometSwiftUIHost.SetString(_native, "text", value.AsString ?? string.Empty); + else if (id == PropertyIds.Text_Runs && value.AsObject is System.Collections.Generic.IReadOnlyList runs) + { + // FormattedText: push the styled runs so the shim renders per-run colour/monospace/ + // underline; also set the flattened text as a measure baseline + fallback if runs are empty. + CometSwiftUIHost.SetString(_native, "text", string.Concat(System.Linq.Enumerable.Select(runs, r => r.Text))); + CometSwiftUIHost.ClearTextRuns(_native); + foreach (var r in runs) + CometSwiftUIHost.AddTextRun(_native, r.Text ?? string.Empty, + r.Color is { } c ? ToArgb(c) : 0, r.Color is not null, + r.Monospace, + r.Background is { } bg ? ToArgb(bg) : 0, r.Background is not null, + r.Underline); + } + else if (id == PropertyIds.TextField_Placeholder) + CometSwiftUIHost.SetString(_native, "placeholder", value.AsString ?? string.Empty); + else if (id == PropertyIds.Image_Source) + CometSwiftUIHost.SetString(_native, "imageurl", value.AsString ?? string.Empty); + else if (id == PropertyIds.Icon_Symbol) + CometSwiftUIHost.SetString(_native, "icon", value.AsString ?? string.Empty); + else if (id == PropertyIds.Icon_Glyph) + CometSwiftUIHost.SetString(_native, "iconglyph", value.AsString ?? string.Empty); + else if (id == PropertyIds.Icon_FontFamily) + CometSwiftUIHost.SetString(_native, "iconfontfamily", value.AsString ?? string.Empty); + else if (id == PropertyIds.Icon_Tint && value.AsColor is { } it) + CometSwiftUIHost.SetColor(_native, "textcolor", ToArgb(it)); + else if (id == PropertyIds.Icon_Size) + CometSwiftUIHost.SetDouble(_native, "fontsize", value.AsDouble); + else if (id == PropertyIds.Icon_FillFrame) + CometSwiftUIHost.SetBool(_native, "iconfillframe", value.AsBool); + else if (id == PropertyIds.Text_Italic) + CometSwiftUIHost.SetBool(_native, "fontitalic", value.AsBool); + else if (id == PropertyIds.Container_Card && value.AsBool) + // The iOS twin has no Card widget: give the hand-composed card the M3 + // resting elevation the real Android Card self-themes (was a Shadow token + // before the AsCard promotion — this keeps the iOS render elevated). + CometSwiftUIHost.SetDouble(_native, "elevation", 1); + else if (id == PropertyIds.GradientBackground && value.AsObject is GradientSpec spec) + { + CometSwiftUIHost.ClearGradientStops(_native); + foreach (var stop in spec.Stops) + CometSwiftUIHost.AddGradientStop(_native, ToArgb(stop)); + // 0 horizontal / 1 vertical / 2 diagonal — extent/offset/mirror (the + // parallax fields) are an Android-first capability, documented deviation here. + CometSwiftUIHost.SetDouble(_native, "gradientdirection", (double)spec.Direction); + } + else if (id == PropertyIds.GradientBorder && value.AsObject is GradientSpec borderSpec) + { + CometSwiftUIHost.ClearBorderGradientStops(_native); + foreach (var stop in borderSpec.Stops) + CometSwiftUIHost.AddBorderGradientStop(_native, ToArgb(stop)); + // Same 0/1/2/3 mapping as the background — Android honors the spec's + // direction for borders via BuildGradientBrush, so the shim must too. + CometSwiftUIHost.SetDouble(_native, "bordergradientdirection", (double)borderSpec.Direction); + } + else if (id == PropertyIds.Toggle_IsOn) + CometSwiftUIHost.SetBool(_native, "ison", value.AsBool); + else if (id == PropertyIds.Slider_Value) + CometSwiftUIHost.SetDouble(_native, "value", value.AsDouble); + else if (id == PropertyIds.HasTapGesture) + CometSwiftUIHost.SetBool(_native, "hastapgesture", value.AsBool); + else if (id == PropertyIds.HasLongPressGesture) + CometSwiftUIHost.SetBool(_native, "haslongpressgesture", value.AsBool); + else if (id == PropertyIds.Opacity) + CometSwiftUIHost.SetDouble(_native, "opacity", value.AsDouble); + else if (id == PropertyIds.IsVisible) + CometSwiftUIHost.SetBool(_native, "isvisible", value.AsBool); + else if (id == PropertyIds.TextField_Borderless) + CometSwiftUIHost.SetBool(_native, "borderless", value.AsBool); + else if (id == PropertyIds.Button_Outlined) + CometSwiftUIHost.SetBool(_native, "outlined", value.AsBool); + else if ((id == PropertyIds.Text_Color || id == PropertyIds.Button_TextColor) && value.AsColor is { } tc) + CometSwiftUIHost.SetColor(_native, "textcolor", ToArgb(tc)); + else if (id == PropertyIds.Text_FontSize) + { + _fontSize = value.AsDouble; + CometSwiftUIHost.SetDouble(_native, "fontsize", value.AsDouble); + } + else if (id == PropertyIds.Text_MaxLines) + CometSwiftUIHost.SetDouble(_native, "maxlines", value.AsInt); + else if (id == PropertyIds.Text_FontWeight) + CometSwiftUIHost.SetDouble(_native, "fontweight", value.AsInt); // Int-kind, not Double + else if (id == PropertyIds.Text_FontFamily) + { + _fontFamily = value.AsString; + CometSwiftUIHost.SetString(_native, "fontfamily", value.AsString ?? string.Empty); + } + else if (id == PropertyIds.BackgroundColor && value.AsColor is { } c) + CometSwiftUIHost.SetColor(_native, "background", ToArgb(c)); + else if (id == PropertyIds.Padding && value.AsObject is Microsoft.Maui.Thickness t) + { + CometSwiftUIHost.SetDouble(_native, "padding", t.Left); // uniform value used by stacks + CometSwiftUIHost.SetDouble(_native, "pad.l", t.Left); + CometSwiftUIHost.SetDouble(_native, "pad.t", t.Top); + CometSwiftUIHost.SetDouble(_native, "pad.r", t.Right); + CometSwiftUIHost.SetDouble(_native, "pad.b", t.Bottom); + } + else if (id == PropertyIds.CornerRadius && value.AsObject is CornerRadii corners) + { + // Four SetDouble calls (reusing the bound host fn) carry per-corner radii. + CometSwiftUIHost.SetDouble(_native, "corner.tl", corners.TopLeft); + CometSwiftUIHost.SetDouble(_native, "corner.tr", corners.TopRight); + CometSwiftUIHost.SetDouble(_native, "corner.br", corners.BottomRight); + CometSwiftUIHost.SetDouble(_native, "corner.bl", corners.BottomLeft); + } + else if (id == PropertyIds.Shadow) + CometSwiftUIHost.SetDouble(_native, "elevation", value.AsDouble); + else if (id == PropertyIds.Border && value.AsObject is BorderSpec border) + { + CometSwiftUIHost.SetDouble(_native, "borderwidth", border.Width); + CometSwiftUIHost.SetColor(_native, "bordercolor", ToArgb(border.Color)); + } + } + + public void InsertChild(int index, ICometBackendNode child) + { + var c = (ISwiftUINativeNode)child; + _children.Insert(index, (ICometBackendNode)c); + CometSwiftUIHost.InsertChild(_native, index, c.Native); + } + + public void RemoveChildAt(int index) + { + _children.RemoveAt(index); + CometSwiftUIHost.RemoveChild(_native, index); + } + + public void MoveChild(int fromIndex, int toIndex) + { + var c = _children[fromIndex]; + _children.RemoveAt(fromIndex); + CometSwiftUIHost.RemoveChild(_native, fromIndex); + _children.Insert(toIndex, c); + CometSwiftUIHost.InsertChild(_native, toIndex, ((ISwiftUINativeNode)c).Native); + } + + // Yoga layout: a leaf's intrinsic size comes from SwiftUI (sizeThatFits), and the + // Yoga-computed parent-relative frame is pushed back so the shim positions it absolutely. + public Size Measure(double widthConstraint, double heightConstraint) + { + var size = CometSwiftUIHost.MeasureNode(_native, widthConstraint, heightConstraint); + return new Size(size.Width, size.Height); + } + + public void Arrange(Rect frame) + => CometSwiftUIHost.SetFrame(_native, frame.X, frame.Y, frame.Width, frame.Height); + + // First-baseline offset from UIFont metrics: the ascender is the distance from the + // top of the line box to the baseline, which is where SwiftUI draws a single-line + // Text's first baseline. Replaces the old null→height fallback so .AlignBaseline() + // and .BaselineHeight() rows line up on iOS too (Compose measures its own engine for + // exactness; UIFont metrics are the correct iOS analog). + public double? MeasureBaseline(double width, double height) + { + if (_kind != "text") + return null; + var size = (System.Runtime.InteropServices.NFloat)(_fontSize ?? 17.0); + var font = (_fontFamily is { Length: > 0 } family ? UIKit.UIFont.FromName(family, size) : null) + ?? UIKit.UIFont.SystemFontOfSize(size); + return (double)font.Ascender; + } + + // Baseline-height inset (gold baselineHeight): pad the leaf content down so its first baseline + // lands at the requested offset — the iOS counterpart of ComposeNode's _contentTopInset. + public void SetContentTopInset(double dp) => CometSwiftUIHost.SetDouble(_native, "contenttopinset", dp); + + public void SetEventSink(ICometEventSink? sink) => _sink = sink; + public void Dispose() { } + + static uint ToArgb(Color c) => + ((uint)(c.Alpha * 255) << 24) | ((uint)(c.Red * 255) << 16) | + ((uint)(c.Green * 255) << 8) | (uint)(c.Blue * 255); + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIScrollNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIScrollNode.cs new file mode 100644 index 000000000..7b264ce8f --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUIScrollNode.cs @@ -0,0 +1,83 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; + +namespace Comet.Platform.SwiftUI +{ + /// + /// Renders a Comet vertical ScrollView as a SwiftUI ScrollView (the iOS + /// counterpart of ). Owns its single content view (so it + /// implements ): it lays the content out with the + /// shared Yoga engine — width pinned to the viewport, height wrapped — so the content + /// self-positions and scrolls as one piece, matching the Compose backend. + /// + sealed class SwiftUIScrollNode : ICometBackendNode, IBackendManagesOwnContent, ISwiftUINativeNode + { + readonly IContainerView _scroll; + readonly BackendContext _context; + readonly CometNode _native; + View? _contentView; + double _width; + + public CometNode Native => _native; + + public SwiftUIScrollNode(IContainerView scroll, BackendContext context) + { + _scroll = scroll; + _context = context; + _native = CometSwiftUIHost.MakeNode("scroll"); + // Mirror the live scroll offset onto the ScrollView's AtTop / ScrollOffset signals (the gold's + // derivedStateOf { scrollState.value == 0 }) — e.g. collapsing the profile FAB on scroll. + CometSwiftUIHost.SetScrollHandler(_native, OnNativeScroll); + BuildContent(); + } + + void OnNativeScroll(double offset) + { + if (_scroll is Comet.ScrollView sv) + { + sv.ScrollOffset.Value = offset; + sv.AtTop.Value = offset <= 1.0; + } + } + + void BuildContent() + { + var children = _scroll.GetChildren(); + _contentView = children is { Count: > 0 } ? children[0] : null; + if (_contentView is null) + return; + + var node = (ISwiftUINativeNode)CometBackendBridge.Materialize(_contentView, _context, _scroll as View); + CometSwiftUIHost.InsertChild(_native, 0, node.Native); + } + + public void ApplyProperty(PropertyId id, in PropertyValue value) { } + + public Size Measure(double widthConstraint, double heightConstraint) => Size.Zero; + + public void Arrange(Rect frame) + { + // Frame the scroll viewport from its Yoga slot (below the bar, filling the rest). + CometSwiftUIHost.SetFrame(_native, frame.X, frame.Y, frame.Width, frame.Height); + + // (Re)lay the content out to the viewport width once we know it; it wraps taller than + // the viewport and its children self-position, so SwiftUI's ScrollView scrolls it. + if (frame.Width > 0 && System.Math.Abs(frame.Width - _width) > 0.5 && _contentView is not null) + { + _width = frame.Width; + CometBackendLayoutEngine.LayoutContent(_contentView, _width); + } + } + + // Content is managed internally; the generic child API is unused. + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + public void SetEventSink(ICometEventSink? sink) { } + public void Dispose() { } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUISelectorPanelNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUISelectorPanelNode.cs new file mode 100644 index 000000000..799afa0be --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUISelectorPanelNode.cs @@ -0,0 +1,135 @@ +#nullable enable +#if IOS +using System; +using Comet.Backend; +using Comet.SwiftUI.Interop; +using Microsoft.Maui.Graphics; +using UIKit; + +namespace Comet.Platform.SwiftUI +{ + /// iOS counterpart of : the gold Jetchat expandable + /// input-selector panel. Hosts one-of-N materialized child views selected by + /// (nothing when collapsed), and measures to the active + /// panel's natural height so the shared Yoga engine grows the footer (and shrinks the message list) — + /// the same layout-driven swap as Android. The reflow is driven by + /// re-laying-out its top screen on ReactiveScheduler.AfterFlush (the SelectorPanel backend + /// schedules the flush on a selector change). + sealed class SwiftUISelectorPanelNode : ICometBackendNode, IBackendManagesOwnContent, ISwiftUINativeNode + { + readonly SelectorPanel _panel; + readonly BackendContext _context; + readonly CometNode _native; + ISwiftUINativeNode?[] _nodes = Array.Empty(); + int _selectorValue; + int _hosted = -1; // index of the child currently inserted into _native (-1 = none) + double _width; + bool _initialized; + + public CometNode Native => _native; + + public SwiftUISelectorPanelNode(SelectorPanel panel, BackendContext context) + { + _panel = panel; + _context = context; + // A Yoga-container host: with a frame it overlays its (self-positioning) child at topLeading, + // exactly like the list/nav hosts. Collapsed = no child + zero measured height. + _native = CometSwiftUIHost.MakeNode("vstack"); + } + + public void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id == PropertyIds.SelectorPanel_Index) + { + _selectorValue = value.AsInt; + UpdateHostedChild(); + } + else if (id == PropertyIds.BackgroundColor && value.AsColor is { } c) + { + // The panel surface (gold Surface(tonalElevation = 8.dp)); only visible while a panel is + // open (collapsed → zero height → nothing drawn). + CometSwiftUIHost.SetColor(_native, "background", ToArgb(c)); + } + } + + void EnsureContent() + { + if (_initialized) + return; + _initialized = true; + var panels = _panel.Panels; + _nodes = new ISwiftUINativeNode?[panels.Count]; + for (int i = 0; i < panels.Count; i++) + if (panels[i] is { } v) + _nodes[i] = (ISwiftUINativeNode)CometBackendBridge.Materialize(v, _context, _panel); + } + + View? ActiveView() + { + var panels = _panel.Panels; + return _selectorValue > 0 && _selectorValue < panels.Count ? panels[_selectorValue] : null; + } + + // Show the active panel (or nothing when collapsed / on a dialog-handled selector with a null slot). + // Swapping the hosted child changes this node's measured height; the nav's AfterFlush reflow then + // re-measures the screen so the footer grows/collapses. + void UpdateHostedChild() + { + EnsureContent(); + int active = _selectorValue > 0 && _selectorValue < _nodes.Length && _nodes[_selectorValue] is not null + ? _selectorValue + : -1; + if (active == _hosted) + return; + _hosted = active; + CometSwiftUIHost.ClearChildren(_native); + if (active >= 0) + { + CometSwiftUIHost.InsertChild(_native, 0, _nodes[active]!.Native); + LayoutActive(); + } + } + + void LayoutActive() + { + if (_width > 0 && ActiveView() is { } v) + CometBackendLayoutEngine.LayoutContent(v, _width); + } + + // Own-content leaf: report the active panel's height (laying its subtree out to the host width so it + // renders positioned) so Yoga grows the footer; zero when collapsed → the slot disappears. + public Size Measure(double widthConstraint, double heightConstraint) + { + EnsureContent(); + var view = ActiveView(); + if (view is null) + return Size.Zero; + double width = double.IsInfinity(widthConstraint) || widthConstraint <= 0 + ? UIScreen.MainScreen.Bounds.Width + : widthConstraint; + return CometBackendLayoutEngine.LayoutContent(view, width); + } + + public void Arrange(Rect frame) + { + CometSwiftUIHost.SetFrame(_native, frame.X, frame.Y, frame.Width, frame.Height); + if (frame.Width > 0 && Math.Abs(frame.Width - _width) > 0.5) + { + _width = frame.Width; + LayoutActive(); + } + } + + // The node manages its own (one-of-N) content; the generic child API is unused. + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + public void SetEventSink(ICometEventSink? sink) { } + public void Dispose() { } + + static uint ToArgb(Color c) => + ((uint)(c.Alpha * 255) << 24) | ((uint)(c.Red * 255) << 16) | + ((uint)(c.Green * 255) << 8) | (uint)(c.Blue * 255); + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/SwiftUI/SwiftUITabRowNode.cs b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUITabRowNode.cs new file mode 100644 index 000000000..edb4c34b8 --- /dev/null +++ b/src/Comet/src/Comet/Platform/SwiftUI/SwiftUITabRowNode.cs @@ -0,0 +1,91 @@ +#nullable enable +#if IOS +using Comet.Backend; +using Microsoft.Maui; +using Microsoft.Maui.Primitives; + +namespace Comet.Platform.SwiftUI +{ + /// The iOS twin of : Comet + /// hand-composed to the M3 primary-tab-row metrics (48dp row, equal-width text tabs, + /// 3dp content-width indicator under the selected tab) from the control's styling + /// tokens — Android drives the real PrimaryTabRow. + sealed class SwiftUITabRowNode : SwiftUIHostedCompositionNode + { + const float RowHeight = 48f; + + TabBar _bar; + int _selected; + bool _applied; + + public SwiftUITabRowNode(TabBar bar, BackendContext context) + : base(context) => _bar = bar; + + public override void OnOwnerViewChanged(View newView, bool isHotReload) + { + if (newView is TabBar bar) + _bar = bar; + } + + // In-flow footprint: a fixed 48dp row (the base's fill size would starve siblings). + public override Size Measure(double widthConstraint, double heightConstraint) + { + double w = double.IsFinite(widthConstraint) && widthConstraint > 0 ? widthConstraint : ScreenDp().Width; + return new Size(w, RowHeight); + } + + protected override View BuildContent() + { + var row = new HStack(spacing: 0f); + for (int i = 0; i < _bar.Titles.Count; i++) + { + int index = i; + var color = index == _selected + ? _bar.SelectedColor ?? Microsoft.Maui.Graphics.Colors.Black + : _bar.UnselectedColor ?? Microsoft.Maui.Graphics.Colors.Gray; + var label = new Text(_bar.Titles[i]) + .FontSize((float)_bar.FontSize) + .FontWeight((FontWeight)_bar.FontWeight) + .Color(color); + if (_bar.FontFamily is { } family) + label = label.FontFamily(family); + + // The label + a content-width indicator stack (the column shrinks to the + // label, so the 3dp bar underneath matches the text width — M3's primary + // indicator), centered in an equal-flex cell. + var cell = new VStack(spacing: 0f) + { + new HStack().FlexGrow(1), + label.HorizontalLayoutAlignment(LayoutAlignment.Center), + new HStack().FlexGrow(1), + new HStack().Frame(height: 3) + .Background(index == _selected ? _bar.SelectedColor : null) + .CornerRadius(1.5f) + .HorizontalLayoutAlignment(LayoutAlignment.Fill), + } + .HorizontalLayoutAlignment(LayoutAlignment.Center); + + row.Add(new VStack(spacing: 0f) { cell } + .FlexGrow(1).FlexBasis(0) + .OnTap(_ => _bar.SelectItem(index))); + } + return row.Frame(height: RowHeight) + .HorizontalLayoutAlignment(LayoutAlignment.Fill); + } + + public override void ApplyProperty(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.Nav_SelectedIndex) + { + base.ApplyProperty(id, in value); + return; + } + if (_applied && value.AsInt == _selected) + return; + _applied = true; + _selected = value.AsInt; + Refresh(); + } + } +} +#endif diff --git a/src/Comet/src/Comet/Platform/Windows/CometView.cs b/src/Comet/src/Comet/Platform/Windows/CometView.cs deleted file mode 100644 index b16ff6116..000000000 --- a/src/Comet/src/Comet/Platform/Windows/CometView.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using Microsoft.Maui; -using Microsoft.Maui.HotReload; -using Microsoft.Maui.Graphics; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using WinSize = global::Windows.Foundation.Size; -using WinGrid = Microsoft.UI.Xaml.Controls.Grid; -using WinRect = global::Windows.Foundation.Rect; - -namespace Comet.Windows -{ - public class CometView : WinGrid, IReloadHandler - { - IView _view; - IViewHandler currentHandler; - UIElement currentPlatformView; - - IMauiContext MauiContext; - - public CometView(IMauiContext mauiContext) - { - MauiContext = mauiContext; - Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White); - } - - public IView CurrentView - { - get => _view; - set => SetView(value); - } - - void SetView(IView view, bool forceRefresh = false) - { - if (view == _view && !forceRefresh) - return; - - // Reuse handlers if view type is compatible - if (view is View v && _view is View pv && - v.GetContentTypeHashCode() == pv.GetContentTypeHashCode() - && currentHandler is not null) - { - _view = view; - v.ViewHandler = currentHandler; - if (_view is IHotReloadableView ihr1) - { - ihr1.ReloadHandler = this; - MauiHotReloadHelper.AddActiveView(ihr1); - } - return; - } - - _view = view; - - if (_view is IHotReloadableView ihr) - { - ihr.ReloadHandler = this; - MauiHotReloadHelper.AddActiveView(ihr); - } - - // Resolve views with a Body (e.g. Component) to their concrete view tree - var viewToRender = _view; - if (viewToRender is View cometView && cometView.Body is not null) - viewToRender = cometView.GetView(); - var newPlatformView = viewToRender?.ToPlatform(MauiContext); - currentHandler = _view?.Handler; - - if (currentPlatformView == newPlatformView) - return; - - if (currentPlatformView is not null) - Children.Remove(currentPlatformView); - - if (newPlatformView != this && newPlatformView is not null) - { - currentPlatformView = newPlatformView; - Children.Add(currentPlatformView); - } - } - - protected override WinSize MeasureOverride(WinSize availableSize) - { - if (_view is null) - return availableSize; - - var width = availableSize.Width > 0 ? availableSize.Width : 1000; - var height = availableSize.Height > 0 ? availableSize.Height : 1000; - - var size = _view.Measure(width, height); - return new WinSize(size.Width, size.Height); - } - - protected override WinSize ArrangeOverride(WinSize finalSize) - { - if (_view is null) - return finalSize; - - _view.Arrange(new Rect(0, 0, finalSize.Width, finalSize.Height)); - - if (currentPlatformView is not null) - { - currentPlatformView.Arrange(new WinRect(0, 0, finalSize.Width, finalSize.Height)); - } - - return finalSize; - } - - public void Reload() => SetView(CurrentView, true); - } -} diff --git a/src/Comet/src/Comet/Platform/Windows/HandlerExtensions.cs b/src/Comet/src/Comet/Platform/Windows/HandlerExtensions.cs deleted file mode 100644 index 02cd90251..000000000 --- a/src/Comet/src/Comet/Platform/Windows/HandlerExtensions.cs +++ /dev/null @@ -1,163 +0,0 @@ -using Microsoft.Maui.Handlers; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Input; - -namespace Comet; - -public static partial class HandlerExtensions -{ - public static void AddGesture(this IViewHandler handler, Gesture gesture) - { - if (handler.PlatformView is not UIElement nativeView) - return; - - if (gesture is TapGesture tapGesture) - { - nativeView.Tapped += (s, e) => tapGesture.Invoke(); - gesture.PlatformGesture = nativeView; - } - else if (gesture is LongPressGesture longPressGesture) - { - nativeView.Holding += (s, e) => - { - if (e.HoldingState == Microsoft.UI.Input.HoldingState.Started) - longPressGesture.Invoke(); - }; - gesture.PlatformGesture = nativeView; - } - else if (gesture is PanGesture panGesture) - { - nativeView.ManipulationMode |= ManipulationModes.TranslateX | ManipulationModes.TranslateY; - nativeView.ManipulationStarted += (s, e) => - { - panGesture.TotalX = 0; - panGesture.TotalY = 0; - panGesture.Status = GestureStatus.Started; - panGesture.Invoke(); - }; - nativeView.ManipulationDelta += (s, e) => - { - panGesture.TotalX = e.Cumulative.Translation.X; - panGesture.TotalY = e.Cumulative.Translation.Y; - panGesture.Status = GestureStatus.Running; - panGesture.Invoke(); - }; - nativeView.ManipulationCompleted += (s, e) => - { - panGesture.TotalX = e.Cumulative.Translation.X; - panGesture.TotalY = e.Cumulative.Translation.Y; - panGesture.Status = GestureStatus.Completed; - panGesture.Invoke(); - }; - gesture.PlatformGesture = nativeView; - } - else if (gesture is PinchGesture pinchGesture) - { - nativeView.ManipulationMode |= ManipulationModes.Scale; - nativeView.ManipulationDelta += (s, e) => - { - pinchGesture.Scale = e.Cumulative.Scale; - pinchGesture.Status = GestureStatus.Running; - pinchGesture.Invoke(); - }; - nativeView.ManipulationCompleted += (s, e) => - { - pinchGesture.Scale = e.Cumulative.Scale; - pinchGesture.Status = GestureStatus.Completed; - pinchGesture.Invoke(); - }; - gesture.PlatformGesture = nativeView; - } - else if (gesture is DragGesture dragGesture) - { - var view = handler.VirtualView as View; - nativeView.CanDrag = dragGesture.CanDrag; - nativeView.DragStarting += (s, e) => - { - var data = dragGesture.DragStarting?.Invoke(view); - dragGesture.DragStartingCommand?.Execute(dragGesture.DragStartingCommandParameter); - if (data is not null) - e.Data.SetText(data.ToString()); - }; - nativeView.DropCompleted += (s, e) => - { - dragGesture.DropCompleted?.Invoke(view); - dragGesture.DropCompletedCommand?.Execute(dragGesture.DropCompletedCommandParameter); - }; - gesture.PlatformGesture = nativeView; - } - else if (gesture is DropGesture dropGesture) - { - var view = handler.VirtualView as View; - nativeView.AllowDrop = dropGesture.AllowDrop; - nativeView.DragOver += (s, e) => - { - var accepted = dropGesture.DragOver?.Invoke(view, null) ?? true; - dropGesture.DragOverCommand?.Execute(null); - e.AcceptedOperation = accepted - ? global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Copy - : global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.None; - }; - nativeView.DragLeave += (s, e) => - { - dropGesture.DragLeave?.Invoke(view); - dropGesture.DragLeaveCommand?.Execute(null); - }; - nativeView.Drop += async (s, e) => - { - object data = null; - if (e.DataView.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text)) - data = await e.DataView.GetTextAsync(); - dropGesture.Drop?.Invoke(view, data); - dropGesture.DropCommand?.Execute(dropGesture.DropCommandParameter ?? data); - }; - gesture.PlatformGesture = nativeView; - } - else if (gesture is PointerGesture pointerGesture) - { - var view = handler.VirtualView as View; - nativeView.PointerEntered += (s, e) => - { - var pos = e.GetCurrentPoint(nativeView).Position; - var point = new Microsoft.Maui.Graphics.Point(pos.X, pos.Y); - pointerGesture.PointerEntered?.Invoke(view, point); - pointerGesture.PointerEnteredCommand?.Execute(point); - }; - nativeView.PointerMoved += (s, e) => - { - var pos = e.GetCurrentPoint(nativeView).Position; - var point = new Microsoft.Maui.Graphics.Point(pos.X, pos.Y); - pointerGesture.PointerMoved?.Invoke(view, point); - pointerGesture.PointerMovedCommand?.Execute(point); - }; - nativeView.PointerExited += (s, e) => - { - var pos = e.GetCurrentPoint(nativeView).Position; - var point = new Microsoft.Maui.Graphics.Point(pos.X, pos.Y); - pointerGesture.PointerExited?.Invoke(view, point); - pointerGesture.PointerExitedCommand?.Execute(point); - }; - nativeView.PointerPressed += (s, e) => - { - var pos = e.GetCurrentPoint(nativeView).Position; - var point = new Microsoft.Maui.Graphics.Point(pos.X, pos.Y); - pointerGesture.PointerPressed?.Invoke(view, point); - pointerGesture.PointerPressedCommand?.Execute(point); - }; - nativeView.PointerReleased += (s, e) => - { - var pos = e.GetCurrentPoint(nativeView).Position; - var point = new Microsoft.Maui.Graphics.Point(pos.X, pos.Y); - pointerGesture.PointerReleased?.Invoke(view, point); - pointerGesture.PointerReleasedCommand?.Execute(point); - }; - gesture.PlatformGesture = nativeView; - } - } - - public static void RemoveGesture(this IViewHandler handler, Gesture gesture) - { - // Windows gesture handlers are attached to the UIElement lifecycle - // and will be cleaned up when the native view is disposed - } -} diff --git a/src/Comet/src/Comet/Platform/Windows/ListCell.cs b/src/Comet/src/Comet/Platform/Windows/ListCell.cs deleted file mode 100644 index aa83268cb..000000000 --- a/src/Comet/src/Comet/Platform/Windows/ListCell.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Maui; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using WGrid = Microsoft.UI.Xaml.Controls.Grid; -using UwpSize = Windows.Foundation.Size; - -namespace Comet.Platform.Windows -{ - class ListCell : WGrid - { - private View _view; - private UIElement _nativeView; - private IElementHandler _handler; - - public ListCell(View view , IMauiContext context) - { - Context = context; - View = view; - } - - public View View - { - get => _view; - set - { - if (value == _view) - return; - - _view = value; - _handler = _view?.ViewHandler; - UpdateView(); - } - } - - public IMauiContext Context { get; } - - private void LayoutChanged(object sender, EventArgs e) - { - InvalidateMeasure(); - InvalidateArrange(); - } - - private void UpdateView() - { - if (_nativeView is not null) - { - Children.Remove(_nativeView); - _nativeView = null; - } - - _nativeView = _view?.ToPlatform(Context); - - if (_nativeView is not null) - { - if (_nativeView is FrameworkElement frameworkElement) - { - WGrid.SetRow(frameworkElement, 0); - WGrid.SetColumn(frameworkElement, 0); - WGrid.SetColumnSpan(frameworkElement, 1); - WGrid.SetRowSpan(frameworkElement, 1); - } - - Children.Add(_nativeView); - } - } - - protected override UwpSize MeasureOverride(UwpSize availableSize) - { - var measuredSize = _view?.Measure(availableSize.Width, availableSize.Height).ToPlatform(); - return measuredSize ?? availableSize; - } - - protected override UwpSize ArrangeOverride(UwpSize finalSize) - { - if (finalSize.Width > 0 && finalSize.Height > 0 && _view is not null) - _view.Frame = new RectF(0, 0, (float)finalSize.Width, (float)finalSize.Height); - - return finalSize; - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CUIContainerView.cs b/src/Comet/src/Comet/Platform/iOS/CUIContainerView.cs deleted file mode 100644 index 4a6ee122b..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUIContainerView.cs +++ /dev/null @@ -1,189 +0,0 @@ -//using System; -//using System.Drawing; -//using CoreAnimation; -//using CoreGraphics; -//using UIKit; - -//namespace Comet.iOS.Controls -//{ -// public class CUIContainerView : UIView -// { -// private UIView _mainView; -// private UIView _overlayView; -// private ShapeView _overlayShapeView; -// private CAShapeLayer _shadowLayer; -// private CAShapeLayer _maskLayer; -// private CGSize _size; -// private CGSize _intrinsicContentSize; -// private Shape _clipShape; - -// public CUIContainerView() -// { -// AutosizesSubviews = true; -// } - -// public override CGRect Frame -// { -// get => base.Frame; -// set -// { -// base.Frame = value; - -// if (_shadowLayer is not null || _maskLayer is not null) -// { -// if (!_size.Equals(value.Size)) -// { -// if (_clipShape is not null) -// { -// var path = _clipShape.PathForBounds(new RectangleF(0, 0, (float)value.Width, (float)value.Height)); -// if (_shadowLayer is not null) -// _shadowLayer.Path = path.ToCGPath(); - -// if (_maskLayer is not null) -// _maskLayer.Path = path.ToCGPath(); -// } -// else -// { -// var fx = value.Size.Width / _size.Width; -// var fy = value.Size.Height / _size.Height; -// var transform = CGAffineTransform.MakeScale(fx, fy); -// var path = _shadowLayer?.Path ?? _maskLayer?.Path; -// var transformedPath = path?.CopyByTransformingPath(transform); -// if (_shadowLayer is not null) -// _shadowLayer.Path = transformedPath; - -// if (_maskLayer is not null) -// _maskLayer.Path = transformedPath; -// } -// } -// } - -// _size = value.Size; -// } -// } - -// public UIView MainView -// { -// get => _mainView; -// set -// { -// if (_mainView is not null) -// { -// ShadowLayer = null; -// MaskLayer = null; -// ClipShape = null; -// _mainView.RemoveFromSuperview(); -// } - -// _mainView = value; - -// if (_mainView is not null) -// { -// base.Frame = _mainView.Bounds; -// _size = _mainView.Bounds.Size; -// _mainView.AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight; -// _mainView.Frame = Bounds; - -// if (_overlayView is not null) -// InsertSubviewBelow(_mainView, _overlayView); -// else -// AddSubview(_mainView); -// } -// } -// } - -// public ShapeView OverlayShapeView -// { -// get => _overlayShapeView; -// set{ -// _overlayShapeView?.Dispose(); -// _overlayShapeView = value; -// OverlayView = value?.ToView(); -// } -// } - -// UIView OverlayView -// { -// get => _overlayView; -// set -// { -// _overlayView?.RemoveFromSuperview(); - -// _overlayView = value; - -// if (_overlayView is not null) -// { -// base.Frame = _mainView?.Bounds ?? Bounds; -// _overlayView.AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight; -// _overlayView.Frame = Bounds; -// AddSubview(_overlayView); -// } -// } -// } - -// public CAShapeLayer ShadowLayer -// { -// get => _shadowLayer; -// set -// { -// _shadowLayer?.RemoveFromSuperLayer(); -// _shadowLayer = value; -// if (_shadowLayer is not null && _mainView is not null) -// Layer.InsertSublayerBelow(_shadowLayer, _mainView.Layer); -// } -// } - -// public override CGSize IntrinsicContentSize => _intrinsicContentSize; - -// public override CGSize SizeThatFits(CGSize size) -// { -// if (_mainView is not null) -// { -// _intrinsicContentSize = _mainView.SizeThatFits(size); -// return _intrinsicContentSize; -// } - -// return base.SizeThatFits(size); -// } - -// public override void SizeToFit() -// { -// if (_mainView is not null) -// { -// _mainView.SizeToFit(); -// _intrinsicContentSize = _mainView.Bounds.Size; -// Frame = _mainView.Bounds; -// } -// else -// { -// base.SizeToFit(); -// } -// } - -// public CAShapeLayer MaskLayer -// { -// get => _maskLayer; -// set -// { -// if (_maskLayer is not null) -// _mainView.Layer.Mask = null; - -// _maskLayer = value; -// if (_mainView is not null) -// _mainView.Layer.Mask = value; -// } -// } - -// public Shape ClipShape -// { -// get => _clipShape; -// set => _clipShape = value; -// } - -// protected override void Dispose(bool disposing) -// { -// base.Dispose(disposing); -// OverlayShapeView?.Dispose(); -// } -// } -//} diff --git a/src/Comet/src/Comet/Platform/iOS/CUINavigationController.cs b/src/Comet/src/Comet/Platform/iOS/CUINavigationController.cs deleted file mode 100644 index 322e2a133..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUINavigationController.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Microsoft.Maui; -using UIKit; - -namespace Comet.iOS -{ - public class CUINavigationController : UINavigationController - { - public static UIColor DefaultBarTintColor { get; private set; } - public static UIColor DefaultTintColor { get; private set; } - public static UIStringAttributes DefaultTitleTextAttributes { get; private set; } - public CUINavigationController() - { - if (DefaultBarTintColor is null) - { - DefaultBarTintColor = NavigationBar.BarTintColor; - DefaultTintColor = NavigationBar.TintColor; - DefaultTitleTextAttributes = NavigationBar.TitleTextAttributes; - } - - // Use clear so the window or parent background shows through the - // status bar area instead of defaulting to opaque SystemBackground. - View.BackgroundColor = UIColor.Clear; - } - - public override void ViewDidLoad() - { - base.ViewDidLoad(); - - // iOS requires the navigation controller's root view to carry - // standard layout margins so the navigation bar (which inherits - // via PreservesSuperviewLayoutMargins) positions the large title - // with the correct leading indent (~16 pt on iPhone). - // In our embedding (CUITabView → UITabBarController → - // CUINavigationController) the system doesn't apply its default - // minimum margins, so we set them explicitly. - View.DirectionalLayoutMargins = new NSDirectionalEdgeInsets(0, 16, 0, 16); - } - public override UIViewController[] PopToRootViewController(bool animated) - { - return base.PopToRootViewController(animated); - } - public override UIViewController PopViewController(bool animated) - { - var vc = base.PopViewController(animated); - var cometVC = vc as CometViewController; - cometVC?.WasPopped(); - - return vc; - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CUIRadioButton.cs b/src/Comet/src/Comet/Platform/iOS/CUIRadioButton.cs deleted file mode 100644 index abd808d42..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUIRadioButton.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using UIKit; - -namespace Comet.iOS -{ - public class CUIRadioButton : UIButton - { - private const float CONTENT_SPACING = 10; - - private static UIImage SelectedImage = UIImage.GetSystemImage("largecircle.fill.circle") - .ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate); - private static UIImage DeselectedImage = UIImage.GetSystemImage("circle") - .ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate); - - public event EventHandler IsCheckedChanged; - - private UIColor _foregroundColor = UIColor.SystemBlue; - private string _title; - private bool _isChecked; - - public CUIRadioButton(bool isChecked = false) - { - _isChecked = isChecked; - HorizontalAlignment = UIControlContentHorizontalAlignment.Left; - ApplyConfiguration(); - TouchUpInside += (sender, e) => { if (!IsChecked) IsChecked = true; }; - } - - public bool IsChecked - { - get => _isChecked; - set - { - if (_isChecked != value) - { - _isChecked = value; - ApplyConfiguration(); - IsCheckedChanged?.Invoke(this, new EventArgs()); - } - } - } - - // UIButtonConfiguration takes over all rendering — legacy SetTitle/SetTitleColor - // are ignored once Configuration is set. Override them to update through the - // configuration instead. - public override void SetTitle(string title, UIControlState forState) - { - if (forState == UIControlState.Normal) - { - _title = title; - ApplyConfiguration(); - } - } - - public override void SetTitleColor(UIColor color, UIControlState forState) - { - if (forState == UIControlState.Normal && color is not null) - { - _foregroundColor = color; - ApplyConfiguration(); - } - } - - void ApplyConfiguration() - { - var config = UIButtonConfiguration.PlainButtonConfiguration; - config.Image = _isChecked ? SelectedImage : DeselectedImage; - config.ImagePadding = CONTENT_SPACING; - config.BaseForegroundColor = _foregroundColor; - config.ContentInsets = new NSDirectionalEdgeInsets(4, 0, 4, 0); - if (_title is not null) - config.Title = _title; - Configuration = config; - } - } -} \ No newline at end of file diff --git a/src/Comet/src/Comet/Platform/iOS/CUIScrollView.cs b/src/Comet/src/Comet/Platform/iOS/CUIScrollView.cs deleted file mode 100644 index e16d7c227..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUIScrollView.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using CoreGraphics; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using UIKit; -namespace Comet.iOS -{ - public class CUIScrollView : UIScrollView - { - internal Action CrossPlatformArrange { get; set; } - public override void LayoutSubviews() - { - base.LayoutSubviews(); - var bounds = Bounds.ToRectangle(); - CrossPlatformArrange?.Invoke(bounds); - } - - public override void SafeAreaInsetsDidChange() - { - base.SafeAreaInsetsDidChange(); - SetNeedsLayout(); - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CUIShapeView.cs b/src/Comet/src/Comet/Platform/iOS/CUIShapeView.cs deleted file mode 100644 index 63bb2acd2..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUIShapeView.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using CoreGraphics; -using Comet.Graphics; -using UIKit; -using Microsoft.Maui.Graphics; -using Microsoft.Maui; -using Microsoft.Maui.Graphics.Platform; -using ObjCRuntime; - -namespace Comet.iOS -{ - public class CUIShapeView : UIView - { - private static readonly CGColorSpace ColorSpace = CGColorSpace.CreateDeviceRGB(); - - private Shape _shape; - - public CUIShapeView() - { - BackgroundColor = UIColor.Clear; - } - - public Shape Shape - { - get => _shape; - set - { - _shape = value; - SetNeedsDisplay(); - } - } - - public View View { get; set; } - - public override void Draw(CGRect rect) - { - var context = UIGraphics.GetCurrentContext(); - if (Shape is not null) - { - var drawingStyle = Shape.GetDrawingStyle(View, DrawingStyle.StrokeFill); - - var lineWidth = Shape.GetLineWidth(View, 1); - var strokeColor = Colors.Black; - object fill = null; - - if (drawingStyle == DrawingStyle.Fill || drawingStyle == DrawingStyle.StrokeFill) - { - fill = Shape.GetFill(View); - } - - var shapeBounds = new RectF( - (float)rect.X + (lineWidth / 2), - (float)rect.Y + (lineWidth / 2), - (float)rect.Width - lineWidth, - (float)rect.Height - lineWidth); - - var path = Shape.PathForBounds(shapeBounds).AsCGPath(); - - if (fill is not null) - { - if (fill is Color color) - { - context.SetFillColor(color.ToCGColor()); - context.AddPath(path); - context.FillPath(); - } - else if (fill is Gradient gradient) - { - context.SaveState(); - context.AddPath(path); - context.Clip(); - - var gradientColors = new nfloat[gradient.Stops.Length * 4]; - var offsets = new nfloat[gradient.Stops.Length]; - - int g = 0; - for (int i = 0; i < gradient.Stops.Length; i++) - { - var stopColor = gradient.Stops[i].Color; - offsets[i] = gradient.Stops[i].Offset; - - if (stopColor is null) stopColor = Colors.White; - - gradientColors[g++] = stopColor.Red; - gradientColors[g++] = stopColor.Green; - gradientColors[g++] = stopColor.Blue; - gradientColors[g++] = stopColor.Alpha; - } - - var cgGradient = new CGGradient(CGColorSpace.CreateDeviceRGB(), gradientColors, offsets); - - if (gradient is LinearGradient linearGradient) - { - var gradientStart = new CGPoint( - (float)rect.X + rect.Width * linearGradient.StartPoint.X, - (float)rect.Y + rect.Height * linearGradient.StartPoint.Y); - - var gradientEnd = new CGPoint( - (float)rect.X + rect.Width * linearGradient.EndPoint.X, - (float)rect.Y + rect.Height * linearGradient.EndPoint.Y); - - context.DrawLinearGradient( - cgGradient, - gradientStart, - gradientEnd, - CGGradientDrawingOptions.DrawsAfterEndLocation | CGGradientDrawingOptions.DrawsBeforeStartLocation); - } - else if (gradient is RadialGradient radialGradient) - { - var _radialFocalPoint = new CGPoint( - (float)rect.X + rect.Width * radialGradient.Center.X, - (float)rect.Y + rect.Height * radialGradient.Center.Y); - - context.DrawRadialGradient( - cgGradient, - _radialFocalPoint, - radialGradient.StartRadius, - _radialFocalPoint, - radialGradient.EndRadius, - CGGradientDrawingOptions.DrawsBeforeStartLocation | CGGradientDrawingOptions.DrawsAfterEndLocation); - } - - cgGradient.Dispose(); - context.RestoreState(); - } - } - - if (drawingStyle == DrawingStyle.Stroke || drawingStyle == DrawingStyle.StrokeFill) - { - strokeColor = Shape.GetStrokeColor(View, Colors.Black); - - context.SetLineWidth(lineWidth); - context.SetStrokeColor(strokeColor.ToCGColor()); - - context.AddPath(path); - context.StrokePath(); - } - } - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CUITabView.cs b/src/Comet/src/Comet/Platform/iOS/CUITabView.cs deleted file mode 100644 index 335b8647d..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUITabView.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using Microsoft.Maui.Handlers; -using Microsoft.Maui.Platform; -using UIKit; -namespace Comet.iOS -{ - public class CUITabView : UIView - { - public IMauiContext Context { get; set; } - UITabBarController tabViewController = new UITabBarController(); - // Explicitly-set tab titles to re-apply after iOS overrides - List _explicitTabTitles; - public CUITabView() - { - tabViewController.View.BackgroundColor = UIColor.Clear; - Add(tabViewController.View); - - // Translucent tab bar so child VCs extend under it and - // ScrollView content scrolls behind it. - tabViewController.TabBar.Translucent = true; - var appearance = new UITabBarAppearance(); - appearance.ConfigureWithTransparentBackground(); - appearance.BackgroundEffect = UIBlurEffect.FromStyle(UIBlurEffectStyle.SystemUltraThinMaterial); - tabViewController.TabBar.StandardAppearance = appearance; - tabViewController.TabBar.ScrollEdgeAppearance = appearance; - } - public void Setup(IList views) - { - if (views is null) - { - tabViewController.ViewControllers = null; - return; - } - var controllers = views.Select(x => - { - // If the view already has a handler providing a UIViewController - // (e.g. NavigationView → CUINavigationController with toolbar items), - // reuse it so toolbar items and navigation stack are preserved. - UIViewController vc; - if (x.Handler is IPlatformViewHandler pvh && pvh.ViewController is not null) - { - vc = pvh.ViewController; - } - else - { - // Ensure the view is realized so its handler is created - x.ToPlatform(Context); - if (x.Handler is IPlatformViewHandler pvh2 && pvh2.ViewController is not null) - { - vc = pvh2.ViewController; - } - else - { - vc = new CometViewController { MauiContext = Context, CurrentView = x }; - } - } - return new Tuple(x, vc); - }).ToList(); - foreach (var pair in controllers) - { - var title = pair.Item1.GetEnvironment(EnvironmentKeys.TabView.Title); - var imagePath = pair.Item1.GetEnvironment(EnvironmentKeys.TabView.Image); - UIImage image = null; - if (!string.IsNullOrWhiteSpace(imagePath)) - { - // Try SF Symbols first, then fall back to bundle - image = UIImage.GetSystemImage(imagePath) ?? UIImage.FromBundle(imagePath); - } - pair.Item2.TabBarItem = new UITabBarItem() - { - Title = title ?? "", - Image = image, - }; - }; - - tabViewController.ViewControllers = controllers.Select(x => x.Item2).ToArray(); - - // Store explicit tab titles and re-apply them after ViewControllers assignment. - // iOS propagates UINavigationController.topViewController.Title → TabBarItem.Title - // when ViewControllers are set and when CometViewController.LoadView runs later, - // which can overwrite our explicit TabText("Activity") with the page's .Title(). - _explicitTabTitles = new List(); - for (int i = 0; i < controllers.Count; i++) - { - var explicitTitle = controllers[i].Item1.GetEnvironment(EnvironmentKeys.TabView.Title); - _explicitTabTitles.Add(explicitTitle); - if (!string.IsNullOrEmpty(explicitTitle)) - tabViewController.ViewControllers[i].TabBarItem.Title = explicitTitle; - } - } - - /// - /// Apply the selected tab index from the Comet TabView to the native UITabBarController. - /// - public void ApplySelectedIndex(int index) - { - if (tabViewController.ViewControllers is not null && index >= 0 && index < tabViewController.ViewControllers.Length) - tabViewController.SelectedIndex = (nint)index; - } - - /// - /// Apply tab bar colors from environment keys set on the TabView. - /// Called from the handler after Setup. - /// - public void ApplyTabBarAppearance(View tabView) - { - if (tabView is null) return; - - var bgColor = tabView.GetEnvironment(EnvironmentKeys.TabView.BarBackgroundColor); - var tintColor = tabView.GetEnvironment(EnvironmentKeys.TabView.BarTintColor); - var unselectedColor = tabView.GetEnvironment(EnvironmentKeys.TabView.BarUnselectedColor); - - if (bgColor is null && tintColor is null && unselectedColor is null) - return; - - // Translucent appearance with blur so content scrolls behind the bar - var appearance = new UITabBarAppearance(); - appearance.ConfigureWithTransparentBackground(); - appearance.BackgroundEffect = UIBlurEffect.FromStyle(UIBlurEffectStyle.SystemUltraThinMaterial); - appearance.ShadowColor = UIColor.Clear; - - if (bgColor is not null) - appearance.BackgroundColor = bgColor.WithAlpha(0.85f).ToPlatform(); - - tabViewController.TabBar.StandardAppearance = appearance; - tabViewController.TabBar.ScrollEdgeAppearance = appearance; - tabViewController.TabBar.Translucent = true; - - if (tintColor is not null) - tabViewController.TabBar.TintColor = tintColor.ToPlatform(); - - if (unselectedColor is not null) - tabViewController.TabBar.UnselectedItemTintColor = unselectedColor.ToPlatform(); - } - - public override void MovedToSuperview() - { - base.MovedToSuperview(); - var vc = this.GetViewController(); - if (vc is not null) - { - vc.AddChildViewController(tabViewController); - tabViewController.DidMoveToParentViewController(vc); - } - } - public override void RemoveFromSuperview() - { - tabViewController.WillMoveToParentViewController(null); - tabViewController.RemoveFromParentViewController(); - base.RemoveFromSuperview(); - } - public override void LayoutSubviews() - { - base.LayoutSubviews(); - tabViewController.View.Frame = this.Bounds; - - // Re-apply explicit tab titles on every layout pass. - // CometViewController.LoadView() sets vc.Title from the content view, - // and iOS propagates that to UINavigationController.TabBarItem.Title. - // This ensures our TabText() values always win. - if (_explicitTabTitles is not null && tabViewController.ViewControllers is not null) - { - for (int i = 0; i < _explicitTabTitles.Count && i < tabViewController.ViewControllers.Length; i++) - { - if (!string.IsNullOrEmpty(_explicitTabTitles[i])) - tabViewController.ViewControllers[i].TabBarItem.Title = _explicitTabTitles[i]; - } - } - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CUITableView.cs b/src/Comet/src/Comet/Platform/iOS/CUITableView.cs deleted file mode 100644 index 80de3faf1..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUITableView.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using Foundation; -using Microsoft.Maui; -using UIKit; - -namespace Comet.iOS -{ - public class CUITableView : UITableView - { - private CUITableViewSource _delegate; - public IMauiContext MauiContext { get; private set; } - public CUITableView(IMauiContext mauiContext) - { - MauiContext = mauiContext; - _delegate = new CUITableViewSource(mauiContext); - WeakDataSource = _delegate; - WeakDelegate = _delegate; - } - - public IListView ListView - { - get => _delegate.ListView; - set - { - ResetDelegate(value); - - // If we have items in the list, we can check to see if there are frame constraints on the root view. If so, - // we can use those as our cell height. - if (value?.Sections() > 0 && value?.Rows(0) > 0) - { - - var v = value.ViewFor(0, 0); - var constraints = v?.GetFrameConstraints(); - - if (constraints?.Height is not null) - RowHeight = (float)constraints.Height; - else - RowHeight = -1; - } - else - { - RowHeight = -1; - } - - - ReloadData(); - } - } - void ResetDelegate(IListView listView) - { - bool hasHeader = false; - bool valueChanged = false; - var uneven = _delegate.UnevenRows; - if (listView?.Sections() > 0) - { - hasHeader = listView.HeaderFor(0) is not null; - } - if (_delegate.HasHeaders != hasHeader) - { - valueChanged = true; - } - if (valueChanged) - { - _delegate = new CUITableViewSource(MauiContext) - { - HasHeaders = hasHeader, - UnevenRows = uneven, - ListView = listView, - }; - _delegate.HasHeaders = hasHeader; - WeakDataSource = _delegate; - WeakDelegate = _delegate; - } - else - _delegate.ListView = listView; - } - public bool UnevenRows - { - get => _delegate.UnevenRows; - set => _delegate.UnevenRows = value; - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CUITableViewCell.cs b/src/Comet/src/Comet/Platform/iOS/CUITableViewCell.cs deleted file mode 100644 index a596c1219..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUITableViewCell.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Foundation; -using Microsoft.Maui; -using UIKit; - -namespace Comet.iOS -{ - [Register("CUITableViewCell")] - public class CUITableViewCell : UITableViewCell - { - public static int _instanceCount; - - CometView cometView; - public CUITableViewCell(IntPtr ptr) : base(ptr) - { - ContentView.Tag = _instanceCount++; - BackgroundColor = UIColor.Clear; - } - - public CUITableViewCell(NSString resuseIdentifier) : base(UITableViewCellStyle.Default,resuseIdentifier) - { - ContentView.Tag = _instanceCount++; - BackgroundColor = UIColor.Clear; - } - - public void SetFromContext(IMauiContext context) - { - if (cometView is not null) - return; - - ContentView.Tag = _instanceCount++; - cometView = new CometView(ContentView.Bounds, context); - cometView.AutoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight; - - ContentView.AutosizesSubviews = true; - ContentView.AddSubview(cometView); - } - - public CUITableViewCell(IMauiContext context, UITableViewCellStyle style, string reuseIdentifier) : base(style, reuseIdentifier) - { - SetFromContext(context); - } - - public void SetView(View view) - { - var previousView = cometView.CurrentView; - cometView.CurrentView = view; - (previousView as View)?.ViewDidDisappear(); - view?.ViewDidAppear(); - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CUITableViewSource.cs b/src/Comet/src/Comet/Platform/iOS/CUITableViewSource.cs deleted file mode 100644 index b2bfb2c8d..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUITableViewSource.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using Foundation; -using Microsoft.Maui; -using ObjCRuntime; -using UIKit; - -namespace Comet.iOS -{ - public class CUITableViewSource : UITableViewSource - { - public CUITableViewSource(IMauiContext context) - { - Context = context; - } - public IMauiContext Context { get; protected set; } - private IListView _listView; - private bool _unevenRows; - private double? _rowHeight; - - public IListView ListView - { - get => _listView; - set - { - _listView = value; - _rowHeight = null; - } - } - - public bool UnevenRows - { - get => _unevenRows; - set - { - _unevenRows = value; - _rowHeight = null; - } - } - public bool HasHeaders { get; set; } - - public override bool RespondsToSelector(Selector sel) - { - if (sel.Name == "tableView:viewForHeaderInSection:") - { - return HasHeaders; - - } - return base.RespondsToSelector(sel); - } - public override UIView GetViewForHeader(UITableView tableView, nint section) => _listView?.HeaderFor((int)section).ToPlatform(Context); - - public override nint NumberOfSections(UITableView tableView) => _listView?.Sections() ?? 0; - - public override nint RowsInSection(UITableView tableview, nint section) => _listView?.Rows((int)section) ?? 0; - - public override void RowSelected(UITableView tableView, NSIndexPath indexPath) - { - tableView.DeselectRow(indexPath, true); - _listView?.OnSelected(indexPath.Section, indexPath.Row); - } - - public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) - { - var v = _listView?.ViewFor(indexPath.Section, indexPath.Row); - var cellIdentifier = new NSString($"{v.GetContentTypeHashCode()}"); - var cell = tableView.DequeueReusableCell(cellIdentifier) as CUITableViewCell ?? new CUITableViewCell(cellIdentifier); - cell.SetFromContext(Context); - cell.SetView(v); - return cell; - } - - public override nfloat GetHeightForRow(UITableView tableView, NSIndexPath indexPath) - { - if (tableView.RowHeight > 0) - return tableView.RowHeight; - - if (_unevenRows) - return (nfloat)CalculateRowHeight(tableView, indexPath.Section, indexPath.Row); - - if (_rowHeight is null) - _rowHeight = CalculateRowHeight(tableView, indexPath.Section, indexPath.Row); - - return (nfloat)_rowHeight; - } - - private double CalculateRowHeight(UITableView tableView, int section, int row) - { - // todo: we really need a "GetOrCreate" method. - var view = _listView?.ViewFor(section, row); - if (view is not null) - { - var constraints = view.GetFrameConstraints(); - - if (constraints?.Height is not null) - return (float)constraints?.Height; - - // todo: this is really inefficient. - if (view.ToPlatform(Context) is not null) - { - var size = tableView.Bounds.Size; - var measure = view.Measure(size.Width,size.Height); - return measure.Height; - } - } - - - return 44f; - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CUITapGestures.cs b/src/Comet/src/Comet/Platform/iOS/CUITapGestures.cs deleted file mode 100644 index 3dd9fdada..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CUITapGestures.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System; -using System.Linq; -using Foundation; -using Microsoft.Maui.Graphics; -using UIKit; -namespace Comet.iOS -{ - public class CUITapGesture : UITapGestureRecognizer - { - public CUITapGesture(TapGesture gesture) : base(() => gesture.Invoke()) - { - gesture.PlatformGesture = this; - } - public override UIGestureRecognizerState State { get => base.State; set => base.State = value; } - } - - public class CUILongPressGesture : UILongPressGestureRecognizer - { - readonly LongPressGesture _gesture; - public CUILongPressGesture(LongPressGesture gesture) : base(() => gesture.Invoke()) - { - _gesture = gesture; - gesture.PlatformGesture = this; - MinimumPressDuration = gesture.MinimumPressDuration; - } - } - - public class CUIPanGesture : UIPanGestureRecognizer - { - readonly PanGesture _gesture; - public CUIPanGesture(PanGesture gesture) - { - _gesture = gesture; - gesture.PlatformGesture = this; - AddTarget(() => - { - var translation = TranslationInView(View); - _gesture.TotalX = translation.X; - _gesture.TotalY = translation.Y; - _gesture.Status = State switch - { - UIGestureRecognizerState.Began => GestureStatus.Started, - UIGestureRecognizerState.Changed => GestureStatus.Running, - UIGestureRecognizerState.Ended => GestureStatus.Completed, - UIGestureRecognizerState.Cancelled => GestureStatus.Canceled, - _ => GestureStatus.Running - }; - _gesture.Invoke(); - }); - } - } - - public class CUIPinchGesture : UIPinchGestureRecognizer - { - readonly PinchGesture _gesture; - public CUIPinchGesture(PinchGesture gesture) - { - _gesture = gesture; - gesture.PlatformGesture = this; - AddTarget(() => - { - _gesture.Scale = Scale; - _gesture.Status = State switch - { - UIGestureRecognizerState.Began => GestureStatus.Started, - UIGestureRecognizerState.Changed => GestureStatus.Running, - UIGestureRecognizerState.Ended => GestureStatus.Completed, - UIGestureRecognizerState.Cancelled => GestureStatus.Canceled, - _ => GestureStatus.Running - }; - _gesture.Invoke(); - }); - } - } - - public class CUISwipeGesture : UISwipeGestureRecognizer - { - public CUISwipeGesture(SwipeGesture gesture) : base(() => gesture.Invoke()) - { - gesture.PlatformGesture = this; - Direction = gesture.Direction switch - { - SwipeDirection.Left => UISwipeGestureRecognizerDirection.Left, - SwipeDirection.Right => UISwipeGestureRecognizerDirection.Right, - SwipeDirection.Up => UISwipeGestureRecognizerDirection.Up, - SwipeDirection.Down => UISwipeGestureRecognizerDirection.Down, - _ => UISwipeGestureRecognizerDirection.Left - }; - } - } - - public class CUIHoverGesture : UIHoverGestureRecognizer - { - readonly PointerGesture _gesture; - readonly WeakReference _viewRef; - - public CUIHoverGesture(PointerGesture gesture, View view) : base(() => { }) - { - _gesture = gesture; - _viewRef = new WeakReference(view); - gesture.PlatformGesture = this; - AddTarget(() => - { - if (!_viewRef.TryGetTarget(out var cometView)) - return; - var location = LocationInView(View); - var point = new Point(location.X, location.Y); - switch (State) - { - case UIGestureRecognizerState.Began: - _gesture.PointerEntered?.Invoke(cometView, point); - _gesture.PointerEnteredCommand?.Execute(point); - break; - case UIGestureRecognizerState.Changed: - _gesture.PointerMoved?.Invoke(cometView, point); - _gesture.PointerMovedCommand?.Execute(point); - break; - case UIGestureRecognizerState.Ended: - case UIGestureRecognizerState.Cancelled: - _gesture.PointerExited?.Invoke(cometView, point); - _gesture.PointerExitedCommand?.Execute(point); - break; - } - }); - } - } - - class CUIDragInteractionDelegate : UIDragInteractionDelegate - { - readonly DragGesture _gesture; - readonly WeakReference _viewRef; - - public CUIDragInteractionDelegate(DragGesture gesture, View view) - { - _gesture = gesture; - _viewRef = new WeakReference(view); - } - - public override UIDragItem[] GetItemsForBeginningSession(UIDragInteraction interaction, IUIDragSession session) - { - if (!_gesture.CanDrag) - return Array.Empty(); - - _viewRef.TryGetTarget(out var view); - var data = _gesture.DragStarting?.Invoke(view); - _gesture.DragStartingCommand?.Execute(_gesture.DragStartingCommandParameter); - - var itemProvider = new NSItemProvider(new NSString(data?.ToString() ?? string.Empty)); - return new[] { new UIDragItem(itemProvider) }; - } - - public override void SessionDidEnd(UIDragInteraction interaction, IUIDragSession session, UIDropOperation operation) - { - _viewRef.TryGetTarget(out var view); - _gesture.DropCompleted?.Invoke(view); - _gesture.DropCompletedCommand?.Execute(_gesture.DropCompletedCommandParameter); - } - } - - class CUIDropInteractionDelegate : UIDropInteractionDelegate - { - readonly DropGesture _gesture; - readonly WeakReference _viewRef; - - public CUIDropInteractionDelegate(DropGesture gesture, View view) - { - _gesture = gesture; - _viewRef = new WeakReference(view); - } - - public override bool CanHandleSession(UIDropInteraction interaction, IUIDropSession session) => _gesture.AllowDrop; - - public override UIDropProposal SessionDidUpdate(UIDropInteraction interaction, IUIDropSession session) - { - _viewRef.TryGetTarget(out var view); - var accepted = _gesture.DragOver?.Invoke(view, null) ?? true; - _gesture.DragOverCommand?.Execute(null); - return new UIDropProposal(accepted ? UIDropOperation.Copy : UIDropOperation.Cancel); - } - - public override void PerformDrop(UIDropInteraction interaction, IUIDropSession session) - { - _viewRef.TryGetTarget(out var view); - - // Try to load string data from the first drag item before invoking callbacks - var items = session.Items; - if (items is not null && items.Length > 0) - { - items[0].ItemProvider.LoadObject(new ObjCRuntime.Class(typeof(NSString)), (data, error) => - { - CoreFoundation.DispatchQueue.MainQueue.DispatchAsync(() => - { - var dropData = data is NSString str ? str.ToString() : null; - _gesture.Drop?.Invoke(view, dropData); - _gesture.DropCommand?.Execute(_gesture.DropCommandParameter); - }); - }); - } - else - { - // No items to load — invoke immediately with null data - _gesture.Drop?.Invoke(view, null); - _gesture.DropCommand?.Execute(_gesture.DropCommandParameter); - } - } - - public override void SessionDidExit(UIDropInteraction interaction, IUIDropSession session) - { - _viewRef.TryGetTarget(out var view); - _gesture.DragLeave?.Invoke(view); - _gesture.DragLeaveCommand?.Execute(null); - } - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CometView.cs b/src/Comet/src/Comet/Platform/iOS/CometView.cs deleted file mode 100644 index a51467d87..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CometView.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using CoreGraphics; -using UIKit; -using Microsoft.Maui.HotReload; -using Microsoft.Maui; - -namespace Comet.iOS -{ - public class CometView : UIView, IReloadHandler - { - bool _inLayout; - - public CometView(IMauiContext mauiContext) { - MauiContext = mauiContext; - BackgroundColor = UIColor.SystemBackground; - } - public CometView(CGRect rect, IMauiContext mauiContext) : base(rect) - { - MauiContext = mauiContext; - BackgroundColor = UIColor.SystemBackground; - } - IView _view; - public IView CurrentView - { - get => _view; - set => SetView(value); - } - public IMauiContext MauiContext { get; internal set; } - - UIView currentPlatformView; - IViewHandler currentHandler; - void SetView(IView view, bool forceRefresh = false) - { - if (view == _view && !forceRefresh) - return; - //reuse the handlers! - if(view is View v && _view is View pv && - v.GetContentTypeHashCode() == pv.GetContentTypeHashCode() - && currentHandler is not null) - { - _view = view; - v.ViewHandler = currentHandler; - if (_view is IHotReloadableView ihr1) - { - ihr1.ReloadHandler = this; - MauiHotReloadHelper.AddActiveView(ihr1); - } - PropagateContentBackground(); - return; - } - - _view = view; - - if (_view is IHotReloadableView ihr) - { - ihr.ReloadHandler = this; - MauiHotReloadHelper.AddActiveView(ihr); - } - // Resolve views with a Body (e.g. Component) to their concrete view tree - // before calling ToPlatform, to avoid circular CometViewHandler→CometView loop. - var viewToRender = _view; - if (viewToRender is View cometView && cometView.Body is not null) - { - viewToRender = cometView.GetView(); - } - var newPlatformView = viewToRender?.ToPlatform(MauiContext); - currentHandler = _view?.Handler; - if (currentPlatformView == newPlatformView) - return; - currentPlatformView?.RemoveFromSuperview(); - if (newPlatformView != this && newPlatformView is not null) - AddSubview(currentPlatformView = newPlatformView); - - PropagateContentBackground(); - } - - /// - /// Copies the content view's background to this container so the safe area - /// regions (behind status bar, home indicator) show the correct color - /// instead of the default SystemBackground. - /// - void PropagateContentBackground() - { - if (_view is not View cometView) return; - - var bg = cometView.GetBackground(); - // Walk into the rendered body if the component itself has no background - if (bg is null && cometView.Body is not null) - { - var bodyView = cometView.GetView() as View; - bg = bodyView?.GetBackground(); - } - - if (bg is Microsoft.Maui.Graphics.SolidPaint solid && solid.Color is not null) - BackgroundColor = solid.Color.ToPlatform(); - } - - - public override void LayoutSubviews() - { - if (_inLayout) - return; - _inLayout = true; - try - { - base.LayoutSubviews(); - if (currentPlatformView is null || Bounds.Width <= 0 || Bounds.Height <= 0) - return; - - // Invalidate measurement so the view tree remeasures with - // new constraints (critical for device rotation). - if (_view is View cometView) - cometView.MeasurementValid = false; - - _view?.Measure(Bounds.Width, Bounds.Height); - _view?.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, Bounds.Width, Bounds.Height)); - currentPlatformView.Frame = Bounds; - currentPlatformView.SetNeedsLayout(); - currentPlatformView.LayoutIfNeeded(); - } - finally - { - _inLayout = false; - } - } - - - - public void Reload() => SetView(CurrentView, true); - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/CometViewController.cs b/src/Comet/src/Comet/Platform/iOS/CometViewController.cs deleted file mode 100644 index 0bd5c5c48..000000000 --- a/src/Comet/src/Comet/Platform/iOS/CometViewController.cs +++ /dev/null @@ -1,288 +0,0 @@ -using System; -using System.Drawing; -using System.Threading.Tasks; -using CoreGraphics; -using Foundation; -using Microsoft.Maui; -using Microsoft.Maui.Graphics; -using UIKit; - -namespace Comet.iOS -{ - public class CometViewController : UIViewController - { - private CometView _containerView; - private View _startingCurrentView; - public IMauiContext MauiContext { get; set; } - - /// - /// Reference to the parent NavigationView. Used to resolve fallback toolbar - /// items when the pushed view doesn't declare its own. - /// - internal NavigationView NavigationViewRef { get; set; } - - public CometViewController() - { - // Ensure edge-to-edge layout so content extends behind the - // translucent navigation bar and tab bar. - EdgesForExtendedLayout = UIRectEdge.All; - ExtendedLayoutIncludesOpaqueBars = true; - } - - public View CurrentView - { - get => _containerView?.CurrentView as View ?? _startingCurrentView; - set - { - if (_containerView is not null) - _containerView.CurrentView = value; - else - _startingCurrentView = value; - - Title = value?.GetTitle() ?? ""; - - // Re-apply nav bar style when the view updates (e.g. on theme change - // re-render) so chrome colors stay in sync. - if (IsViewLoaded && NavigationController is not null) - ApplyStyle(); - } - } - - public object PlatformView => null; - - public bool HasContainer { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - - bool wasPopped; - public void WasPopped() => wasPopped = true; - - public override void LoadView() - { - base.View = _containerView = new CometView(MauiContext); - _containerView.CurrentView = _startingCurrentView; - Title = _startingCurrentView?.GetTitle() ?? ""; - _startingCurrentView = null; - } - internal CometView ContainerView - { - get => _containerView; - set - { - _containerView?.RemoveFromSuperview(); - View = _containerView = value; - } - } - - /// - /// Walks the Comet view tree to find the effective background paint. - /// Components and views with a Body delegate their rendering to a child - /// view tree, so the background is often on the child, not the component. - /// - static Paint GetEffectiveBackground(View view) - { - if (view is null) return null; - - var bg = view.GetBackground(); - if (bg is not null) return bg; - - // Walk into the rendered body view tree - if (view.Body is not null) - { - var bodyView = view.GetView() as View; - if (bodyView is not null) - { - bg = bodyView.GetBackground(); - if (bg is not null) return bg; - } - } - - return null; - } - - public override void ViewDidAppear(bool animated) - { - base.ViewDidAppear(animated); - CurrentView?.ViewDidAppear(); - - // Set the UIWindow background to match the content so safe area - // edges (status bar, home indicator) show the correct color instead of - // black/white letterboxing. - if (View?.Window is not null) - { - UIKit.UIColor bgColor = null; - var bg = GetEffectiveBackground(CurrentView); - if (bg is Microsoft.Maui.Graphics.SolidPaint solid && solid.Color is not null) - bgColor = solid.Color.ToPlatform(); - bgColor ??= _containerView?.BackgroundColor; - if (bgColor is not null) - View.Window.BackgroundColor = bgColor; - } - } - - public override void ViewWillAppear(bool animated) - { - base.ViewWillAppear(animated); - - var view = CurrentView; - - // Propagate background color to the container view so it extends - // into safe area insets (prevents white/black letterboxing). - if (view is not null && _containerView is not null) - { - var bg = GetEffectiveBackground(view); - if (bg is Microsoft.Maui.Graphics.SolidPaint solid && solid.Color is not null) - { - _containerView.BackgroundColor = solid.Color.ToPlatform(); - } - } - - ApplyStyle(); - ApplyToolbarItemsFromView(); - } - - /// - /// Applies toolbar items from the current view to the navigation item. - /// Called in ViewWillAppear after LoadView has built the Component's view tree, - /// so .ToolbarItems() set inside Render() are discoverable via BuiltView. - /// - void ApplyToolbarItemsFromView() - { - var view = CurrentView; - if (view is null) return; - - // Check the view (Component) and its built view for toolbar items - var items = view.GetToolbarItems(); - - // Fall back to the NavigationView's toolbar items - if (items.Count == 0 && NavigationViewRef is not null) - items = NavigationViewRef.ToolbarItems; - - if (items is null || items.Count == 0) return; - - var rightItems = new System.Collections.Generic.List(); - foreach (var item in items) - { - if (item.Order == ToolbarItemOrder.Secondary) continue; - var toolbarAction = item.OnClicked; - UIBarButtonItem barItem; - - // Render font icon as UIImage if font family is specified - if (!string.IsNullOrEmpty(item.IconGlyph) && !string.IsNullOrEmpty(item.IconFontFamily)) - { - var image = Handlers.NavigationViewHandler.CreateFontIconImage(item.IconGlyph, item.IconFontFamily, 24); - if (image is not null) - { - barItem = new UIBarButtonItem( - image.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate), - UIBarButtonItemStyle.Plain, - (s, e) => toolbarAction?.Invoke()); - } - else - { - barItem = new UIBarButtonItem( - item.IconGlyph ?? item.Text ?? "", - UIBarButtonItemStyle.Plain, - (s, e) => toolbarAction?.Invoke()); - } - } - // Try SF Symbol (works for "plus", "trash", "star.fill", etc.) - else if (!string.IsNullOrEmpty(item.IconGlyph)) - { - var sfImage = UIImage.GetSystemImage(item.IconGlyph); - if (sfImage is not null) - { - barItem = new UIBarButtonItem( - sfImage, - UIBarButtonItemStyle.Plain, - (s, e) => toolbarAction?.Invoke()); - } - else - { - barItem = new UIBarButtonItem( - item.Text ?? item.IconGlyph, - UIBarButtonItemStyle.Plain, - (s, e) => toolbarAction?.Invoke()); - } - } - else - { - barItem = new UIBarButtonItem( - item.IconGlyph ?? item.Text ?? "", - UIBarButtonItemStyle.Plain, - (s, e) => toolbarAction?.Invoke()); - } - barItem.Enabled = item.IsEnabled; - rightItems.Add(barItem); - } - if (rightItems.Count > 0) - NavigationItem.RightBarButtonItems = rightItems.ToArray(); - } - - public override void ViewDidDisappear(bool animated) - { - base.ViewDidDisappear(animated); - CurrentView?.ViewDidDisappear(); - if (wasPopped) - { - CurrentView?.Dispose(); - CurrentView = null; - } - } - - public void ApplyStyle() - { - if (NavigationController is null) - return; - - var navBar = NavigationController.NavigationBar; - var barColor = CurrentView?.GetNavigationBackgroundColor()?.ToPlatform(); - var textColor = CurrentView?.GetNavigationTextColor()?.ToPlatform(); - - // Use the standard iOS translucent blur for the nav bar. - // ConfigureWithDefaultBackground gives the native glass/blur effect - // that lets scrolled content show through the bar. - var appearance = new UINavigationBarAppearance(); - appearance.ConfigureWithDefaultBackground(); - appearance.ShadowColor = UIColor.Clear; - - if (barColor is not null) - appearance.BackgroundColor = barColor; - - if (textColor is not null) - { - appearance.LargeTitleTextAttributes = new UIStringAttributes { ForegroundColor = textColor }; - appearance.TitleTextAttributes = new UIStringAttributes { ForegroundColor = textColor }; - navBar.TintColor = textColor; - } - - // StandardAppearance: the bar look when content is scrolled behind it. - navBar.StandardAppearance = appearance; - - // ScrollEdgeAppearance: the bar look when content is at the top edge - // (not scrolled). Use transparent so the bar blends with the page - // background when the user hasn't scrolled yet. - var edgeAppearance = new UINavigationBarAppearance(); - edgeAppearance.ConfigureWithTransparentBackground(); - edgeAppearance.ShadowColor = UIColor.Clear; - if (textColor is not null) - { - edgeAppearance.LargeTitleTextAttributes = new UIStringAttributes { ForegroundColor = textColor }; - edgeAppearance.TitleTextAttributes = new UIStringAttributes { ForegroundColor = textColor }; - } - navBar.ScrollEdgeAppearance = edgeAppearance; - - navBar.CompactAppearance = appearance; - navBar.Translucent = true; - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - CurrentView?.Dispose(); - CurrentView = null; - } - base.Dispose(disposing); - } - - } -} diff --git a/src/Comet/src/Comet/Platform/iOS/HandlerExtensions.cs b/src/Comet/src/Comet/Platform/iOS/HandlerExtensions.cs deleted file mode 100644 index ca9219fc3..000000000 --- a/src/Comet/src/Comet/Platform/iOS/HandlerExtensions.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Comet.iOS; -using Microsoft.Maui.Handlers; -using UIKit; - -namespace Comet; - -public static partial class HandlerExtensions -{ - public static void AddGesture(this IViewHandler handler, Gesture gesture) - { - var nativeView = (UIView)handler.PlatformView; - nativeView.UserInteractionEnabled = true; - var view = handler.VirtualView as View; - - if (gesture is DragGesture dragGesture) - { - var del = new CUIDragInteractionDelegate(dragGesture, view); - var interaction = new UIDragInteraction(del); - interaction.Enabled = true; - nativeView.AddInteraction(interaction); - gesture.PlatformGesture = interaction; - return; - } - - if (gesture is DropGesture dropGesture) - { - var del = new CUIDropInteractionDelegate(dropGesture, view); - var interaction = new UIDropInteraction(del); - nativeView.AddInteraction(interaction); - gesture.PlatformGesture = interaction; - return; - } - - if (gesture is PointerGesture pointerGesture) - { - var hover = new CUIHoverGesture(pointerGesture, view); - nativeView.AddGestureRecognizer(hover); - return; - } - - nativeView.AddGestureRecognizer(gesture.ToGestureRecognizer()); - } - - public static void RemoveGesture(this IViewHandler handler, Gesture gesture) - { - var nativeView = (UIView)handler.PlatformView; - if (gesture.PlatformGesture is UIGestureRecognizer g) - nativeView.RemoveGestureRecognizer(g); - else if (gesture.PlatformGesture is IUIInteraction interaction) - nativeView.RemoveInteraction(interaction); - } -} - - diff --git a/src/Comet/src/Comet/Platform/iOS/iOSExtensions.cs b/src/Comet/src/Comet/Platform/iOS/iOSExtensions.cs deleted file mode 100644 index ba3ab4276..000000000 --- a/src/Comet/src/Comet/Platform/iOS/iOSExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using UIKit; -namespace Comet.iOS -{ - public static class iOSExtensions - { - public static UIViewController GetViewController(this UIView view) - { - if (view.NextResponder is UIViewController vc) - return vc; - if (view.NextResponder is UIView uIView) - return uIView.GetViewController(); - return null; - } - - public static UIGestureRecognizer ToGestureRecognizer(this Gesture gesture) - { - if (gesture is TapGesture tapGesture) - return new CUITapGesture(tapGesture); - if (gesture is LongPressGesture longPressGesture) - return new CUILongPressGesture(longPressGesture); - if (gesture is PanGesture panGesture) - return new CUIPanGesture(panGesture); - if (gesture is PinchGesture pinchGesture) - return new CUIPinchGesture(pinchGesture); - if (gesture is SwipeGesture swipeGesture) - return new CUISwipeGesture(swipeGesture); - throw new NotSupportedException($"Gesture type {gesture.GetType().Name} is not supported."); - } - } -} diff --git a/src/Comet/src/Comet/Reactive/PropertySubscription.cs b/src/Comet/src/Comet/Reactive/PropertySubscription.cs index 841dbb5bd..ce451b279 100644 --- a/src/Comet/src/Comet/Reactive/PropertySubscription.cs +++ b/src/Comet/src/Comet/Reactive/PropertySubscription.cs @@ -140,6 +140,14 @@ public PropertySubscription(Signal signal, EqualityComparer? comparer = nu Evaluate(); } + /// + /// Forces a re-evaluation of a Func-backed subscription. Needed when the compute reads + /// state the reactive system can't see (an untracked Peek, an external collection): the + /// dependency set never fires, so a data-changed signal (e.g. ListView.ReloadData) must + /// re-pull the snapshot explicitly. No-op for static subscriptions. + /// + internal void Reevaluate() => Evaluate(); + /// /// Binds this subscription to a and property name so that /// value changes dispatch to . diff --git a/src/Comet/src/Comet/Reactive/ReactiveScheduler.cs b/src/Comet/src/Comet/Reactive/ReactiveScheduler.cs index ef9207e45..e28eda03f 100644 --- a/src/Comet/src/Comet/Reactive/ReactiveScheduler.cs +++ b/src/Comet/src/Comet/Reactive/ReactiveScheduler.cs @@ -32,6 +32,44 @@ internal static bool SuppressNotifications set => _suppressNotifications = value; } + /// Depth of active scopes on this thread. + [ThreadStatic] + static int _holdDepth; + + /// + /// Defers reactive flushes for the duration of the returned scope. An own-content node + /// swapping its hosted subtree materializes views whose modifiers write the environment; + /// each write schedules a flush, and on the UI thread the flush runs INLINE — its + /// AfterFlush layout pass would then re-arrange the node that is still mid-build and + /// re-enter the swap (double-built subtrees, lost node state). Holding makes the swap + /// atomic: writes just mark the flush pending, and it runs once when the scope closes. + /// + public static IDisposable HoldFlushes() + { + _holdDepth++; + return new FlushHold(); + } + + sealed class FlushHold : IDisposable + { + bool _released; + public void Dispose() + { + if (_released) + return; + _released = true; + if (--_holdDepth == 0 && _flushScheduled) + { + // Re-drive scheduling for the pending work that accumulated during the hold. + lock (_lock) + { + _flushScheduled = false; + } + EnsureFlushScheduled(); + } + } + } + public static void EnsureFlushScheduled() { // If a flush is already in progress, skip scheduling — the current @@ -41,6 +79,17 @@ public static void EnsureFlushScheduled() if (_flushScheduled || _flushing) return; + // A HoldFlushes scope is active on this thread: record that a flush is wanted and + // let the scope run it on release (keeps hosted-subtree swaps atomic). + if (_holdDepth > 0) + { + lock (_lock) + { + _flushScheduled = true; + } + return; + } + lock (_lock) { if (_flushScheduled) @@ -111,21 +160,84 @@ internal static void MarkViewDirty(CometView view) EnsureFlushScheduled(); } + /// + /// Raised on the UI thread after a full flush cycle settles (effects + view reloads + their + /// backend property pushes are done). The layout-driving backends subscribe to this to + /// recompute Yoga layout once per flush, so reactive content-size changes reflow. + /// + public static event Action? AfterFlush; + + /// True while is running on this thread — including its + /// phase, which deliberately excludes. + [ThreadStatic] + static bool _inFlushEntry; + static void FlushEntry() { - lock (_lock) - { - _flushScheduled = false; - } + // Re-entrancy guard. An AfterFlush handler that BUILDS views — an own-content node + // refreshing its hosted subtree during the post-flush layout pass — writes the + // environment, which calls EnsureFlushScheduled; with _flushing already false that + // would run a nested FlushEntry inline, whose AfterFlush re-arranges the node that is + // still mid-build and recurses without bound (the Reply detail-swap stack overflow). + // Instead leave _flushScheduled set and return: the outer call's pass loop below + // picks the new work up after the current pass settles. + if (_inFlushEntry) + return; - _flushing = true; + _inFlushEntry = true; try { - Flush(depth: 0); + for (int pass = 0; ; pass++) + { + lock (_lock) + { + _flushScheduled = false; + } + + _flushing = true; + try + { + Flush(depth: 0); + } + finally + { + _flushing = false; + } + + AfterFlush?.Invoke(); + + lock (_lock) + { + if (!_flushScheduled && _dirtyEffects.Count == 0 && _dirtyViews.Count == 0) + return; + } + + if (pass >= MaxFlushDepth) + { + ReactiveDiagnostics.NotifyFlushDepthWarning(pass); + Debug.WriteLine( + $"[Comet.Reactive] ReactiveScheduler exceeded {MaxFlushDepth} AfterFlush passes. " + + "This indicates AfterFlush work that re-dirties the graph every pass " + + "(e.g. a layout handler rebuilding views unconditionally). Breaking the cycle."); +#if DEBUG + throw new InvalidOperationException( + $"Reactive AfterFlush cycle detected: exceeded {MaxFlushDepth} passes. " + + "Check AfterFlush handlers that write signals/environment on every pass."); +#else + lock (_lock) + { + _flushScheduled = false; + _dirtyEffects.Clear(); + _dirtyViews.Clear(); + } + return; +#endif + } + } } finally { - _flushing = false; + _inFlushEntry = false; } } diff --git a/src/Comet/src/vendor/LICENSE.Microsoft.AndroidX.Compose b/src/Comet/src/vendor/LICENSE.Microsoft.AndroidX.Compose new file mode 100644 index 000000000..19bd1b69f --- /dev/null +++ b/src/Comet/src/vendor/LICENSE.Microsoft.AndroidX.Compose @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jonathan Peppers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Attributes.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Attributes.cs new file mode 100644 index 000000000..7de898b0f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Attributes.cs @@ -0,0 +1,545 @@ +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Source text added to the user's compilation via +/// . +/// +internal static class Attributes +{ + public const string Source = """ + // + #nullable enable + namespace AndroidX.Compose + { + /// + /// Apply at assembly scope to generate a named-bit + /// [Flags] internal enum <EnumName> from the + /// parameter shape of 's static + /// method named by the constructor's methodName + /// argument. Bit numbers match the Kotlin parameter index + /// in the bytecode — the position the $default + /// bitmask expects. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Assembly, + AllowMultiple = true)] + internal sealed class ComposeDefaultsAttribute : global::System.Attribute + { + public ComposeDefaultsAttribute(string methodName, string enumName) { } + } + + /// + /// Apply at assembly scope to generate a named-bit + /// [Flags] internal enum <EnumName> from an + /// explicit positional parameter list. Use this overload + /// when the Kotlin $default-bearing overload was + /// stripped from the binding (mangled JVM name, inline-class + /// param, etc.) and there is no IMethodSymbol to read. + /// Each name occupies one bit at its index; prefix a name + /// with ! to consume the bit without emitting an + /// enum member (e.g. params the caller always provides). + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Assembly, + AllowMultiple = true)] + internal sealed class ComposeDefaultsAttribute : global::System.Attribute + { + public ComposeDefaultsAttribute(string enumName, params string[] parameterNames) { } + } + + /// + /// Apply to a partial static method on + /// ComposeBridges to have AndroidX.Compose.SourceGenerators + /// emit its JNI-bridge body. The partial method declares the + /// user-facing C# signature; the generator fills in cache + /// fields, lazy class/method ID resolution, the JValue array, + /// the $default bitmask, and the + /// try/finally + GC.KeepAlive block. + /// + /// + /// Class: JNI-internal class name, e.g. + /// androidx/compose/material3/ButtonKt. + /// JvmName: the (often mangled) Kotlin-emitted + /// method name, e.g. Button or Surface-T9BRK9s. + /// Signature: full JNI signature string for the + /// underlying static method. Ends in + /// ...Composer;I*I)<ReturnType> (one trailing + /// I per $changed group plus one for + /// $default) when the function has at least one + /// defaultable parameter, or ...Composer;I*)<ReturnType> + /// when every parameter is required (no $default slot + /// in the bytecode). The return-type token is V for + /// void bridges or an L...; for state holders / + /// factories. + /// Defaults: typeof(XxxDefault) — the + /// $default bitmask enum produced by + /// . The generator reads + /// the matching declarative [ComposeDefaults(...)] + /// attribute to learn the Kotlin parameter names + bit + /// positions. Required when the JNI signature has a + /// $default slot; must be omitted otherwise. The + /// generator cross-checks this against the signature and + /// reports CN2005 on a mismatch. + /// InstanceField: when set, the bridge calls an + /// instance method on a Kotlin object singleton — name + /// the $$INSTANCE-style static field + /// (e.g. INSTANCE) and the generator emits + /// GetMethodID + CallObjectMethod instead. + /// Suspend: opts into the Kotlin suspend + /// bridge shape. The trailing C# parameter must be + /// Kotlin.Coroutines.IContinuation (or an implementor — + /// SuspendContinuation works), the C# return type must + /// be System.IntPtr (raw — boxing into a + /// peer would collide + /// with Mono's peer cache for the COROUTINE_SUSPENDED + /// singleton), and the JNI return type must be + /// Ljava/lang/Object;. The generator detects two + /// sub-shapes from the JNI signature: + /// + /// Instance suspend (no trailing + /// I L<Object>): the first C# user parameter + /// (which must be IntPtr) is the receiver; the bridge + /// emits GetMethodID + CallObjectMethod(receiver, + /// ...) and the receiver is NOT in the JNI signature. + /// Static $default suspend + /// (trailing I L<Object>): same shape as + /// 's extension-with-default + /// flow — the first C# user parameter is the receiver AND + /// occupies JNI slot 0; the bridge emits + /// GetStaticMethodID + CallStaticObjectMethod. + /// + /// Cannot be combined with a Composer slot or a constructor + /// JvmName. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Method, + AllowMultiple = false)] + internal sealed class ComposeBridgeAttribute : global::System.Attribute + { + public ComposeBridgeAttribute() { } + public string Class { get; set; } = ""; + public string JvmName { get; set; } = ""; + public string Signature { get; set; } = ""; + public global::System.Type? Defaults { get; set; } + public string? InstanceField { get; set; } + public bool Suspend { get; set; } + } + + /// + /// Apply alongside on a + /// partial method in ComposeBridges to have + /// ComposeFacadeGenerator emit the corresponding + /// user-facing facade class (a public sealed partial + /// / + /// in namespace AndroidX.Compose). + /// + /// + /// The facade's constructor, base class, and Render body + /// are inferred from the bridge's C# parameter shape: + /// + /// IModifier? modifier → render uses + /// BuildModifier(); no ctor param. + /// IFunction0 <name> → ctor takes + /// System.Action <name>; render wraps in + /// ComposableLambda0. + /// IFunction2 content / + /// IFunction3 content → container shape, base + /// ComposableContainer; render uses + /// ComposableLambdas.Wrap2 / Wrap3. + /// Primitive (string, int, + /// long, bool, float, double) → + /// positional ctor parameter. + /// + /// Anything else (e.g. IFunction1 callbacks, value-class + /// handles, the caller-controlled int defaults hatch) is + /// rejected via diagnostic CN3002 — leave that bridge hand-written. + /// Docs & overrides: the generated class is + /// sealed partial. Add a sibling stub + /// src/Microsoft.AndroidX.Compose/<Name>.cs with the + /// ///<summary> comment (and any extra members + /// the facade needs) — the generator deliberately does not + /// emit a summary so the stub owns it. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Method, + AllowMultiple = false)] + internal sealed class ComposeFacadeAttribute : global::System.Attribute + { + public ComposeFacadeAttribute() { } + + /// Optional override for the generated class name. Defaults to the bridge method name. + public string? ClassName { get; set; } + + /// + /// Optional scope to publish from an IFunction3 content + /// lambda. "Row" (e.g. , + /// ) or "Column". The lambda + /// receives the scope handle and pushes it via + /// RenderContext.PushScope(scope, ScopeKind.X) so + /// child composables can read it. Requires a bridge with + /// an IFunction3 content parameter. + /// + public string? Scope { get; set; } + + /// + /// Phase 6 — name of a ColorScheme property (e.g. + /// "secondaryContainer") to use as a runtime fallback + /// when the user leaves the bridge's container-color + /// parameter at its sentinel 0L. The generator + /// emits a long ContainerColor { get; set; } + /// property and binds the named bridge param (see + /// ) to + /// ContainerColor != 0L ? ContainerColor : + /// MaterialTheme.Instance.GetColorScheme(composer,0).<Slot>. + /// + public string? DefaultColorFromTheme { get; set; } + + /// + /// Phase 6 — the name of the bridge's long + /// parameter that + /// targets. Optional when the bridge has exactly one + /// long user parameter; required when there's + /// ambiguity. + /// + public string? ColorParameter { get; set; } + + /// + /// Phase 8 — fallback for the $default enum when + /// the facade method has no paired + /// . Used by tiny + /// pass-through "wrapper" bridges (extended partial methods + /// with hand-written bodies that delegate straight to a + /// bound binding instead of doing JNI). Set this to + /// typeof(XxxDefault) to drive the auto-mask logic + /// for direct-binding facades. Bridge-mode facades read + /// Defaults from their [ComposeBridge] + /// instead — this property is ignored in that case. + /// + public global::System.Type? Defaults { get; set; } + + /// + /// Opt in to hybrid container shape: bridge has exactly + /// one non-nullable IFunction2 or IFunction3 + /// body slot plus one or more nullable IFunction2? / + /// IFunction3? slots. The non-nullable body slot + /// becomes RenderChildren and the facade derives + /// from ; the nullable + /// slots surface as named ? + /// properties (Phase 3 style). For an IFunction3 + /// body, set too if children need + /// the published RowScope/ColumnScope. For + /// an IFunction2 body, has no + /// effect. + /// + public bool Container { get; set; } + + /// + /// Phase 9 — name of a synthetic facade property that + /// drives bridge branching. When the property is non-null + /// at render time the generated Render routes to + /// the method on + /// ComposeBridges; otherwise it calls the primary + /// bridge (the one paired with this attribute's + /// ). + /// + /// + /// The property is synthesized from the extra + /// IFunction2/IFunction3 parameter that the + /// alternate bridge has and the primary lacks. The value + /// of BranchOn must equal the PascalCased Kotlin + /// parameter name of that extra slot. The alternate's + /// user parameters must form the primary's user-param + /// name set plus exactly one extra slot. Must be paired + /// with . + /// + public string? BranchOn { get; set; } + + /// + /// Phase 9 — name of the sibling static partial method on + /// ComposeBridges to route to when the facade's + /// property is non-null. The + /// alternate must declare its own + /// with a matching Defaults enum and accept all + /// of the primary bridge's user parameters (by name) plus + /// exactly one extra slot matching . + /// + public string? AlternateBridge { get; set; } + + /// + /// Opt in to publishing a per-child row position for the + /// generated container body. The emitted Wrap2 / + /// Wrap3 content lambda calls + /// RenderChildrenIndexed(c) instead of + /// RenderChildren(c), which wraps every child + /// in RenderContext.PushRow(Children.Count) + + /// SetIndex(i) so a child composable + /// (e.g. ) can read its + /// position from + /// / to + /// compute Kotlin defaults that depend on position + /// (start/end shape, divider placement, ...). Only valid + /// on a container facade — fails CN3006 if the bridge + /// has no IFunction2/IFunction3 body slot. + /// + public bool IndexedChildren { get; set; } + + /// + /// Phase 11 — name of a sibling static method on + /// ComposeBridges that the facade exposes as an + /// extra ctor dispatching to a second bridge. The + /// secondary's user parameters must equal the primary's + /// shared (by-name) set plus exactly one extra + /// non-nullable reference-type parameter (the + /// "discriminator"). The generator adds a + /// readonly TDiscrim? _<discriminator> + /// backing field, an extra ctor whose first positional + /// argument is the discriminator, and prepends a + /// if (_<discriminator> is not null) { call + /// secondary; return; } branch to Render. + /// The primary's body keeps its existing shape (e.g. + /// Phase 7 painter-id + painter ctors). Used by + /// to dispatch between the + /// ImageVector overload (directly bound binding) + /// and the Painter overload (JNI bridge). + /// + public string? SecondaryCtor { get; set; } + + /// + /// Phase 11 — optional $default enum for the + /// bridge. Reads + /// [ComposeBridge].Defaults on the secondary + /// method first; this property is the fallback when the + /// secondary is a Phase 8-style wrapper without + /// [ComposeBridge]. + /// + public global::System.Type? SecondaryDefaults { get; set; } + } + + /// + /// Phase 3 — apply to a Kotlin-function bridge parameter to + /// override the C# property name the facade exposes for that + /// slot. Default is the parameter name PascalCased. Useful + /// when the Kotlin name reads awkwardly in C# (e.g. + /// content → Body, + /// headlineContent → Headline, + /// tooltip → Tip). + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Parameter, + AllowMultiple = false)] + internal sealed class SlotAttribute : global::System.Attribute + { + public SlotAttribute(string propertyName) { } + } + + /// + /// Phase 2 — apply to an IFunction1 bridge parameter + /// to mark it as a typed C# callback. The facade gets an + /// Action<T> ctor parameter; the generated + /// Render body wraps it in a ComposableLambda1 + /// that un-boxes the Kotlin value for T. + /// Supported T: bool, string, float. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Parameter, + AllowMultiple = false)] + internal sealed class CallbackAttribute : global::System.Attribute + { + public CallbackAttribute(global::System.Type valueType) { } + } + + /// + /// Phase 7 — apply to the Painter bridge parameter + /// that takes the resolved Painter wrapper. The facade + /// replaces this parameter with a synthetic ctor argument + /// int drawableResourceId; Render calls + /// ComposeBridges.PainterResource(id, composer) to + /// resolve the handle, wraps it into a managed + /// Painter peer via + /// JniHandleOwnership.TransferLocalRef, and forwards + /// it to the annotated parameter. The bridge's + /// auto-emitted GC.KeepAlive(painter) keeps the + /// peer alive across the call. At most one bridge + /// parameter may carry [PainterResource]. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Parameter, + AllowMultiple = false)] + internal sealed class PainterResourceAttribute : global::System.Attribute + { + public PainterResourceAttribute() { } + } + + /// + /// Phase 4 — apply to the IntPtr bridge parameter that + /// carries a Kotlin state-holder handle (e.g. + /// DatePickerState, DateRangePickerState). The + /// facade calls the named Remember*State bridge on + /// ComposeBridges to resolve the handle, optionally + /// binds it to a caller-supplied wrapper of type + /// , and forwards the handle to this + /// parameter. The wrapper is exposed as a defaulted ctor + /// argument (StateType? state = null). + /// + /// + /// Remember: the nameof(ComposeBridges.X) + /// of a static partial bridge on ComposeBridges whose + /// last parameter is an IComposer and that returns + /// IntPtr. The remaining (leading) parameters are + /// treated as init values — each one is read off the + /// caller-supplied StateType wrapper at render time. + /// Phase 4 covers the zero-user-param shape (e.g. + /// RememberDatePickerState(IComposer) -> IntPtr); + /// Phase 4b adds support for parameterised remembers (e.g. + /// RememberTimePickerState(int, int, bool, IComposer)). + /// Each Remember user param must resolve to a readable + /// instance member on : the generator + /// looks up the PascalCased name first (e.g. + /// initialHour → InitialHour) and falls back + /// to Initial<PascalCase> for the Kotlin + /// convention where initialX remember args correspond + /// to live wrapper property X. + /// StateType: the C# wrapper class that exposes + /// the state-holder's properties. It must declare an + /// instance, writable, non-readonly field named Jvm + /// whose declared type is the binding-generated interface + /// (e.g. IDatePickerState) — the generator emits + /// state.Jvm = Java.Lang.Object.GetObject<IXxxState> + /// (handle, JniHandleOwnership.DoNotTransfer)! the first + /// time Render sees a non-null wrapper. For Phase 4b + /// (parameterised Remember) the type must also be + /// constructible with no arguments (parameterless ctor or + /// all-defaulted-params ctor) so the facade can auto-create + /// a default wrapper when the caller passes null. + /// SharedState (Phase 4c): when true, + /// the generated Render preamble checks whether + /// _state.Jvm is already populated (from an earlier + /// sibling render that received the same wrapper instance) + /// and skips the RememberXxxState call in that case, + /// reusing the cached JNI handle directly. This lets two or + /// more sibling facades share the same state-holder peer + /// (e.g. a and a + /// driven by the same + /// ). Defaults to false + /// — every render calls Remember and Compose's slot-table + /// caches per-call-site. Opt in only when the facade is + /// designed to be paired with at least one sibling that + /// uses the same . + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Parameter, + AllowMultiple = false)] + internal sealed class StateHolderAttribute : global::System.Attribute + { + public StateHolderAttribute() { } + public string Remember { get; set; } = ""; + public global::System.Type StateType { get; set; } = null!; + public bool SharedState { get; set; } + } + + /// + /// Annotates an IFunction1 parameter on a + /// Remember*State(…) helper on ComposeBridges + /// to tell ComposeFacadeGenerator that the value is + /// a per-instance JNI veto adapter — Kotlin's + /// confirmStateChange / confirmValueChange + /// callback. The facade allocates the JCW once per node + /// (readonly field) and exposes a + /// System.Func<T, bool>? property the user + /// can mutate freely without invalidating Kotlin's + /// remember cache key (which is part of the + /// Java identity of the adapter, not the C# delegate). + /// + /// + /// The value type T is what the Kotlin + /// callback receives (e.g. DrawerValue, + /// SheetValue) and what the developer-facing + /// Func<T, bool> takes. + /// AdapterType — JCW class implementing + /// Kotlin.Jvm.Functions.IFunction1 with a writable + /// System.Func<T, bool>? Callback property the + /// generator assigns from the facade's + /// . Must be registered with + /// Mono.Android ([Register]) so its Java peer + /// identity is stable. Defaults to convention + /// AndroidX.Compose.<TName>ConfirmStateChange. + /// PropertyName — name of the property the + /// facade exposes. Defaults to ConfirmStateChange. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Parameter, + AllowMultiple = false)] + internal sealed class ConfirmStateChangeAttribute : global::System.Attribute + { + public ConfirmStateChangeAttribute(global::System.Type valueType) { } + public global::System.Type? AdapterType { get; set; } + public string? PropertyName { get; set; } + } + + /// + /// Apply to a partial class deriving from + /// to have + /// AndroidX.Compose.SourceGenerators emit a cached + /// accessor for the Kotlin Companion singleton and one + /// implementing partial property per + /// . Eliminates the + /// repeated raw-JNI plumbing that every Compose value-class / + /// reference-class wrapper would otherwise hand-roll. + /// + /// + /// outerJniClass: the slash-separated JNI class + /// name of the outer Kotlin type — e.g. + /// androidx/compose/ui/text/font/FontWeight. The + /// generator derives the companion JNI name by appending + /// $Companion. + /// InlineClass: set true when the outer + /// type is a Kotlin @JvmInline value class whose + /// companion getters return the packed int value + /// (Kotlin emits getXxx-mangled()I). The generator + /// emits a helper that boxes the result via the synthesized + /// static box-impl(I)L<outer>; method to produce + /// the boxed reference expected by C# call sites. + /// The defining partial declarations live in the user's + /// hand-written file (so XML docs stay on the public surface); + /// the generator emits the implementing partials, the + /// per-property cache fields, the cached Companion() + /// accessor, and one shared ResolveSimple or + /// ResolveInline helper. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Class, + AllowMultiple = false)] + internal sealed class ComposeCompanionAttribute : global::System.Attribute + { + public ComposeCompanionAttribute(string outerJniClass) { } + public bool InlineClass { get; set; } + } + + /// + /// Apply to a public static partial T PropertyName { get; } + /// declaration on a class carrying + /// . The generator emits + /// the implementing partial body that calls the named Kotlin + /// getter on the cached Companion and wraps the resulting JNI + /// handle in a fresh T peer via + /// (IntPtr, JniHandleOwnership.TransferLocalRef). + /// + /// + /// getterName: the exact Kotlin-emitted method + /// name on the Companion class (e.g. getThin, + /// getCenter-e0LSkKk for inline-class mangled names). + /// Hyphens and other non-identifier characters are accepted — + /// the generator forwards the string straight to + /// JNIEnv.GetMethodID. + /// ReturnDescriptor: optional JNI return-type + /// descriptor (e.g. + /// Landroidx/compose/ui/text/font/SystemFontFamily;) + /// for getters whose return type is a subtype of the outer + /// class. Defaults to L<outerJniClass>;. Must + /// not be set when the containing + /// has + /// InlineClass = true — inline-class getters always + /// return packed I and the generator routes the + /// boxed reference through the synthesized + /// box-impl(I)L<outer>;. + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Property, + AllowMultiple = false)] + internal sealed class ComposeCompanionGetterAttribute : global::System.Attribute + { + public ComposeCompanionGetterAttribute(string getterName) { } + public string? ReturnDescriptor { get; set; } + } + } + """; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeBridgeGenerator.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeBridgeGenerator.cs new file mode 100644 index 000000000..b5dfe45f3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeBridgeGenerator.cs @@ -0,0 +1,1018 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Emits JNI-bridge bodies for partial methods on +/// AndroidX.Compose.ComposeBridges decorated with +/// [ComposeBridge(...)]. The author writes a stub like +/// +/// [ComposeBridge(Class="...Kt", JvmName="Foo", Signature="(...III)V", +/// Defaults=typeof(FooDefault))] +/// public static partial void Foo(IFunction0 onClick, IModifier? modifier, +/// IFunction3 content, IComposer composer); +/// +/// and the generator emits the cache fields, lazy class/method ID +/// resolution, JValue array fill, $default bitmask, and +/// try/finally with GC.KeepAlive. +/// +[Generator(LanguageNames.CSharp)] +public sealed class ComposeBridgeGenerator : IIncrementalGenerator +{ + const string BridgeAttributeMetadataName = "AndroidX.Compose.ComposeBridgeAttribute"; + const string DeclarativeDefaultsAttributeMetadataName = "AndroidX.Compose.ComposeDefaultsAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Attributes.cs (shared with ComposeDefaultsGenerator) already emits + // ComposeBridgeAttribute via RegisterPostInitializationOutput. + + var methods = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => + node is MethodDeclarationSyntax m && + m.Modifiers.Any(t => t.IsKind(SyntaxKind.PartialKeyword)) && + m.AttributeLists.Count > 0, + transform: static (ctx, _) => ctx) + .Where(static ctx => ctx.SemanticModel.GetDeclaredSymbol((MethodDeclarationSyntax)ctx.Node) is IMethodSymbol); + + var combined = methods.Combine(context.CompilationProvider); + + context.RegisterSourceOutput(combined, static (spc, pair) => + { + var (ctx, compilation) = pair; + var bridgeAttr = compilation.GetTypeByMetadataName(BridgeAttributeMetadataName); + if (bridgeAttr is null) return; + + var declarativeAttr = compilation.GetTypeByMetadataName(DeclarativeDefaultsAttributeMetadataName); + + var method = (IMethodSymbol)ctx.SemanticModel.GetDeclaredSymbol((MethodDeclarationSyntax)ctx.Node)!; + var attr = method.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, bridgeAttr)); + if (attr is null) return; + + var result = Build(method, attr, declarativeAttr, compilation); + foreach (var diag in result.Diagnostics) + spc.ReportDiagnostic(diag); + if (result.Source is { } source && result.HintName is { } hint) + spc.AddSource(hint, SourceText.From(source, Encoding.UTF8)); + }); + } + + static GenerationResult Build(IMethodSymbol method, AttributeData attr, INamedTypeSymbol? declarativeAttr, Compilation compilation) + { + var loc = attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; + string? className = ReadString(attr, "Class"); + string? jvmName = ReadString(attr, "JvmName"); + string? signature = ReadString(attr, "Signature"); + string? instanceField = ReadString(attr, "InstanceField"); + var defaultsType = ReadType(attr, "Defaults"); + bool isSuspend = ReadBool(attr, "Suspend"); + + if (className is null || jvmName is null || signature is null) + { + return new GenerationResult(null, null, new[] { + Diagnostic.Create(Diagnostics.MalformedAttribute, loc, method.Name) + }); + } + + if (!JniSignature.TryParse(signature, out var sigParams, out var jniReturnType, out var sigError)) + { + return new GenerationResult(null, null, new[] { + Diagnostic.Create(Diagnostics.BridgeMalformedSignature, loc, method.Name, sigError ?? "?") + }); + } + + // Detect the JNI signature "shape". Five supported today: + // 1. ComposableWithDefault — ...Composer;I+ trailing (multiple Is). + // 2. ComposableNoDefault — ...Composer;I trailing (single I). + // 3. ExtensionWithDefault — non-@Composable Kotlin extension + // function with default values; tail is `I L`, + // no Composer. The marker is always Ljava/lang/Object; and + // is passed `null` (IntPtr.Zero). + // 4. PlainStatic — plain Kotlin static method on a Kt + // class with no Composer slot and no $default bitmask; just + // (args…)Return. Used for Modifier-chain helpers and other + // synchronous Kotlin utilities (e.g. RoundedCornerShape). + // 5. Constructor — JvmName is "". Emits + // GetMethodID + NewObject and wraps the returned handle via + // Java.Lang.Object.GetObject(.., TransferLocalRef). + // Used for stripped Kotlin ctors whose parameters were + // mangled by inline-class compilation (e.g. + // GridCells.Adaptive(Dp)). + bool isConstructor = jvmName == ""; + if (isConstructor) + { + // Ctor-specific validation: no Composer, no $default, no + // InstanceField, no Defaults attr, non-void return, + // signature must end with `V`. + if (instanceField is not null) + return ConstructorError(loc, method.Name, "InstanceField is not valid with a constructor bridge"); + if (defaultsType is not null) + return ConstructorError(loc, method.Name, "Defaults is not valid with a constructor bridge"); + for (int i = 0; i < method.Parameters.Length; i++) + { + if (ComposeDefaultsGenerator.IsComposer(method.Parameters[i].Type)) + return ConstructorError(loc, method.Name, "constructors have no Composer slot — remove the IComposer parameter"); + } + if (method.ReturnsVoid) + return ConstructorError(loc, method.Name, "return type must be the constructed object, not void"); + // Signature must end with `V` (JVM constructors return void at + // the bytecode level even though NewObject hands back a handle). + if (!signature.EndsWith(")V", StringComparison.Ordinal)) + return ConstructorError(loc, method.Name, "JNI signature must end with ')V' (constructors return void at the bytecode level)"); + } + + // Suspend-bridge up-front validation. Catches conflicting flags + // before the slot-accounting math runs so callers get a focused + // error instead of a downstream signature-mismatch report. + IParameterSymbol? continuationParam = null; + if (isSuspend) + { + if (isConstructor) + return SuspendError(loc, method.Name, "cannot combine 'Suspend = true' with a '' constructor bridge"); + if (instanceField is not null) + return SuspendError(loc, method.Name, "cannot combine 'Suspend = true' with 'InstanceField'"); + for (int i = 0; i < method.Parameters.Length; i++) + { + if (ComposeDefaultsGenerator.IsComposer(method.Parameters[i].Type)) + return SuspendError(loc, method.Name, "suspend bridges have no Composer slot — remove the IComposer parameter"); + } + if (method.Parameters.Length == 0 || + !IsContinuationType(method.Parameters[method.Parameters.Length - 1].Type)) + { + return SuspendError(loc, method.Name, + "trailing parameter must be 'Kotlin.Coroutines.IContinuation' (or an implementor such as 'SuspendContinuation')"); + } + continuationParam = method.Parameters[method.Parameters.Length - 1]; + if (method.ReturnsVoid || method.ReturnType.SpecialType != SpecialType.System_IntPtr) + { + return SuspendError(loc, method.Name, + "return type must be 'System.IntPtr' — wrapping the result in a Java.Lang.Object peer collides with Mono's peer cache for the COROUTINE_SUSPENDED singleton"); + } + if (jniReturnType != "Ljava/lang/Object;") + { + return SuspendError(loc, method.Name, + $"JNI return type must be 'Ljava/lang/Object;' (Kotlin suspend functions always return a boxed Object), but signature returns '{jniReturnType}'"); + } + } + + int composerSigIdx = -1; + if (!isConstructor) + { + for (int i = 0; i < sigParams.Count; i++) + { + if (sigParams[i].Code == 'L' && sigParams[i].ClassName == "androidx/compose/runtime/Composer") + { + composerSigIdx = i; + break; + } + } + } + bool hasComposerSlot = composerSigIdx >= 0; + + bool extensionWithDefault = false; + bool signatureHasDefault; + if (isConstructor) + { + // Ctor shape never has $default or extension marker — every + // C# user param maps positionally to a JNI slot. + signatureHasDefault = false; + } + else if (hasComposerSlot) + { + // Kotlin's @Composable codegen emits ceil(userParams/10) (min 1) + // $changed groups, and one $default slot iff at least one + // parameter has a default value: + // trailingI = $changedGroups + ($default ? 1 : 0) + int trailingInts = 0; + for (int i = sigParams.Count - 1; i >= 0 && sigParams[i].Code == 'I' && sigParams[i].ArrayDepth == 0; i--) + trailingInts++; + int sigUserParamCount = sigParams.Count - 1 /*composer*/ - trailingInts; + int expectedChangedSlots = Math.Max(1, (sigUserParamCount + 9) / 10); + signatureHasDefault = trailingInts > expectedChangedSlots; + } + else if (sigParams.Count >= 2 && + sigParams[sigParams.Count - 1].Code == 'L' && + sigParams[sigParams.Count - 1].ClassName == "java/lang/Object" && + sigParams[sigParams.Count - 1].ArrayDepth == 0 && + sigParams[sigParams.Count - 2].Code == 'I' && + sigParams[sigParams.Count - 2].ArrayDepth == 0) + { + // Non-@Composable extension function with $default + synthetic + // marker. Kotlin's bytecode passes the marker as null at every + // call site; we always emit IntPtr.Zero for that slot. + extensionWithDefault = true; + signatureHasDefault = true; + } + else + { + // Plain static call, no Composer, no $default — e.g. + // PaddingKt.padding(Modifier, Dp), RoundedCornerShape(Dp). + // Every Kotlin parameter is required; the C# stub lists them + // positionally (with an optional leading IntPtr receiver + // when the Kotlin function is an extension method). + signatureHasDefault = false; + } + + bool hasDefaultSlot = signatureHasDefault; + + // Cross-validate signature against attribute metadata: a mismatch + // would silently produce a broken bridge (wrong $default slot + // packing, missing bitmask, etc.) so we fail fast. + if (hasDefaultSlot && defaultsType is null) + { + return new GenerationResult(null, null, new[] { + Diagnostic.Create(Diagnostics.BridgeDefaultsMismatch, loc, method.Name, + "JNI signature has a $default slot but [ComposeBridge] omits 'Defaults'") + }); + } + if (!hasDefaultSlot && defaultsType is not null) + { + return new GenerationResult(null, null, new[] { + Diagnostic.Create(Diagnostics.BridgeDefaultsMismatch, loc, method.Name, + "[ComposeBridge] specifies 'Defaults' but the JNI signature has no $default slot") + }); + } + + // Locate the matching declarative [ComposeDefaults] attribute, if any. + // When [ComposeBridge] omits Defaults the bridge targets a + // @Composable function whose Kotlin codegen emitted only $changed + // slots (no $default bitmask) — every parameter is required. + IReadOnlyList kotlinNames; + string? defaultsEnumName = defaultsType?.Name; + if (defaultsType is not null && declarativeAttr is not null) + { + var match = compilation.Assembly.GetAttributes() + .FirstOrDefault(a => + SymbolEqualityComparer.Default.Equals(a.AttributeClass, declarativeAttr) && + a.ConstructorArguments.Length >= 1 && + a.ConstructorArguments[0].Value is string s && + s == defaultsEnumName); + if (match is null) + { + return new GenerationResult(null, null, new[] { + Diagnostic.Create(Diagnostics.BridgeMissingDefaults, loc, defaultsEnumName ?? "?", method.Name) + }); + } + var arr = match.ConstructorArguments[1]; + kotlinNames = arr.Kind == TypedConstantKind.Array + ? arr.Values.Select(v => v.Value as string ?? string.Empty).ToArray() + : Array.Empty(); + } + else if (defaultsType is not null) + { + return new GenerationResult(null, null, new[] { + Diagnostic.Create(Diagnostics.BridgeMissingDefaults, loc, defaultsEnumName ?? "?", method.Name) + }); + } + else + { + kotlinNames = Array.Empty(); + } + + // C# parameter walk. For composable shapes the last param must be + // an IComposer; for extension shapes there is no Composer slot and + // the last param is just the trailing user parameter. Suspend + // bridges have already had their trailing IContinuation param + // captured above; we strip it from csTail here so the rest of the + // accounting math treats it parallel to the Composer slot. + var csParams = method.Parameters; + IParameterSymbol? composerParam; + int csTail; // exclusive upper bound of "user-controlled" params before composer + if (hasComposerSlot) + { + if (csParams.Length == 0 || !ComposeDefaultsGenerator.IsComposer(csParams[csParams.Length - 1].Type)) + { + return new GenerationResult(null, null, new[] { + Diagnostic.Create(Diagnostics.MalformedAttribute, loc, method.Name) + }); + } + composerParam = csParams[csParams.Length - 1]; + csTail = csParams.Length - 1; + } + else + { + composerParam = null; + csTail = csParams.Length; + } + + if (continuationParam is not null) + csTail -= 1; + + // Detect optional `int defaults` immediately before composer (or + // at the very end for extension shapes). Caller-controlled bitmask + // is only meaningful when the function actually has a $default slot. + bool callerProvidesDefaults = false; + int userTail = csTail; + if (hasDefaultSlot && userTail > 0 && + csParams[userTail - 1].Type.SpecialType == SpecialType.System_Int32 && + csParams[userTail - 1].Name == "defaults") + { + callerProvidesDefaults = true; + userTail -= 1; + } + var userParams = csParams.Take(userTail).ToArray(); + + // Detect leading extension receiver. For composable shapes the + // convention is `IntPtr Scope` (e.g. `IntPtr rowScope`); for + // extension-with-default shapes the receiver is whatever the first + // sigParam is (e.g. Modifier) and we bind it positionally to the + // first IntPtr C# parameter regardless of name; for the plain-static + // shape we treat the first user param as the receiver iff the + // first sigParam is an object (`L`) AND the first C# param is + // `IntPtr` — that covers Modifier-chain extensions while leaving + // non-extension static calls (e.g. `RoundedCornerShape(Dp)`) alone. + // + // The "instance suspend" shape (Suspend = true, no $default + // marker tail) is its own case: the first user IntPtr is the + // instance the JNI virtual call dispatches on — it lives OUTSIDE + // the JValue[] args, so we track it separately and do not include + // it in receiverSlotCount / JNI slot 0 accounting. + IParameterSymbol? receiverParam = null; + IParameterSymbol? instanceReceiverParam = null; + bool isInstanceSuspend = isSuspend && !extensionWithDefault; + if (isInstanceSuspend) + { + if (userParams.Length == 0 || + userParams[0].Type.SpecialType != SpecialType.System_IntPtr) + { + return SuspendError(loc, method.Name, + "instance suspend bridges must declare a leading 'IntPtr' parameter for the receiver (the state-holder this suspend method dispatches on)"); + } + instanceReceiverParam = userParams[0]; + userParams = userParams.Skip(1).ToArray(); + } + else if (extensionWithDefault) + { + if (userParams.Length == 0 || + userParams[0].Type.SpecialType != SpecialType.System_IntPtr) + { + return new GenerationResult(null, null, new[] { + Diagnostic.Create(Diagnostics.MalformedAttribute, loc, method.Name) + }); + } + receiverParam = userParams[0]; + userParams = userParams.Skip(1).ToArray(); + } + else if (hasComposerSlot && + userParams.Length > 0 && + userParams[0].Type.SpecialType == SpecialType.System_IntPtr && + userParams[0].Name.EndsWith("Scope", StringComparison.Ordinal)) + { + receiverParam = userParams[0]; + userParams = userParams.Skip(1).ToArray(); + } + else if (!hasComposerSlot && !extensionWithDefault && !isConstructor && + userParams.Length > 0 && sigParams.Count > 0 && + userParams[0].Type.SpecialType == SpecialType.System_IntPtr && + sigParams[0].Code == 'L' && sigParams[0].ArrayDepth == 0) + { + receiverParam = userParams[0]; + userParams = userParams.Skip(1).ToArray(); + } + + // Match each remaining user param to a Kotlin bit position. + // With $default: by Kotlin name from the declarative list. + // Without $default: positionally — the user param at index N goes + // into JNI slot N (after any extension receiver). + var kotlinNameMap = new Dictionary(StringComparer.Ordinal); + for (int bit = 0; bit < kotlinNames.Count; bit++) + { + var raw = kotlinNames[bit]; + if (raw.Length > 0 && raw[0] == '!') raw = raw.Substring(1); + if (!string.IsNullOrEmpty(raw) && !kotlinNameMap.ContainsKey(raw)) + kotlinNameMap[raw] = bit; + } + + // userParam name -> kotlin bit + var userBitOf = new Dictionary(StringComparer.Ordinal); + var diags = new List(); + if (hasDefaultSlot) + { + foreach (var p in userParams) + { + if (!kotlinNameMap.TryGetValue(p.Name, out var bit)) + { + diags.Add(Diagnostic.Create(Diagnostics.BridgeUnknownParameter, loc, + method.Name, p.Name, defaultsEnumName ?? "?")); + continue; + } + userBitOf[p.Name] = bit; + } + } + else + { + for (int i = 0; i < userParams.Length; i++) + userBitOf[userParams[i].Name] = i; + } + if (diags.Count > 0) + return new GenerationResult(null, null, diags.ToArray()); + + // CN2007: recognized Compose value types (Color/Dp/Sp/Em/TextAlign) + // rely on the auto-default-mask logic to leave the $default bit + // set when null. Without a $default slot there's no way to + // signal "use Kotlin default" to the call, so reject them up + // front rather than silently emitting a value-zeroed argument. + if (!hasDefaultSlot) + { + foreach (var p in userParams) + { + if (ComposeValueTypes.TryGet(p.Type, out var vtName, out _)) + { + diags.Add(Diagnostic.Create(Diagnostics.BridgeValueTypeNeedsDefaults, loc, + method.Name, p.Name, vtName)); + } + } + if (diags.Count > 0) + return new GenerationResult(null, null, diags.ToArray()); + } + + var changedSlots = ChangedSlotCount(sigParams, hasDefaultSlot, hasComposerSlot); + int defaultSlotCount = hasDefaultSlot ? 1 : 0; + int receiverSlotCount = receiverParam is null ? 0 : 1; + int composerSlotCount = hasComposerSlot ? 1 : 0; + int markerSlotCount = extensionWithDefault ? 1 : 0; + int continuationSlotCount = isSuspend ? 1 : 0; + int actualUserSlots = sigParams.Count - receiverSlotCount - composerSlotCount - continuationSlotCount - changedSlots - defaultSlotCount - markerSlotCount; + int expectedUserSlots = hasDefaultSlot ? kotlinNames.Count : userParams.Length; + if (actualUserSlots != expectedUserSlots) + { + diags.Add(Diagnostic.Create(Diagnostics.BridgeSignatureMismatch, loc, + method.Name, actualUserSlots, defaultsEnumName ?? "?", expectedUserSlots)); + return new GenerationResult(null, null, diags.ToArray()); + } + + // Validate the Kotlin Continuation slot lands where we expect: + // for instance suspend it is the last JNI parameter; for static + // $default suspend it sits immediately before `I L` + // (the trailing `$default` mask + synthetic-overload marker). + int continuationSlotIdx = -1; + if (isSuspend) + { + continuationSlotIdx = isInstanceSuspend + ? sigParams.Count - 1 + : sigParams.Count - 3; + if (continuationSlotIdx < 0 || + sigParams[continuationSlotIdx].Code != 'L' || + sigParams[continuationSlotIdx].ClassName != "kotlin/coroutines/Continuation" || + sigParams[continuationSlotIdx].ArrayDepth != 0) + { + return SuspendError(loc, method.Name, + isInstanceSuspend + ? "JNI signature must end with 'Lkotlin/coroutines/Continuation;)Ljava/lang/Object;' for an instance suspend bridge" + : "JNI signature must place 'Lkotlin/coroutines/Continuation;' immediately before the trailing 'IL' $default+marker pair"); + } + } + + // CN2008: each recognized Compose value type lowers to a specific + // JNI primitive slot (Dp -> F, Sp -> J, TextAlign -> I). Validate + // that the bridge's JNI signature actually has that slot at the + // bit's position — otherwise EmitUserArgValue would silently + // route a packed long / float into the wrong JValue field and + // crash at runtime with a type-mismatch / corrupt arg. + foreach (var p in userParams) + { + if (!ComposeValueTypes.TryGet(p.Type, out var vtName, out var info)) + continue; + if (!userBitOf.TryGetValue(p.Name, out var bit)) + continue; + int slotIndex = receiverSlotCount + bit; + if (slotIndex < 0 || slotIndex >= sigParams.Count) + continue; + var actualCode = sigParams[slotIndex].Code; + if (actualCode != info.Slot) + { + diags.Add(Diagnostic.Create(Diagnostics.BridgeValueTypeSlotMismatch, loc, + method.Name, p.Name, vtName, info.Slot, actualCode)); + } + } + if (diags.Count > 0) + return new GenerationResult(null, null, diags.ToArray()); + + var source = Emit(method, attr, className, jvmName, signature, defaultsEnumName, sigParams, + kotlinNames, userParams, userBitOf, receiverParam, callerProvidesDefaults, hasDefaultSlot, + hasComposerSlot, extensionWithDefault, instanceField, isConstructor, + isSuspend, isInstanceSuspend, instanceReceiverParam, continuationParam, continuationSlotIdx); + var hint = $"AndroidX.Compose.{method.ContainingType.Name}.{method.Name}.g.cs"; + return new GenerationResult(source, hint, Array.Empty()); + } + + static GenerationResult ConstructorError(Location loc, string methodName, string message) => + new(null, null, new[] { + Diagnostic.Create(Diagnostics.BridgeConstructorShape, loc, methodName, message) + }); + + static GenerationResult SuspendError(Location loc, string methodName, string message) => + new(null, null, new[] { + Diagnostic.Create(Diagnostics.BridgeSuspendInvalid, loc, methodName, message) + }); + + // Recognises Kotlin.Coroutines.IContinuation itself or any type that + // implements it (covers the runtime's SuspendContinuation JCW). + static bool IsContinuationType(ITypeSymbol type) + { + if (IsExactContinuation(type)) return true; + foreach (var iface in type.AllInterfaces) + { + if (IsExactContinuation(iface)) return true; + } + return false; + + static bool IsExactContinuation(ITypeSymbol t) => + t.Name == "IContinuation" + && t.ContainingNamespace?.ToDisplayString() == "Kotlin.Coroutines"; + } + + /// + /// Number of trailing I params that represent $changed + /// slots. $changed only exists in @Composable signatures, so + /// when is false this returns + /// 0. With =true the + /// very last I is the $default bitmask, so this returns + /// trailing-1; otherwise every trailing I is part of + /// the $changed group(s). + /// + static int ChangedSlotCount(IReadOnlyList sigParams, bool hasDefaultSlot, bool hasComposer) + { + if (!hasComposer) return 0; + int trailingInts = 0; + for (int i = sigParams.Count - 1; i >= 0 && sigParams[i].Code == 'I' && sigParams[i].ArrayDepth == 0; i--) + trailingInts++; + return hasDefaultSlot ? Math.Max(0, trailingInts - 1) : trailingInts; + } + + static string Emit( + IMethodSymbol method, AttributeData _attr, + string className, string jvmName, string signature, string? enumName, + IReadOnlyList sigParams, + IReadOnlyList kotlinNames, + IParameterSymbol[] userParams, + Dictionary userBitOf, + IParameterSymbol? receiverParam, + bool callerProvidesDefaults, + bool hasDefaultSlot, + bool hasComposerSlot, + bool extensionWithDefault, + string? instanceField, + bool isConstructor, + bool isSuspend, + bool isInstanceSuspend, + IParameterSymbol? instanceReceiverParam, + IParameterSymbol? continuationParam, + int continuationSlotIdx) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// Generated by AndroidX.Compose.SourceGenerators.ComposeBridgeGenerator."); + sb.AppendLine("#nullable enable"); + + var containingType = method.ContainingType; + var ns = containingType.ContainingNamespace?.IsGlobalNamespace == false + ? containingType.ContainingNamespace.ToDisplayString() + : null; + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + } + + var typeKeyword = containingType.IsStatic ? "static partial class" : (containingType.IsValueType ? "partial struct" : "partial class"); + sb.Append(typeKeyword).Append(' ').AppendLine(containingType.Name); + sb.AppendLine("{"); + + // Cache fields. Suffix with method name to avoid clashes when one + // ComposeBridges file declares multiple bridges on the same Kt class. + var sym = SafeSym(method.Name); + sb.Append(" static global::System.IntPtr s_").Append(sym).AppendLine("_class;"); + sb.Append(" static global::System.IntPtr s_").Append(sym).AppendLine("_method;"); + if (instanceField is not null) + sb.Append(" static global::System.IntPtr s_").Append(sym).AppendLine("_instance;"); + sb.AppendLine(); + + // Method signature — match the partial declaration. + EmitMethodSignature(sb, method); + sb.AppendLine(" {"); + + // Lazy init. + sb.Append(" if (s_").Append(sym).AppendLine("_method == global::System.IntPtr.Zero)"); + sb.AppendLine(" {"); + sb.Append(" s_").Append(sym).Append("_class = global::Android.Runtime.JNIEnv.FindClass(\"").Append(className).AppendLine("\");"); + if (isConstructor || isInstanceSuspend) + { + // Both use a virtual GetMethodID with the declared name: + // constructors use "" (no special API), instance + // suspends dispatch on the caller-supplied receiver. + sb.Append(" s_").Append(sym).Append("_method = global::Android.Runtime.JNIEnv.GetMethodID(s_") + .Append(sym).Append("_class, \"").Append(jvmName).Append("\", \"").Append(signature).AppendLine("\");"); + } + else if (instanceField is null) + { + sb.Append(" s_").Append(sym).Append("_method = global::Android.Runtime.JNIEnv.GetStaticMethodID(s_") + .Append(sym).Append("_class, \"").Append(jvmName).Append("\", \"").Append(signature).AppendLine("\");"); + } + else + { + sb.Append(" var __fid = global::Android.Runtime.JNIEnv.GetStaticFieldID(s_").Append(sym) + .Append("_class, \"").Append(instanceField).Append("\", \"L").Append(className).AppendLine(";\");"); + sb.Append(" var __local = global::Android.Runtime.JNIEnv.GetStaticObjectField(s_").Append(sym).AppendLine("_class, __fid);"); + sb.Append(" s_").Append(sym).AppendLine("_instance = global::Android.Runtime.JNIEnv.NewGlobalRef(__local);"); + sb.AppendLine(" global::Android.Runtime.JNIEnv.DeleteLocalRef(__local);"); + sb.Append(" s_").Append(sym).Append("_method = global::Android.Runtime.JNIEnv.GetMethodID(s_") + .Append(sym).Append("_class, \"").Append(jvmName).Append("\", \"").Append(signature).AppendLine("\");"); + } + sb.AppendLine(" }"); + sb.AppendLine(); + + // Defaults computation (only when caller didn't pass one and the + // function actually has a $default slot). + if (hasDefaultSlot && !callerProvidesDefaults) + { + sb.Append(" int defaults = (int)global::AndroidX.Compose.").Append(enumName).AppendLine(".All;"); + foreach (var p in userParams) + { + if (!userBitOf.TryGetValue(p.Name, out var bit)) continue; + // Skip bits whose Kotlin name has '!' prefix — no enum member. + if (kotlinNames[bit].StartsWith("!", StringComparison.Ordinal)) continue; + + 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) || IsNullablePrimitive(p.Type)) + { + sb.Append(" if (").Append(EscapeIdent(p.Name)).Append(" is not null) defaults &= ~(int)global::AndroidX.Compose.") + .Append(enumName).Append('.').Append(member).AppendLine(";"); + } + else + { + sb.Append(" defaults &= ~(int)global::AndroidX.Compose.").Append(enumName).Append('.').Append(member).AppendLine(";"); + } + } + sb.AppendLine(); + } + + // String NewString hoist. + var stringParams = userParams.Where(p => p.Type.SpecialType == SpecialType.System_String).ToList(); + foreach (var sp in stringParams) + { + sb.Append(" global::System.IntPtr __ref_").Append(sp.Name) + .Append(" = global::Android.Runtime.JNIEnv.NewString(").Append(EscapeIdent(sp.Name)).AppendLine(");"); + } + + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" unsafe"); + sb.AppendLine(" {"); + + int argCount = sigParams.Count + (instanceField is not null ? 0 : 0); // sigParams already excludes 'this' + sb.Append(" global::Android.Runtime.JValue* args = stackalloc global::Android.Runtime.JValue[").Append(argCount).AppendLine("];"); + + // Walk sigParams positions and emit assignments. + // Layout: receiver? + kotlin params + composer + $changed* + $default. + int idx = 0; + if (receiverParam is not null) + { + sb.Append(" args[").Append(idx).Append("] = new global::Android.Runtime.JValue(").Append(EscapeIdent(receiverParam.Name)).AppendLine(");"); + idx++; + } + + // Kotlin user params: walk one bit per JNI slot before composer. + int userSlotCount = hasDefaultSlot ? kotlinNames.Count : userParams.Length; + for (int bit = 0; bit < userSlotCount; bit++, idx++) + { + var sigType = sigParams[idx]; + var supplier = userParams.FirstOrDefault(p => userBitOf.TryGetValue(p.Name, out var b) && b == bit); + sb.Append(" args[").Append(idx).Append("] = new global::Android.Runtime.JValue("); + if (supplier is null) + { + sb.Append(sigType.ZeroLiteral); + } + else + { + EmitUserArgValue(sb, supplier, sigType); + } + sb.AppendLine(");"); + } + + // Continuation slot — sits after user params for both suspend + // sub-shapes (last slot for instance suspend; before $default+marker + // for static $default suspend). We cast through Java.Lang.Object + // because every IContinuation implementor in our world subclasses + // Java.Lang.Object (SuspendContinuation does so directly). + if (continuationParam is not null) + { + sb.Append(" args[").Append(idx) + .Append("] = new global::Android.Runtime.JValue(((global::Java.Lang.Object)") + .Append(EscapeIdent(continuationParam.Name)).AppendLine(").Handle);"); + idx++; + } + + // Composer (only present in @Composable shapes). + IParameterSymbol? composer = hasComposerSlot ? method.Parameters[method.Parameters.Length - 1] : null; + if (composer is not null) + { + sb.Append(" args[").Append(idx).Append("] = new global::Android.Runtime.JValue(((global::Java.Lang.Object)").Append(EscapeIdent(composer.Name)).AppendLine(").Handle);"); + idx++; + } + + // $changed slots. + int changedCount = ChangedSlotCount(sigParams, hasDefaultSlot, hasComposerSlot); + for (int c = 0; c < changedCount; c++, idx++) + sb.Append(" args[").Append(idx).AppendLine("] = new global::Android.Runtime.JValue(0);"); + + // $default (only when present in the bytecode signature). + if (hasDefaultSlot) + { + sb.Append(" args[").Append(idx).AppendLine("] = new global::Android.Runtime.JValue(defaults);"); + idx++; + } + + // Synthetic-overload marker for non-@Composable extension functions + // with $default. Kotlin always passes null here. + if (extensionWithDefault) + { + sb.Append(" args[").Append(idx).AppendLine("] = new global::Android.Runtime.JValue(global::System.IntPtr.Zero);"); + idx++; + } + + // Call. + var callTarget = isInstanceSuspend + ? EscapeIdent(instanceReceiverParam!.Name) + : (instanceField is null ? $"s_{sym}_class" : $"s_{sym}_instance"); + bool returnsValue = !method.ReturnsVoid; + if (isConstructor) + { + // Constructor shape: NewObject + wrap handle into the C# + // return type. Java.Lang.Object.GetObject(handle, + // TransferLocalRef) consumes the local ref so we don't need + // to DeleteLocalRef it here. + var returnTypeFq = method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers)); + sb.Append(" var __handle = global::Android.Runtime.JNIEnv.NewObject(s_") + .Append(sym).Append("_class, s_").Append(sym).AppendLine("_method, args);"); + sb.Append(" return global::Java.Lang.Object.GetObject<") + .Append(returnTypeFq).AppendLine(">(__handle, global::Android.Runtime.JniHandleOwnership.TransferLocalRef)!;"); + } + else + { + // Instance suspend dispatches on the caller-supplied receiver + // handle. Suspend bridges always return raw IntPtr (the + // COROUTINE_SUSPENDED sentinel must NOT be wrapped in a peer + // — see SuspendBridges.cs file header), so we route through + // CallObjectMethod / CallStaticObjectMethod regardless of the + // bool returnsValue branch above. + string callMethod; + if (isInstanceSuspend) + callMethod = "CallObjectMethod"; + else if (returnsValue) + callMethod = instanceField is null ? "CallStaticObjectMethod" : "CallObjectMethod"; + else + callMethod = instanceField is null ? "CallStaticVoidMethod" : "CallVoidMethod"; + sb.Append(" "); + if (returnsValue) sb.Append("return "); + sb.Append("global::Android.Runtime.JNIEnv.").Append(callMethod).Append('(').Append(callTarget) + .Append(", s_").Append(sym).AppendLine("_method, args);"); + } + sb.AppendLine(" }"); + sb.AppendLine(" }"); + + // finally: DeleteLocalRef strings, KeepAlive managed handles. + sb.AppendLine(" finally"); + sb.AppendLine(" {"); + foreach (var sp in stringParams) + sb.Append(" global::Android.Runtime.JNIEnv.DeleteLocalRef(__ref_").Append(sp.Name).AppendLine(");"); + foreach (var p in userParams) + { + if (NeedsKeepAlive(p)) + sb.Append(" global::System.GC.KeepAlive(").Append(EscapeIdent(p.Name)).AppendLine(");"); + } + if (composer is not null) + sb.Append(" global::System.GC.KeepAlive(").Append(EscapeIdent(composer.Name)).AppendLine(");"); + if (continuationParam is not null) + sb.Append(" global::System.GC.KeepAlive(").Append(EscapeIdent(continuationParam.Name)).AppendLine(");"); + sb.AppendLine(" }"); + + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + static void EmitMethodSignature(StringBuilder sb, IMethodSymbol method) + { + sb.Append(" "); + if (method.DeclaredAccessibility == Accessibility.Public) sb.Append("public "); + else if (method.DeclaredAccessibility == Accessibility.Internal) sb.Append("internal "); + if (method.IsStatic) sb.Append("static "); + sb.Append("partial "); + sb.Append(method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + | SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers))) + .Append(' ').Append(method.Name).Append('('); + for (int i = 0; i < method.Parameters.Length; i++) + { + var p = method.Parameters[i]; + if (i > 0) sb.Append(", "); + sb.Append(p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + | SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers))); + sb.Append(' ').Append(EscapeIdent(p.Name)); + } + sb.AppendLine(")"); + } + + static void EmitUserArgValue(StringBuilder sb, IParameterSymbol p, JniType sigType) + { + var name = EscapeIdent(p.Name); + if (p.Type.SpecialType == SpecialType.System_String) + { + sb.Append("__ref_").Append(p.Name); + return; + } + if (p.Type.SpecialType == SpecialType.System_IntPtr) + { + sb.Append(name); + return; + } + if (IsNullableIntPtr(p.Type)) + { + // `IntPtr? x` → null means "let the Kotlin default kick in"; + // pass IntPtr.Zero in that slot. The auto-mask logic still + // clears the corresponding $default bit only when the user + // supplied a value. + 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 + // template takes the Nullable wrapper directly and + // returns the JNI primitive — null cases yield the Kotlin + // default zero (the auto-mask leaves the bit set). + sb.Append(string.Format(info.LowerTemplate, name)); + return; + } + if (p.Type.SpecialType is SpecialType.System_Boolean + or SpecialType.System_Int32 + or SpecialType.System_Int64 + or SpecialType.System_Single + or SpecialType.System_Double) + { + sb.Append(name); + return; + } + if (IsModifierType(p.Type)) + { + sb.Append("global::AndroidX.Compose.ComposeBridges.ModifierHandle(").Append(name).Append(')'); + return; + } + // Reference type → handle, with null check for nullable annotations. + bool nullable = p.NullableAnnotation == NullableAnnotation.Annotated; + if (nullable) + { + sb.Append(name).Append(" is null ? global::System.IntPtr.Zero : ((global::Java.Lang.Object)") + .Append(name).Append(").Handle"); + } + else + { + sb.Append("((global::Java.Lang.Object)").Append(name).Append(").Handle"); + } + } + + static bool NeedsKeepAlive(IParameterSymbol p) + { + if (p.Type.SpecialType is SpecialType.System_Boolean + or SpecialType.System_Int32 + or SpecialType.System_Int64 + or SpecialType.System_Single + or SpecialType.System_Double + or SpecialType.System_IntPtr + or SpecialType.System_String) + return false; + if (IsNullableIntPtr(p.Type)) return false; + 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; + } + + static bool IsNullableIntPtr(ITypeSymbol t) => + t is INamedTypeSymbol n && + n.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + 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) + { + if (n.Name == "IModifier" && n.ContainingNamespace?.ToDisplayString() == "AndroidX.Compose") + return true; + } + return false; + } + + static string PascalCase(string raw) + { + if (string.IsNullOrEmpty(raw)) return raw; + if (char.IsUpper(raw[0])) return raw; + return char.ToUpperInvariant(raw[0]) + raw.Substring(1); + } + + static string SafeSym(string s) => s.Replace('-', '_'); + + // Wrap C# reserved keywords in `@` so a Kotlin parameter literally named + // `checked`/`event`/etc. can be declared as a C# parameter and referenced + // inside the generated body. + static string EscapeIdent(string name) => + Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(name) + == Microsoft.CodeAnalysis.CSharp.SyntaxKind.None + ? name : "@" + name; + + static string? ReadString(AttributeData attr, string name) + { + foreach (var na in attr.NamedArguments) + { + if (na.Key == name && na.Value.Value is string s) return s; + } + return null; + } + + static INamedTypeSymbol? ReadType(AttributeData attr, string name) + { + foreach (var na in attr.NamedArguments) + { + if (na.Key == name && na.Value.Value is INamedTypeSymbol t) return t; + } + return null; + } + + static bool ReadBool(AttributeData attr, string name) + { + foreach (var na in attr.NamedArguments) + { + if (na.Key == name && na.Value.Value is bool b) return b; + } + return false; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeCompanionEmitter.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeCompanionEmitter.cs new file mode 100644 index 000000000..00ff82915 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeCompanionEmitter.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using System.Text; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Emits the implementing partial of a [ComposeCompanion]-decorated +/// class: cache fields for the outer class / companion / per-property +/// peers, a lazy Companion() accessor that mirrors the +/// hand-written pattern (FindClass outer → GetStaticFieldID Companion → +/// NewGlobalRef the instance → DeleteLocalRef), one shared +/// ResolveSimple(getter, returnDescriptor) helper (or +/// ResolveInline(mangledGetter) for inline-class variants that +/// route through static box-impl(I)L<outer>;), and one +/// implementing partial property per declared getter. +/// +internal static class ComposeCompanionEmitter +{ + /// + /// One companion-getter declaration, gathered from + /// [ComposeCompanionGetter] on a public static partial + /// property. + /// + public readonly struct Getter + { + public Getter(string propertyName, string returnTypeFullyQualified, string getterName, string? returnDescriptor) + { + PropertyName = propertyName; + ReturnTypeFullyQualified = returnTypeFullyQualified; + GetterName = getterName; + ReturnDescriptor = returnDescriptor; + } + + public string PropertyName { get; } + /// e.g. global::AndroidX.Compose.FontWeight. + public string ReturnTypeFullyQualified { get; } + /// Kotlin getter name (e.g. getThin or getCenter-e0LSkKk). + public string GetterName { get; } + /// Override return descriptor (e.g. Landroidx/.../SystemFontFamily;); null = derive from outer. + public string? ReturnDescriptor { get; } + } + + public static string Emit( + string? @namespace, + string className, + string outerJniClass, + bool inlineClass, + IReadOnlyList getters) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// Generated by AndroidX.Compose.SourceGenerators.ComposeCompanionGenerator."); + sb.AppendLine("// Mirrors the hand-written FindClass → GetStaticFieldID(Companion) → NewGlobalRef pattern"); + sb.AppendLine("// shared by FontWeight/TextDecoration/ContentScale/FontFamily/TextAlign/FontStyle."); + sb.AppendLine("#nullable enable"); + if (@namespace is not null) + { + sb.Append("namespace ").Append(@namespace).AppendLine(";"); + } + sb.AppendLine(); + + sb.Append("partial class ").AppendLine(className); + sb.AppendLine("{"); + + // Companion field cache. NewGlobalRef-backed (process-lifetime) so the + // boxed singleton stays addressable for every subsequent CallObjectMethod. + sb.AppendLine(" static global::System.IntPtr s_companion;"); + + if (inlineClass) + { + // box-impl static method ID lives on the outer class itself. + sb.AppendLine(" static global::System.IntPtr s_box_method;"); + } + sb.AppendLine(); + + // Per-property cache fields. One nullable peer per declared getter. + foreach (var g in getters) + { + sb.Append(" static ").Append(g.ReturnTypeFullyQualified) + .Append("? s_").Append(CamelCase(g.PropertyName)).AppendLine(";"); + } + sb.AppendLine(); + + EmitCompanionAccessor(sb, outerJniClass); + sb.AppendLine(); + + if (inlineClass) + EmitResolveInline(sb, className, outerJniClass); + else + EmitResolveSimple(sb, className); + sb.AppendLine(); + + EmitProperties(sb, outerJniClass, inlineClass, getters); + + sb.AppendLine("}"); + return sb.ToString(); + } + + static void EmitCompanionAccessor(StringBuilder sb, string outerJniClass) + { + sb.AppendLine(" static global::System.IntPtr Companion()"); + sb.AppendLine(" {"); + sb.AppendLine(" if (s_companion == global::System.IntPtr.Zero)"); + sb.AppendLine(" {"); + sb.Append(" global::System.IntPtr __outer = global::Android.Runtime.JNIEnv.FindClass(\"") + .Append(outerJniClass).AppendLine("\");"); + sb.Append(" global::System.IntPtr __fid = global::Android.Runtime.JNIEnv.GetStaticFieldID(__outer, \"Companion\", \"L") + .Append(outerJniClass).AppendLine("$Companion;\");"); + sb.AppendLine(" global::System.IntPtr __local = global::Android.Runtime.JNIEnv.GetStaticObjectField(__outer, __fid);"); + sb.AppendLine(" s_companion = global::Android.Runtime.JNIEnv.NewGlobalRef(__local);"); + sb.AppendLine(" global::Android.Runtime.JNIEnv.DeleteLocalRef(__local);"); + sb.AppendLine(" }"); + sb.AppendLine(" return s_companion;"); + sb.AppendLine(" }"); + } + + static void EmitResolveSimple(StringBuilder sb, string className) + { + sb.Append(" static ").Append(className).AppendLine(" ResolveSimple(string getterName, string returnDescriptor)"); + sb.AppendLine(" {"); + sb.AppendLine(" global::System.IntPtr __cls = global::Android.Runtime.JNIEnv.FindClass(__companionJni);"); + sb.AppendLine(" global::System.IntPtr __mid = global::Android.Runtime.JNIEnv.GetMethodID(__cls, getterName, \"()\" + returnDescriptor);"); + sb.AppendLine(" global::System.IntPtr __value = global::Android.Runtime.JNIEnv.CallObjectMethod(Companion(), __mid);"); + sb.Append(" return new ").Append(className).AppendLine("(__value, global::Android.Runtime.JniHandleOwnership.TransferLocalRef);"); + sb.AppendLine(" }"); + } + + static void EmitResolveInline(StringBuilder sb, string className, string outerJniClass) + { + sb.Append(" static unsafe ").Append(className).AppendLine(" ResolveInline(string mangledGetter)"); + sb.AppendLine(" {"); + sb.AppendLine(" // 1. Companion.()I returns the packed int."); + sb.AppendLine(" global::System.IntPtr __companionCls = global::Android.Runtime.JNIEnv.FindClass(__companionJni);"); + sb.AppendLine(" global::System.IntPtr __getter = global::Android.Runtime.JNIEnv.GetMethodID(__companionCls, mangledGetter, \"()I\");"); + sb.AppendLine(" int __packed = global::Android.Runtime.JNIEnv.CallIntMethod(Companion(), __getter);"); + sb.AppendLine(); + sb.AppendLine(" // 2. .box-impl(int) -> L; to materialize the boxed reference."); + sb.Append(" global::System.IntPtr __outerCls = global::Android.Runtime.JNIEnv.FindClass(\"") + .Append(outerJniClass).AppendLine("\");"); + sb.AppendLine(" if (s_box_method == global::System.IntPtr.Zero)"); + sb.Append(" s_box_method = global::Android.Runtime.JNIEnv.GetStaticMethodID(__outerCls, \"box-impl\", \"(I)L") + .Append(outerJniClass).AppendLine(";\");"); + sb.AppendLine(" global::Android.Runtime.JValue* __args = stackalloc global::Android.Runtime.JValue[1];"); + sb.AppendLine(" __args[0] = new global::Android.Runtime.JValue(__packed);"); + sb.AppendLine(" global::System.IntPtr __boxed = global::Android.Runtime.JNIEnv.CallStaticObjectMethod(__outerCls, s_box_method, __args);"); + sb.Append(" return new ").Append(className).AppendLine("(__boxed, global::Android.Runtime.JniHandleOwnership.TransferLocalRef);"); + sb.AppendLine(" }"); + } + + static void EmitProperties( + StringBuilder sb, + string outerJniClass, + bool inlineClass, + IReadOnlyList getters) + { + // Embed the companion-JNI string once as a private const so both + // Resolve helpers and the (hypothetical) future call sites stay + // in sync without repeating the literal. + sb.Append(" const string __companionJni = \"").Append(outerJniClass).AppendLine("$Companion\";"); + sb.AppendLine(); + + foreach (var g in getters) + { + var cacheField = "s_" + CamelCase(g.PropertyName); + sb.Append(" public static partial ").Append(g.ReturnTypeFullyQualified).Append(' ') + .Append(g.PropertyName).Append(" => ").Append(cacheField).Append(" ??= "); + + if (inlineClass) + { + sb.Append("ResolveInline(\"").Append(EscapeForString(g.GetterName)).AppendLine("\");"); + } + else + { + var returnDescriptor = g.ReturnDescriptor ?? ("L" + outerJniClass + ";"); + sb.Append("ResolveSimple(\"").Append(EscapeForString(g.GetterName)) + .Append("\", \"").Append(EscapeForString(returnDescriptor)).AppendLine("\");"); + } + } + } + + static string CamelCase(string name) + { + if (string.IsNullOrEmpty(name)) return name; + if (char.IsLower(name[0])) return name; + return char.ToLowerInvariant(name[0]) + name.Substring(1); + } + + static string EscapeForString(string raw) + { + // Companion getter strings come from C# attribute arguments, which + // already round-trip through C# string escaping. The only way an + // illegal character lands here is a backslash or a double quote in + // the source attribute, neither of which is plausible for JNI + // method names or descriptors. Keep the escape conservative. + if (raw.IndexOf('\\') < 0 && raw.IndexOf('"') < 0) return raw; + return raw.Replace("\\", "\\\\").Replace("\"", "\\\""); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeCompanionGenerator.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeCompanionGenerator.cs new file mode 100644 index 000000000..08e80bd27 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeCompanionGenerator.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Emits the implementing partial of [ComposeCompanion]-decorated +/// classes — a Roslyn-time replacement for the +/// "FindClass → GetStaticFieldID(Companion) → NewGlobalRef" +/// boilerplate that every Compose value/reference wrapper would +/// otherwise hand-roll. Per-property partial-property declarations +/// carrying [ComposeCompanionGetter] get their bodies emitted +/// here too. See for the actual +/// codegen. +/// +[Generator(LanguageNames.CSharp)] +public sealed class ComposeCompanionGenerator : IIncrementalGenerator +{ + const string CompanionAttributeMetadataName = "AndroidX.Compose.ComposeCompanionAttribute"; + const string GetterAttributeMetadataName = "AndroidX.Compose.ComposeCompanionGetterAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Attributes.cs (shared with ComposeDefaultsGenerator) emits the + // ComposeCompanion / ComposeCompanionGetter attribute sources via + // RegisterPostInitializationOutput. We don't re-register here — + // doing so would collide with the defaults generator and emit + // duplicate-class errors. Tests instantiate Attributes.Source + // directly when running this generator in isolation. + + var classes = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => + node is ClassDeclarationSyntax c && + c.AttributeLists.Count > 0, + transform: static (ctx, _) => ctx) + .Where(static ctx => ctx.SemanticModel.GetDeclaredSymbol((ClassDeclarationSyntax)ctx.Node) is INamedTypeSymbol); + + var combined = classes.Combine(context.CompilationProvider); + + context.RegisterSourceOutput(combined, static (spc, pair) => + { + var (ctx, compilation) = pair; + var companionAttr = compilation.GetTypeByMetadataName(CompanionAttributeMetadataName); + var getterAttr = compilation.GetTypeByMetadataName(GetterAttributeMetadataName); + if (companionAttr is null || getterAttr is null) return; + + var classSyntax = (ClassDeclarationSyntax)ctx.Node; + var classSymbol = (INamedTypeSymbol)ctx.SemanticModel.GetDeclaredSymbol(classSyntax)!; + + var attr = classSymbol.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, companionAttr)); + if (attr is null) return; + + // Generator runs once per class-decl syntax node. If a class + // is declared in multiple partials we may see it twice; only + // emit when this is the syntax node that *carries* the + // attribute, to avoid duplicate-output collisions. + if (!CarriesAttribute(classSyntax, attr)) return; + + var result = Build(classSymbol, attr, getterAttr); + foreach (var diag in result.Diagnostics) + spc.ReportDiagnostic(diag); + if (result.Source is { } source && result.HintName is { } hint) + spc.AddSource(hint, SourceText.From(source, Encoding.UTF8)); + }); + + // Second pass — flag orphaned [ComposeCompanionGetter] properties + // whose containing class lacks [ComposeCompanion]. Without this + // diagnostic the developer would just hit the generic + // "partial property has no implementing declaration" error and + // have to guess what's missing. + var orphanProps = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => + node is PropertyDeclarationSyntax p && + p.AttributeLists.Count > 0, + transform: static (ctx, _) => ctx) + .Where(static ctx => ctx.SemanticModel.GetDeclaredSymbol((PropertyDeclarationSyntax)ctx.Node) is IPropertySymbol); + + var orphanCombined = orphanProps.Combine(context.CompilationProvider); + + context.RegisterSourceOutput(orphanCombined, static (spc, pair) => + { + var (ctx, compilation) = pair; + var companionAttr = compilation.GetTypeByMetadataName(CompanionAttributeMetadataName); + var getterAttr = compilation.GetTypeByMetadataName(GetterAttributeMetadataName); + if (companionAttr is null || getterAttr is null) return; + + var propSymbol = (IPropertySymbol)ctx.SemanticModel.GetDeclaredSymbol((PropertyDeclarationSyntax)ctx.Node)!; + var ga = propSymbol.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, getterAttr)); + if (ga is null) return; + + var containingType = propSymbol.ContainingType; + if (containingType is null) return; + + var hostAttr = containingType.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, companionAttr)); + if (hostAttr is not null) return; + + var loc = ga.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; + spc.ReportDiagnostic(Diagnostic.Create(Diagnostics.CompanionGetterMissingHost, loc, + containingType.ToDisplayString(), propSymbol.Name)); + }); + } + + static bool CarriesAttribute(ClassDeclarationSyntax classSyntax, AttributeData attr) + { + var attrSyntax = attr.ApplicationSyntaxReference?.GetSyntax(); + if (attrSyntax is null) return true; + // Walk up to the parent class declaration. + for (SyntaxNode? cur = attrSyntax; cur is not null; cur = cur.Parent) + { + if (cur is ClassDeclarationSyntax owner) + return owner == classSyntax; + } + return true; + } + + static GenerationResult Build(INamedTypeSymbol classSymbol, AttributeData attr, INamedTypeSymbol getterAttr) + { + var loc = attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; + var classDisplay = classSymbol.ToDisplayString(); + + // Class-level validation. + var diags = new List(); + + bool isPartial = classSymbol.DeclaringSyntaxReferences + .Any(r => r.GetSyntax() is ClassDeclarationSyntax c && + c.Modifiers.Any(SyntaxKind.PartialKeyword)); + if (!isPartial) + { + diags.Add(Diagnostic.Create(Diagnostics.CompanionNotPartial, loc, classDisplay)); + return new GenerationResult(null, null, diags.ToArray()); + } + + string? outerJniClass = null; + if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s)) + outerJniClass = s; + if (outerJniClass is null) + { + diags.Add(Diagnostic.Create(Diagnostics.CompanionMalformedOuter, loc, classDisplay)); + return new GenerationResult(null, null, diags.ToArray()); + } + + bool inlineClass = false; + foreach (var named in attr.NamedArguments) + { + if (named.Key == "InlineClass" && named.Value.Value is bool b) + inlineClass = b; + } + + // Walk all properties for [ComposeCompanionGetter] declarations. + var getters = new List(); + foreach (var member in classSymbol.GetMembers()) + { + if (member is not IPropertySymbol prop) continue; + var ga = prop.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, getterAttr)); + if (ga is null) continue; + + var propLoc = ga.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? loc; + + // Property shape: static partial with a get-only accessor and + // a non-void return type. Setter is forbidden (companions are + // read-only singletons). + if (!prop.IsStatic || !IsDeclaredPartial(prop) || prop.SetMethod is not null || prop.GetMethod is null) + { + diags.Add(Diagnostic.Create(Diagnostics.CompanionGetterShape, propLoc, classDisplay, prop.Name)); + continue; + } + + if (ga.ConstructorArguments.Length < 1 || ga.ConstructorArguments[0].Value is not string getterName || string.IsNullOrWhiteSpace(getterName)) + { + diags.Add(Diagnostic.Create(Diagnostics.CompanionMalformedGetter, propLoc, classDisplay, prop.Name)); + continue; + } + + string? returnDescriptor = null; + foreach (var named in ga.NamedArguments) + { + if (named.Key == "ReturnDescriptor" && named.Value.Value is string rd && !string.IsNullOrWhiteSpace(rd)) + returnDescriptor = rd; + } + + if (inlineClass && returnDescriptor is not null) + { + diags.Add(Diagnostic.Create(Diagnostics.CompanionInlineReturnConflict, propLoc, classDisplay, prop.Name)); + continue; + } + + // Return type must declare an accessible (IntPtr, JniHandleOwnership) + // constructor so the generated wrapper line compiles. + if (!HasJniPeerCtor(prop.Type)) + { + diags.Add(Diagnostic.Create(Diagnostics.CompanionMissingPeerCtor, propLoc, classDisplay, prop.Name, prop.Type.ToDisplayString())); + continue; + } + + getters.Add(new ComposeCompanionEmitter.Getter( + propertyName: prop.Name, + returnTypeFullyQualified: prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes)), + getterName: getterName, + returnDescriptor: returnDescriptor)); + } + + // Also check for orphaned [ComposeCompanionGetter] on classes + // that don't carry [ComposeCompanion] — handled by a separate + // generator pass below, not here. (This generator only fires for + // classes that *do* carry [ComposeCompanion].) + + if (diags.Count > 0) + return new GenerationResult(null, null, diags.ToArray()); + + var @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == false + ? classSymbol.ContainingNamespace.ToDisplayString() + : null; + + var source = ComposeCompanionEmitter.Emit(@namespace, classSymbol.Name, outerJniClass, inlineClass, getters); + var hint = $"AndroidX.Compose.Companion.{classSymbol.Name}.g.cs"; + return new GenerationResult(source, hint, Array.Empty()); + } + + static bool IsDeclaredPartial(IPropertySymbol prop) => + prop.DeclaringSyntaxReferences + .Any(r => r.GetSyntax() is PropertyDeclarationSyntax p && + p.Modifiers.Any(SyntaxKind.PartialKeyword)); + + static bool HasJniPeerCtor(ITypeSymbol type) + { + if (type is not INamedTypeSymbol n) return false; + foreach (var ctor in n.InstanceConstructors) + { + if (ctor.Parameters.Length != 2) continue; + if (ctor.DeclaredAccessibility == Accessibility.Private && + !SymbolEqualityComparer.Default.Equals(ctor.ContainingType, type)) + { + continue; + } + var p0 = ctor.Parameters[0].Type; + var p1 = ctor.Parameters[1].Type; + if (p0.SpecialType == SpecialType.System_IntPtr || p0.ToDisplayString() == "System.IntPtr" || p0.ToDisplayString() == "nint") + { + if (p1.ToDisplayString() == "Android.Runtime.JniHandleOwnership") + return true; + } + } + return false; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeDefaultsEmitter.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeDefaultsEmitter.cs new file mode 100644 index 000000000..dca498f8e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeDefaultsEmitter.cs @@ -0,0 +1,168 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; + +namespace AndroidX.Compose.SourceGenerators; + +internal static class ComposeDefaultsEmitter +{ + /// + /// One bit position in the generated enum. + /// is null when the bit is consumed (occupies + /// a position) but no member is emitted — e.g. params the caller always + /// provides, or Kotlin lambda content slots. + /// + public readonly struct Slot + { + public Slot(int bit, string? enumMember, string rawName, string? skipReason) + { + Bit = bit; + EnumMember = enumMember; + RawName = rawName; + SkipReason = skipReason; + } + + public int Bit { get; } + public string? EnumMember { get; } + public string RawName { get; } + public string? SkipReason { get; } + } + + /// Build slots from a Roslyn method symbol (generic attribute path). + public static IReadOnlyList SlotsFromSymbol(IMethodSymbol method, int composerIndex) + { + var slots = new List(composerIndex); + for (int i = 0; i < composerIndex; i++) + { + var p = method.Parameters[i]; + if (ComposeDefaultsGenerator.IsKotlinFunction(p.Type)) + { + slots.Add(new Slot(i, null, p.Name, $"{p.Type.Name} — always provided, no enum member.")); + continue; + } + slots.Add(new Slot(i, PascalCase(p.Name, i), p.Name, null)); + } + return slots; + } + + /// Build slots from a positional name list (declarative attribute path). + public static IReadOnlyList SlotsFromNames(IReadOnlyList names) + { + var slots = new List(names.Count); + for (int i = 0; i < names.Count; i++) + { + var raw = names[i] ?? string.Empty; + if (raw.Length > 0 && raw[0] == '!') + { + var inner = raw.Substring(1); + slots.Add(new Slot(i, null, inner, "marked '!' — always provided, no enum member.")); + } + else + { + slots.Add(new Slot(i, PascalCase(raw, i), raw, null)); + } + } + return slots; + } + + public static string Emit(string enumName, string sourceComment, IReadOnlyList slots) + { + var members = slots.Where(s => s.EnumMember is not null).ToList(); + + // Kotlin lowers > 32-slot $default bitmasks into a pair of int + // values (II at the JVM boundary). To keep a single enum that + // can model every bit position without `1 << 32` silently + // wrapping to bit 0, switch to a long-backed enum once we + // exceed 31 positional slots. ≤ 31 slots stay byte-for-byte + // identical to the pre-issue-#178 output. + bool useLong = slots.Count > 31; + string shiftSuffix = useLong ? "L" : ""; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.Append("// Generated by AndroidX.Compose.SourceGenerators from "); + sb.AppendLine(sourceComment); + sb.AppendLine("// Bit positions match the Kotlin parameter index in the $default bitmask."); + sb.AppendLine("#nullable enable"); + sb.AppendLine("namespace AndroidX.Compose"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.Flags]"); + sb.Append(" internal enum ").Append(enumName); + if (useLong) + sb.AppendLine(" : long"); + else + sb.AppendLine(); + sb.AppendLine(" {"); + sb.AppendLine(" None = 0,"); + + int maxNameLen = members.Count == 0 ? 0 : members.Max(m => m.EnumMember!.Length); + foreach (var slot in members) + { + sb.Append(" ").Append(slot.EnumMember).Append(' ', maxNameLen - slot.EnumMember!.Length) + .Append(" = 1").Append(shiftSuffix).Append(" << ").Append(slot.Bit).AppendLine(","); + } + + foreach (var slot in slots) + { + if (slot.EnumMember is not null) continue; + sb.Append(" // bit ").Append(slot.Bit).Append(": ").Append(slot.RawName) + .Append(" — ").AppendLine(slot.SkipReason ?? "skipped."); + } + + sb.AppendLine(); + sb.AppendLine(" /// OR of every user-defaultable bit."); + if (members.Count == 0) + { + sb.AppendLine(" All = 0,"); + } + else + { + sb.Append(" All = "); + sb.Append(string.Join(" | ", members.Select(m => m.EnumMember))); + sb.AppendLine(","); + } + sb.AppendLine(" }"); + + if (useLong) + { + sb.AppendLine(); + sb.Append(" /// Helpers for the long-backed ").Append(enumName).AppendLine(" mask."); + sb.Append(" internal static class ").Append(enumName).AppendLine("Extensions"); + sb.AppendLine(" {"); + sb.AppendLine(" /// "); + sb.AppendLine(" /// Split a long-backed default mask into the pair of int"); + sb.AppendLine(" /// values Kotlin's $default trampolines expect when the"); + sb.AppendLine(" /// bit count exceeds 32 (low 32 bits → Mask0, high 32 bits → Mask1)."); + sb.AppendLine(" /// "); + sb.Append(" public static (int Mask0, int Mask1) Split(this ").Append(enumName).AppendLine(" value) =>"); + sb.AppendLine(" ((int)((long)value & 0xFFFFFFFFL), (int)(((long)value >> 32) & 0xFFFFFFFFL));"); + sb.AppendLine(" }"); + } + + sb.AppendLine("}"); + return sb.ToString(); + } + + static string PascalCase(string raw, int positionFallback) + { + if (string.IsNullOrEmpty(raw)) + return "Param" + positionFallback; + + if (raw.Length >= 2 && raw[0] == 'p' && IsAllDigits(raw, 1)) + return "Param" + raw.Substring(1); + + if (char.IsUpper(raw[0])) + return raw; + + return char.ToUpperInvariant(raw[0]) + raw.Substring(1); + } + + static bool IsAllDigits(string s, int start) + { + for (int i = start; i < s.Length; i++) + if (!char.IsDigit(s[i])) return false; + return start < s.Length; + } +} + diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeDefaultsGenerator.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeDefaultsGenerator.cs new file mode 100644 index 000000000..85645879e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeDefaultsGenerator.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Generates [Flags] enum bodies that name each bit in a Kotlin +/// $default bitmask. Triggered by an assembly-level +/// [assembly: ComposeDefaults<FooKt>("Foo", "FooDefault")]. +/// +[Generator(LanguageNames.CSharp)] +public sealed class ComposeDefaultsGenerator : IIncrementalGenerator +{ + const string GenericAttributeMetadataName = "AndroidX.Compose.ComposeDefaultsAttribute`1"; + const string DeclarativeAttributeMetadataName = "AndroidX.Compose.ComposeDefaultsAttribute"; + const string ComposerNamespace = "AndroidX.Compose.Runtime"; + const string ComposerName = "IComposer"; + const string KotlinFunctionNamespace = "Kotlin.Jvm.Functions"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static ctx => + ctx.AddSource("ComposeDefaultsAttribute.g.cs", + SourceText.From(Attributes.Source, Encoding.UTF8))); + + var requests = context.CompilationProvider.Select(static (compilation, _) => BuildAll(compilation)); + + context.RegisterSourceOutput(requests, static (spc, results) => + { + foreach (var result in results) + { + foreach (var diag in result.Diagnostics) + spc.ReportDiagnostic(diag); + + if (result.Source is { } source && result.HintName is { } hint) + spc.AddSource(hint, SourceText.From(source, Encoding.UTF8)); + } + }); + } + + static ImmutableArray BuildAll(Compilation compilation) + { + var genericAttr = compilation.GetTypeByMetadataName(GenericAttributeMetadataName); + var declarativeAttr = compilation.GetTypeByMetadataName(DeclarativeAttributeMetadataName); + if (genericAttr is null && declarativeAttr is null) + return ImmutableArray.Empty; + + var assemblyAttributes = compilation.Assembly.GetAttributes(); + var builder = ImmutableArray.CreateBuilder(); + + foreach (var attr in assemblyAttributes) + { + if (attr.AttributeClass is not { } attrClass) continue; + + if (genericAttr is not null && + SymbolEqualityComparer.Default.Equals(attrClass.ConstructedFrom, genericAttr)) + { + builder.Add(BuildFromSymbol(attr)); + continue; + } + + if (declarativeAttr is not null && + SymbolEqualityComparer.Default.Equals(attrClass, declarativeAttr)) + { + builder.Add(BuildFromNames(attr)); + continue; + } + } + + return builder.ToImmutable(); + } + + static GenerationResult BuildFromSymbol(AttributeData attr) + { + var loc = attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; + + if (attr.AttributeClass is not { TypeArguments.Length: 1 } attrClass || + attrClass.TypeArguments[0] is not INamedTypeSymbol containingType || + attr.ConstructorArguments.Length < 2 || + attr.ConstructorArguments[0].Value is not string methodName || + attr.ConstructorArguments[1].Value is not string enumName || + string.IsNullOrWhiteSpace(enumName)) + { + return new GenerationResult(null, null, + new[] { Diagnostic.Create(Diagnostics.MalformedAttribute, loc, "") }); + } + + var candidates = containingType.GetMembers(methodName) + .OfType() + .Where(m => m.IsStatic) + .ToList(); + + if (candidates.Count == 0) + { + return new GenerationResult(null, null, + new[] { Diagnostic.Create(Diagnostics.MethodNotFound, loc, methodName, containingType.ToDisplayString()) }); + } + + var method = candidates.OrderByDescending(m => m.Parameters.Length).First(); + + int composerIndex = -1; + for (int i = 0; i < method.Parameters.Length; i++) + { + if (IsComposer(method.Parameters[i].Type)) + { + composerIndex = i; + break; + } + } + + if (composerIndex < 0) + { + return new GenerationResult(null, null, + new[] { Diagnostic.Create(Diagnostics.NotComposable, loc, method.ToDisplayString()) }); + } + + var slots = ComposeDefaultsEmitter.SlotsFromSymbol(method, composerIndex); + var sourceComment = $"{containingType.ToDisplayString()}.{method.Name}"; + var source = ComposeDefaultsEmitter.Emit(enumName, sourceComment, slots); + return new GenerationResult(source, $"AndroidX.Compose.{enumName}.g.cs", Array.Empty()); + } + + static GenerationResult BuildFromNames(AttributeData attr) + { + var loc = attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; + + if (attr.ConstructorArguments.Length < 2 || + attr.ConstructorArguments[0].Value is not string enumName || + string.IsNullOrWhiteSpace(enumName)) + { + return new GenerationResult(null, null, + new[] { Diagnostic.Create(Diagnostics.MalformedAttribute, loc, "") }); + } + + // params string[] arrives as a single TypedConstant of Kind=Array. + var arr = attr.ConstructorArguments[1]; + if (arr.Kind != TypedConstantKind.Array) + { + return new GenerationResult(null, null, + new[] { Diagnostic.Create(Diagnostics.MalformedAttribute, loc, enumName) }); + } + + var names = arr.Values + .Select(v => v.Value as string ?? string.Empty) + .ToArray(); + + var slots = ComposeDefaultsEmitter.SlotsFromNames(names); + var source = ComposeDefaultsEmitter.Emit(enumName, $"declarative names for '{enumName}'", slots); + return new GenerationResult(source, $"AndroidX.Compose.{enumName}.g.cs", Array.Empty()); + } + + internal static bool IsComposer(ITypeSymbol type) => + type is INamedTypeSymbol n && + n.Name == ComposerName && + n.ContainingNamespace?.ToDisplayString() == ComposerNamespace; + + internal static bool IsKotlinFunction(ITypeSymbol type) + { + if (type is not INamedTypeSymbol n) return false; + var ns = n.ContainingNamespace?.ToDisplayString(); + return ns == KotlinFunctionNamespace && n.Name.StartsWith("IFunction", StringComparison.Ordinal); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeFacadeGenerator.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeFacadeGenerator.cs new file mode 100644 index 000000000..c5650f924 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeFacadeGenerator.cs @@ -0,0 +1,2514 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Emits user-facing facade classes from [ComposeFacade]-decorated +/// bridge methods on AndroidX.Compose.ComposeBridges. The bridge owns +/// the JNI plumbing; the facade is a thin +/// / +/// wrapper that builds the bridge's user-controlled args (Action → +/// ComposableLambda0, modifier → BuildModifier(), content +/// → ComposableLambdas.Wrap2/3) and forwards through. +/// +/// Phases supported (see issue #78): +/// +/// Phase 1 — modifier, IFunction0 onClick, IFunction2/3 content, +/// primitives, scope publication. +/// Phase 2 — IFunction1 + [Callback(typeof(T))] → typed +/// Action<T> ctor with JNI un-boxing. +/// Phase 3 — multiple IFunction2? / IFunction3? slots +/// surfaced as named ComposableNode? properties; required Fn2/3 +/// slots become required ComposableNode properties with a +/// throwing null-check. Auto-mask emission against the bridge's +/// $default enum. +/// Phase 6 — [ComposeFacade(DefaultColorFromTheme="...")] +/// adds a Color ContainerColor property with a +/// MaterialTheme.colorScheme fallback. +/// Phase 7 — [PainterResource] on an IntPtr param +/// (the painter handle the bridge forwards) emits a synthetic +/// int drawableResourceId ctor arg + painterResource() +/// + try/finally + DeleteLocalRef dance. +/// +/// +[Generator(LanguageNames.CSharp)] +public sealed class ComposeFacadeGenerator : IIncrementalGenerator +{ + const string FacadeAttributeMetadataName = "AndroidX.Compose.ComposeFacadeAttribute"; + const string BridgeAttributeMetadataName = "AndroidX.Compose.ComposeBridgeAttribute"; + const string DeclarativeDefaultsAttributeMetadataName = "AndroidX.Compose.ComposeDefaultsAttribute"; + const string GenericDefaultsAttributeMetadataName = "AndroidX.Compose.ComposeDefaultsAttribute`1"; + const string SlotAttributeMetadataName = "AndroidX.Compose.SlotAttribute"; + const string CallbackAttributeMetadataName = "AndroidX.Compose.CallbackAttribute"; + const string PainterResourceAttributeMetadataName = "AndroidX.Compose.PainterResourceAttribute"; + const string StateHolderAttributeMetadataName = "AndroidX.Compose.StateHolderAttribute"; + const string ConfirmStateChangeAttributeMetadataName = "AndroidX.Compose.ConfirmStateChangeAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var methods = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => + node is MethodDeclarationSyntax m && + m.Modifiers.Any(t => t.IsKind(SyntaxKind.PartialKeyword)) && + m.AttributeLists.Count > 0, + transform: static (ctx, _) => ctx) + .Where(static ctx => ctx.SemanticModel.GetDeclaredSymbol((MethodDeclarationSyntax)ctx.Node) is IMethodSymbol); + + var combined = methods.Combine(context.CompilationProvider); + + context.RegisterSourceOutput(combined, static (spc, pair) => + { + var (ctx, compilation) = pair; + var facadeAttr = compilation.GetTypeByMetadataName(FacadeAttributeMetadataName); + var bridgeAttr = compilation.GetTypeByMetadataName(BridgeAttributeMetadataName); + var declarativeAttr = compilation.GetTypeByMetadataName(DeclarativeDefaultsAttributeMetadataName); + var genericDefaultsAttr = compilation.GetTypeByMetadataName(GenericDefaultsAttributeMetadataName); + var slotAttr = compilation.GetTypeByMetadataName(SlotAttributeMetadataName); + var callbackAttr = compilation.GetTypeByMetadataName(CallbackAttributeMetadataName); + var painterAttr = compilation.GetTypeByMetadataName(PainterResourceAttributeMetadataName); + var stateHolderAttr = compilation.GetTypeByMetadataName(StateHolderAttributeMetadataName); + var confirmAttr = compilation.GetTypeByMetadataName(ConfirmStateChangeAttributeMetadataName); + if (facadeAttr is null) return; + + var method = (IMethodSymbol)ctx.SemanticModel.GetDeclaredSymbol((MethodDeclarationSyntax)ctx.Node)!; + var attr = method.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, facadeAttr)); + if (attr is null) return; + + var ctxObj = new Context(method, attr, bridgeAttr, declarativeAttr, genericDefaultsAttr, slotAttr, callbackAttr, painterAttr, stateHolderAttr, confirmAttr, compilation); + var result = Build(ctxObj); + foreach (var diag in result.Diagnostics) + spc.ReportDiagnostic(diag); + if (result.Source is { } source && result.HintName is { } hint) + spc.AddSource(hint, SourceText.From(source, Encoding.UTF8)); + }); + } + + sealed class Context + { + public IMethodSymbol Method { get; } + public AttributeData Attr { get; } + public INamedTypeSymbol? BridgeAttr { get; } + public INamedTypeSymbol? DeclarativeAttr { get; } + public INamedTypeSymbol? GenericDefaultsAttr { get; } + public INamedTypeSymbol? SlotAttr { get; } + public INamedTypeSymbol? CallbackAttr { get; } + public INamedTypeSymbol? PainterAttr { get; } + public INamedTypeSymbol? StateHolderAttr { get; } + public INamedTypeSymbol? ConfirmStateChangeAttr { get; } + public Compilation Compilation { get; } + + public Context(IMethodSymbol method, AttributeData attr, INamedTypeSymbol? bridgeAttr, + INamedTypeSymbol? declarativeAttr, INamedTypeSymbol? genericDefaultsAttr, + INamedTypeSymbol? slotAttr, INamedTypeSymbol? callbackAttr, + INamedTypeSymbol? painterAttr, INamedTypeSymbol? stateHolderAttr, + INamedTypeSymbol? confirmStateChangeAttr, Compilation compilation) + { + Method = method; + Attr = attr; + BridgeAttr = bridgeAttr; + DeclarativeAttr = declarativeAttr; + GenericDefaultsAttr = genericDefaultsAttr; + SlotAttr = slotAttr; + CallbackAttr = callbackAttr; + PainterAttr = painterAttr; + StateHolderAttr = stateHolderAttr; + ConfirmStateChangeAttr = confirmStateChangeAttr; + Compilation = compilation; + } + } + + static GenerationResult Build(Context c) + { + var method = c.Method; + var attr = c.Attr; + var loc = attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; + + // CN3001 — facade must attach to ComposeBridges. + var container = method.ContainingType; + if (container.Name != "ComposeBridges" || + container.ContainingNamespace?.ToDisplayString() != "AndroidX.Compose") + { + return Fail(Diagnostics.FacadeWrongContainingType, loc, method.Name, container.ToDisplayString()); + } + + // CN3004 — paired with [ComposeBridge]. Relaxed for "wrapper" + // facades: a partial method with a hand-written body and no + // [ComposeBridge] is a tiny pass-through to a bound binding + // (e.g. BoxKt.Box) — its $default enum is read from + // [ComposeFacade].Defaults instead. + AttributeData? bridge = null; + if (c.BridgeAttr is not null) + { + bridge = method.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.BridgeAttr)); + if (bridge is null && !HasMethodBody(method)) + { + return Fail(Diagnostics.FacadeMissingBridge, loc, method.Name); + } + } + + string className = ReadString(attr, "ClassName") ?? method.Name; + string? scope = ReadString(attr, "Scope"); + string? themeColor = ReadString(attr, "DefaultColorFromTheme"); + string? colorParameter = ReadString(attr, "ColorParameter"); + bool containerOptIn = ReadBool(attr, "Container"); + bool indexedChildren = ReadBool(attr, "IndexedChildren"); + string? branchOn = ReadString(attr, "BranchOn"); + string? alternateBridgeName = ReadString(attr, "AlternateBridge"); + string? secondaryCtorName = ReadString(attr, "SecondaryCtor"); + INamedTypeSymbol? secondaryDefaultsType = ReadType(attr, "SecondaryDefaults"); + + // Composer is the trailing param for @Composable bridges (the only + // shape facade generation supports). + var csParams = method.Parameters; + if (csParams.Length == 0 || !ComposeDefaultsGenerator.IsComposer(csParams[csParams.Length - 1].Type)) + { + return Fail(Diagnostics.FacadeUnsupportedParameter, loc, method.Name, "", + "bridge must be @Composable (trailing IComposer parameter) for facade generation"); + } + var composerParam = csParams[csParams.Length - 1]; + var userParams = csParams.Take(csParams.Length - 1).ToArray(); + + // Look up the bridge's Defaults enum so Phase 3 can emit the + // matching auto-mask code. Bridge-mode reads it from + // [ComposeBridge].Defaults; wrapper-mode (no bridge) falls + // back to [ComposeFacade].Defaults. + DefaultsInfo? defaults = null; + INamedTypeSymbol? defaultsType = bridge is not null ? ReadType(bridge, "Defaults") : null; + defaultsType ??= ReadType(attr, "Defaults"); + if (defaultsType is not null) + { + // Try declarative form first; fall back to walking the enum + // directly (covers generic-form-generated enums when this + // generator runs AFTER ComposeDefaultsGenerator has emitted + // the type). Final fallback reconstructs slot info from the + // generic-form assembly attribute itself — required when the + // sibling generator hasn't run yet in the current pipeline pass. + if (c.DeclarativeAttr is not null) + { + defaults = DefaultsInfo.TryRead(c.Compilation, c.DeclarativeAttr, defaultsType.Name); + } + defaults ??= DefaultsInfo.TryReadFromEnum(defaultsType); + defaults ??= DefaultsInfo.TryReadFromGenericAttribute(c.Compilation, c.GenericDefaultsAttr, defaultsType.Name); + } + + // Look up a sibling "defaults: int" param the caller manages. If + // present, the bridge does NOT auto-mask — the facade owns every + // bit. (We still support Phase 1 bridges that don't have this + // param: those rely on bridge-side auto-mask logic.) + bool callerProvidesDefaults = false; + IParameterSymbol? defaultsParam = null; + if (userParams.Length > 0) + { + var last = userParams[userParams.Length - 1]; + if (last.Type.SpecialType == SpecialType.System_Int32 && last.Name == "defaults") + { + callerProvidesDefaults = true; + defaultsParam = last; + userParams = userParams.Take(userParams.Length - 1).ToArray(); + } + } + + // Classify each user param. + var slots = new List(userParams.Length); + var diags = new List(); + int fnContentCount = 0; + + foreach (var p in userParams) + { + var slot = Classify(p, c, method.Name, loc, diags); + if (slot is null) continue; + slots.Add(slot.Value); + if (slot.Value.Kind is FacadeSlotKind.Content2 or FacadeSlotKind.Content3) + fnContentCount++; + } + + // Multi-slot detection: if any Fn2/3 slot is nullable, or any has + // an explicit [Slot], OR there's >1 Fn2/3 slot, treat the facade + // as a multi-slot LEAF (named properties). Otherwise stick with + // the Phase 1 "container with children" shape. + // Hybrid container exception: exactly 1 non-nullable Fn2/Fn3 + // (without [Slot]) PLUS 1+ nullable Fn2/3 slots, AND either: + // (a) `[ComposeFacade(Scope = "...")]` is set — disambiguates + // from leafs that happen to have a required Fn2 label + // slot (e.g. AssistChip). Only valid when the body slot + // is Fn3 (Fn2 has no scope receiver to publish). OR + // (b) `[ComposeFacade(Container = true)]` is set explicitly — + // allows Fn2 body without a scope (e.g. + // ModalWideNavigationRail's `content: @Composable () -> Unit`). + bool hasMultiSlot = slots.Any(s => s.IsNullableSlot || s.HasSlotAttribute) || fnContentCount > 1; + bool isHybridContainer = false; + if (hasMultiSlot && (!string.IsNullOrEmpty(scope) || containerOptIn)) + { + var nonNullableFn3 = slots.Where(s => + s.Kind == FacadeSlotKind.Content3 && + s.Param.NullableAnnotation != NullableAnnotation.Annotated && + !s.HasSlotAttribute).ToArray(); + var nonNullableFn2 = slots.Where(s => + s.Kind == FacadeSlotKind.Content2 && + s.Param.NullableAnnotation != NullableAnnotation.Annotated && + !s.HasSlotAttribute).ToArray(); + var nullableContent = slots.Where(s => + s.Kind is FacadeSlotKind.Content2 or FacadeSlotKind.Content3 && + s.Param.NullableAnnotation == NullableAnnotation.Annotated).ToArray(); + // Scope path: Fn3 body only. + if (!string.IsNullOrEmpty(scope)) + { + isHybridContainer = + nonNullableFn3.Length == 1 && + nullableContent.Length >= 1 && + !slots.Any(s => s.HasSlotAttribute); + } + // Container=true path: accept either Fn2 or Fn3 body. + else if (containerOptIn) + { + int totalBodies = nonNullableFn3.Length + nonNullableFn2.Length; + isHybridContainer = + totalBodies == 1 && + nullableContent.Length >= 1 && + !slots.Any(s => s.HasSlotAttribute); + } + } + if (hasMultiSlot) + { + // Re-classify the Fn2/Fn3 slots into property slots (the + // generator picks one shape per bridge; mixing is invalid). + // Hybrid container: leave the sole non-nullable body slot + // (Fn2 or Fn3) as Content2/3 so it renders as RenderChildren. + for (int i = 0; i < slots.Count; i++) + { + var s = slots[i]; + bool isContainerBody = isHybridContainer + && s.Kind is FacadeSlotKind.Content2 or FacadeSlotKind.Content3 + && s.Param.NullableAnnotation != NullableAnnotation.Annotated + && !s.HasSlotAttribute; + if (s.Kind is FacadeSlotKind.Content2 && !isContainerBody) + slots[i] = s.WithKind(s.Param.NullableAnnotation == NullableAnnotation.Annotated + ? FacadeSlotKind.NamedFunction2 : FacadeSlotKind.RequiredFunction2); + else if (s.Kind is FacadeSlotKind.Content3 && !isContainerBody) + slots[i] = s.WithKind(s.Param.NullableAnnotation == NullableAnnotation.Annotated + ? FacadeSlotKind.NamedFunction3 : FacadeSlotKind.RequiredFunction3); + } + } + + // Scope only makes sense with a Content3 (container shape — Phase 1 + // pure container, or the new hybrid container with named slots). + if (!string.IsNullOrEmpty(scope)) + { + if ((hasMultiSlot && !isHybridContainer) || !slots.Any(s => s.Kind == FacadeSlotKind.Content3)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeScopeMisuse, loc, method.Name, scope ?? "?")); + } + else if (!IsKnownScopeKind(c.Compilation, scope!)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeScopeMisuse, loc, method.Name, scope!)); + } + } + + // Phase 6 — bind DefaultColorFromTheme to a `long` user param. + FacadeSlot? colorSlot = null; + if (!string.IsNullOrEmpty(themeColor)) + { + var longSlots = slots + .Select((s, i) => (s, i)) + .Where(t => t.s.Param.Type.SpecialType == SpecialType.System_Int64) + .ToArray(); + FacadeSlot? picked = null; + if (!string.IsNullOrEmpty(colorParameter)) + { + var match = longSlots.FirstOrDefault(t => t.s.Param.Name == colorParameter); + if (match.s.Param is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeColorThemeBindingFailed, loc, method.Name, + $"ColorParameter='{colorParameter}' does not match any 'long' user parameter")); + } + else + { + picked = match.s; + slots[match.i] = match.s.WithKind(FacadeSlotKind.ThemeColor); + } + } + else if (longSlots.Length == 1) + { + picked = longSlots[0].s; + slots[longSlots[0].i] = longSlots[0].s.WithKind(FacadeSlotKind.ThemeColor); + } + else if (longSlots.Length == 0) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeColorThemeBindingFailed, loc, method.Name, + "no 'long' user parameter to bind ContainerColor to")); + } + else + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeColorThemeBindingFailed, loc, method.Name, + $"multiple 'long' user parameters — set ColorParameter to one of: {string.Join(", ", longSlots.Select(t => t.s.Param.Name))}")); + } + colorSlot = picked; + } + + // Phase 7 — at most one [PainterResource] parameter per bridge. + int painterCount = 0; + foreach (var s in slots) + if (s.Kind == FacadeSlotKind.PainterResource) painterCount++; + if (painterCount > 1) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSlotConflict, loc, method.Name, + $"[PainterResource] may only be applied to one bridge parameter, found {painterCount}")); + } + + // IndexedChildren only makes sense on a container shape (Phase 1 + // pure container, or the hybrid container with named slots). The + // emitted body calls RenderChildrenIndexed instead of + // RenderChildren, so a leaf facade with no content body has + // nowhere for the indexed loop to live. + if (indexedChildren) + { + bool hasBodySlot = slots.Any(s => + (s.Kind is FacadeSlotKind.Content2 or FacadeSlotKind.Content3) && + s.Param.NullableAnnotation != NullableAnnotation.Annotated && + !s.HasSlotAttribute); + if (!hasBodySlot) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSlotConflict, loc, method.Name, + "IndexedChildren=true requires a container body (a non-nullable IFunction2/IFunction3 content parameter)")); + } + } + + // Phase 3 sanity — if the bridge takes a caller-controlled + // `int defaults`, we must know its enum (`Defaults = typeof(...)` + // + matching `[assembly: ComposeDefaults(...)]`) or the emitted + // code will reference an undeclared `__defaults` local. + if (callerProvidesDefaults && defaults is null) + { + string reason = defaultsType is null + ? "the bridge has an 'int defaults' parameter but no 'Defaults = typeof(...)' on [ComposeBridge]" + : $"no '[assembly: ComposeDefaults(\"{defaultsType.Name}\", ...)]' declaration was found for the bridge's Defaults enum"; + diags.Add(Diagnostic.Create(Diagnostics.FacadeSlotConflict, loc, method.Name, reason)); + } + + // Branching (CN3010). When BranchOn/AlternateBridge are set, the + // facade dispatches between this primary bridge and a sibling + // bridge whose param list is a strict superset (one extra + // optional slot). The extra slot becomes a nullable property on + // the facade; the if-branch passes it, the else-branch omits it. + BranchInfo? branchInfo = null; + if (!string.IsNullOrEmpty(branchOn) || !string.IsNullOrEmpty(alternateBridgeName)) + { + branchInfo = BuildBranchInfo(c, method, branchOn, alternateBridgeName, loc, + userParams, slots, callerProvidesDefaults, isHybridContainer, diags); + if (branchInfo is not null) + { + // Append the synthesised slot, force the facade into + // multi-slot leaf shape. + slots.Add(branchInfo.BranchedSlot); + for (int i = 0; i < slots.Count; i++) + { + var s = slots[i]; + if (s.Kind == FacadeSlotKind.Content2) + slots[i] = s.WithKind(s.Param.NullableAnnotation == NullableAnnotation.Annotated + ? FacadeSlotKind.NamedFunction2 : FacadeSlotKind.RequiredFunction2); + else if (s.Kind == FacadeSlotKind.Content3) + slots[i] = s.WithKind(s.Param.NullableAnnotation == NullableAnnotation.Annotated + ? FacadeSlotKind.NamedFunction3 : FacadeSlotKind.RequiredFunction3); + } + hasMultiSlot = true; + } + } + + // Phase 11 — SecondaryCtor: an extra ctor on the facade that + // dispatches to a second bridge (with its own Defaults enum) + // when its discriminating reference-type field is non-null. + // Modelled on BranchOn, but discriminates by ctor (nullable + // backing field) instead of slot-presence. Used by Icon to add + // an ImageVector overload alongside the Painter Phase 7 ctors. + SecondaryCtorInfo? secondaryCtorInfo = null; + if (!string.IsNullOrEmpty(secondaryCtorName) || secondaryDefaultsType is not null) + { + // CN3012 — both must be set together. A typo on one side + // would otherwise silently no-op. + if (string.IsNullOrEmpty(secondaryCtorName) || secondaryDefaultsType is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, method.Name, + "SecondaryCtor and SecondaryDefaults must both be set")); + } + // CN3012 — branching + SecondaryCtor is unsupported (both + // would prepend their own dispatch in Render; combining them + // is not modelled). + else if (branchInfo is not null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, method.Name, + "SecondaryCtor cannot be combined with BranchOn/AlternateBridge")); + } + else + { + secondaryCtorInfo = BuildSecondaryCtorInfo(c, method, secondaryCtorName!, + secondaryDefaultsType, loc, userParams, slots, callerProvidesDefaults, diags); + } + } + + if (diags.Count > 0) + return new GenerationResult(null, null, diags); + + var source = Emit(className, method.Name, scope, composerParam, slots, hasMultiSlot, + callerProvidesDefaults, defaultsParam, defaults, defaultsType?.Name, themeColor, colorSlot, + userParams, branchInfo, indexedChildren, secondaryCtorInfo); + var hint = $"AndroidX.Compose.Facade.{className}.g.cs"; + return new GenerationResult(source, hint, Array.Empty()); + } + + static BranchInfo? BuildBranchInfo(Context c, IMethodSymbol primary, + string? branchOn, string? alternateBridgeName, Location loc, + IReadOnlyList primaryUserParams, + IReadOnlyList primarySlots, + bool callerProvidesDefaults, bool isHybridContainer, + List diags) + { + // Both required. + if (string.IsNullOrEmpty(branchOn) || string.IsNullOrEmpty(alternateBridgeName)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + "BranchOn and AlternateBridge must both be set")); + return null; + } + + // Primary must use the caller-managed-defaults shape. + if (!callerProvidesDefaults) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + "branching requires the primary bridge to declare a trailing 'int defaults' parameter")); + return null; + } + + // Container shape disallowed — branching only supports multi-slot + // leafs (every slot exposed as a property). Hybrid containers + // (Scope opt-in with a named-slot mix) are explicitly rejected + // because the container body would bleed across branches in + // surprising ways. Pure Phase 1 container shapes are silently + // reclassified to leaf at the call site in Build(). + if (isHybridContainer) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + "branching is not supported on hybrid container shapes (Scope opt-in with named slots)")); + return null; + } + + // Resolve alternate method symbol on ComposeBridges. + var bridgesType = c.Compilation.GetTypeByMetadataName("AndroidX.Compose.ComposeBridges"); + if (bridgesType is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + "AndroidX.Compose.ComposeBridges not found in compilation")); + return null; + } + + var allOverloads = bridgesType.GetMembers(alternateBridgeName!).OfType() + .Where(m => m.IsStatic).ToArray(); + // Require @Composable shape: trailing IComposer + (optionally) a + // [ComposeBridge] attribute. Filter to the candidate set first + // (this matches what the StateHolder validator does for Remember). + var candidates = allOverloads.Where(m => + m.Parameters.Length >= 1 && + ComposeDefaultsGenerator.IsComposer(m.Parameters[m.Parameters.Length - 1].Type) && + (c.BridgeAttr is null || + m.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.BridgeAttr))) + ).ToArray(); + + if (candidates.Length == 0) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"no static @Composable method 'ComposeBridges.{alternateBridgeName}' with [ComposeBridge] and a trailing IComposer parameter was found")); + return null; + } + if (candidates.Length > 1) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"AlternateBridge='{alternateBridgeName}' is ambiguous — {candidates.Length} matching overloads of ComposeBridges.{alternateBridgeName} found")); + return null; + } + + var altMethod = candidates[0]; + // Strip trailing composer + (optional) `int defaults`. + var altAll = altMethod.Parameters; + var altUser = altAll.Take(altAll.Length - 1).ToArray(); + IParameterSymbol? altDefaultsParam = null; + if (altUser.Length > 0 && + altUser[altUser.Length - 1].Type.SpecialType == SpecialType.System_Int32 && + altUser[altUser.Length - 1].Name == "defaults") + { + altDefaultsParam = altUser[altUser.Length - 1]; + altUser = altUser.Take(altUser.Length - 1).ToArray(); + } + if (altDefaultsParam is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"alternate bridge 'ComposeBridges.{alternateBridgeName}' must declare a trailing 'int defaults' parameter")); + return null; + } + + // Compute the diff. Alternate's user-param set must be exactly + // primary's set plus one extra whose Pascal-cased name matches + // BranchOn. + var primaryNames = new HashSet(primaryUserParams.Select(p => p.Name), StringComparer.Ordinal); + var altNames = new HashSet(altUser.Select(p => p.Name), StringComparer.Ordinal); + var missing = primaryNames.Where(n => !altNames.Contains(n)).ToArray(); + if (missing.Length > 0) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"alternate bridge 'ComposeBridges.{alternateBridgeName}' is missing primary parameters: {string.Join(", ", missing)}")); + return null; + } + var extras = altUser.Where(p => !primaryNames.Contains(p.Name)).ToArray(); + if (extras.Length != 1) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"alternate bridge 'ComposeBridges.{alternateBridgeName}' must add exactly one parameter vs primary; found {extras.Length} ({string.Join(", ", extras.Select(e => e.Name))})")); + return null; + } + var extra = extras[0]; + var pascalExtra = Pascal(extra.Name); + if (!string.Equals(pascalExtra, branchOn, StringComparison.Ordinal)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"extra parameter '{extra.Name}' (Pascal '{pascalExtra}') does not match BranchOn='{branchOn}'")); + return null; + } + + // Shape compatibility on shared params. Compose function types + // are compared by Kotlin arity (nullability is allowed to differ + // because the facade hides it); everything else is compared by + // canonical type symbol equality (nullable-aware). + foreach (var pp in primaryUserParams) + { + var ap = altUser.First(a => a.Name == pp.Name); + if (!AreCompatibleSharedParams(pp, ap)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"shared parameter '{pp.Name}' has incompatible types between primary ({pp.Type.ToDisplayString()}) and alternate ({ap.Type.ToDisplayString()})")); + return null; + } + } + + // Synthesise the branched slot. It must be a Kotlin function so + // the facade can expose it as a ComposableNode? property. + var extraArity = KotlinFunctionArity(extra.Type); + FacadeSlotKind branchedKind; + if (extraArity == 2) branchedKind = FacadeSlotKind.NamedFunction2; + else if (extraArity == 3) branchedKind = FacadeSlotKind.NamedFunction3; + else + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"extra parameter '{extra.Name}' must be Kotlin.Jvm.Functions.IFunction2 or IFunction3; was '{extra.Type.ToDisplayString()}'")); + return null; + } + + // Resolve the alternate's Defaults enum (declarative first, then + // generic-form enum walk). + AttributeData? altBridgeAttr = c.BridgeAttr is null ? null + : altMethod.GetAttributes().FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.BridgeAttr)); + INamedTypeSymbol? altDefaultsType = altBridgeAttr is not null ? ReadType(altBridgeAttr, "Defaults") : null; + if (altDefaultsType is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"alternate bridge 'ComposeBridges.{alternateBridgeName}' has no '[ComposeBridge].Defaults' enum to drive the per-branch defaults mask")); + return null; + } + DefaultsInfo? altDefaults = null; + if (c.DeclarativeAttr is not null) + altDefaults = DefaultsInfo.TryRead(c.Compilation, c.DeclarativeAttr, altDefaultsType.Name); + altDefaults ??= DefaultsInfo.TryReadFromEnum(altDefaultsType); + altDefaults ??= DefaultsInfo.TryReadFromGenericAttribute(c.Compilation, c.GenericDefaultsAttr, altDefaultsType.Name); + if (altDefaults is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeBranchInvalid, loc, primary.Name, + $"could not resolve [assembly: ComposeDefaults(\"{altDefaultsType.Name}\", ...)] for the alternate bridge")); + return null; + } + + // Note: the branched slot's SlotPropertyName is the PascalCased + // BranchOn; we pass it explicitly so PropertyName() agrees with + // the if-condition the emitter produces. + var branchedSlot = new FacadeSlot(extra, branchedKind, slotPropertyName: branchOn); + + return new BranchInfo( + alternateMethodName: altMethod.Name, + alternateUserParams: altUser, + alternateDefaults: altDefaults!.Value, + alternateDefaultsEnumName: altDefaultsType.Name, + branchedSlot: branchedSlot, + branchProperty: branchOn!); + } + + static bool AreCompatibleSharedParams(IParameterSymbol a, IParameterSymbol b) + { + var aArity = KotlinFunctionArity(a.Type); + var bArity = KotlinFunctionArity(b.Type); + if (aArity >= 0 || bArity >= 0) return aArity == bArity; + // Strip nullability and compare type symbols. + var aType = a.Type.WithNullableAnnotation(NullableAnnotation.None); + var bType = b.Type.WithNullableAnnotation(NullableAnnotation.None); + return SymbolEqualityComparer.Default.Equals(aType, bType); + } + + internal sealed class BranchInfo + { + public BranchInfo(string alternateMethodName, IReadOnlyList alternateUserParams, + DefaultsInfo alternateDefaults, string alternateDefaultsEnumName, + FacadeSlot branchedSlot, string branchProperty) + { + AlternateMethodName = alternateMethodName; + AlternateUserParams = alternateUserParams; + AlternateDefaults = alternateDefaults; + AlternateDefaultsEnumName = alternateDefaultsEnumName; + BranchedSlot = branchedSlot; + BranchProperty = branchProperty; + } + public string AlternateMethodName { get; } + /// Alternate bridge's user parameters, in declaration order, excluding trailing IComposer and `int defaults`. + public IReadOnlyList AlternateUserParams { get; } + public DefaultsInfo AlternateDefaults { get; } + public string AlternateDefaultsEnumName { get; } + public FacadeSlot BranchedSlot { get; } + /// The PascalCased property name on the facade (e.g. "Subtitle"). + public string BranchProperty { get; } + } + + /// + /// Phase 11 — metadata for a [ComposeFacade(SecondaryCtor=...)] + /// secondary bridge. The generator emits an extra ctor on the facade + /// whose first positional argument is the secondary's discriminator + /// (a non-nullable reference-type parameter the primary does not + /// have). Render() prepends a branch: + /// if (_<discriminator> is not null) { ...secondary call...; + /// return; }. + /// + internal sealed class SecondaryCtorInfo + { + public SecondaryCtorInfo(IMethodSymbol method, IReadOnlyList userParams, + IParameterSymbol discriminator, DefaultsInfo defaults, string defaultsEnumName) + { + Method = method; + UserParams = userParams; + Discriminator = discriminator; + Defaults = defaults; + DefaultsEnumName = defaultsEnumName; + } + public IMethodSymbol Method { get; } + /// Secondary's user parameters in declaration order, excluding trailing IComposer and `int defaults`. + public IReadOnlyList UserParams { get; } + /// The single secondary-unique param that drives ctor dispatch. + public IParameterSymbol Discriminator { get; } + public DefaultsInfo Defaults { get; } + public string DefaultsEnumName { get; } + } + + static SecondaryCtorInfo? BuildSecondaryCtorInfo(Context c, IMethodSymbol primary, + string secondaryName, INamedTypeSymbol? secondaryDefaultsType, Location loc, + IReadOnlyList primaryUserParams, + IReadOnlyList primarySlots, + bool primaryCallerProvidesDefaults, + List diags) + { + // CN3012 — primary must use the caller-managed-defaults shape so + // the secondary's per-call mask has somewhere to land. + if (!primaryCallerProvidesDefaults) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + "SecondaryCtor requires the primary bridge to declare a trailing 'int defaults' parameter")); + return null; + } + + var bridgesType = c.Compilation.GetTypeByMetadataName("AndroidX.Compose.ComposeBridges"); + if (bridgesType is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + "AndroidX.Compose.ComposeBridges not found in compilation")); + return null; + } + + var allOverloads = bridgesType.GetMembers(secondaryName).OfType() + .Where(m => m.IsStatic).ToArray(); + var candidates = allOverloads.Where(m => + m.Parameters.Length >= 1 && + ComposeDefaultsGenerator.IsComposer(m.Parameters[m.Parameters.Length - 1].Type)).ToArray(); + + if (candidates.Length == 0) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"no static @Composable method 'ComposeBridges.{secondaryName}' with a trailing IComposer parameter was found")); + return null; + } + if (candidates.Length > 1) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"SecondaryCtor='{secondaryName}' is ambiguous — {candidates.Length} matching overloads of ComposeBridges.{secondaryName} found")); + return null; + } + + var secondary = candidates[0]; + // Strip trailing composer + (required) `int defaults`. + var secAll = secondary.Parameters; + var secUser = secAll.Take(secAll.Length - 1).ToArray(); + if (secUser.Length == 0 || + secUser[secUser.Length - 1].Type.SpecialType != SpecialType.System_Int32 || + secUser[secUser.Length - 1].Name != "defaults") + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"secondary 'ComposeBridges.{secondaryName}' must declare a trailing 'int defaults' parameter")); + return null; + } + secUser = secUser.Take(secUser.Length - 1).ToArray(); + + // Discover the discriminator — the single secondary-unique + // user param (not present by name in primary). All other + // secondary params must match a primary param by name with a + // compatible type. + var primaryNames = new HashSet(primaryUserParams.Select(p => p.Name), StringComparer.Ordinal); + var extras = secUser.Where(p => !primaryNames.Contains(p.Name)).ToArray(); + if (extras.Length != 1) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"secondary 'ComposeBridges.{secondaryName}' must add exactly one parameter vs primary; found {extras.Length} ({string.Join(", ", extras.Select(e => e.Name))})")); + return null; + } + var discriminator = extras[0]; + + // Discriminator must be a non-nullable reference type so the + // facade's `_field is not null` check works as a discriminator. + if (!discriminator.Type.IsReferenceType || + discriminator.NullableAnnotation == NullableAnnotation.Annotated) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"discriminator parameter '{discriminator.Name}' must be a non-nullable reference type; was '{discriminator.Type.ToDisplayString()}'")); + return null; + } + + // Shared params (by name) must have compatible types. + foreach (var sp in secUser) + { + if (sp.Name == discriminator.Name) continue; + var pp = primaryUserParams.First(a => a.Name == sp.Name); + if (!AreCompatibleSharedParams(pp, sp)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"shared parameter '{sp.Name}' has incompatible types between primary ({pp.Type.ToDisplayString()}) and secondary ({sp.Type.ToDisplayString()})")); + return null; + } + } + + // Primary's discriminator slot (e.g. PainterResource) has no + // analog in secondary — every primary user param missing from + // secondary should be such a slot. We don't strictly require + // PainterResource specifically (kept general for future use), + // but at least one primary param must be unique-to-primary + // (otherwise primary and secondary have identical user-param + // shape and there's no reason to introduce a second bridge). + var primaryUnique = primaryUserParams.Where(p => + !secUser.Any(s => s.Name == p.Name)).ToArray(); + if (primaryUnique.Length == 0) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"primary and secondary have identical user-parameter names; SecondaryCtor requires at least one primary-only slot (e.g. [PainterResource])")); + return null; + } + + // Resolve secondary's Defaults enum: prefer its own + // [ComposeBridge].Defaults; fall back to the primary's + // [ComposeFacade(SecondaryDefaults=typeof(...))]. + INamedTypeSymbol? defaultsType = null; + if (c.BridgeAttr is not null) + { + var secBridgeAttr = secondary.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.BridgeAttr)); + if (secBridgeAttr is not null) + defaultsType = ReadType(secBridgeAttr, "Defaults"); + } + defaultsType ??= secondaryDefaultsType; + if (defaultsType is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"secondary 'ComposeBridges.{secondaryName}' has no '[ComposeBridge].Defaults' enum and no 'SecondaryDefaults = typeof(...)' fallback on [ComposeFacade]")); + return null; + } + DefaultsInfo? defaults = null; + if (c.DeclarativeAttr is not null) + defaults = DefaultsInfo.TryRead(c.Compilation, c.DeclarativeAttr, defaultsType.Name); + defaults ??= DefaultsInfo.TryReadFromEnum(defaultsType); + defaults ??= DefaultsInfo.TryReadFromGenericAttribute(c.Compilation, c.GenericDefaultsAttr, defaultsType.Name); + if (defaults is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSecondaryCtorInvalid, loc, primary.Name, + $"could not resolve [assembly: ComposeDefaults(\"{defaultsType.Name}\", ...)] for the secondary bridge")); + return null; + } + + return new SecondaryCtorInfo(secondary, secUser, discriminator, + defaults.Value, defaultsType.Name); + } + + static FacadeSlot? Classify(IParameterSymbol p, Context c, string methodName, Location loc, List diags) + { + // [StateHolder] — annotates the IntPtr bridge param carrying a + // Kotlin state-holder handle. The facade emits a RememberXxxState + // round-trip and an optional `.Jvm` population for a wrapper. + AttributeData? stateAttr = null; + if (c.StateHolderAttr is not null) + { + stateAttr = p.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.StateHolderAttr)); + } + if (stateAttr is not null) + { + // Mutex with [PainterResource]. + if (c.PainterAttr is not null && + p.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.PainterAttr))) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"parameter '{p.Name}' has both [StateHolder] and [PainterResource]; pick one")); + return null; + } + if (p.Type.SpecialType != SpecialType.System_IntPtr) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] must annotate an 'IntPtr' parameter; '{p.Name}' is '{p.Type.ToDisplayString()}'")); + return null; + } + string? remember = ReadString(stateAttr, "Remember"); + INamedTypeSymbol? stateType = ReadType(stateAttr, "StateType"); + if (string.IsNullOrEmpty(remember)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}' is missing required property 'Remember'")); + return null; + } + if (stateType is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}' is missing required property 'StateType'")); + return null; + } + if (!SyntaxFacts.IsValidIdentifier(remember!)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': Remember value '{remember}' is not a valid C# identifier")); + return null; + } + + // Validate the Remember bridge resolves to a static method on + // AndroidX.Compose.ComposeBridges whose last parameter is an + // IComposer and that returns IntPtr. Any number of leading + // user parameters is allowed (Phase 4 = zero, Phase 4b = N). + var bridgesType = c.Compilation.GetTypeByMetadataName("AndroidX.Compose.ComposeBridges"); + if (bridgesType is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': cannot resolve type 'AndroidX.Compose.ComposeBridges'")); + return null; + } + var rememberMethods = bridgesType.GetMembers(remember!).OfType().ToArray(); + if (rememberMethods.Length == 0) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': no static method 'ComposeBridges.{remember}' found")); + return null; + } + var rememberFit = rememberMethods.FirstOrDefault(m => + m.IsStatic && + m.Parameters.Length >= 1 && + ComposeDefaultsGenerator.IsComposer(m.Parameters[m.Parameters.Length - 1].Type) && + m.ReturnType.SpecialType == SpecialType.System_IntPtr); + if (rememberFit is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': 'ComposeBridges.{remember}' must be a static method whose last parameter is an IComposer and that returns IntPtr")); + return null; + } + + // Validate StateType has a writable instance Jvm field. + var jvmMember = stateType.GetMembers("Jvm").OfType().FirstOrDefault(); + if (jvmMember is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': StateType '{stateType.ToDisplayString()}' has no instance field named 'Jvm'")); + return null; + } + if (jvmMember.IsStatic || jvmMember.IsConst || jvmMember.IsReadOnly) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': StateType '{stateType.ToDisplayString()}'.Jvm must be a non-static, non-const, non-readonly field")); + return null; + } + if (jvmMember.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': StateType '{stateType.ToDisplayString()}'.Jvm must be accessible (public or internal)")); + return null; + } + + // Phase 4b — Remember has N user params before composer. Each + // user param must either: + // (a) carry [ConfirmStateChange] — surface as a per-instance + // JCW veto adapter (CN3010 if the configuration is + // invalid); OR + // (b) resolve to a readable instance member on the + // StateType (case-insensitive PascalCase match; fall + // back to `Initial` for the Kotlin + // "initialX → live X" wrapper convention). + // Phase 4b also requires an accessible parameterless + // construction path on the StateType so the ctor can + // auto-create a default wrapper when the caller passes null. + var rememberUserParams = rememberFit.Parameters + .Take(rememberFit.Parameters.Length - 1) + .ToArray(); + string[] rememberArgExpressions = System.Array.Empty(); + var confirmInfos = new List(); + if (rememberUserParams.Length > 0) + { + if (!HasAccessibleParameterlessConstructor(stateType)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': parameterised Remember requires StateType '{stateType.ToDisplayString()}' to be constructible with no arguments (parameterless ctor or all-defaulted-param ctor)")); + return null; + } + + rememberArgExpressions = new string[rememberUserParams.Length]; + for (int i = 0; i < rememberUserParams.Length; i++) + { + var up = rememberUserParams[i]; + + // [ConfirmStateChange] — per-instance JCW veto adapter. + AttributeData? confirmAttr = null; + if (c.ConfirmStateChangeAttr is not null) + { + confirmAttr = up.GetAttributes().FirstOrDefault(a => + SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.ConfirmStateChangeAttr)); + } + if (confirmAttr is not null) + { + var info = ResolveConfirmStateChange(c, up, confirmAttr, methodName, loc, diags); + if (info is null) return null; + confirmInfos.Add(info.Value); + rememberArgExpressions[i] = "_" + info.Value.FieldIdentifier; + continue; + } + + var resolved = ResolveStateMember(stateType, up.Name); + if (resolved is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeStateHolderInvalid, loc, methodName, + $"[StateHolder] on '{p.Name}': cannot resolve Remember parameter '{up.Name}' on StateType '{stateType.ToDisplayString()}'; expected a readable instance member named '{Pascal(up.Name)}' or 'Initial{Pascal(up.Name)}'")); + return null; + } + rememberArgExpressions[i] = "_" + p.Name + "!." + resolved; + } + } + + return new FacadeSlot(p, FacadeSlotKind.StateHolder, + rememberMethodName: remember, + stateWrapperType: stateType, + stateJvmType: jvmMember.Type, + rememberArgExpressions: rememberArgExpressions, + sharedState: ReadBool(stateAttr, "SharedState"), + confirmStateChanges: confirmInfos.ToArray()); + } + + // [PainterResource] — annotates the bridge param that takes + // the resolved Painter wrapper. The facade exposes a synthetic + // `int drawableResourceId` ctor arg in its place. + if (c.PainterAttr is not null && + p.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.PainterAttr))) + { + if (!IsPainterType(p.Type)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadePainterMisuse, loc, methodName, + $"[PainterResource] must annotate an 'AndroidX.Compose.UI.Graphics.Painter.Painter' parameter; '{p.Name}' is '{p.Type.ToDisplayString()}'")); + return null; + } + return new FacadeSlot(p, FacadeSlotKind.PainterResource); + } + + // [Callback(typeof(T))] + if (c.CallbackAttr is not null) + { + var cba = p.GetAttributes() + .FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, c.CallbackAttr)); + if (cba is not null) + { + if (KotlinFunctionArity(p.Type) != 1) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeSlotConflict, loc, methodName, + $"[Callback] on parameter '{p.Name}' requires a Kotlin.Jvm.Functions.IFunction1 parameter type; was '{p.Type.ToDisplayString()}'")); + return null; + } + var typeArg = cba.ConstructorArguments.Length > 0 ? cba.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (typeArg is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeCallbackUnsupportedType, loc, methodName, p.Name, "")); + return null; + } + if (typeArg.SpecialType is not (SpecialType.System_Boolean or SpecialType.System_String or SpecialType.System_Single)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeCallbackUnsupportedType, loc, methodName, p.Name, + typeArg.ToDisplayString())); + return null; + } + return new FacadeSlot(p, FacadeSlotKind.Callback, callbackType: typeArg); + } + } + + // IModifier? + if (IsModifier(p.Type)) + return new FacadeSlot(p, FacadeSlotKind.Modifier); + + // Kotlin extension receiver — IntPtr with a "Scope"-suffixed name. + // Auto-bound to RenderContext.CurrentScope; not a ctor slot, not + // tracked in the $default bitmask. + if (p.Type.SpecialType == SpecialType.System_IntPtr && + p.Name.EndsWith("Scope", StringComparison.Ordinal)) + return new FacadeSlot(p, FacadeSlotKind.ScopeReceiver); + + var funcArity = KotlinFunctionArity(p.Type); + if (funcArity == 0) + return new FacadeSlot(p, FacadeSlotKind.OnClick); + + if (funcArity == 2 || funcArity == 3) + { + string? slotName = ReadSlotAttribute(p, c.SlotAttr); + return new FacadeSlot(p, funcArity == 2 ? FacadeSlotKind.Content2 : FacadeSlotKind.Content3, + slotPropertyName: slotName); + } + + if (funcArity > 0) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeUnsupportedParameter, loc, methodName, p.Name, + $"IFunction{funcArity} (mark with [Callback(typeof(T))] for a typed Action ctor slot)")); + return null; + } + + if (IsPrimitiveCtorType(p.Type)) + return new FacadeSlot(p, FacadeSlotKind.Primitive); + + // Compose @JvmInline value-class types (Dp?/Sp?/Em?/TextAlign?) + // and reference-typed wrappers (FontWeight?/TextDecoration?/ + // Shape?). Surfaced as nullable auto-properties; the bridge + // generator handles JNI lowering and auto-mask bit clearing. + if (IsOptionalValueType(p.Type, p.NullableAnnotation)) + return new FacadeSlot(p, FacadeSlotKind.OptionalValue); + + diags.Add(Diagnostic.Create(Diagnostics.FacadeUnsupportedParameter, loc, methodName, p.Name, p.Type.ToDisplayString())); + return null; + } + + static string Emit(string className, string bridgeMethodName, string? scope, + IParameterSymbol composerParam, IReadOnlyList slots, + bool isMultiSlot, bool callerProvidesDefaults, IParameterSymbol? defaultsParam, + DefaultsInfo? defaults, string? defaultsEnumName, + string? themeColor, FacadeSlot? colorSlot, + IReadOnlyList primaryUserParams, + BranchInfo? branchInfo, + bool indexedChildren, + SecondaryCtorInfo? secondaryCtorInfo) + { + // After classification, only the container's body survives as + // Content2/3 (multi-slot leafs re-classified to Named/Required). + // For the hybrid "container + named slots" shape (e.g. + // BottomAppBar's required `actions` Function3 + nullable + // `floatingActionButton` Function2 slot), the Content2/3 body + // and the Named slots coexist — the class still derives from + // ComposableContainer and the body wraps RenderChildren. + bool isContainer = slots.Any(s => s.Kind is FacadeSlotKind.Content2 or FacadeSlotKind.Content3); + string baseClass = isContainer ? "global::AndroidX.Compose.ComposableContainer" : "global::AndroidX.Compose.ComposableNode"; + + // Ctor slots: every non-modifier, non-named-property slot. + // Required slots come first. Among defaulted slots, StateHolder + // comes BEFORE defaulted-primitive slots so legacy positional + // call sites that pass a state holder as the single argument + // (e.g. `new Foo(stateHolder)`) continue to bind to the + // state-holder slot. Reversing this order would silently + // shift positional binding to a primitive slot and break + // callers — see PR #240 / Jetnews + Jetchat regression. + var ctorSlotsAll = slots.Where(s => IsCtorSlot(s)).ToArray(); + var ctorSlots = ctorSlotsAll + .Where(s => !HasFacadeCtorDefault(s)) + .Concat(ctorSlotsAll.Where(s => s.Kind == FacadeSlotKind.StateHolder)) + .Concat(ctorSlotsAll.Where(s => HasFacadeCtorDefault(s) && s.Kind != FacadeSlotKind.StateHolder)) + .ToArray(); + // Named-property slots (Phase 3). + var namedSlots = slots.Where(s => s.Kind is FacadeSlotKind.NamedFunction2 or FacadeSlotKind.NamedFunction3 + or FacadeSlotKind.RequiredFunction2 or FacadeSlotKind.RequiredFunction3).ToArray(); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// Generated by AndroidX.Compose.SourceGenerators.ComposeFacadeGenerator."); + sb.AppendLine("#nullable enable"); + sb.AppendLine("namespace AndroidX.Compose"); + sb.AppendLine("{"); + + sb.Append(" public sealed partial class ").Append(className).Append(" : ").AppendLine(baseClass); + sb.AppendLine(" {"); + + // Backing fields for ctor slots. StateHolder fields are + // emitted writable (no `readonly`) so that hand-written + // partial declarations of the same facade can pre-populate + // them via `init`-only properties — useful for thin + // convenience accessors like `InitiallyOpen = true` over the + // state holder's constructor argument. + foreach (var s in ctorSlots) + { + var typeRef = CtorFieldType(s); + var modifier = s.Kind == FacadeSlotKind.StateHolder ? "" : "readonly "; + sb.Append(" ").Append(modifier).Append(typeRef).Append(" _").Append(CtorIdentifier(s)).AppendLine(";"); + // Phase 7 — for PainterResource, also emit a sibling + // Painter? field so the facade can accept a pre-resolved + // Painter as an alternative to the resource id. Exactly + // one of `_drawableResourceId` / `_painter` is set per + // instance (see the two ctor overloads below). + if (s.Kind == FacadeSlotKind.PainterResource) + { + sb.AppendLine(" readonly global::AndroidX.Compose.UI.Graphics.Painter.Painter? _painter;"); + } + } + + // Phase 11 — discriminator field for SecondaryCtor. Nullable + // reference field so the Render preamble can branch on + // `_ is not null` to dispatch to the secondary + // bridge. Exactly one of the primary's ctor slots or this field + // is populated per instance. + if (secondaryCtorInfo is not null) + { + var discTypeFqn = SecondaryFieldType(secondaryCtorInfo); + sb.Append(" readonly ").Append(discTypeFqn).Append(" _") + .Append(secondaryCtorInfo.Discriminator.Name).AppendLine(";"); + } + + // Phase 10 — per-instance JCW veto adapter fields. One per + // [ConfirmStateChange] across all StateHolder slots. Stable + // JNI identity for Kotlin's `remember` cache key. + foreach (var s in slots.Where(s => s.Kind == FacadeSlotKind.StateHolder)) + { + foreach (var info in s.ConfirmStateChanges) + { + var adapterFqn = info.AdapterType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes)); + sb.Append(" readonly ").Append(adapterFqn).Append(" _").Append(info.FieldIdentifier) + .Append(" = new ").Append(adapterFqn).AppendLine("();"); + } + } + + // Phase 6 — ContainerColor property + (no theme fallback here; happens in Render). + if (themeColor is not null && colorSlot is not null) + { + sb.AppendLine(" /// Optional explicit . Leave at the default to inherit the active MaterialTheme.colorScheme fallback."); + sb.AppendLine(" public global::AndroidX.Compose.Color ContainerColor { get; set; }"); + } + + // Phase 3 — named properties. + foreach (var s in namedSlots) + { + sb.Append(" public global::AndroidX.Compose.ComposableNode? ").Append(PropertyName(s)).AppendLine(" { get; set; }"); + } + + // OptionalValue — Compose value-class types (Dp?/Sp?/Em?/ + // TextAlign?) and reference-typed wrappers (FontWeight?/ + // TextDecoration?/Shape?). Surfaced as nullable auto-properties + // for object-init syntax: `new Text("hi") { FontSize = 24.Sp() }`. + var optionalValueSlots = slots.Where(s => s.Kind == FacadeSlotKind.OptionalValue).ToArray(); + foreach (var s in optionalValueSlots) + { + sb.Append(" public ").Append(OptionalValueDisplay(s)).Append(' ') + .Append(PropertyName(s)).AppendLine(" { get; set; }"); + } + + // Phase 10 — public `Func?` properties for each + // [ConfirmStateChange] adapter. Null = "always allow"; the + // adapter's Invoke reads this property each call. + foreach (var s in slots.Where(s => s.Kind == FacadeSlotKind.StateHolder)) + { + foreach (var info in s.ConfirmStateChanges) + { + var valueFqn = info.ValueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes)); + sb.Append(" public global::System.Func<").Append(valueFqn).Append(", bool>? ") + .Append(info.PropertyName).AppendLine(" { get; set; }"); + } + } + + // Constructor. + if (ctorSlots.Length > 0) + { + // Phase 7 — when a PainterResource slot is present, emit + // TWO ctors. The "id" overload (default) takes an + // `int drawableResourceId` and the Render preamble resolves + // it inside the composition. The "painter" overload takes + // a pre-resolved `Painter` (e.g. from + // Resources.PainterResource) and forwards its handle + // directly. Exactly one of `_drawableResourceId` / + // `_painter` is set per instance; Render() branches on + // `_painter is not null`. + EmitFacadeCtor(sb, className, ctorSlots, painterShape: PainterCtorShape.Id); + if (ctorSlots.Any(s => s.Kind == FacadeSlotKind.PainterResource)) + { + EmitFacadeCtor(sb, className, ctorSlots, painterShape: PainterCtorShape.Painter); + } + } + + // Phase 11 — secondary ctor. The discriminator parameter + // replaces the primary's discriminator slot (e.g. + // PainterResource); all other secondary-shared primitive ctor + // slots are reused with their primary positions and defaults. + if (secondaryCtorInfo is not null) + { + EmitSecondaryCtor(sb, className, ctorSlots, secondaryCtorInfo); + } + + // Render + var composerName = EscapeIdent(composerParam.Name); + sb.Append(" public override void Render(global::AndroidX.Compose.Runtime.IComposer ") + .Append(composerName).AppendLine(")"); + sb.AppendLine(" {"); + + // Phase 11 — secondary dispatch. Runs BEFORE any primary-only + // preamble (PainterResource resolution etc.) so it can return + // early when the caller used the secondary ctor. + if (secondaryCtorInfo is not null) + { + EmitSecondaryDispatch(sb, slots, secondaryCtorInfo, composerName); + } + + // Required-named-slot null checks. + foreach (var s in namedSlots.Where(s => s.Kind is FacadeSlotKind.RequiredFunction2 or FacadeSlotKind.RequiredFunction3)) + { + var name = PropertyName(s); + sb.Append(" if (").Append(name).AppendLine(" is null)"); + sb.Append(" throw new global::System.InvalidOperationException(\"") + .Append(className).Append('.').Append(name).AppendLine(" is required (the Kotlin parameter has no default).\");"); + } + + // StateHolder preamble — call RememberXxxState and (optionally) + // populate the caller-supplied wrapper's Jvm field. Phase 4 + // (zero-user-param Remember): _state is nullable, Jvm is only + // populated when the caller supplied a wrapper. Phase 4b + // (parameterised Remember): the ctor guaranteed _state is + // non-null, so we read init values off it and skip the + // null-guard on the Jvm assignment. Phase 4c (SharedState=true): + // when _state.Jvm is already populated (because an earlier + // sibling render bound the same wrapper), skip the Remember + // call entirely and reuse the cached JNI handle — this is what + // lets a TimePicker and a TimeInput share state, or the + // SearchBar family share a SearchBarState peer across the + // collapsed bar + expanded popup. + foreach (var s in slots.Where(s => s.Kind == FacadeSlotKind.StateHolder)) + { + var id = CtorIdentifier(s); + var jvmFqn = s.StateJvmType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes)); + + // Phase 10 — assign the developer-supplied delegate onto each + // per-instance JCW adapter BEFORE the Remember call so the + // adapter is wired up by the time Kotlin invokes it. Skip on + // SharedState cache-hit paths because the assignment is + // identity-stable (same `_Adapter` field) regardless of + // whether Remember runs; doing it unconditionally also avoids + // a missed assignment when the developer mutates the property + // between renders without a re-Remember. + foreach (var info in s.ConfirmStateChanges) + { + sb.Append(" _").Append(info.FieldIdentifier).Append(".Callback = ") + .Append(info.PropertyName).AppendLine(";"); + } + + if (s.SharedState) + { + EmitStateHolderPreambleShared(sb, s, id, jvmFqn, composerName); + } + else + { + sb.Append(" var __").Append(s.Param.Name) + .Append(" = global::AndroidX.Compose.ComposeBridges.").Append(s.RememberMethodName).Append('('); + foreach (var argExpr in s.RememberArgExpressions) + sb.Append(argExpr).Append(", "); + sb.Append(composerName).AppendLine(");"); + if (s.IsParameterisedStateHolder) + { + sb.Append(" if (_").Append(id).AppendLine(".Jvm is null)"); + sb.Append(" _").Append(id).Append(".Jvm = global::Java.Lang.Object.GetObject<") + .Append(jvmFqn).Append(">(__").Append(s.Param.Name) + .AppendLine(", global::Android.Runtime.JniHandleOwnership.DoNotTransfer)!;"); + } + else + { + sb.Append(" if (_").Append(id).Append(" is not null && _").Append(id).AppendLine(".Jvm is null)"); + sb.Append(" _").Append(id).Append(".Jvm = global::Java.Lang.Object.GetObject<") + .Append(jvmFqn).Append(">(__").Append(s.Param.Name) + .AppendLine(", global::Android.Runtime.JniHandleOwnership.DoNotTransfer)!;"); + } + } + } + + // OnClick wrappers — one per line so slot-table keys stay distinct. + foreach (var s in slots.Where(s => s.Kind == FacadeSlotKind.OnClick)) + { + sb.Append(" var __").Append(s.Param.Name) + .Append(" = new global::AndroidX.Compose.ComposableLambda0(_").Append(s.Param.Name).AppendLine(");"); + } + + // Callback wrappers (Phase 2). + foreach (var s in slots.Where(s => s.Kind == FacadeSlotKind.Callback)) + { + EmitCallbackWrapper(sb, s); + } + + // Modifier evaluation. Only hoist to a local when needed for + // the auto-mask (callerProvidesDefaults). Otherwise the bridge + // call uses BuildModifier() inline to keep Phase 1 output stable. + // Branching always hoists (both branches reference __modifier in + // their per-branch mask). + var modifierSlot = slots.FirstOrDefault(s => s.Kind == FacadeSlotKind.Modifier); + bool hoistModifier = modifierSlot.Param is not null && (callerProvidesDefaults || branchInfo is not null); + if (hoistModifier) + { + sb.AppendLine(" var __modifier = BuildModifier();"); + } + + // Named slot wrappers (Phase 3). One per line. When branching, + // skip the branched slot — it gets wrapped inside the if-branch + // only (the primary bridge has no corresponding parameter). + foreach (var s in namedSlots) + { + if (branchInfo is not null && + SymbolEqualityComparer.Default.Equals(s.Param, branchInfo.BranchedSlot.Param)) + { + continue; + } + var name = PropertyName(s); + string wrap = s.Kind is FacadeSlotKind.NamedFunction3 or FacadeSlotKind.RequiredFunction3 + ? "Wrap3" + : "Wrap2"; + bool nullable = s.Kind is FacadeSlotKind.NamedFunction2 or FacadeSlotKind.NamedFunction3; + if (nullable) + { + sb.Append(" var __").Append(s.Param.Name).Append(" = ").Append(name) + .Append(" is null ? null : global::AndroidX.Compose.ComposableLambdas.").Append(wrap) + .Append('(').Append(composerName).Append(", c => ").Append(name).AppendLine(".Render(c));"); + } + else + { + sb.Append(" var __").Append(s.Param.Name).Append(" = global::AndroidX.Compose.ComposableLambdas.") + .Append(wrap).Append('(').Append(composerName).Append(", c => ").Append(name).AppendLine("!.Render(c));"); + } + } + + // Content wrapper (Phase 1 only — multi-slot already handled). + if (isContainer) + { + string renderChildrenCall = indexedChildren ? "RenderChildrenIndexed(c)" : "RenderChildren(c)"; + var contentSlot = slots.First(s => s.Kind is FacadeSlotKind.Content2 or FacadeSlotKind.Content3); + int arity = contentSlot.Kind == FacadeSlotKind.Content3 ? 3 : 2; + sb.Append(" var __").Append(contentSlot.Param.Name).Append(" = global::AndroidX.Compose.ComposableLambdas."); + if (arity == 2) + { + sb.Append("Wrap2(").Append(composerName).Append(", c => ").Append(renderChildrenCall).AppendLine(");"); + } + else if (!string.IsNullOrEmpty(scope)) + { + sb.Append("Wrap3(").Append(composerName).AppendLine(", (__scope, c) =>"); + sb.AppendLine(" {"); + sb.Append(" using var __scopeFrame = global::AndroidX.Compose.RenderContext.PushScope(__scope, global::AndroidX.Compose.ScopeKind.") + .Append(scope).AppendLine(");"); + sb.Append(" ").Append(renderChildrenCall).AppendLine(";"); + sb.AppendLine(" });"); + } + else + { + sb.Append("Wrap3(").Append(composerName).Append(", c => ").Append(renderChildrenCall).AppendLine(");"); + } + } + + // Phase 6 — theme color resolution. + if (themeColor is not null && colorSlot is not null) + { + sb.Append(" long __color = (long)ContainerColor != 0L ? (long)ContainerColor : global::AndroidX.Compose.Material3.MaterialTheme.Instance.GetColorScheme(") + .Append(composerName).Append(", 0).").Append(Pascal(themeColor)).AppendLine(";"); + } + + // Phase 7 — `[PainterResource]` lowering. If the caller used + // the Painter ctor, forward the wrapper itself; the bridge's + // auto-`GC.KeepAlive(painter)` covers it. Otherwise we resolve + // the drawable resource id inside the composition via + // `painterResource(id)` and wrap the local ref into a fresh + // managed peer (`TransferLocalRef` consumes the local ref so + // there is nothing to `DeleteLocalRef` afterwards). + var painterIdSlot = slots.FirstOrDefault(s => s.Kind == FacadeSlotKind.PainterResource); + bool hasPainter = painterIdSlot.Param is not null; + if (hasPainter) + { + sb.AppendLine(" global::AndroidX.Compose.UI.Graphics.Painter.Painter __painterPeer;"); + sb.AppendLine(" if (_painter is not null)"); + sb.AppendLine(" {"); + sb.AppendLine(" __painterPeer = _painter;"); + sb.AppendLine(" }"); + sb.AppendLine(" else"); + sb.AppendLine(" {"); + sb.AppendLine(" var __painterRef = global::AndroidX.Compose.ComposeBridges.PainterResource(_drawableResourceId, " + + composerName + ");"); + sb.AppendLine(" __painterPeer = global::Java.Lang.Object.GetObject("); + sb.AppendLine(" __painterRef, global::Android.Runtime.JniHandleOwnership.TransferLocalRef)!;"); + sb.AppendLine(" }"); + } + + string indent = " "; + + // Phase 3 — auto-mask defaults + bridge call. + if (branchInfo is not null) + { + EmitBranchedRender(sb, indent, bridgeMethodName, slots, primaryUserParams, + defaults!.Value, defaultsEnumName!, branchInfo, composerName, hoistModifier); + } + else + { + if (callerProvidesDefaults && defaults is { } d) + { + EmitDefaultsMask(sb, indent, d, slots, namedSlots, modifierSlot.Param is not null, hasPainter); + } + + // Bridge call. Preserve original bridge param order. + sb.Append(indent).Append("global::AndroidX.Compose.ComposeBridges.").Append(bridgeMethodName).Append('('); + bool first = true; + // Walk method.Parameters via slots in their original order; the + // defaults param (if any) was removed from `slots` so we add it + // back here from the local __defaults symbol. + foreach (var s in slots) + { + if (!first) sb.Append(", "); + first = false; + sb.Append(BridgeArgExpr(s, hoistModifier)); + } + if (callerProvidesDefaults) + { + if (!first) sb.Append(", "); + sb.Append("__defaults"); + first = false; + } + if (!first) sb.Append(", "); + sb.Append(composerName).AppendLine(");"); + } + + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + static void EmitBranchedRender(StringBuilder sb, string indent, + string primaryMethodName, IReadOnlyList slots, + IReadOnlyList primaryUserParams, + DefaultsInfo primaryDefaults, string primaryDefaultsEnumName, + BranchInfo branch, string composerName, bool hoistModifier) + { + // Lookup table from bridge-param-name to facade slot. Built once + // and reused for both branches' call emission. Names that only + // exist in the alternate (the branched slot) map to the + // synthesized slot; names shared by both bridges map to the + // primary's classification. + var slotByName = new Dictionary(StringComparer.Ordinal); + foreach (var s in slots) + slotByName[s.Param.Name] = s; + + var branched = branch.BranchedSlot; + var branchPropName = branch.BranchProperty; + int arity = branched.Kind == FacadeSlotKind.NamedFunction3 ? 3 : 2; + string wrap = arity == 3 ? "Wrap3" : "Wrap2"; + + var allNamedSlots = slots.Where(s => s.Kind is FacadeSlotKind.NamedFunction2 or FacadeSlotKind.NamedFunction3 + or FacadeSlotKind.RequiredFunction2 or FacadeSlotKind.RequiredFunction3).ToArray(); + + // Alternate branch: the branched slot is provided. + sb.Append(indent).Append("if (").Append(branchPropName).AppendLine(" is not null)"); + sb.Append(indent).AppendLine("{"); + var inner = indent + " "; + sb.Append(inner).Append("var __").Append(branched.Param.Name) + .Append(" = global::AndroidX.Compose.ComposableLambdas.").Append(wrap) + .Append('(').Append(composerName).Append(", c => ") + .Append(branchPropName).AppendLine("!.Render(c));"); + EmitDefaultsMask(sb, inner, branch.AlternateDefaults, slots, allNamedSlots, + hasModifier: hoistModifier, hasPainter: false); + EmitBridgeCallByParams(sb, inner, branch.AlternateMethodName, branch.AlternateUserParams, + slotByName, composerName, hoistModifier); + sb.Append(indent).AppendLine("}"); + + // Primary branch: branched slot is null. + sb.Append(indent).AppendLine("else"); + sb.Append(indent).AppendLine("{"); + EmitDefaultsMask(sb, inner, primaryDefaults, slots, allNamedSlots, + hasModifier: hoistModifier, hasPainter: false); + EmitBridgeCallByParams(sb, inner, primaryMethodName, primaryUserParams, + slotByName, composerName, hoistModifier); + sb.Append(indent).AppendLine("}"); + } + + static void EmitBridgeCallByParams(StringBuilder sb, string indent, + string bridgeMethodName, IReadOnlyList bridgeUserParams, + IReadOnlyDictionary slotByName, + string composerName, bool hoistModifier) + { + sb.Append(indent).Append("global::AndroidX.Compose.ComposeBridges.").Append(bridgeMethodName).Append('('); + foreach (var p in bridgeUserParams) + { + if (slotByName.TryGetValue(p.Name, out var slot)) + sb.Append(BridgeArgExpr(slot, hoistModifier)); + else + sb.Append("default"); + sb.Append(", "); + } + sb.Append("__defaults, ").Append(composerName).AppendLine(");"); + } + + static void EmitCallbackWrapper(StringBuilder sb, FacadeSlot s) + { + var t = s.CallbackType!; + string expr = t.SpecialType switch + { + SpecialType.System_Boolean => "v is global::Java.Lang.Boolean __b && __b.BooleanValue()", + SpecialType.System_Single => "v is global::Java.Lang.Float __f ? __f.FloatValue() : 0f", + SpecialType.System_String => "v?.ToString() ?? string.Empty", + _ => "default!", + }; + sb.Append(" var __").Append(s.Param.Name) + .Append(" = new global::AndroidX.Compose.ComposableLambda1(v => _") + .Append(s.Param.Name).Append('(').Append(expr).AppendLine("));"); + } + + static void EmitStateHolderPreambleShared(StringBuilder sb, FacadeSlot s, + string id, string jvmFqn, string composerName) + { + // Phase 4c: skip the RememberXxxState call when _state.Jvm is + // already populated (a sibling facade rendered first and bound + // this wrapper to its peer). When the cache miss path runs, + // populate _state.Jvm so the next sibling reuses the handle. + sb.Append(" global::System.IntPtr __").Append(s.Param.Name).AppendLine(";"); + if (s.IsParameterisedStateHolder) + { + // Phase 4b shape: _state is non-null thanks to ctor auto-create. + // The `!` on the first dereference flow-narrows _state to + // non-null for the rest of the method (matches the existing + // non-shared Phase 4b convention). + sb.Append(" if (_").Append(id).AppendLine("!.Jvm is not null)"); + sb.AppendLine(" {"); + sb.Append(" __").Append(s.Param.Name) + .Append(" = ((global::Android.Runtime.IJavaObject)_").Append(id).AppendLine(".Jvm!).Handle;"); + sb.AppendLine(" }"); + sb.AppendLine(" else"); + sb.AppendLine(" {"); + sb.Append(" __").Append(s.Param.Name) + .Append(" = global::AndroidX.Compose.ComposeBridges.").Append(s.RememberMethodName).Append('('); + foreach (var argExpr in s.RememberArgExpressions) + sb.Append(argExpr).Append(", "); + sb.Append(composerName).AppendLine(");"); + sb.Append(" _").Append(id).Append(".Jvm = global::Java.Lang.Object.GetObject<") + .Append(jvmFqn).Append(">(__").Append(s.Param.Name) + .AppendLine(", global::Android.Runtime.JniHandleOwnership.DoNotTransfer)!;"); + sb.AppendLine(" }"); + } + else + { + // Phase 4 shape: _state may be null; only cache when present. + // Use an explicit `if (_state is not null)` so flow narrowing + // applies to both branches without sprinkling null-forgiving + // operators throughout. + sb.Append(" if (_").Append(id).Append(" is not null && _").Append(id).AppendLine(".Jvm is not null)"); + sb.AppendLine(" {"); + sb.Append(" __").Append(s.Param.Name) + .Append(" = ((global::Android.Runtime.IJavaObject)_").Append(id).AppendLine(".Jvm).Handle;"); + sb.AppendLine(" }"); + sb.AppendLine(" else"); + sb.AppendLine(" {"); + sb.Append(" __").Append(s.Param.Name) + .Append(" = global::AndroidX.Compose.ComposeBridges.").Append(s.RememberMethodName).Append('('); + foreach (var argExpr in s.RememberArgExpressions) + sb.Append(argExpr).Append(", "); + sb.Append(composerName).AppendLine(");"); + sb.Append(" if (_").Append(id).AppendLine(" is not null)"); + sb.Append(" _").Append(id).Append(".Jvm = global::Java.Lang.Object.GetObject<") + .Append(jvmFqn).Append(">(__").Append(s.Param.Name) + .AppendLine(", global::Android.Runtime.JniHandleOwnership.DoNotTransfer)!;"); + sb.AppendLine(" }"); + } + } + + static void EmitDefaultsMask(StringBuilder sb, string indent, DefaultsInfo d, + IReadOnlyList slots, IReadOnlyList namedSlots, + bool hasModifier, bool hasPainter, + string defaultsVar = "__defaults", string modifierVar = "__modifier") + { + sb.Append(indent).Append("int ").Append(defaultsVar).Append(" = (int)global::AndroidX.Compose.").Append(d.EnumName).AppendLine(".All;"); + + // For each slot the facade DEFINITELY supplies, clear its bit. + // - Modifier: clear when modifier local != null. + // - Primitive / Callback / OnClick: bit is `!`-suppressed by + // convention (caller-owned); skip silently when absent. + // - Named optional slot: clear when property non-null. + // - Required slot (Function2/3 / PainterHandle): always cleared. + + foreach (var s in slots) + { + string? bitMember = MatchEnumMember(d, s); + if (bitMember is null) continue; + switch (s.Kind) + { + case FacadeSlotKind.Modifier: + sb.Append(indent).Append("if (").Append(modifierVar).Append(" is not null) ").Append(defaultsVar).Append(" &= ~(int)global::AndroidX.Compose.") + .Append(d.EnumName).Append('.').Append(bitMember).AppendLine(";"); + break; + case FacadeSlotKind.NamedFunction2: + case FacadeSlotKind.NamedFunction3: + sb.Append(indent).Append("if (__").Append(s.Param.Name).Append(" is not null) ").Append(defaultsVar).Append(" &= ~(int)global::AndroidX.Compose.") + .Append(d.EnumName).Append('.').Append(bitMember).AppendLine(";"); + break; + case FacadeSlotKind.OptionalValue: + sb.Append(indent).Append("if (").Append(PropertyName(s)).Append(" is not null) ").Append(defaultsVar).Append(" &= ~(int)global::AndroidX.Compose.") + .Append(d.EnumName).Append('.').Append(bitMember).AppendLine(";"); + break; + case FacadeSlotKind.RequiredFunction2: + case FacadeSlotKind.RequiredFunction3: + case FacadeSlotKind.PainterResource: + case FacadeSlotKind.Primitive: + case FacadeSlotKind.ThemeColor: + case FacadeSlotKind.StateHolder: + sb.Append(indent).Append(defaultsVar).Append(" &= ~(int)global::AndroidX.Compose.") + .Append(d.EnumName).Append('.').Append(bitMember).AppendLine(";"); + break; + } + } + } + + static string? MatchEnumMember(DefaultsInfo d, FacadeSlot s) + { + // For named slots: prefer the C# property name (e.g. user + // overrode via [Slot]). For everything else: the bridge param + // name maps to the Kotlin name 1:1. + string lookupName = s.Kind switch + { + FacadeSlotKind.NamedFunction2 or FacadeSlotKind.NamedFunction3 + or FacadeSlotKind.RequiredFunction2 or FacadeSlotKind.RequiredFunction3 + => PropertyName(s), + _ => s.Param.Name, + }; + // First try Kotlin name (== bridge param name). Then fall back + // to property-name match (case-insensitive). + var bk = d.FindByKotlinName(s.Param.Name); + if (bk is { } bks && bks.EnumMember is { } em1) return em1; + var bp = d.FindByEnumMember(lookupName); + if (bp is { } bps && bps.EnumMember is { } em2) return em2; + return null; + } + + static string BridgeArgExpr(FacadeSlot s, bool hoistModifier) => + s.Kind switch + { + FacadeSlotKind.Modifier => hoistModifier ? "__modifier" : "BuildModifier()", + FacadeSlotKind.OnClick => "__" + s.Param.Name, + FacadeSlotKind.Content2 => "__" + s.Param.Name, + FacadeSlotKind.Content3 => "__" + s.Param.Name, + FacadeSlotKind.NamedFunction2 => "__" + s.Param.Name, + FacadeSlotKind.NamedFunction3 => "__" + s.Param.Name, + FacadeSlotKind.RequiredFunction2 => "__" + s.Param.Name, + FacadeSlotKind.RequiredFunction3 => "__" + s.Param.Name, + FacadeSlotKind.Callback => "__" + s.Param.Name, + FacadeSlotKind.Primitive => "_" + s.Param.Name, + FacadeSlotKind.PainterResource => "__painterPeer", + FacadeSlotKind.ThemeColor => "__color", + FacadeSlotKind.ScopeReceiver => "global::AndroidX.Compose.RenderContext.CurrentScope", + FacadeSlotKind.StateHolder => "__" + s.Param.Name, + FacadeSlotKind.OptionalValue => PropertyName(s), + _ => "default", + }; + + static bool IsCtorSlot(FacadeSlot s) => + s.Kind is FacadeSlotKind.OnClick or FacadeSlotKind.Primitive or FacadeSlotKind.Callback + or FacadeSlotKind.PainterResource or FacadeSlotKind.StateHolder; + + /// + /// True when the slot surfaces as a defaulted ctor parameter on the + /// generated facade — either a + /// (always = null) or a + /// whose bridge parameter carries an explicit default value. Used to + /// push defaulted slots after required slots so the ctor compiles + /// (C# requires optional params to trail required ones). + /// + static bool HasFacadeCtorDefault(FacadeSlot s) => + s.Kind == FacadeSlotKind.StateHolder || + (s.Kind == FacadeSlotKind.Primitive && s.Param.HasExplicitDefaultValue); + + /// + /// Format an as + /// a C# literal for emission in a generated ctor parameter list. + /// Handles the primitive types accepted by + /// (bool, int, + /// long, float, double, string) plus + /// enum members. Falls back to default for shapes that + /// shouldn't reach here (the caller already guards with + /// ). + /// + static string FormatPrimitiveDefaultLiteral(IParameterSymbol p) + { + var value = p.ExplicitDefaultValue; + if (value is null) return "null"; + return value switch + { + bool b => b ? "true" : "false", + string s => SymbolDisplay.FormatLiteral(s, quote: true), + int i => i.ToString(System.Globalization.CultureInfo.InvariantCulture), + long l => l.ToString(System.Globalization.CultureInfo.InvariantCulture) + "L", + float f => FormatFloatLiteral(f), + double d => FormatDoubleLiteral(d), + _ => "default", + }; + } + + static string FormatFloatLiteral(float f) + { + if (float.IsNaN(f)) return "float.NaN"; + if (float.IsPositiveInfinity(f)) return "float.PositiveInfinity"; + if (float.IsNegativeInfinity(f)) return "float.NegativeInfinity"; + // "R" round-trips the value; append "f" suffix so it's a float + // literal (otherwise C# parses as double). + return f.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "f"; + } + + static string FormatDoubleLiteral(double d) + { + if (double.IsNaN(d)) return "double.NaN"; + if (double.IsPositiveInfinity(d)) return "double.PositiveInfinity"; + if (double.IsNegativeInfinity(d)) return "double.NegativeInfinity"; + var text = d.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + // Force `double` interpretation when no decimal point / exponent + // appears (e.g. "2" → "2d") so the literal can't be mistaken for + // an int by the C# parser. + if (text.IndexOfAny(new[] { '.', 'e', 'E' }) < 0) + text += "d"; + return text; + } + + // Phase 7 — PainterResource ctor variant. The generator emits one + // ctor per shape: `Id` takes an `int drawableResourceId` (Render + // resolves it via painterResource), `Painter` takes a pre-resolved + // Painter (Render forwards the wrapper's handle directly). + enum PainterCtorShape { Id, Painter } + + /// + /// Phase 11 — fully-qualified type name for the secondary + /// discriminator's nullable backing field. + /// + static string SecondaryFieldType(SecondaryCtorInfo info) + { + var format = SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + var rendered = info.Discriminator.Type.ToDisplayString(format); + if (!rendered.EndsWith("?")) rendered += "?"; + return rendered; + } + + /// + /// Phase 11 — emit the secondary ctor. Signature: discriminator + /// first (as a non-nullable parameter), then every primary ctor + /// slot that isn't the primary-only discriminator slot + /// (e.g. PainterResource). The discriminator field is stored; all + /// other ctor slots are stored as if the primary ctor had been + /// invoked (so the Render preamble can reuse the same field + /// expressions for shared properties). + /// + static void EmitSecondaryCtor(StringBuilder sb, string className, + FacadeSlot[] ctorSlots, SecondaryCtorInfo info) + { + // Discriminator's parameter type, non-nullable, fully qualified. + var format = SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + var discType = info.Discriminator.Type.ToDisplayString(format); + var discName = info.Discriminator.Name; + + // Which primary ctor slots survive into the secondary ctor? + // Everything except the primary-only discriminator slot — which + // is the slot whose param name does NOT appear in the + // secondary's user-param list. + var secondaryParamNames = new HashSet( + info.UserParams.Select(p => p.Name), StringComparer.Ordinal); + + bool ShouldEmit(FacadeSlot s) => + secondaryParamNames.Contains(s.Param.Name); + + var emittedSlots = ctorSlots.Where(ShouldEmit).ToArray(); + var skippedSlots = ctorSlots.Where(s => !ShouldEmit(s)).ToArray(); + + sb.Append(" public ").Append(className).Append('('); + sb.Append(discType).Append(' ').Append(EscapeIdent(discName)); + foreach (var s in emittedSlots) + { + sb.Append(", "); + sb.Append(CtorParamType(s)).Append(' ').Append(EscapeIdent(CtorIdentifier(s))); + if (s.Kind == FacadeSlotKind.StateHolder) + sb.Append(" = null"); + else if (s.Kind == FacadeSlotKind.Primitive && s.Param.HasExplicitDefaultValue) + sb.Append(" = ").Append(FormatPrimitiveDefaultLiteral(s.Param)); + } + sb.AppendLine(")"); + sb.AppendLine(" {"); + sb.Append(" _").Append(discName).Append(" = ").Append(EscapeIdent(discName)) + .Append(" ?? throw new global::System.ArgumentNullException(nameof(").Append(EscapeIdent(discName)).AppendLine("));"); + + // Store the emitted slots in their fields. Same logic as + // EmitFacadeCtor's body for non-PainterResource non-stateholder + // slots. + foreach (var s in emittedSlots) + { + if (s.IsParameterisedStateHolder) + { + var fqType = s.StateWrapperType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes)); + sb.Append(" _").Append(CtorIdentifier(s)).Append(" = ") + .Append(EscapeIdent(CtorIdentifier(s))).Append(" ?? new ").Append(fqType).AppendLine("();"); + } + else + { + sb.Append(" _").Append(CtorIdentifier(s)).Append(" = ") + .Append(EscapeIdent(CtorIdentifier(s))).AppendLine(";"); + } + } + // Skipped slots (primary-only, e.g. PainterResource id) get + // default values — the discriminator dispatch in Render won't + // touch them, so leaving them at default is fine for readonly + // fields. C# zero-inits implicitly, so no explicit assignment + // needed. + _ = skippedSlots; + + sb.AppendLine(" }"); + } + + /// + /// Phase 11 — emit the secondary dispatch branch at the top of + /// Render. When the discriminator field is non-null, build the + /// secondary's defaults mask (using its own enum) and call the + /// secondary bridge with shared property expressions, then return. + /// All preamble that follows (PainterResource resolution, etc.) + /// runs only on the primary path. + /// + static void EmitSecondaryDispatch(StringBuilder sb, + IReadOnlyList slots, SecondaryCtorInfo info, string composerName) + { + var discName = info.Discriminator.Name; + sb.Append(" if (_").Append(discName).AppendLine(" is not null)"); + sb.AppendLine(" {"); + + // Modifier hoist scoped to this block. Use a distinct local + // name to avoid CS0136 with the outer primary path's + // `__modifier` declared later in the same Render method. + var modifierSlot = slots.FirstOrDefault(s => s.Kind == FacadeSlotKind.Modifier); + bool hasModifier = modifierSlot.Param is not null; + if (hasModifier) + { + sb.AppendLine(" var __secModifier = BuildModifier();"); + } + + // Defaults mask using the SECONDARY's enum, into __secDefaults + // (again scoped distinctly from the primary's __defaults). + var allNamedSlots = slots.Where(s => s.Kind is FacadeSlotKind.NamedFunction2 or FacadeSlotKind.NamedFunction3 + or FacadeSlotKind.RequiredFunction2 or FacadeSlotKind.RequiredFunction3).ToArray(); + EmitDefaultsMask(sb, " ", info.Defaults, slots, allNamedSlots, + hasModifier: hasModifier, hasPainter: false, + defaultsVar: "__secDefaults", modifierVar: "__secModifier"); + + // The discriminator IS always supplied on this path — clear + // its own enum bit if the secondary's defaults enum has one + // named after it (e.g. IconDefault.ImageVector for `imageVector`). + var discBit = info.Defaults.FindByKotlinName(discName); + if (discBit is { } discSlot && discSlot.EnumMember is { } discBitMember) + { + sb.Append(" __secDefaults &= ~(int)global::AndroidX.Compose.") + .Append(info.DefaultsEnumName).Append('.').Append(discBitMember).AppendLine(";"); + } + + // Build the secondary bridge call. The discriminator slot + // uses the `_` field (with `!` since we just + // null-checked it); shared params map to the primary's slot + // expressions via slotByName. + var slotByName = new Dictionary(StringComparer.Ordinal); + foreach (var s in slots) + slotByName[s.Param.Name] = s; + + sb.Append(" global::AndroidX.Compose.ComposeBridges.").Append(info.Method.Name).Append('('); + for (int i = 0; i < info.UserParams.Count; i++) + { + var p = info.UserParams[i]; + if (i > 0) sb.Append(", "); + if (p.Name == discName) + { + sb.Append("_").Append(discName).Append('!'); + } + else if (slotByName.TryGetValue(p.Name, out var slot)) + { + // Re-route Modifier slot expressions to __secModifier. + if (slot.Kind == FacadeSlotKind.Modifier && hasModifier) + sb.Append("__secModifier"); + else + sb.Append(BridgeArgExpr(slot, hoistModifier: false)); + } + else + { + sb.Append("default"); + } + } + sb.Append(", __secDefaults, ").Append(composerName).AppendLine(");"); + sb.AppendLine(" return;"); + sb.AppendLine(" }"); + } + + static void EmitFacadeCtor(StringBuilder sb, string className, + FacadeSlot[] ctorSlots, PainterCtorShape painterShape) + { + sb.Append(" public ").Append(className).Append('('); + for (int i = 0; i < ctorSlots.Length; i++) + { + if (i > 0) sb.Append(", "); + var s = ctorSlots[i]; + if (s.Kind == FacadeSlotKind.PainterResource && painterShape == PainterCtorShape.Painter) + { + sb.Append("global::AndroidX.Compose.UI.Graphics.Painter.Painter painter"); + } + else + { + sb.Append(CtorParamType(s)).Append(' ').Append(EscapeIdent(CtorIdentifier(s))); + if (s.Kind == FacadeSlotKind.StateHolder) + sb.Append(" = null"); + else if (s.Kind == FacadeSlotKind.Primitive && s.Param.HasExplicitDefaultValue) + sb.Append(" = ").Append(FormatPrimitiveDefaultLiteral(s.Param)); + } + } + sb.AppendLine(")"); + sb.AppendLine(" {"); + foreach (var s in ctorSlots) + { + // Phase 4b — auto-create wrapper instance when the + // Remember bridge has user params. The Render body + // reads init values off `_state` to pass into Remember, + // so the field must be non-null on entry. + if (s.IsParameterisedStateHolder) + { + var fqType = s.StateWrapperType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes)); + sb.Append(" _").Append(CtorIdentifier(s)).Append(" = ") + .Append(EscapeIdent(CtorIdentifier(s))).Append(" ?? new ").Append(fqType).AppendLine("();"); + } + else if (s.Kind == FacadeSlotKind.PainterResource && painterShape == PainterCtorShape.Painter) + { + // Painter ctor: store the wrapper into _painter; leave + // _drawableResourceId at default (the sibling ctor sets + // it). Render branches on `_painter is not null`. + sb.AppendLine(" _painter = painter ?? throw new global::System.ArgumentNullException(nameof(painter));"); + } + else + { + sb.Append(" _").Append(CtorIdentifier(s)).Append(" = ").Append(EscapeIdent(CtorIdentifier(s))).AppendLine(";"); + } + } + sb.AppendLine(" }"); + } + + static string CtorFieldType(FacadeSlot slot) => CtorParamType(slot); + + static string CtorIdentifier(FacadeSlot slot) => + slot.Kind == FacadeSlotKind.PainterResource ? "drawableResourceId" : slot.Param.Name; + + static string CtorParamType(FacadeSlot slot) + { + var format = SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + return slot.Kind switch + { + FacadeSlotKind.OnClick => "global::System.Action", + FacadeSlotKind.Callback => "global::System.Action<" + slot.CallbackType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes)) + ">", + FacadeSlotKind.PainterResource => "int", + FacadeSlotKind.StateHolder => slot.StateWrapperType!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes)) + "?", + FacadeSlotKind.Primitive => slot.Param.Type.ToDisplayString(format), + _ => slot.Param.Type.ToDisplayString(), + }; + } + + static string PropertyName(FacadeSlot s) => s.SlotPropertyName ?? Pascal(s.Param.Name); + + /// + /// C# type display for an + /// property declaration. For value-class types (Dp/Sp/Em/TextAlign) + /// the param is already Nullable<T> and round-trips + /// fine with FullyQualifiedFormat. For reference-typed + /// wrappers (FontWeight/TextDecoration/Shape) we have to append + /// ? manually because the unannotated symbol does not carry + /// nullable annotation through ToDisplayString. + /// + static string OptionalValueDisplay(FacadeSlot s) + { + var t = s.Param.Type; + var format = SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + var rendered = t.ToDisplayString(format); + if (t.IsReferenceType && !rendered.EndsWith("?")) rendered += "?"; + return rendered; + } + + static string Pascal(string s) + { + if (string.IsNullOrEmpty(s)) return s; + return char.ToUpperInvariant(s[0]) + s.Substring(1); + } + + static bool IsModifier(ITypeSymbol type) => + type is INamedTypeSymbol n && + n.Name == "IModifier" && + n.ContainingNamespace?.ToDisplayString() == "AndroidX.Compose.UI"; + + static bool IsPainterType(ITypeSymbol type) => + type is INamedTypeSymbol n && + n.Name == "Painter" && + n.ContainingNamespace?.ToDisplayString() == "AndroidX.Compose.UI.Graphics.Painter"; + + static int KotlinFunctionArity(ITypeSymbol type) + { + if (type is not INamedTypeSymbol n) return -1; + if (n.ContainingNamespace?.ToDisplayString() != "Kotlin.Jvm.Functions") return -1; + var name = n.Name; + if (!name.StartsWith("IFunction", StringComparison.Ordinal)) return -1; + var tail = name.Substring("IFunction".Length); + return int.TryParse(tail, out var arity) ? arity : -1; + } + + static bool IsPrimitiveCtorType(ITypeSymbol type) => + type.TypeKind == TypeKind.Enum || + IsJavaEnum(type) || + type.SpecialType is SpecialType.System_String + or SpecialType.System_Int32 + or SpecialType.System_Int64 + or SpecialType.System_Boolean + or SpecialType.System_Single + or SpecialType.System_Double; + + /// + /// True when derives (transitively) from + /// Java.Lang.Enum — i.e. it's a Kotlin/Java enum class + /// generated by the Xamarin.Android binding (e.g. Compose's + /// ToggleableState: On, Off, + /// Indeterminate). Recognized as a primitive-like ctor + /// slot: surfaced as a positional ctor parameter and forwarded + /// to the bridge unchanged. + /// + static bool IsJavaEnum(ITypeSymbol type) + { + for (var t = type.BaseType; t is not null; t = t.BaseType) + { + if (t.Name == "Enum" && + t.ContainingNamespace?.ToDisplayString() == "Java.Lang") + return true; + } + return false; + } + + /// + /// True for parameters typed as Nullable<T> where T + /// is a recognized Compose @JvmInline value class (Dp/Sp/Em/ + /// 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 + // we must consult the annotation explicitly. + if (type.IsReferenceType + && annotation == NullableAnnotation.Annotated + && ComposeReferenceTypes.IsRecognized(type)) + return true; + 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("AndroidX.Compose.ScopeKind"); + if (enumType is null) return scope is "Row" or "Column"; + foreach (var m in enumType.GetMembers()) + if (m.Kind == SymbolKind.Field && m.Name == scope) + return true; + return false; + } + + static GenerationResult Fail(DiagnosticDescriptor desc, Location loc, params object?[] args) => + new(null, null, new[] { Diagnostic.Create(desc, loc, args) }); + + static string? ReadString(AttributeData attr, string name) + { + foreach (var na in attr.NamedArguments) + if (na.Key == name && na.Value.Value is string s) return s; + return null; + } + + static INamedTypeSymbol? ReadType(AttributeData attr, string name) + { + foreach (var na in attr.NamedArguments) + if (na.Key == name && na.Value.Value is INamedTypeSymbol t) return t; + return null; + } + + static bool ReadBool(AttributeData attr, string name) + { + foreach (var na in attr.NamedArguments) + if (na.Key == name && na.Value.Value is bool b) return b; + return false; + } + + static string? ReadSlotAttribute(IParameterSymbol p, INamedTypeSymbol? slotAttr) + { + if (slotAttr is null) return null; + var a = p.GetAttributes().FirstOrDefault(x => SymbolEqualityComparer.Default.Equals(x.AttributeClass, slotAttr)); + if (a is null) return null; + if (a.ConstructorArguments.Length > 0 && a.ConstructorArguments[0].Value is string s) return s; + return null; + } + + static string EscapeIdent(string name) => + SyntaxFacts.GetKeywordKind(name) == SyntaxKind.None ? name : "@" + name; + + /// + /// Phase 10 — validate a [ConfirmStateChange] attribute on a + /// Remember-bridge user param and produce its emission metadata. + /// Reports CN3010 on any failure and returns null. + /// + static ConfirmStateChangeInfo? ResolveConfirmStateChange(Context c, IParameterSymbol up, + AttributeData attr, string methodName, Location loc, List diags) + { + // (a) IFunction1 / IFunction1? param required. + if (KotlinFunctionArity(up.Type) != 1) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeConfirmStateChangeInvalid, loc, methodName, + $"[ConfirmStateChange] on Remember parameter '{up.Name}' requires a Kotlin.Jvm.Functions.IFunction1 parameter type; was '{up.Type.ToDisplayString()}'")); + return null; + } + + // (b) Value type — typeof(T) ctor arg. + if (attr.ConstructorArguments.Length == 0 || + attr.ConstructorArguments[0].Value is not INamedTypeSymbol valueType) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeConfirmStateChangeInvalid, loc, methodName, + $"[ConfirmStateChange] on Remember parameter '{up.Name}' is missing its required typeof(T) constructor argument")); + return null; + } + + // (c) Adapter type — explicit AdapterType or convention lookup. + INamedTypeSymbol? adapterType = ReadType(attr, "AdapterType"); + if (adapterType is null) + { + var conventionName = "AndroidX.Compose." + valueType.Name + "ConfirmStateChange"; + adapterType = c.Compilation.GetTypeByMetadataName(conventionName); + if (adapterType is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeConfirmStateChangeInvalid, loc, methodName, + $"[ConfirmStateChange] on Remember parameter '{up.Name}': cannot resolve adapter type '{conventionName}' by convention; set AdapterType = typeof(...) explicitly")); + return null; + } + } + + // (d) AdapterType must implement Kotlin.Jvm.Functions.IFunction1. + bool implementsIFunction1 = adapterType.AllInterfaces.Any(i => + i.Name == "IFunction1" && + i.ContainingNamespace?.ToDisplayString() == "Kotlin.Jvm.Functions"); + if (!implementsIFunction1) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeConfirmStateChangeInvalid, loc, methodName, + $"[ConfirmStateChange] on Remember parameter '{up.Name}': AdapterType '{adapterType.ToDisplayString()}' must implement Kotlin.Jvm.Functions.IFunction1")); + return null; + } + + // (e) AdapterType must have a public parameterless ctor. + bool hasParameterlessCtor = adapterType.InstanceConstructors.Any(ic => + ic.DeclaredAccessibility == Accessibility.Public && + (ic.Parameters.Length == 0 || ic.Parameters.All(pp => pp.HasExplicitDefaultValue))); + if (adapterType.InstanceConstructors.Length > 0 && !hasParameterlessCtor) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeConfirmStateChangeInvalid, loc, methodName, + $"[ConfirmStateChange] on Remember parameter '{up.Name}': AdapterType '{adapterType.ToDisplayString()}' must declare a public parameterless constructor")); + return null; + } + + // (f) AdapterType must have an accessible writable `Callback` + // property of type Func or Func?. + var callbackProp = adapterType.GetMembers("Callback").OfType().FirstOrDefault(); + if (callbackProp is null || callbackProp.SetMethod is null) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeConfirmStateChangeInvalid, loc, methodName, + $"[ConfirmStateChange] on Remember parameter '{up.Name}': AdapterType '{adapterType.ToDisplayString()}' must declare a writable instance property 'Callback'")); + return null; + } + if (callbackProp.Type is not INamedTypeSymbol ct || + ct.Name != "Func" || ct.ContainingNamespace?.ToDisplayString() != "System" || + ct.TypeArguments.Length != 2 || + !SymbolEqualityComparer.Default.Equals(ct.TypeArguments[0], valueType) || + ct.TypeArguments[1].SpecialType != SpecialType.System_Boolean) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeConfirmStateChangeInvalid, loc, methodName, + $"[ConfirmStateChange] on Remember parameter '{up.Name}': AdapterType '{adapterType.ToDisplayString()}'.Callback must be 'System.Func<{valueType.ToDisplayString()}, bool>?'")); + return null; + } + + // (g) Optional property name override; default = "ConfirmStateChange". + string propertyName = ReadString(attr, "PropertyName") ?? "ConfirmStateChange"; + if (!SyntaxFacts.IsValidIdentifier(propertyName)) + { + diags.Add(Diagnostic.Create(Diagnostics.FacadeConfirmStateChangeInvalid, loc, methodName, + $"[ConfirmStateChange] on Remember parameter '{up.Name}': PropertyName '{propertyName}' is not a valid C# identifier")); + return null; + } + + return new ConfirmStateChangeInfo(up, adapterType, valueType, propertyName); + } + + /// + /// Phase 4b — resolve the wrapper-side member name for a Remember + /// bridge user parameter. Tries an exact match + /// first, then falls back to Initial<Pascal> for the + /// Kotlin convention where initialX remember args correspond + /// to live wrapper property X. Looks at both properties and + /// fields, accepting any readable, non-static, accessible member. + /// Returns the resolved C# identifier or null when no match. + /// + static string? ResolveStateMember(INamedTypeSymbol stateType, string paramName) + { + var pascal = Pascal(paramName); + if (FindReadableMember(stateType, pascal) is { } direct) return direct; + if (FindReadableMember(stateType, "Initial" + pascal) is { } prefixed) return prefixed; + return null; + } + + static string? FindReadableMember(INamedTypeSymbol stateType, string name) + { + // Walk the inheritance chain so inherited members count. We stop + // at the first match — properties and fields share a name space + // in C#, so a class can't declare both with the same name. + for (var t = (INamedTypeSymbol?)stateType; t is not null; t = t.BaseType) + { + foreach (var m in t.GetMembers(name)) + { + if (m.IsStatic) continue; + if (m.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal + or Accessibility.ProtectedOrInternal)) + continue; + if (m is IPropertySymbol prop && prop.GetMethod is not null) return prop.Name; + if (m is IFieldSymbol f) return f.Name; + } + } + return null; + } + + /// + /// True when exposes an accessible + /// (public or internal) parameterless construction path — + /// new T() compiles. Counts an explicit parameterless ctor + /// as well as an all-defaulted-params ctor (e.g. + /// TimePickerState(int initialHour = 12, …)). Also accepts + /// the implicit ctor when no instance ctors are declared. + /// + static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol stateType) + { + var ctors = stateType.InstanceConstructors; + if (ctors.Length == 0) return true; + foreach (var c in ctors) + { + if (c.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal + or Accessibility.ProtectedOrInternal)) + continue; + if (c.Parameters.Length == 0) return true; + if (c.Parameters.All(p => p.HasExplicitDefaultValue)) return true; + } + return false; + } + + static bool HasMethodBody(IMethodSymbol method) + { + if (HasBodyInRefs(method)) return true; + if (method.PartialImplementationPart is { } impl && HasBodyInRefs(impl)) return true; + if (method.PartialDefinitionPart is { } defn && HasBodyInRefs(defn)) return true; + return false; + } + + static bool HasBodyInRefs(IMethodSymbol m) + { + foreach (var sr in m.DeclaringSyntaxReferences) + { + if (sr.GetSyntax() is MethodDeclarationSyntax mds && + (mds.Body is not null || mds.ExpressionBody is not null)) + { + return true; + } + } + return false; + } + + /// + /// Phase 10 — metadata for a [ConfirmStateChange(typeof(T))] + /// Remember-bridge parameter. The facade allocates one JCW + /// instance per node (readonly field + /// _<FieldIdentifier>), exposes a developer-mutable + /// Func<T, bool>? <PropertyName> property, and + /// assigns _<FieldIdentifier>.Callback = <PropertyName> + /// in the Render preamble before the RememberXxxState call. + /// The Remember bridge sees a stable JNI reference (the JCW), so + /// Kotlin's remember cache key is unaffected when the + /// developer rewires the C# delegate. + /// + internal readonly struct ConfirmStateChangeInfo + { + public ConfirmStateChangeInfo(IParameterSymbol rememberParam, + INamedTypeSymbol adapterType, INamedTypeSymbol valueType, string propertyName) + { + RememberParam = rememberParam; + AdapterType = adapterType; + ValueType = valueType; + PropertyName = propertyName; + } + public IParameterSymbol RememberParam { get; } + public INamedTypeSymbol AdapterType { get; } + public INamedTypeSymbol ValueType { get; } + public string PropertyName { get; } + /// + /// Backing-field identifier (no leading underscore). Conventional + /// form: lowercased first char of plus + /// "Adapter". Two different s + /// produce two different fields. + /// + public string FieldIdentifier => + char.ToLowerInvariant(PropertyName[0]) + PropertyName.Substring(1) + "Adapter"; + } + + internal enum FacadeSlotKind + { + Modifier, + OnClick, + Content2, + Content3, + Primitive, + Callback, + NamedFunction2, + NamedFunction3, + RequiredFunction2, + RequiredFunction3, + PainterResource, + ThemeColor, + ScopeReceiver, + StateHolder, + OptionalValue, + } + + internal readonly struct FacadeSlot + { + public FacadeSlot(IParameterSymbol param, FacadeSlotKind kind, + ITypeSymbol? callbackType = null, string? slotPropertyName = null, + string? rememberMethodName = null, INamedTypeSymbol? stateWrapperType = null, + ITypeSymbol? stateJvmType = null, string[]? rememberArgExpressions = null, + bool sharedState = false, ConfirmStateChangeInfo[]? confirmStateChanges = null) + { + Param = param; + Kind = kind; + CallbackType = callbackType; + SlotPropertyName = slotPropertyName; + RememberMethodName = rememberMethodName; + StateWrapperType = stateWrapperType; + StateJvmType = stateJvmType; + RememberArgExpressions = rememberArgExpressions ?? System.Array.Empty(); + SharedState = sharedState; + ConfirmStateChanges = confirmStateChanges ?? System.Array.Empty(); + } + public IParameterSymbol Param { get; } + public FacadeSlotKind Kind { get; } + public ITypeSymbol? CallbackType { get; } + public string? SlotPropertyName { get; } + public string? RememberMethodName { get; } + public INamedTypeSymbol? StateWrapperType { get; } + public ITypeSymbol? StateJvmType { get; } + /// + /// Phase 4b — one C# expression per user parameter of the + /// Remember*State bridge (composer excluded). Each expression + /// reads a member of the caller-supplied state wrapper, e.g. + /// _state!.InitialHour. For Remember params marked with + /// [ConfirmStateChange], the expression instead reads the + /// per-instance JCW field (e.g. _confirmStateChangeAdapter). + /// Empty when the Remember bridge has zero user params (Phase 4). + /// + public string[] RememberArgExpressions { get; } + /// + /// Phase 4c — when true, the generated Render preamble + /// checks whether _state.Jvm is already populated (from an + /// earlier sibling render) and skips the RememberXxxState + /// call in that case, reusing the cached JNI handle. + /// + public bool SharedState { get; } + /// + /// Phase 10 — zero or more [ConfirmStateChange] Remember + /// params hoisted to per-node JCW adapter fields with stable + /// JNI identity. + /// + public ConfirmStateChangeInfo[] ConfirmStateChanges { get; } + public bool HasSlotAttribute => SlotPropertyName is not null; + public bool IsNullableSlot => Param.NullableAnnotation == NullableAnnotation.Annotated + && KindIsFnSlot(Kind); + public bool IsParameterisedStateHolder => + Kind == FacadeSlotKind.StateHolder && RememberArgExpressions.Length > 0; + static bool KindIsFnSlot(FacadeSlotKind k) => + k is FacadeSlotKind.Content2 or FacadeSlotKind.Content3 + or FacadeSlotKind.NamedFunction2 or FacadeSlotKind.NamedFunction3 + or FacadeSlotKind.RequiredFunction2 or FacadeSlotKind.RequiredFunction3; + public FacadeSlot WithKind(FacadeSlotKind newKind) => + new(Param, newKind, CallbackType, SlotPropertyName, RememberMethodName, + StateWrapperType, StateJvmType, RememberArgExpressions, SharedState, ConfirmStateChanges); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeReferenceTypes.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeReferenceTypes.cs new file mode 100644 index 000000000..a041e1ecd --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeReferenceTypes.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Registry of Compose reference-typed wrapper classes (Kotlin +/// classes — not @JvmInline value class) that are passed through +/// JNI as object handles. Distinct from , +/// which lowers @JvmInline primitives to F/J/I +/// JNI slots. +/// +/// Used by to recognize these +/// types as OptionalValue facade slots — surfaced as nullable +/// auto-properties on the generated facade class. The bridge generator's +/// existing reference-type code path (x is null ? IntPtr.Zero : +/// ((Java.Lang.Object)x).Handle) handles the actual JNI lowering. +/// +/// Explicit registry (not heuristics) so adding a new wrapper is a +/// single-line opt-in and we don't accidentally classify unrelated +/// helper types in the AndroidX.Compose namespace as facade properties. +/// +internal static class ComposeReferenceTypes +{ + /// + /// Set of fully-qualified C# metadata names (no nullable + /// annotation, no global:: prefix) recognized as Compose + /// reference-type wrappers. + /// + public static readonly IReadOnlyCollection Recognized = new HashSet + { + "AndroidX.Compose.FontWeight", + "AndroidX.Compose.FontFamily", + "AndroidX.Compose.FontStyle", + "AndroidX.Compose.TextAlign", + "AndroidX.Compose.TextDecoration", + "AndroidX.Compose.Shape", + "AndroidX.Compose.Alignment", + "AndroidX.Compose.ContentScale", + "AndroidX.Compose.PaddingValues", + "AndroidX.Compose.Material3.ITopAppBarScrollBehavior", + "AndroidX.Compose.Material3.ButtonColors", + "AndroidX.Compose.Material3.SliderColors", + "AndroidX.Compose.Material3.CheckboxColors", + "AndroidX.Compose.Material3.SwitchColors", + "AndroidX.Compose.UI.Text.TextStyle", + "AndroidX.Compose.UI.Text.Input.IVisualTransformation", + "AndroidX.Compose.Foundation.Text.KeyboardOptions", + "Kotlin.Ranges.IClosedFloatingPointRange", + }; + + /// + /// True iff is a recognized Compose + /// reference-type wrapper (with or without a nullable annotation). + /// + public static bool IsRecognized(ITypeSymbol type) + { + if (type is not INamedTypeSymbol n) return false; + if (!n.IsReferenceType) return false; + var ns = n.ContainingNamespace?.IsGlobalNamespace == true + ? string.Empty + : (n.ContainingNamespace?.ToDisplayString() ?? string.Empty); + var name = ns.Length == 0 ? n.Name : ns + "." + n.Name; + return Recognized.Contains(name); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeValueTypes.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeValueTypes.cs new file mode 100644 index 000000000..41ec8b498 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/ComposeValueTypes.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Registry of Compose @JvmInline value class types whose +/// parameters lower to JNI primitives. The bridge generator uses this +/// to (1) emit the right JValue slot, and (2) extend the +/// auto-default-mask logic so passing a non-null value clears +/// the corresponding $default bit, mirroring the existing +/// IModifier? / IntPtr? handling. +/// +/// Reference-typed Compose wrappers (FontWeight, +/// TextDecoration, Shape) are NOT in this registry — +/// they go through the generic "reference-type → handle with null +/// check" path in +/// .EmitUserArgValue, which +/// already handles them. +/// +internal static class ComposeValueTypes +{ + /// + /// Map from C# fully-qualified metadata name (without trailing + /// ?; the parameter is recognized only as a nullable + /// wrapper) to a (slot, lowering-template) pair. + /// {0} in the template is replaced with the C# parameter + /// identifier. The lowering takes a Nullable<T> and + /// returns the JNI primitive — when the value is null it + /// returns the appropriate zero literal so Kotlin's default-value + /// path wins (the auto-default-mask leaves the bit set). + /// + public static readonly IReadOnlyDictionary Recognized = + new Dictionary + { + // androidx.compose.ui.unit.Dp → JNI float. + ["AndroidX.Compose.Dp"] = + ('F', "global::AndroidX.Compose.Dp.Pack({0})"), + + // androidx.compose.ui.unit.TextUnit (sp variant) → JNI long. + ["AndroidX.Compose.Sp"] = + ('J', "global::AndroidX.Compose.Sp.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.) + ["AndroidX.Compose.TextOverflow"] = + ('I', "global::AndroidX.Compose.TextOverflow.Pack({0})"), + + // androidx.compose.ui.graphics.Color is bound by + // Xamarin.AndroidX.Compose.UI.Graphics 1.11.2.1, and the + // managed-side `AndroidX.Compose.Color` is a value-type wrapper + // over the same packed ULong. The Kotlin + // `@JvmInline value class Color(val value: ULong)` surfaces + // as a packed `long` at the JNI boundary; the implicit + // `Color -> long` operator turns the C# struct into the + // bridge's actual `long` JNI slot. + ["AndroidX.Compose.Color"] = + ('J', "(long)({0}.GetValueOrDefault())"), + }; + + /// + /// Detect a parameter whose type is Nullable<T> where + /// T is one of the recognized value types. Returns the + /// underlying full name and the registry entry, or false. + /// + public static bool TryGet(ITypeSymbol type, out string fullName, out (char Slot, string LowerTemplate) info) + { + fullName = string.Empty; + info = default; + if (type is not INamedTypeSymbol n) return false; + if (n.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T) return false; + if (n.TypeArguments.Length != 1) return false; + var inner = n.TypeArguments[0]; + var ns = inner.ContainingNamespace?.IsGlobalNamespace == true + ? string.Empty + : (inner.ContainingNamespace?.ToDisplayString() ?? string.Empty); + var name = ns.Length == 0 ? inner.Name : ns + "." + inner.Name; + if (!Recognized.TryGetValue(name, out info)) return false; + fullName = name; + return true; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/DefaultsInfo.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/DefaultsInfo.cs new file mode 100644 index 000000000..45e522b42 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/DefaultsInfo.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// Parsed view of a declarative [assembly: ComposeDefaults("EnumName", +/// "p0", "!p1", ...)] attribute. Each slot maps a Kotlin parameter +/// name to its bit position in the $default bitmask; !- +/// prefixed names are consumed by the attribute (they occupy a bit) but +/// are omitted from the generated enum. Shared by +/// and +/// . +/// +internal readonly struct DefaultsInfo +{ + public string EnumName { get; } + public IReadOnlyList Slots { get; } + + public DefaultsInfo(string enumName, IReadOnlyList slots) + { + EnumName = enumName; + Slots = slots; + } + + public DefaultsSlot? FindByKotlinName(string kotlinName) + { + foreach (var s in Slots) + if (string.Equals(s.KotlinName, kotlinName, StringComparison.Ordinal)) + return s; + return null; + } + + /// + /// Look up a slot whose generated + /// matches (case-insensitive). Used + /// when a facade has renamed a slot via [Slot]: the property + /// name is the user-facing name, but the underlying enum member is + /// still the Kotlin name PascalCased. + /// + public DefaultsSlot? FindByEnumMember(string propertyName) + { + foreach (var s in Slots) + if (s.EnumMember is { } em && + string.Equals(em, propertyName, StringComparison.OrdinalIgnoreCase)) + return s; + return null; + } + + public static DefaultsInfo? TryRead(Compilation compilation, INamedTypeSymbol declarativeAttr, string enumName) + { + var match = compilation.Assembly.GetAttributes() + .FirstOrDefault(a => + SymbolEqualityComparer.Default.Equals(a.AttributeClass, declarativeAttr) && + a.ConstructorArguments.Length >= 1 && + a.ConstructorArguments[0].Value is string s && + s == enumName); + if (match is null) return null; + + var arr = match.ConstructorArguments[1]; + var names = arr.Kind == TypedConstantKind.Array + ? arr.Values.Select(v => v.Value as string ?? string.Empty).ToArray() + : Array.Empty(); + + var slots = new List(names.Length); + for (int i = 0; i < names.Length; i++) + { + var raw = names[i]; + bool suppressed = raw.StartsWith("!", StringComparison.Ordinal); + var kotlin = suppressed ? raw.Substring(1) : raw; + slots.Add(new DefaultsSlot( + kotlinName: kotlin, + bit: i, + enumMember: suppressed ? null : Pascal(kotlin))); + } + return new DefaultsInfo(enumName, slots); + } + + /// + /// Read slot info directly from the generated enum's members. Used as + /// a fallback when the enum was declared via the generic form + /// ([assembly: ComposeDefaults<T>("Method", "EnumName")]), + /// which doesn't leave a declarative attribute behind for + /// to parse. Each non-All enum member's + /// constant value is treated as a single-bit mask and decoded via + /// log2; the member name (PascalCased) maps back to a camelCase + /// Kotlin parameter name. + /// + public static DefaultsInfo? TryReadFromEnum(INamedTypeSymbol enumType) + { + if (enumType.TypeKind != TypeKind.Enum) return null; + + var slots = new List(); + foreach (var member in enumType.GetMembers().OfType()) + { + if (!member.IsConst) continue; + if (member.Name == "All") continue; + if (member.ConstantValue is not int raw || raw == 0) continue; + // Treat the value as an unsigned bitmask so bit 31 (which is + // negative when read as `int`) is decoded correctly. Single-bit + // masks only — the All sentinel is filtered above and any + // composite values are skipped. + uint v = unchecked((uint)raw); + if ((v & (v - 1)) != 0) continue; + int bit = 0; + for (uint x = v; x > 1; x >>= 1) bit++; + var kotlin = char.ToLowerInvariant(member.Name[0]) + member.Name.Substring(1); + slots.Add(new DefaultsSlot(kotlin, bit, member.Name)); + } + if (slots.Count == 0) return null; + slots.Sort((a, b) => a.Bit.CompareTo(b.Bit)); + return new DefaultsInfo(enumType.Name, slots); + } + + /// + /// Reconstruct slot info from an + /// [assembly: ComposeDefaults<T>("Method", "EnumName")] + /// attribute when the enum type generated by + /// isn't yet visible in the + /// current compilation snapshot. Walks 's + /// assembly attributes, locates the generic-form attribute whose + /// second positional argument matches , + /// finds the named static method on its T type argument, and + /// extracts slot names via . + /// Returns null when no matching attribute exists. + /// + public static DefaultsInfo? TryReadFromGenericAttribute(Compilation compilation, + INamedTypeSymbol? genericAttr, string enumName) + { + if (genericAttr is null) return null; + + foreach (var a in compilation.Assembly.GetAttributes()) + { + if (a.AttributeClass is not { TypeArguments.Length: 1 } cls) continue; + if (!SymbolEqualityComparer.Default.Equals(cls.ConstructedFrom, genericAttr)) continue; + if (a.ConstructorArguments.Length < 2) continue; + if (a.ConstructorArguments[0].Value is not string methodName) continue; + if (a.ConstructorArguments[1].Value is not string name) continue; + if (!string.Equals(name, enumName, StringComparison.Ordinal)) continue; + if (cls.TypeArguments[0] is not INamedTypeSymbol containingType) continue; + + var candidates = containingType.GetMembers(methodName).OfType() + .Where(m => m.IsStatic).ToList(); + if (candidates.Count == 0) return null; + var method = candidates.OrderByDescending(m => m.Parameters.Length).First(); + + int composerIndex = -1; + for (int i = 0; i < method.Parameters.Length; i++) + { + if (ComposeDefaultsGenerator.IsComposer(method.Parameters[i].Type)) + { + composerIndex = i; + break; + } + } + if (composerIndex < 0) return null; + + var slots = ComposeDefaultsEmitter.SlotsFromSymbol(method, composerIndex); + var infoSlots = slots.Select(s => new DefaultsSlot(s.RawName, s.Bit, s.EnumMember)).ToList(); + return new DefaultsInfo(enumName, infoSlots); + } + + return null; + } + + static string Pascal(string s) + { + if (string.IsNullOrEmpty(s)) return s; + return char.ToUpperInvariant(s[0]) + s.Substring(1); + } +} + +internal readonly struct DefaultsSlot +{ + public string KotlinName { get; } + public int Bit { get; } + /// Null when the entry was !-suppressed. + public string? EnumMember { get; } + + public DefaultsSlot(string kotlinName, int bit, string? enumMember) + { + KotlinName = kotlinName; + Bit = bit; + EnumMember = enumMember; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Diagnostics.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Diagnostics.cs new file mode 100644 index 000000000..222a75a58 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Diagnostics.cs @@ -0,0 +1,254 @@ +using Microsoft.CodeAnalysis; + +namespace AndroidX.Compose.SourceGenerators; + +internal static class Diagnostics +{ + public static readonly DiagnosticDescriptor MethodNotFound = new( + id: "CN1001", + title: "Compose method not found", + messageFormat: "No static method '{0}' found on type '{1}'", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor NotComposable = new( + id: "CN1002", + title: "Method is not @Composable", + messageFormat: "Method '{0}' has no IComposer parameter — not a @Composable signature", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MalformedAttribute = new( + id: "CN1003", + title: "Malformed [ComposeDefaults] attribute", + messageFormat: "Could not read [ComposeDefaults] attribute arguments for enum '{0}'", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeMissingDefaults = new( + id: "CN2001", + title: "[ComposeBridge] missing or unmatched Defaults", + messageFormat: "Could not resolve a [ComposeDefaults] attribute matching '{0}' for bridge '{1}'", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeSignatureMismatch = new( + id: "CN2002", + title: "[ComposeBridge] signature/Defaults mismatch", + messageFormat: "Bridge '{0}' signature has {1} user-parameter slot(s) before composer but Defaults '{2}' declares {3}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeUnknownParameter = new( + id: "CN2003", + title: "[ComposeBridge] partial method parameter not in Defaults", + messageFormat: "Bridge '{0}' has parameter '{1}' that does not match any Kotlin parameter name in Defaults '{2}'", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeMalformedSignature = new( + id: "CN2004", + title: "[ComposeBridge] malformed JNI signature", + messageFormat: "Bridge '{0}' has malformed JNI signature: {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeDefaultsMismatch = new( + id: "CN2005", + title: "[ComposeBridge] Defaults disagrees with JNI signature", + messageFormat: "Bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeConstructorShape = new( + id: "CN2006", + title: "[ComposeBridge] constructor shape requirements not met", + messageFormat: "Constructor bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeValueTypeNeedsDefaults = new( + id: "CN2007", + title: "[ComposeBridge] recognized Compose value type requires a $default slot", + messageFormat: "Bridge '{0}' parameter '{1}' is a recognized Compose value type ({2}) — those rely on the auto-default-mask logic, but this bridge has no $default slot", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeValueTypeSlotMismatch = new( + id: "CN2008", + title: "[ComposeBridge] value-type parameter lowers to the wrong JNI slot", + messageFormat: "Bridge '{0}' parameter '{1}' is a recognized Compose value type ({2}) that lowers to JNI slot '{3}' but the signature has slot '{4}' at that position — declare a different parameter type or fix the signature", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor BridgeSuspendInvalid = new( + id: "CN2009", + title: "[ComposeBridge(Suspend = true)] configuration is invalid", + messageFormat: "Suspend bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeWrongContainingType = new( + id: "CN3001", + title: "[ComposeFacade] must be applied to a method on ComposeBridges", + messageFormat: "[ComposeFacade] on '{0}' was found on type '{1}'; facade generation only runs on methods declared in AndroidX.Compose.ComposeBridges", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeUnsupportedParameter = new( + id: "CN3002", + title: "[ComposeFacade] bridge has an unsupported parameter shape", + messageFormat: "Facade for bridge '{0}' cannot be generated: parameter '{1}' has unsupported type '{2}'. Leave this bridge hand-written and remove [ComposeFacade].", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeScopeMisuse = new( + id: "CN3003", + title: "[ComposeFacade] Scope set without an IFunction3 content slot", + messageFormat: "Facade for bridge '{0}' has Scope='{1}' but no IFunction3 content parameter to publish the scope from", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeMissingBridge = new( + id: "CN3004", + title: "[ComposeFacade] requires a sibling [ComposeBridge]", + messageFormat: "[ComposeFacade] on '{0}' is missing the required [ComposeBridge] attribute; facade generation only wraps bridge-emitted methods", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeCallbackUnsupportedType = new( + id: "CN3005", + title: "[Callback] unbox type is not supported", + messageFormat: "Facade for bridge '{0}': [Callback] on parameter '{1}' uses unsupported value type '{2}'. Supported types: bool, string, float.", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeSlotConflict = new( + id: "CN3006", + title: "[ComposeFacade] slot configuration is invalid", + messageFormat: "Facade for bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeColorThemeBindingFailed = new( + id: "CN3007", + title: "[ComposeFacade(DefaultColorFromTheme=...)] could not bind to a bridge parameter", + messageFormat: "Facade for bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadePainterMisuse = new( + id: "CN3008", + title: "[PainterResource] must annotate an 'IntPtr' bridge parameter", + messageFormat: "Facade for bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeStateHolderInvalid = new( + id: "CN3009", + title: "[StateHolder] configuration is invalid", + messageFormat: "Facade for bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeBranchInvalid = new( + id: "CN3010", + title: "[ComposeFacade(BranchOn=..., AlternateBridge=...)] is invalid", + messageFormat: "Facade for bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeConfirmStateChangeInvalid = new( + id: "CN3011", + title: "[ConfirmStateChange] configuration is invalid", + messageFormat: "Facade for bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FacadeSecondaryCtorInvalid = new( + id: "CN3012", + title: "[ComposeFacade(SecondaryCtor=...)] is invalid", + messageFormat: "Facade for bridge '{0}': {1}", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CompanionNotPartial = new( + id: "CN4001", + title: "[ComposeCompanion] target class must be partial", + messageFormat: "Class '{0}' carries [ComposeCompanion] but is not declared 'partial'", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CompanionMalformedOuter = new( + id: "CN4002", + title: "[ComposeCompanion] outer JNI class is missing or malformed", + messageFormat: "Class '{0}' [ComposeCompanion] requires a non-empty slash-separated outer JNI class name", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CompanionGetterShape = new( + id: "CN4003", + title: "[ComposeCompanionGetter] must be on a 'public static partial T { get; }' property", + messageFormat: "Property '{0}.{1}' carries [ComposeCompanionGetter] but is not a public static partial get-only declaration", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CompanionMalformedGetter = new( + id: "CN4004", + title: "[ComposeCompanionGetter] getter name is missing or empty", + messageFormat: "Property '{0}.{1}' [ComposeCompanionGetter] requires a non-empty Kotlin getter name", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CompanionGetterMissingHost = new( + id: "CN4005", + title: "[ComposeCompanionGetter] requires the containing class to carry [ComposeCompanion]", + messageFormat: "Property '{0}.{1}' carries [ComposeCompanionGetter] but class '{0}' has no [ComposeCompanion]", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CompanionMissingPeerCtor = new( + id: "CN4006", + title: "[ComposeCompanionGetter] return type lacks an (IntPtr, JniHandleOwnership) constructor", + messageFormat: "Property '{0}.{1}': return type '{2}' must declare an accessible '(System.IntPtr, Android.Runtime.JniHandleOwnership)' constructor so the generated body can wrap the JNI handle", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CompanionInlineReturnConflict = new( + id: "CN4007", + title: "[ComposeCompanionGetter(ReturnDescriptor=...)] cannot be combined with [ComposeCompanion(InlineClass = true)]", + messageFormat: "Property '{0}.{1}': inline-class companions always box through the outer type via 'box-impl(I)L;' — remove the per-property ReturnDescriptor", + category: "AndroidX.Compose", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/GenerationResult.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/GenerationResult.cs new file mode 100644 index 000000000..2eeaa878f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/GenerationResult.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace AndroidX.Compose.SourceGenerators; + +internal sealed class GenerationResult +{ + public GenerationResult(string? source, string? hintName, IReadOnlyList diagnostics) + { + Source = source; + HintName = hintName; + Diagnostics = diagnostics; + } + + public string? Source { get; } + public string? HintName { get; } + public IReadOnlyList Diagnostics { get; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/JniSignature.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/JniSignature.cs new file mode 100644 index 000000000..e43329a2a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/JniSignature.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; + +namespace AndroidX.Compose.SourceGenerators; + +/// +/// JNI method-signature parser. +/// Produces a list of entries for the parameter +/// section between the leading ( and trailing ). +/// +internal static class JniSignature +{ + /// + /// Try to parse 's parameter list. Returns + /// true on success and writes the parsed parameter types and + /// return-type token to the out parameters; on failure writes a + /// human-readable reason to . + /// + public static bool TryParse(string signature, out IReadOnlyList parameters, out string returnType, out string? error) + { + parameters = Array.Empty(); + returnType = string.Empty; + error = null; + + if (string.IsNullOrEmpty(signature) || signature[0] != '(') + { + error = "signature must start with '('"; + return false; + } + + var list = new List(); + int i = 1; + while (i < signature.Length && signature[i] != ')') + { + if (!TryReadOne(signature, ref i, out var type, out error)) + return false; + list.Add(type); + } + + if (i >= signature.Length || signature[i] != ')') + { + error = "missing ')' terminator"; + return false; + } + i++; + if (i >= signature.Length) + { + error = "missing return type"; + return false; + } + + returnType = signature.Substring(i); + parameters = list; + return true; + } + + static bool TryReadOne(string s, ref int i, out JniType type, out string? error) + { + type = default; + error = null; + int arrayDepth = 0; + while (i < s.Length && s[i] == '[') + { + arrayDepth++; + i++; + } + if (i >= s.Length) + { + error = "ran off end while reading type"; + return false; + } + char c = s[i]; + switch (c) + { + case 'Z': case 'B': case 'C': case 'S': case 'I': case 'J': case 'F': case 'D': + i++; + type = new JniType(c, null, arrayDepth); + return true; + case 'L': + int end = s.IndexOf(';', i + 1); + if (end < 0) + { + error = "object type missing ';' terminator"; + return false; + } + var name = s.Substring(i + 1, end - i - 1); + i = end + 1; + type = new JniType('L', name, arrayDepth); + return true; + default: + error = $"unexpected JNI type code '{c}'"; + return false; + } + } +} + +/// One parameter slot from a parsed JNI signature. +internal readonly struct JniType +{ + public JniType(char code, string? className, int arrayDepth) + { + Code = code; + ClassName = className; + ArrayDepth = arrayDepth; + } + + /// One of Z B C S I J F D L. + public char Code { get; } + + /// For L types only: the JNI internal class name (no leading L, no trailing ;). + public string? ClassName { get; } + + /// 0 for plain types, >0 for arrays. + public int ArrayDepth { get; } + + public bool IsObject => Code == 'L' || ArrayDepth > 0; + + /// The C# zero-literal to pass when this slot is being defaulted. + public string ZeroLiteral => ArrayDepth > 0 + ? "global::System.IntPtr.Zero" + : Code switch + { + 'Z' => "false", + 'B' or 'C' or 'S' or 'I' => "0", + 'J' => "0L", + 'F' => "0f", + 'D' => "0d", + 'L' => "global::System.IntPtr.Zero", + _ => "default", + }; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Microsoft.AndroidX.Compose.SourceGenerators.csproj b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Microsoft.AndroidX.Compose.SourceGenerators.csproj new file mode 100644 index 000000000..ea706d814 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose.SourceGenerators/Microsoft.AndroidX.Compose.SourceGenerators.csproj @@ -0,0 +1,16 @@ + + + netstandard2.0 + latest + enable + true + false + true + $(NoWarn);RS2008 + Microsoft.AndroidX.Compose.SourceGenerators + + + + + + diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AlertDialog.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AlertDialog.cs new file mode 100644 index 000000000..e393b8f92 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AlertDialog.cs @@ -0,0 +1,26 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 AlertDialog. The first composable in the facade to +/// use named slot properties instead of a single collection +/// initializer: ConfirmButton is required, and any of +/// DismissButton, Icon, Title, and Text may +/// be supplied via C# object-initializer syntax. Slots left null +/// are reported as defaulted through the $default bitmask so +/// Compose substitutes the Kotlin defaults. +/// +/// +/// new AlertDialog(onDismissRequest: () => show.Value = false) +/// { +/// Title = new Text("Confirm?"), +/// Text = new Text("This cannot be undone."), +/// ConfirmButton = new Button(onClick: ...) { new Text("OK") }, +/// DismissButton = new Button(onClick: ...) { new Text("Cancel") }, +/// } +/// +/// +/// This shape is the template that ModalBottomSheet, +/// DatePickerDialog, TimePickerDialog, and the tooltip +/// composables will follow once their *State bridges land. +/// +public sealed partial class AlertDialog; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Alignment.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Alignment.cs new file mode 100644 index 000000000..51cbef52a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Alignment.cs @@ -0,0 +1,101 @@ +using Android.Runtime; +using AndroidX.Compose.UI; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around the androidx.compose.ui.Alignment singletons +/// exposed by Kotlin's Alignment.Companion object. Each public +/// static property forwards to the bound +/// AndroidX.Compose.UI.IAlignment.Companion singleton and caches +/// the wrapper for the lifetime of the process. +/// +/// Use these with +/// inside a child to position the child within the +/// parent. Use the nested singletons inside a +/// and the nested singletons +/// inside a . +/// +/// +/// Inherits so the bridge generator's +/// reference-type code path +/// (((Java.Lang.Object)alignment).Handle) can lower an +/// instance to its underlying Kotlin +/// Alignment JNI handle — used by facades like +/// that take an optional alignment slot. +/// +public sealed class Alignment : Java.Lang.Object +{ + Alignment(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + // Wrap the bound IAlignment peer's JNI handle in a brand-new + // Java.Lang.Object peer so casts to Java.Lang.Object on this + // instance return our own (stable) handle. Same approach as Shape. + static Alignment From(IAlignment java) + { + IntPtr handle = ((Java.Lang.Object)java).Handle; + return new Alignment(JNIEnv.NewLocalRef(handle), JniHandleOwnership.TransferLocalRef); + } + + static Alignment? s_topStart, s_topCenter, s_topEnd, s_centerStart, s_center, s_centerEnd, s_bottomStart, s_bottomCenter, s_bottomEnd; + + /// Alignment.TopStart — top-left in LTR, top-right in RTL. + public static Alignment TopStart => s_topStart ??= From(IAlignment.Companion.TopStart); + /// Alignment.TopCenter — top-center. + public static Alignment TopCenter => s_topCenter ??= From(IAlignment.Companion.TopCenter); + /// Alignment.TopEnd — top-right in LTR, top-left in RTL. + public static Alignment TopEnd => s_topEnd ??= From(IAlignment.Companion.TopEnd); + /// Alignment.CenterStart — vertically centered, leading edge. + public static Alignment CenterStart => s_centerStart ??= From(IAlignment.Companion.CenterStart); + /// Alignment.Center — centered both axes. + public static Alignment Center => s_center ??= From(IAlignment.Companion.Center); + /// Alignment.CenterEnd — vertically centered, trailing edge. + public static Alignment CenterEnd => s_centerEnd ??= From(IAlignment.Companion.CenterEnd); + /// Alignment.BottomStart — bottom-left in LTR, bottom-right in RTL. + public static Alignment BottomStart => s_bottomStart ??= From(IAlignment.Companion.BottomStart); + /// Alignment.BottomCenter — bottom-center. + public static Alignment BottomCenter => s_bottomCenter ??= From(IAlignment.Companion.BottomCenter); + /// Alignment.BottomEnd — bottom-right in LTR, bottom-left in RTL. + public static Alignment BottomEnd => s_bottomEnd ??= From(IAlignment.Companion.BottomEnd); + + /// + /// Vertical-only alignment singletons (Alignment.Vertical) for + /// use with + /// inside a . + /// + public sealed class Vertical + { + internal IAlignmentVertical Java { get; } + Vertical(IAlignmentVertical java) => Java = java; + + static Vertical? s_top, s_center, s_bottom; + + /// Alignment.Top — align to the top of the row. + public static Vertical Top => s_top ??= new Vertical(IAlignment.Companion.Top); + /// Alignment.CenterVertically — align to the vertical center. + public static Vertical CenterVertically => s_center ??= new Vertical(IAlignment.Companion.CenterVertically); + /// Alignment.Bottom — align to the bottom of the row. + public static Vertical Bottom => s_bottom ??= new Vertical(IAlignment.Companion.Bottom); + } + + /// + /// Horizontal-only alignment singletons (Alignment.Horizontal) + /// for use with + /// inside a . + /// + public sealed class Horizontal + { + internal IAlignmentHorizontal Java { get; } + Horizontal(IAlignmentHorizontal java) => Java = java; + + static Horizontal? s_start, s_center, s_end; + + /// Alignment.Start — align to the leading edge. + public static Horizontal Start => s_start ??= new Horizontal(IAlignment.Companion.Start); + /// Alignment.CenterHorizontally — align to the horizontal center. + public static Horizontal CenterHorizontally => s_center ??= new Horizontal(IAlignment.Companion.CenterHorizontally); + /// Alignment.End — align to the trailing edge. + public static Horizontal End => s_end ??= new Horizontal(IAlignment.Companion.End); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidView.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidView.cs new file mode 100644 index 000000000..0790ac26c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidView.cs @@ -0,0 +1,79 @@ +using Android.Content; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.ViewInterop; +using AView = Android.Views.View; + +namespace AndroidX.Compose; + +/// +/// C# facade over Compose's androidx.compose.ui.viewinterop.AndroidView +/// interop composable — hosts a stock Android +/// inside the enclosing Compose composition. Use when you need to +/// embed something Compose doesn't render natively (a third-party +/// custom view, a not-yet-converted control, a MAUI handler's +/// ToPlatform() result, …). +/// +/// +/// The factory lambda runs once per +/// composition slot — Compose caches the materialised view and +/// reuses it across recompositions. The update lambda, if +/// supplied, runs on each recomposition so callers can push fresh +/// property values into the cached view. +/// +/// The Kotlin signature takes a (Context) -> T +/// factory and an optional (T) -> Unit update. Both +/// lambdas execute on Compose's main thread. +/// +public sealed class AndroidView : ComposableNode +{ + readonly Func _factory; + readonly Action? _update; + + /// + /// Create a new wrapping the result of + /// . + /// + /// Materialiser called once per composition + /// slot. Compose hands it the runtime + /// . + /// Optional per-recomposition update hook. + /// Receives the cached view; mutate its properties to push state + /// changes Compose can't observe natively. + public AndroidView(Func factory, Action? update = null) + { + ArgumentNullException.ThrowIfNull(factory); + _factory = factory; + _update = update; + } + + /// + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var updateJcw = _update is null ? null : new AndroidViewUpdateAdapter(_update); + var factoryJcw = new AndroidViewFactoryAdapter(_factory); + + // Bit positions in AndroidView's $default mask: + // 0 = factory (always supplied) + // 1 = modifier + // 2 = update + int defaults = 0; + if (modifier is null) defaults |= 1 << 1; + if (updateJcw is null) defaults |= 1 << 2; + + AndroidView_androidKt.AndroidView( + factory: factoryJcw, + modifier: modifier, + update: updateJcw, + _composer: composer, + // Binder names are positionally derived and misleading — + // double-checked via ilspycmd against + // `AndroidView(...,Composer,II)V`: + // `p4` = Kotlin's `$changed` + // `_changed` = Kotlin's `$default` + // (Kotlin lowering always emits `$changed` before + // `$default`.) + p4: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidViewFactoryAdapter.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidViewFactoryAdapter.cs new file mode 100644 index 000000000..e75581ee6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidViewFactoryAdapter.cs @@ -0,0 +1,21 @@ +using Android.Content; +using Android.Runtime; +using Kotlin.Jvm.Functions; +using AView = Android.Views.View; + +namespace AndroidX.Compose; + +/// +/// Function1<Context, View> JCW used by +/// as Compose's factory argument. +/// +[Register("net/compose/AndroidViewFactoryAdapter")] +internal sealed class AndroidViewFactoryAdapter : Java.Lang.Object, IFunction1 +{ + readonly Func _factory; + + public AndroidViewFactoryAdapter(Func factory) => _factory = factory; + + public Java.Lang.Object Invoke(Java.Lang.Object? p0) => + _factory((Context)p0!); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidViewUpdateAdapter.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidViewUpdateAdapter.cs new file mode 100644 index 000000000..c6286db19 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AndroidViewUpdateAdapter.cs @@ -0,0 +1,29 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; +using AView = Android.Views.View; + +namespace AndroidX.Compose; + +/// +/// Function1<View, Unit> JCW used by +/// as Compose's optional update +/// argument. +/// +[Register("net/compose/AndroidViewUpdateAdapter")] +internal sealed class AndroidViewUpdateAdapter : Java.Lang.Object, IFunction1 +{ + static Java.Lang.Object? s_unit; + + readonly Action _update; + + public AndroidViewUpdateAdapter(Action update) => _update = update; + + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + _update((AView)p0!); + // Kotlin `Unit`. Returning null would fault inside Compose's + // adapter; a singleton wrapper keeps allocations off the hot + // recomposition path. + return s_unit ??= Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnimatedContent.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnimatedContent.cs new file mode 100644 index 000000000..2d70587d2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnimatedContent.cs @@ -0,0 +1,94 @@ +using AndroidX.Compose.Animation; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// AnimatedContent — animates between content keyed by +/// . When changes, +/// the previous content animates out as the new content animates in, +/// using Compose's default ContentTransform (a fade + size +/// animation). +/// +/// +/// The state type. Same constraints as : +/// a primitive boxable to a wrapper, +/// or a subclass. +/// +/// +/// Mirror of the bound (targetState: T, ...) overload of +/// androidx.compose.animation.AnimatedContentKt.AnimatedContent — +/// no JNI bridge needed; every parameter except targetState +/// and content defaults to Kotlin's value. +/// The content callback receives the state +/// value Compose is currently rendering — during a transition both +/// the previous and new values are rendered simultaneously while one +/// animates out and the other animates in. The +/// AnimatedContentScope receiver (which exposes +/// Modifier.animateEnterExit) is not surfaced in v1. +/// +/// +/// +/// var step = Remember(() => new MutableNumberState<int>(0)); +/// new AnimatedContent<int>( +/// targetState: step.Value, +/// content: i => new Text($"Step {i}")) +/// +/// +public sealed class AnimatedContent : ComposableNode +{ + readonly T _targetState; + readonly Func _content; + + /// Build an AnimatedContent keyed on with per-state content. + /// The state value that drives the transition. + /// Builds the to display for a given state value. Invoked once per active state during the transition. + public AnimatedContent(T targetState, Func content) + { + _targetState = targetState; + ArgumentNullException.ThrowIfNull(content); + _content = content; + } + + /// The state value passed to the underlying AnimatedContent. + public T TargetState => _targetState; + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var boxed = MutableState.ToJava(_targetState); + + // The Function4 + // content slot delivers (scope, boxedState, composer, $changed). + // We discard the scope (animateEnterExit not surfaced in v1) + // and unbox the second arg back to T. + var content = ComposableLambdas.Wrap4(composer, (scope, p1, c) => + { + T value = MutableState.FromJava(p1); + _content(value).Render(c); + }); + + // $default bit positions (Kotlin source order): + // 0 = targetState (always provided → cleared) + // 1 = modifier (cleared when caller set it) + // 2 = transitionSpec (left set → use Compose default) + // 3 = contentAlignment (left set → use Compose default) + // 4 = label (left set → use Compose default) + // 5 = contentKey (left set → use Compose default) + // 6 = content (always provided → cleared) + int defaults = (1 << 2) | (1 << 3) | (1 << 4) | (1 << 5); + if (modifier is null) defaults |= 1 << 1; + + AnimatedContentKt.AnimatedContent( + targetState: boxed, + modifier: modifier, + transitionSpec: null, + contentAlignment: null, + label: null, + contentKey: null, + content: content, + _composer: composer, + p8: 0, // $changed + _changed: defaults); // $default + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnimatedVisibility.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnimatedVisibility.cs new file mode 100644 index 000000000..298a45e5a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnimatedVisibility.cs @@ -0,0 +1,75 @@ +using AndroidX.Compose.Animation; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// AnimatedVisibility — animates the appearance and disappearance +/// of its content as toggles. By default Compose +/// fades and expands/shrinks the content; pass enter +/// and exit to the constructor to choose different +/// transitions (build them with the factories +/// — Transitions.FadeIn(), Transitions.SlideInVertically(), +/// etc., or combine them with the + operator from the bound +/// Compose API). +/// +/// +/// Children are rendered through Compose's AnimatedVisibilityScope +/// receiver. The scope's helpers (Modifier.animateEnterExit) are +/// not exposed in v1 — children render with the discarded scope. +/// Mirror of the bound +/// +/// overload — no JNI bridge. Enter and exit must be ctor parameters +/// (not init-only properties) because +/// is a — C# disallows mixing +/// collection initializers with object initializers. +/// +public sealed class AnimatedVisibility : ComposableContainer +{ + readonly bool _visible; + readonly EnterTransition? _enter; + readonly ExitTransition? _exit; + + /// Build an AnimatedVisibility container with the given visibility and optional transitions. + /// When the content is shown (entering if previously hidden); when the content is hidden (exiting if previously shown). + /// Optional enter transition (fade-in, slide-in, scale-in, …) applied when flips to . Build via the factories. When (default), Compose uses its own fade-plus-expand default. + /// Optional exit transition applied when flips to . Build via the factories. When (default), Compose uses its own fade-plus-shrink default. + public AnimatedVisibility(bool visible, EnterTransition? enter = null, ExitTransition? exit = null) + { + _visible = visible; + _enter = enter; + _exit = exit; + } + + /// Current visibility passed to the underlying Compose AnimatedVisibility. + public bool Visible => _visible; + + /// The enter transition supplied at construction, or for the Compose default. + public EnterTransition? Enter => _enter; + + /// The exit transition supplied at construction, or for the Compose default. + public ExitTransition? Exit => _exit; + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var content = ComposableLambdas.Wrap3(composer, RenderChildren); + + // We never pass `label`, so it always stays set. + var defaults = AnimatedVisibilityDefault.Label; + if (modifier is null) defaults |= AnimatedVisibilityDefault.Modifier; + if (_enter is null) defaults |= AnimatedVisibilityDefault.Enter; + if (_exit is null) defaults |= AnimatedVisibilityDefault.Exit; + + AnimatedVisibilityKt.AnimatedVisibility( + visible: _visible, + modifier: modifier, + enter: _enter, + exit: _exit, + label: null, + content: content, + _composer: composer, + p7: 0, // $changed + _changed: (int)defaults); // $default + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedString.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedString.cs new file mode 100644 index 000000000..f8b9d3a18 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedString.cs @@ -0,0 +1,50 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around androidx.compose.ui.text.AnnotatedString — +/// Compose's rich-text type. Holds a plain string body plus a list of +/// ranges and link annotations. Build instances +/// via ; pass them to +/// to render. +/// +/// +/// This is a thin shell over the bound +/// . We don't +/// expose the binding type directly so the public surface stays in the +/// AndroidX.Compose namespace alongside and +/// , and so future generator-emitted +/// bridges can accept it via the standard reference-type handle path. +/// +public sealed class AnnotatedString : Java.Lang.Object +{ + AnnotatedString(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + internal AnnotatedString(AndroidX.Compose.UI.Text.AnnotatedString binding) + : base(binding.Handle, JniHandleOwnership.DoNotTransfer) + { + GC.KeepAlive(binding); + } + + /// + /// Construct an with no styling — a + /// plain string. Equivalent to AnnotatedString(text) in + /// Kotlin. + /// + public AnnotatedString(string text) + : this(new AndroidX.Compose.UI.Text.AnnotatedString( + text, new List())) + { + } + + /// The raw character content of this string, ignoring spans. + public string Text => Binding().Text; + + /// Number of characters in the string. + public int Length => Binding().Length; + + AndroidX.Compose.UI.Text.AnnotatedString Binding() => + Java.Lang.Object.GetObject(Handle, JniHandleOwnership.DoNotTransfer)!; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedStringBuilder.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedStringBuilder.cs new file mode 100644 index 000000000..884ac1632 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedStringBuilder.cs @@ -0,0 +1,106 @@ +namespace AndroidX.Compose; + +/// +/// Mutable builder for . Mirrors Kotlin's +/// androidx.compose.ui.text.AnnotatedString.Builder + +/// buildAnnotatedString { ... } DSL. Pushed styles and links +/// form a nested stack — every / +/// pairs with a . +/// +/// +/// +/// var b = new AnnotatedStringBuilder(); +/// b.Append("Hello "); +/// b.PushStyle(new SpanStyle { FontWeight = FontWeight.Bold }); +/// b.Append("world"); +/// b.Pop(); +/// AnnotatedString result = b.ToAnnotatedString(); +/// +/// +public sealed class AnnotatedStringBuilder +{ + readonly AndroidX.Compose.UI.Text.AnnotatedString.Builder _builder = new(); + + /// Current length of the builder's text buffer. + public int Length => _builder.Length; + + /// Append a plain run of text at the current cursor. + public AnnotatedStringBuilder Append(string text) + { + _builder.Append(text); + return this; + } + + /// + /// Push a onto the style stack — every + /// character appended until the matching takes + /// these style overrides on top of any outer styles. + /// + /// The stack index of the pushed style, mirroring the + /// Kotlin builder. Callers can pass this to + /// to pop down to a specific level. + public int PushStyle(SpanStyle style) + { + ArgumentNullException.ThrowIfNull(style); + return _builder.PushStyle(style.Build()); + } + + /// + /// Push a onto the link stack — every + /// character appended until the matching becomes + /// part of the link's click target. + /// + /// The stack index of the pushed annotation. + public int PushLink(LinkAnnotation link) + { + ArgumentNullException.ThrowIfNull(link); + return _builder.PushLink(link.Binding); + } + + /// Pop the most recently pushed style or link. + public void Pop() => _builder.Pop(); + + /// + /// Pop the style/link stack down to (but not including) the given + /// index — i.e. discard everything pushed AFTER that index. + /// + public void Pop(int index) => _builder.Pop(index); + + /// + /// Attach a non-rendering string annotation to a previously + /// appended range. Used by clients to look up metadata at tapped + /// offsets without affecting the displayed text. + /// + public void AddStringAnnotation(string tag, string annotation, int start, int end) => + _builder.AddStringAnnotation(tag, annotation, start, end); + + /// Apply a to a specific range. + public void AddStyle(SpanStyle style, int start, int end) + { + ArgumentNullException.ThrowIfNull(style); + _builder.AddStyle(style.Build(), start, end); + } + + /// Attach a to a specific range. + public void AddLink(LinkAnnotation link, int start, int end) + { + ArgumentNullException.ThrowIfNull(link); + switch (link.Binding) + { + case AndroidX.Compose.UI.Text.LinkAnnotation.Url url: + _builder.AddLink(url, start, end); + break; + case AndroidX.Compose.UI.Text.LinkAnnotation.Clickable c: + _builder.AddLink(c, start, end); + break; + default: + throw new NotSupportedException( + $"Unknown LinkAnnotation subtype '{link.Binding?.GetType().FullName ?? "null"}'. " + + "Add an explicit AddLink overload when Compose introduces a new variant."); + } + } + + /// Materialize the immutable . + public AnnotatedString ToAnnotatedString() => + new AnnotatedString(_builder.ToAnnotatedString()); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedText.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedText.cs new file mode 100644 index 000000000..99f39d4ba --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AnnotatedText.cs @@ -0,0 +1,101 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 rendering an — +/// rich text with per-span styling and clickable link/mention +/// annotations. The companion type to for +/// plain-string content; use it whenever the body has inline markup +/// (bold runs, links, mentions, code spans, …) the caller pre-built +/// via . +/// +/// +/// Hand-written instead of source-generated: the source-generated +/// facade emits a single Render from one +/// bridge declaration, so it can't be extended with a second +/// Text(AnnotatedString) constructor that routes to a different +/// JNI bridge (the AnnotatedString overload's mangled +/// Text-IbK3jfQ signature carries an extra Map slot for +/// inline content). Modelled as a sibling facade rather than demoting +/// to hand-written — matches the precedent set by +/// exposing both vector-asset and resource-id paths. +/// +public sealed class AnnotatedText : ComposableNode +{ + readonly AnnotatedString _text; + + /// Construct a rich-text node from an . + public AnnotatedText(AnnotatedString text) + { + ArgumentNullException.ThrowIfNull(text); + _text = text; + } + + /// Foreground text color (overrides any default from the enclosing theme). + public Color? Color { get; set; } + + /// Font size (sp). + public Sp? FontSize { get; set; } + + /// Italic / Normal. + public FontStyle? FontStyle { get; set; } + + /// Font weight. + public FontWeight? FontWeight { get; set; } + + /// Font family. + public FontFamily? FontFamily { get; set; } + + /// Letter spacing (sp). + public Sp? LetterSpacing { get; set; } + + /// Text decoration. + public TextDecoration? Decoration { get; set; } + + /// Horizontal text alignment. + public TextAlign? Align { get; set; } + + /// Line height (sp). + public Sp? LineHeight { get; set; } + + /// Overflow strategy when text exceeds its bounds. + public TextOverflow? Overflow { get; set; } + + /// Whether soft line wrapping is enabled. + public bool? SoftWrap { get; set; } + + /// Maximum visible line count. + public int? MaxLines { get; set; } + + /// Minimum visible line count. + public int? MinLines { get; set; } + + /// Base TextStyle (explicit slots above still win — Compose's + /// Text merges direct params over the style). Use for slots with no direct + /// param, e.g. lineBreak. + public AndroidX.Compose.UI.Text.TextStyle? Style { get; set; } + + /// + public override void Render(IComposer composer) + { + ComposeBridges.TextAnnotated( + text: _text, + modifier: BuildModifier(), + color: Color, + fontSize: FontSize, + fontStyle: FontStyle, + fontWeight: FontWeight, + fontFamily: FontFamily, + letterSpacing: LetterSpacing, + decoration: Decoration, + align: Align, + lineHeight: LineHeight, + overflow: Overflow, + softWrap: SoftWrap, + maxLines: MaxLines, + minLines: MinLines, + style: Style, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Arrangement.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Arrangement.cs new file mode 100644 index 000000000..b4972b274 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Arrangement.cs @@ -0,0 +1,126 @@ +using BindingArrangement = AndroidX.Compose.Foundation.Layout.Arrangement; + +namespace AndroidX.Compose; + +/// +/// Compose's Arrangement — how a or +/// distributes its children along its main axis. +/// Pass one of the static factories to +/// 's horizontalArrangement or +/// 's verticalArrangement: +/// +/// +/// new Row(horizontalArrangement: Arrangement.SpaceBetween) { /* … */ } +/// new Column(verticalArrangement: Arrangement.SpacedBy(8)) { /* … */ } +/// +/// +/// Each factory wraps the corresponding member of the bound +/// androidx.compose.foundation.layout.Arrangement Kotlin singleton: +/// +/// / — horizontal-only. +/// / — vertical-only. +/// , , +/// , , +/// / — usable in +/// either orientation. +/// +/// Passing a horizontal-only arrangement to (or +/// vice-versa) throws at the +/// container's constructor — the orientation check is fail-fast so +/// mistakes surface at the call site, not during composition. +/// +public sealed class Arrangement +{ + static readonly Lazy _start = + new(() => new Arrangement(BindingArrangement.Instance.Start)); + static readonly Lazy _end = + new(() => new Arrangement(BindingArrangement.Instance.End)); + static readonly Lazy _top = + new(() => new Arrangement(BindingArrangement.Instance.Top)); + static readonly Lazy _bottom = + new(() => new Arrangement(BindingArrangement.Instance.Bottom)); + static readonly Lazy _center = + new(() => new Arrangement(BindingArrangement.Instance.Center)); + static readonly Lazy _spaceBetween = + new(() => new Arrangement(BindingArrangement.Instance.SpaceBetween)); + static readonly Lazy _spaceAround = + new(() => new Arrangement(BindingArrangement.Instance.SpaceAround)); + static readonly Lazy _spaceEvenly = + new(() => new Arrangement(BindingArrangement.Instance.SpaceEvenly)); + + /// Pack children toward the start of the main axis. Horizontal only. + public static Arrangement Start => _start.Value; + + /// Pack children toward the end of the main axis. Horizontal only. + public static Arrangement End => _end.Value; + + /// Pack children toward the top of the main axis. Vertical only. + public static Arrangement Top => _top.Value; + + /// Pack children toward the bottom of the main axis. Vertical only. + public static Arrangement Bottom => _bottom.Value; + + /// Center the children along the main axis. Either orientation. + public static Arrangement Center => _center.Value; + + /// + /// Place children so the first sits at the start, the last at the + /// end, and the rest are evenly spaced between them. Either orientation. + /// + public static Arrangement SpaceBetween => _spaceBetween.Value; + + /// + /// Place children with equal space surrounding each one — including a + /// half-space at the start and end. Either orientation. + /// + public static Arrangement SpaceAround => _spaceAround.Value; + + /// + /// Place children with equal space between them — including full + /// equal-sized gaps at the start and end. Either orientation. + /// + public static Arrangement SpaceEvenly => _spaceEvenly.Value; + + /// + /// Place children with density-independent + /// pixels of space between consecutive items. Either orientation. + /// Replaces the common Spacer().Width(dp) / + /// Spacer().Height(dp) idiom between every pair of children. + /// + /// + /// Kept alongside so a literal call like + /// SpacedBy(8) still resolves to the overload + /// without going through the implicit int → Dp conversion. + /// + public static Arrangement SpacedBy(int dp) => + new(BindingArrangement.Instance.SpacedBy((float)dp)); + + /// + /// Place children with of space between + /// consecutive items. Either orientation. Accepts a typed + /// — useful when a layout constant is already hoisted + /// (Arrangement.SpacedBy(padding)) or when constructed via + /// arithmetic (Arrangement.SpacedBy(basePad * 2)). + /// + public static Arrangement SpacedBy(Dp dp) => + new(BindingArrangement.Instance.SpacedBy(dp.Value)); + + internal BindingArrangement.IHorizontal? Horizontal { get; } + internal BindingArrangement.IVertical? Vertical { get; } + + Arrangement(BindingArrangement.IHorizontal horizontal) + { + Horizontal = horizontal; + } + + Arrangement(BindingArrangement.IVertical vertical) + { + Vertical = vertical; + } + + Arrangement(BindingArrangement.IHorizontalOrVertical both) + { + Horizontal = both; + Vertical = both; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AssistChip.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AssistChip.cs new file mode 100644 index 000000000..216df4812 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/AssistChip.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 AssistChip. Label is required; +/// LeadingIcon and TrailingIcon are optional slots: +/// +/// new AssistChip(onClick: ...) { Label = new Text("Filter") } +/// +/// +public sealed partial class AssistChip; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BackHandler.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BackHandler.cs new file mode 100644 index 000000000..9462da7a6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BackHandler.cs @@ -0,0 +1,36 @@ +namespace AndroidX.Compose; + +/// +/// Intercepts the system back press from within a composition. Wraps +/// Kotlin's androidx.activity.compose.BackHandler(enabled, onBack): +/// while this composable is active and enabled is +/// true, the activity's OnBackPressedDispatcher routes the +/// next back gesture to onBack instead of popping the +/// activity / navigator. The registration is added and removed +/// automatically as the composable enters and leaves the composition. +/// +/// +/// Use to collapse an in-pane selection / detail view before letting the +/// back press escape, or to confirm a destructive action: +/// +/// +/// var detailOpen = Remember(() => new MutableState<bool>(false)); +/// +/// new Column +/// { +/// new BackHandler( +/// onBack: () => detailOpen.Value = false, +/// enabled: detailOpen.Value), +/// // ...detail pane... +/// }; +/// +/// +public sealed partial class BackHandler +{ + /// + /// Convenience constructor that always intercepts back presses + /// (enabled: true). Equivalent to + /// new BackHandler(onBack, enabled: true). + /// + public BackHandler(Action onBack) : this(onBack, enabled: true) { } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Badge.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Badge.cs new file mode 100644 index 000000000..aac31dc50 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Badge.cs @@ -0,0 +1,12 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 Badge. A small colored marker, typically dropped +/// inside a slot. Children are the +/// badge content (usually a count), laid out in a RowScope: +/// +/// new Badge { new Text("3") } +/// +/// Pass no children for a plain dot. +/// +public sealed partial class Badge; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BadgedBox.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BadgedBox.cs new file mode 100644 index 000000000..4b05521b5 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BadgedBox.cs @@ -0,0 +1,15 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 BadgedBox. Wraps a Content node +/// (typically an ) and overlays a Badge +/// on its top-end corner: +/// +/// new BadgedBox +/// { +/// Badge = new Badge { new Text("3") }, +/// Content = new Icon(Resource.Drawable.ic_inbox, "Inbox"), +/// } +/// +/// +public sealed partial class BadgedBox; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BasicTextField.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BasicTextField.cs new file mode 100644 index 000000000..b036f2800 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BasicTextField.cs @@ -0,0 +1,80 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation BasicTextField (String overload) — the un-decorated text field: just the +/// editable text and cursor, with no Material container, indicator line, label, or placeholder. +/// Use it (plus a for background/padding) for a borderless input that blends +/// into its surroundings, e.g. a chat composer. Placeholder/affordances are the caller's to draw. +/// +/// +/// Hand-written rather than [ComposeFacade]-generated to keep the surface tiny: only +/// value/onValueChange/modifier/textStyle/singleLine are exposed; +/// every other Kotlin parameter (cursorBrush, decorationBox, keyboard options, …) keeps its default. +/// +public sealed class BasicTextField : ComposableNode +{ + readonly string? _value; + readonly System.Action? _onValueChange; + readonly UI.Text.Input.TextFieldValue? _tfv; + readonly System.Action? _onTfvChange; + + /// Optional TextStyle (Kotlin textStyle) — text color, size, weight, etc. + public TextStyle? TextStyle { get; set; } + + /// Optional IME config (Kotlin keyboardOptions) — keyboard type + the soft-keyboard + /// action key (e.g. ImeAction.Send). Null keeps Compose's default. + public AndroidX.Compose.Foundation.Text.KeyboardOptions? KeyboardOptions { get; set; } + + /// Optional per-IME-action callbacks (Kotlin keyboardActions) — e.g. an onSend handler + /// fired when the keyboard's Send key is pressed. Build via . + public AndroidX.Compose.Foundation.Text.KeyboardActions? KeyboardActions { get; set; } + + /// Whether the field is constrained to a single line. + public bool? SingleLine { get; set; } + + /// String-overload ctor — pass the current value and an edit callback. + public BasicTextField(string value, System.Action onValueChange) + { + _value = value; + _onValueChange = onValueChange; + } + + /// Bind to a of so edits recompose. + public BasicTextField(MutableState state) + : this(state.Value ?? string.Empty, v => state.Value = v) + { + } + + /// + /// TextFieldValue-overload ctor — the callback hands back the full + /// (text + selection + composition), and + /// caller-supplied selection is honoured on render (programmatic caret placement, + /// e.g. insert-at-cursor). Build values via ComposeExtensions.NewTextFieldValue. + /// + public BasicTextField(UI.Text.Input.TextFieldValue value, System.Action onValueChange) + { + _tfv = value; + _onTfvChange = onValueChange; + } + + /// + public override void Render(IComposer composer) + { + if (_tfv is not null) + { + // Compose hands the fresh Kotlin TextFieldValue peer to the callback; the peer + // registry maps it back to the bound binding type (same pattern as the Material + // TextFieldWithValue path). + var onTfvChange = new ComposableLambda1(v => _onTfvChange!( + Java.Lang.Object.GetObject( + v!.Handle, Android.Runtime.JniHandleOwnership.DoNotTransfer)!)); + ComposeBridges.BasicTextFieldWithValue(_tfv, onTfvChange, BuildModifier(), TextStyle?.Build(), KeyboardOptions, KeyboardActions, SingleLine, composer); + return; + } + + var onValueChange = new ComposableLambda1(v => _onValueChange!(v?.ToString() ?? string.Empty)); + ComposeBridges.BasicTextField(_value!, onValueChange, BuildModifier(), TextStyle?.Build(), KeyboardOptions, KeyboardActions, SingleLine, composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BottomAppBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BottomAppBar.cs new file mode 100644 index 000000000..9004d6952 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BottomAppBar.cs @@ -0,0 +1,18 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 BottomAppBar. The actions slot is laid out in a +/// RowScope and filled from this bar's children; +/// is an optional +/// trailing slot: +/// +/// new BottomAppBar +/// { +/// FloatingActionButton = new FloatingActionButton(onClick: ...) { ... }, +/// +/// new IconButton(onClick: ...) { new Icon(painter, "Search") }, +/// new IconButton(onClick: ...) { new Icon(painter, "Settings") }, +/// } +/// +/// +public sealed partial class BottomAppBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BottomSheetScaffold.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BottomSheetScaffold.cs new file mode 100644 index 000000000..e4d6f9575 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BottomSheetScaffold.cs @@ -0,0 +1,140 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 BottomSheetScaffold. Hosts a persistent bottom +/// sheet alongside a primary content area, plus optional top bar and +/// snackbar slots. Pass an optional to +/// configure the underlying SheetState; set +/// to veto user-initiated transitions. +/// +/// +/// Stays hand-written: the Kotlin signature has two non-nullable +/// @Composable lambdas (sheetContent and content), +/// which the source generator's hybrid-container path doesn't yet +/// support. The new / +/// wiring mirrors the generator's +/// [StateHolder] + [ConfirmStateChange] contract so a future +/// migration is mechanical. +/// +/// +/// var sheet = Remember(() => new SheetStateHolder(skipPartiallyExpanded: false)); +/// new BottomSheetScaffold(sheetState: sheet) +/// { +/// ConfirmValueChange = v => v != SheetValue.Hidden, +/// SheetContent = new Column { new Text("Sheet") }, +/// TopBar = new TopAppBar { Title = new Text("App") }, +/// new Text("Main content"), +/// } +/// +/// +public sealed class BottomSheetScaffold : ComposableContainer +{ + readonly SheetStateHolder? _sheetState; + + // One JCW per scaffold instance — its JNI identity is part of the + // Kotlin `remember` cache key, so allocating fresh each render + // would invalidate the cached state holder. Callback is read on + // every Invoke, so callers can mutate ConfirmValueChange freely. + readonly SheetValueConfirmStateChange _confirmValueChangeAdapter = new(); + + /// + /// Construct the scaffold. Pass a + /// to opt into the skipPartiallyExpanded setting and gain + /// imperative control via + /// / . + /// + public BottomSheetScaffold(SheetStateHolder? sheetState = null) + { + _sheetState = sheetState; + } + + /// Required: the persistent bottom-sheet content. + public ComposableNode? SheetContent { get; set; } + + /// Optional: the sheet's drag handle. + public ComposableNode? SheetDragHandle { get; set; } + + /// Optional: persistent top bar above the main content. + public ComposableNode? TopBar { get; set; } + + /// + /// Veto callback consulted on every sheet transition. Return + /// false to refuse the transition (gesture or programmatic). + /// null behaves as Kotlin's default { true } — + /// every transition allowed. + /// + public Func? ConfirmValueChange { get; set; } + + public override void Render(IComposer composer) + { + if (SheetContent is null) + throw new InvalidOperationException( + "BottomSheetScaffold.SheetContent is required."); + + // Update the JCW callback BEFORE the Remember calls. Compose + // captures the IFunction1's JNI peer at first composition; we + // keep the peer's identity stable but read Callback fresh on + // every Invoke so the user can mutate ConfirmValueChange. + _confirmValueChangeAdapter.Callback = ConfirmValueChange; + + SheetState? sheetState = null; + if (_sheetState is not null || ConfirmValueChange is not null) + { + // Always call RememberStandardBottomSheetState on every + // recomposition — Kotlin's slot-table-keyed remember{} + // returns the same instance, but skipping the call would + // shift slot positions for the RememberBottomSheetScaffoldState + // call that follows. Cache identity at the wrapper level + // (Jvm field) but keep the call shape stable. + sheetState = BottomSheetScaffoldKt.RememberStandardBottomSheetState( + initialValue: SheetValue.PartiallyExpanded, + confirmValueChange: _confirmValueChangeAdapter, + skipHiddenState: true, + _composer: composer, + p4: 0, + _changed: 0); + if (_sheetState is not null) + _sheetState.Jvm = sheetState; + } + + // Bound C# call — RememberBottomSheetScaffoldState is NOT stripped. + // _changed (= Kotlin's $default) bit 0 = bottomSheetState defaulted, + // bit 1 = snackbarHostState defaulted. We never wire snackbar so + // bit 1 stays set; bit 0 clears only when we provided a sheetState. + var changed = sheetState is null ? 3 : 2; + var scaffoldState = BottomSheetScaffoldKt.RememberBottomSheetScaffoldState( + bottomSheetState: sheetState, + snackbarHostState: null, + _composer: composer, + p3: 0, + _changed: changed); + + var sheet = ComposableLambdas.Wrap3(composer, c => SheetContent.Render(c)); + var content = ComposableLambdas.Wrap3(composer, c => RenderChildren(c)); + + var dragHandle = SheetDragHandle is null ? null + : ComposableLambdas.Wrap2(composer, c => SheetDragHandle.Render(c)); + var topBar = TopBar is null ? null + : ComposableLambdas.Wrap2(composer, c => TopBar.Render(c)); + + int defaults = (int)BottomSheetScaffoldDefault.All; + var modifier = BuildModifier(); + if (modifier is not null) defaults &= ~(int)BottomSheetScaffoldDefault.Modifier; + if (dragHandle is not null) defaults &= ~(int)BottomSheetScaffoldDefault.SheetDragHandle; + if (topBar is not null) defaults &= ~(int)BottomSheetScaffoldDefault.TopBar; + + ComposeBridges.BottomSheetScaffold( + sheetContent: sheet, + modifier: modifier, + scaffoldState: ((Java.Lang.Object)scaffoldState).Handle, + sheetDragHandle: dragHandle, + topBar: topBar, + snackbarHost: null, + content: content, + defaults: defaults, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Box.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Box.cs new file mode 100644 index 000000000..24fb9da3a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Box.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Foundation Box composable — stacks its children on top of +/// each other (later children draw above earlier ones). Useful for +/// overlays, badges, and absolute positioning via alignment-modifier +/// chains. Always uses the 7-param Kotlin overload so children are +/// rendered through the content slot. +/// +public sealed partial class Box; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BoxConstraints.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BoxConstraints.cs new file mode 100644 index 000000000..c44a3d61d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BoxConstraints.cs @@ -0,0 +1,46 @@ +namespace AndroidX.Compose; + +/// +/// Constraints surfaced to 's content +/// callback. Mirrors androidx.compose.foundation.layout.BoxWithConstraintsScope: +/// / / +/// / are layout dp +/// values measured during the current composition pass. Use them to +/// pick a different child layout based on the available space (the +/// idiomatic Compose alternative to runtime "is this a phone or a +/// tablet" checks). +/// +/// +/// +/// / may be +/// when the parent imposes no +/// upper bound — branch on before +/// comparing against a fixed dp threshold. +/// +/// +/// Constraints are only valid for the call that produced them; do not +/// store the struct and read it after composition completes. +/// +/// +public readonly struct BoxConstraints +{ + /// Minimum width the content may take, in dp. Always finite. + public float MinWidth { get; } + + /// Maximum width the content may take, in dp. May be . + public float MaxWidth { get; } + + /// Minimum height the content may take, in dp. Always finite. + public float MinHeight { get; } + + /// Maximum height the content may take, in dp. May be . + public float MaxHeight { get; } + + internal BoxConstraints(float minWidth, float maxWidth, float minHeight, float maxHeight) + { + MinWidth = minWidth; + MaxWidth = maxWidth; + MinHeight = minHeight; + MaxHeight = maxHeight; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BoxWithConstraints.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BoxWithConstraints.cs new file mode 100644 index 000000000..268480308 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BoxWithConstraints.cs @@ -0,0 +1,71 @@ +using Android.Runtime; +using AndroidX.Compose.Foundation.Layout; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation BoxWithConstraints — a variant +/// that hands its content the layout constraints (max/min width and +/// height in dp) it has been given by its parent. Use this when the +/// child layout depends on the available space, e.g. switching between +/// a single-column phone layout and a two-pane tablet layout. +/// +/// +/// new BoxWithConstraints(c => +/// c.MaxWidth < 600 +/// ? new Column { /* compact */ } +/// : new Row { /* wide */ }) +/// +/// +/// The callback runs every composition pass, so it must be cheap; if +/// the produced node depends on more than the constraints (counters, +/// remembered state) read those values from inside the callback rather +/// than capturing them in the outer scope. +/// +public sealed class BoxWithConstraints : ComposableNode +{ + readonly Func _content; + + /// + /// Build a that lays out the + /// composable returned from . The callback + /// receives the current layout's . + /// + public BoxWithConstraints(Func content) + { + ArgumentNullException.ThrowIfNull(content); + _content = content; + } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var lambda = ComposableLambdas.Wrap3(composer, (scopeHandle, comp) => + { + // Scope is delivered as a raw JNI handle; wrap with + // DoNotTransfer so the local ref is freed by Compose, then + // cast to the typed binding interface. + var scope = Java.Lang.Object.GetObject( + scopeHandle, JniHandleOwnership.DoNotTransfer)!; + var constraints = new BoxConstraints( + minWidth: scope.MinWidth, + maxWidth: scope.MaxWidth, + minHeight: scope.MinHeight, + maxHeight: scope.MaxHeight); + _content(constraints).Render(comp); + }); + + int defaults = (int)BoxWithConstraintsDefault.All; + if (modifier is not null) defaults &= ~(int)BoxWithConstraintsDefault.Modifier; + + BoxWithConstraintsKt.BoxWithConstraints( + modifier: modifier, + contentAlignment: null, + propagateMinConstraints: false, + content: lambda, + _composer: composer, + p5: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Brush.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Brush.cs new file mode 100644 index 000000000..67c3a2a12 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Brush.cs @@ -0,0 +1,153 @@ +using Android.Runtime; +using BoundBrush = AndroidX.Compose.UI.Graphics.Brush; +using BoundSolidColor = AndroidX.Compose.UI.Graphics.SolidColor; + +namespace AndroidX.Compose; + +/// +/// Static factory entrypoint for Kotlin's +/// androidx.compose.ui.graphics.Brush — the value passed to fill +/// / stroke slots that want richer paint than a single +/// . The instances returned by these factories are +/// bound peers and can +/// be passed straight to Modifier.Background(brush, …) / +/// Modifier.Border(width, brush, …). +/// +/// +/// +/// Mirrors Kotlin's Brush.linearGradient(…), +/// Brush.horizontalGradient(…), etc. — Kotlin exposes these via +/// Brush.Companion, but the binder doesn't surface a public +/// accessor for the singleton (and the gradient factories' inline +/// Color / TileMode params come through under mangled +/// names like LinearGradient_mHitzGk), so this class wraps the +/// Companion call into a friendly C# entrypoint. +/// +/// +/// Brush instances are immutable — keep one in a field (or +/// composer.Remember(() => Brush.HorizontalGradient(…))) +/// when the same brush renders every frame; otherwise let the +/// composition build a fresh instance each pass. +/// +/// +public static class Brush +{ + /// + /// SolidColor(color) — a brush that paints a single flat + /// color. Mostly useful for APIs that demand a brush rather than a + /// ; for the typical "fill with a color" case + /// prefer the Modifier.Background(Color) overload, which + /// avoids the extra JNI allocation. + /// + public static BoundBrush SolidColor(Color color) => + Java.Lang.Object.GetObject( + ComposeBridges.BrushSolidColor(color), + JniHandleOwnership.TransferLocalRef)!; + + /// + /// Brush.linearGradient(colors, start, end, tileMode) — + /// gradient interpolating along the line from + /// to . Colors are + /// spaced evenly along the line; pass + /// and for + /// a fill-the-region diagonal gradient (matches Kotlin's + /// Brush.linearGradient(colors) default). + /// + public static BoundBrush LinearGradient( + Color[] colors, + Offset start, + Offset end, + TileMode tileMode = TileMode.Clamp) => + ComposeBridges.BrushCompanion().LinearGradient_mHitzGk( + ComposeBridges.ToColorList(colors), start.Packed, end.Packed, (int)tileMode); + + /// + /// Convenience overload — matches Kotlin's + /// Brush.linearGradient(colors) default of + /// start = Offset.Zero, end = Offset.Infinite, + /// tileMode = Clamp. resolves to + /// the bottom-right corner of the drawing region at paint time, so + /// this produces a top-left-to-bottom-right diagonal gradient. + /// + public static BoundBrush LinearGradient(params Color[] colors) => + LinearGradient(colors, Offset.Zero, Offset.Infinite, TileMode.Clamp); + + /// + /// Brush.horizontalGradient(colors, startX, endX, tileMode) + /// — gradient along the X axis. The Y component spans the full + /// height of whatever box this brush is painted onto. + /// + public static BoundBrush HorizontalGradient( + Color[] colors, + float startX = 0f, + float endX = float.PositiveInfinity, + TileMode tileMode = TileMode.Clamp) => + ComposeBridges.BrushCompanion().HorizontalGradient_8A_3gB4( + ComposeBridges.ToColorList(colors), startX, endX, (int)tileMode); + + /// + /// Brush.horizontalGradient(colors) — Kotlin default + /// (full-width sweep, ). + /// + public static BoundBrush HorizontalGradient(params Color[] colors) => + HorizontalGradient(colors, 0f, float.PositiveInfinity, TileMode.Clamp); + + /// + /// Brush.verticalGradient(colors, startY, endY, tileMode) + /// — gradient along the Y axis. The X component spans the full + /// width of whatever box this brush is painted onto. + /// + public static BoundBrush VerticalGradient( + Color[] colors, + float startY = 0f, + float endY = float.PositiveInfinity, + TileMode tileMode = TileMode.Clamp) => + ComposeBridges.BrushCompanion().VerticalGradient_8A_3gB4( + ComposeBridges.ToColorList(colors), startY, endY, (int)tileMode); + + /// + /// Brush.verticalGradient(colors) — Kotlin default + /// (full-height sweep, ). + /// + public static BoundBrush VerticalGradient(params Color[] colors) => + VerticalGradient(colors, 0f, float.PositiveInfinity, TileMode.Clamp); + + /// + /// Brush.radialGradient(colors, center, radius, tileMode) + /// — gradient radiating outward from to + /// . Pass + /// for to centre the gradient on the + /// painted box. + /// + public static BoundBrush RadialGradient( + Color[] colors, + Offset center, + float radius = float.PositiveInfinity, + TileMode tileMode = TileMode.Clamp) => + ComposeBridges.BrushCompanion().RadialGradient_P_Vx_Ks( + ComposeBridges.ToColorList(colors), center.Packed, radius, (int)tileMode); + + /// + /// Brush.radialGradient(colors) — auto-centred, + /// auto-sized, . + /// + public static BoundBrush RadialGradient(params Color[] colors) => + RadialGradient(colors, Offset.Unspecified, float.PositiveInfinity, TileMode.Clamp); + + /// + /// Brush.sweepGradient(colors, center) — angular gradient + /// that wraps around through 360°. Pass + /// for + /// to centre on the painted box. + /// + public static BoundBrush SweepGradient(Color[] colors, Offset center) => + ComposeBridges.BrushCompanion().SweepGradient_Uv8p0NA( + ComposeBridges.ToColorList(colors), center.Packed); + + /// + /// Brush.sweepGradient(colors) — auto-centred angular + /// gradient sweeping through 360°. + /// + public static BoundBrush SweepGradient(params Color[] colors) => + SweepGradient(colors, Offset.Unspecified); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BrushBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BrushBridges.cs new file mode 100644 index 000000000..c82d3c2b9 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/BrushBridges.cs @@ -0,0 +1,116 @@ +using System.Threading; +using Android.Runtime; +using BoundBrush = AndroidX.Compose.UI.Graphics.Brush; +using BoundColor = AndroidX.Compose.UI.Graphics.Color; + +namespace AndroidX.Compose; + +// Hand-written JNI for the two Compose-graphics symbols the +// Mono.Android binder strips: +// +// - `androidx.compose.ui.graphics.Color.box-impl(J)Color` — the +// Kotlin-synthetic boxing factory that turns a packed `long` into +// a `Color` object. The `Color` class itself is bound, but its +// ctor and `box-impl` static are skipped (value-class lowering). +// - `androidx.compose.ui.graphics.SolidColor.(J)V` — same +// reason; the ctor takes a value-class `Color`. +// +// Everything else (`Brush.Companion`'s gradient factories, +// `RectangleShapeKt.RectangleShape`) is bound and called directly +// from `Brush` / `Shape`. +internal static partial class ComposeBridges +{ + static BoundBrush.Companion? s_brushCompanion; + + // The Java.Lang.Class OBJECTS are retained — caching only .Handle left a global + // ref the runtime could invalidate once the wrapper was collected ("jclass is an + // invalid global reference" SIGABRT on the first gradient after a GC). + static Java.Lang.Class? s_colorClass; + static IntPtr s_color_boxImpl; + + static Java.Lang.Class? s_solidColorClass; + static IntPtr s_solidColor_ctor; + + // Lazy access to the `androidx.compose.ui.graphics.Brush$Companion` + // singleton — the binder exposes the type but not a public C# accessor + // for the Kotlin `Companion` static field, so we fetch it via JNI + // once and cache the bound peer. Subsequent gradient calls go + // straight through the bound `LinearGradient_mHitzGk`/etc. methods. + internal static BoundBrush.Companion BrushCompanion() + { + if (s_brushCompanion is not null) return s_brushCompanion; + IntPtr local = IntPtr.Zero; + try + { + IntPtr brushClass = Java.Lang.Class.FromType(typeof(BoundBrush)).Handle; + IntPtr fid = JNIEnv.GetStaticFieldID( + brushClass, "Companion", + "Landroidx/compose/ui/graphics/Brush$Companion;"); + local = JNIEnv.GetStaticObjectField(brushClass, fid); + return s_brushCompanion = Java.Lang.Object.GetObject( + local, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + if (local != IntPtr.Zero && s_brushCompanion is null) + JNIEnv.DeleteLocalRef(local); + } + } + + // Box a packed-long Color into a bound `BoundColor` peer via the + // Kotlin-synthetic `Color.box-impl(J)` static. Required because + // `BoundBrush.Companion.LinearGradient_mHitzGk(IList, ...)` + // (and every other gradient factory) takes a List of *boxed* Color + // objects — packed longs alone aren't acceptable. + internal static unsafe BoundColor BoxColor(long packed) + { + if (Volatile.Read(ref s_color_boxImpl) == IntPtr.Zero) + { + // Publication order matters: the guard is the method id, written LAST via + // Volatile so the class reference is visible before the fast path takes it. + var cls = Java.Lang.Class.FromType(typeof(BoundColor)); + var mid = JNIEnv.GetStaticMethodID( + cls.Handle, "box-impl", "(J)Landroidx/compose/ui/graphics/Color;"); + s_colorClass = cls; + Volatile.Write(ref s_color_boxImpl, mid); + } + var args = stackalloc JValue[1]; + args[0] = new JValue(packed); + IntPtr handle = JNIEnv.CallStaticObjectMethod( + s_colorClass!.Handle, s_color_boxImpl, args); + return Java.Lang.Object.GetObject( + handle, JniHandleOwnership.TransferLocalRef)!; + } + + // Build an `IList` from a managed `Color[]` for the + // gradient factories. `JavaList` is the Mono.Android wrapper + // over `java.util.ArrayList` — no further JNI required for `add`. + internal static IList ToColorList(Color[] colors) + { + ArgumentNullException.ThrowIfNull(colors); + if (colors.Length == 0) + throw new ArgumentException( + "Gradient must have at least one color stop.", nameof(colors)); + var list = new JavaList(); + foreach (var c in colors) + list.Add(BoxColor(c)); + return list; + } + + // `new androidx.compose.ui.graphics.SolidColor(Color)` — the ctor + // is stripped because its parameter is a value-class `Color`. + internal static unsafe IntPtr BrushSolidColor(long color) + { + if (Volatile.Read(ref s_solidColor_ctor) == IntPtr.Zero) + { + var cls = Java.Lang.Class.FromType( + typeof(AndroidX.Compose.UI.Graphics.SolidColor)); + var ctor = JNIEnv.GetMethodID(cls.Handle, "", "(J)V"); + s_solidColorClass = cls; + Volatile.Write(ref s_solidColor_ctor, ctor); + } + var args = stackalloc JValue[1]; + args[0] = new JValue(color); + return JNIEnv.NewObject(s_solidColorClass!.Handle, s_solidColor_ctor, args); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Button.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Button.cs new file mode 100644 index 000000000..d050a4206 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Button.cs @@ -0,0 +1,8 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 filled Button. Takes an onClick in its +/// constructor and uses collection-initializer syntax for content: +/// new Button(onClick: () => count++) { new Text("Tap") } +/// +public sealed partial class Button; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Card.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Card.cs new file mode 100644 index 000000000..c2cc6ef50 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Card.cs @@ -0,0 +1,12 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 non-clickable Card — a tonal surface with +/// rounded corners that lays its children out as a +/// . Children are added via collection-initializer +/// syntax: +/// +/// new Card { new Text("Title"), new Text("Subtitle") } +/// +/// +public sealed partial class Card; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CenterAlignedTopAppBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CenterAlignedTopAppBar.cs new file mode 100644 index 000000000..28b26bf8c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CenterAlignedTopAppBar.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 CenterAlignedTopAppBar — same shape as +/// but centers the Title horizontally +/// between the navigation icon and actions. Supports the same +/// ScrollBehavior slot — see for the +/// pairing with . +/// +public sealed partial class CenterAlignedTopAppBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Checkbox.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Checkbox.cs new file mode 100644 index 000000000..1ff1f4946 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Checkbox.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 Checkbox: +/// +/// new Checkbox(@checked: state.Value, onCheckedChange: v => state.Value = v) +/// +/// +public sealed partial class Checkbox; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CircularProgressIndicator.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CircularProgressIndicator.cs new file mode 100644 index 000000000..f7d648e32 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CircularProgressIndicator.cs @@ -0,0 +1,48 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 indeterminate CircularProgressIndicator. Use to show +/// loading state when the duration is unknown: +/// +/// new CircularProgressIndicator() +/// +/// The determinate (progress-driven) overload is not yet wrapped — the +/// progress callback parameter requires a state-reading Function0<Float> +/// adapter that hasn't been added yet. +/// +public sealed class CircularProgressIndicator : ComposableNode +{ + /// Optional explicit color. Leave null to use the Material default. + public Color? Color { get; set; } + + /// Optional stroke width in Dp. Leave null for the Material default. + public float? StrokeWidthDp { get; set; } + + /// Optional explicit track color. Leave null to use the Material default. + public Color? TrackColor { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + + int defaults = (int)CircularProgressIndicatorDefault.All; + if (modifier is not null) defaults &= ~(int)CircularProgressIndicatorDefault.Modifier; + if (Color.HasValue) defaults &= ~(int)CircularProgressIndicatorDefault.Color; + if (StrokeWidthDp.HasValue) defaults &= ~(int)CircularProgressIndicatorDefault.StrokeWidth; + if (TrackColor.HasValue) defaults &= ~(int)CircularProgressIndicatorDefault.TrackColor; + + ProgressIndicatorKt.CircularProgressIndicator( + modifier: modifier, + color: Color is { } c ? c : 0L, + strokeWidth: StrokeWidthDp ?? 0f, + trackColor: TrackColor is { } t ? t : 0L, + p4: 0, + gapSize: 0f, + _composer: composer, + strokeCap: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CollectedState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CollectedState.cs new file mode 100644 index 000000000..cb6fd0689 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CollectedState.cs @@ -0,0 +1,55 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Read-only typed adapter around the read-only Compose +/// androidx.compose.runtime.State<T> returned by +/// androidx.lifecycle.compose.collectAsStateWithLifecycle(...). +/// The wrapped state is itself a snapshot-aware Compose state — reading +/// from inside a composition subscribes the calling +/// scope, and the lifecycle-aware Kotlin collector publishes a new value +/// every time the source Flow/StateFlow emits while the +/// hosting LifecycleOwner is at least at the requested minimum +/// active state. +/// +/// +/// Caller-asserted element type of the original Kotlin +/// Flow<T>/StateFlow<T>. Because the bound +/// IFlow/IStateFlow interfaces are non-generic on the +/// .NET side (Kotlin generic type parameters are erased through JNI), +/// this is an unchecked assertion — if the underlying flow actually +/// emits a different boxed type, the getter will +/// throw when it tries to unwrap it. +/// +/// +/// Use the +/// +/// or +/// +/// extension methods to obtain instances. The ctor is internal because +/// the wrapped must come from the lifecycle-aware +/// Kotlin collector to be subscribed to its +/// correctly. +/// +public sealed class CollectedState : IState +{ + readonly IState _state; + + internal CollectedState(IState state) => _state = state; + + /// + /// The latest value collected from the underlying Kotlin flow. + /// Reading from a composition scope subscribes that scope so it + /// recomposes when a new value is emitted (while the + /// is in the configured active state). + /// + public T Value => MutableState.FromJava(_state.Value); + + /// + /// Returns the underlying value's string representation so + /// $"...{state}..." interpolation reads as Kotlin would + /// (a null value renders as "null"). + /// + public override string ToString() => Value?.ToString() ?? "null"; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Color.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Color.cs new file mode 100644 index 000000000..7b128b294 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Color.cs @@ -0,0 +1,247 @@ +using System.Globalization; + +namespace AndroidX.Compose; + +/// +/// C# mirror of Kotlin's +/// androidx.compose.ui.graphics.Color +/// (@JvmInline value class Color(val value: ULong)). +/// +/// +/// +/// Compose's Color is an inline value class around an unsigned 64-bit +/// integer; the wire representation crossing JNI is just the underlying +/// . This struct preserves that wire shape while giving +/// the C# facade a typed, ergonomic surface to express colors in. +/// +/// +/// For the sRGB color space (the only one in common use) the packed layout +/// is 0xAARRGGBB_00000000UL — the 32-bit ARGB value occupies the +/// high half of the long, and the low 6 bits encode the color space (0 +/// for sRGB). and the +/// named constants follow this layout. +/// +/// +/// An implicit conversion to means every bridge and +/// binding method that already accepts a packed color value +/// (Modifier.Background(long), Text.Color, +/// ColorSchemeKt.LightColorScheme(...), etc.) accepts a +/// directly without any conversion ceremony at the +/// call site. +/// +/// +public readonly struct Color : IEquatable +{ + /// The underlying 64-bit packed value (matches Kotlin's ULong). + public ulong PackedValue { get; } + + /// Construct a from a raw packed value. + /// + /// Packed sRGB layout: 0xAARRGGBB_00000000UL. Use the + /// / + /// factories for typical construction. + /// + public Color(ulong packedValue) + { + PackedValue = packedValue; + } + + /// Construct a from the packed value as a signed long. + public Color(long packedValue) + { + PackedValue = unchecked((ulong)packedValue); + } + + /// Construct an opaque sRGB color from 8-bit RGB components. + public Color(byte red, byte green, byte blue) : this(0xFF, red, green, blue) { } + + /// Construct an sRGB color from 8-bit ARGB components. + public Color(byte alpha, byte red, byte green, byte blue) + { + uint argb = ((uint)alpha << 24) | ((uint)red << 16) | ((uint)green << 8) | blue; + PackedValue = (ulong)argb << 32; + } + + /// The 8-bit alpha channel (0-255). + public byte A => (byte)(PackedValue >> 56); + + /// The 8-bit red channel (0-255). + public byte R => (byte)(PackedValue >> 48); + + /// The 8-bit green channel (0-255). + public byte G => (byte)(PackedValue >> 40); + + /// The 8-bit blue channel (0-255). + public byte B => (byte)(PackedValue >> 32); + + /// Construct an sRGB color from 8-bit ARGB components. + public static Color FromArgb(byte alpha, byte red, byte green, byte blue) + => new(alpha, red, green, blue); + + /// Construct an opaque sRGB color from 8-bit RGB components. + public static Color FromRgb(byte red, byte green, byte blue) + => new(0xFF, red, green, blue); + + /// Construct an sRGB color from a packed 32-bit ARGB integer (0xAARRGGBB). + public static Color FromArgb(uint argb) => new((ulong)argb << 32); + + /// + /// Parse a CSS-style hex color literal. Accepts #RGB, #RRGGBB, + /// or #AARRGGBB, with or without the leading #. #RGB + /// and #RRGGBB are treated as fully opaque. + /// + /// is . + /// The string is not a valid color literal. + public static Color FromHex(string hex) + { + ArgumentNullException.ThrowIfNull(hex); + var span = hex.AsSpan().Trim(); + if (span.Length > 0 && span[0] == '#') span = span.Slice(1); + switch (span.Length) + { + case 3: + { + int r = HexNibble(span[0]); + int g = HexNibble(span[1]); + int b = HexNibble(span[2]); + return FromArgb(0xFF, (byte)(r * 0x11), (byte)(g * 0x11), (byte)(b * 0x11)); + } + case 6: + { + uint rgb = uint.Parse(span, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + return FromArgb(0xFF000000u | rgb); + } + case 8: + { + uint argb = uint.Parse(span, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + return FromArgb(argb); + } + default: + throw new FormatException( + $"Color hex literal must be #RGB, #RRGGBB, or #AARRGGBB; got '{hex}'."); + } + } + + static int HexNibble(char c) => c switch + { + >= '0' and <= '9' => c - '0', + >= 'a' and <= 'f' => c - 'a' + 10, + >= 'A' and <= 'F' => c - 'A' + 10, + _ => throw new FormatException($"Invalid hex digit '{c}'."), + }; + + /// + /// Return a copy of this color with the alpha channel replaced. + /// + public Color WithAlpha(byte alpha) => new(alpha, R, G, B); + + /// + /// Return a copy of this color with the alpha channel set to + /// × 255 (clamped to 0..1). + /// + public Color WithOpacity(float opacity) + { + if (opacity < 0f) opacity = 0f; + if (opacity > 1f) opacity = 1f; + return WithAlpha((byte)(opacity * 255f + 0.5f)); + } + + /// + /// Implicit conversion to the packed the Compose + /// bridges and bindings accept directly. + /// + public static implicit operator long(Color c) => unchecked((long)c.PackedValue); + + /// + /// Implicit conversion from the packed the Compose + /// bindings surface (the Kotlin + /// @JvmInline value class Color(val value: ULong) lowers to a + /// packed long across JNI). Lets callers pass scheme.OnSurface + /// directly anywhere a is expected without + /// wrapping in new Color(...). + /// + public static implicit operator Color(long packedValue) => new(packedValue); + + /// + /// Whether this color carries a real value. Matches Kotlin's + /// Color.isSpecified — useful for "fall back to theme" + /// branches where is the sentinel. + /// + public bool IsSpecified => PackedValue != Unspecified.PackedValue; + + /// + /// Inverse of — matches Kotlin's + /// Color.isUnspecified. + /// + public bool IsUnspecified => PackedValue == Unspecified.PackedValue; + + // ----- Named constants matching androidx.compose.ui.graphics.Color.Companion ----- + + /// Fully-opaque black (#FF000000). + public static Color Black => new(0xFF000000_00000000UL); + + /// Fully-transparent / zero-alpha black — matches Kotlin's Color.Transparent. + public static Color Transparent => new(0UL); + + /// Fully-opaque white (#FFFFFFFF). + public static Color White => new(0xFFFFFFFF_00000000UL); + + /// Fully-opaque red (#FFFF0000). + public static Color Red => new(0xFFFF0000_00000000UL); + + /// Fully-opaque green (#FF00FF00). + public static Color Green => new(0xFF00FF00_00000000UL); + + /// Fully-opaque blue (#FF0000FF). + public static Color Blue => new(0xFF0000FF_00000000UL); + + /// Fully-opaque cyan (#FF00FFFF). + public static Color Cyan => new(0xFF00FFFF_00000000UL); + + /// Fully-opaque magenta (#FFFF00FF). + public static Color Magenta => new(0xFFFF00FF_00000000UL); + + /// Fully-opaque yellow (#FFFFFF00). + public static Color Yellow => new(0xFFFFFF00_00000000UL); + + /// Fully-opaque mid-gray — matches Kotlin's Color.Gray (#FF888888). + public static Color Gray => new(0xFF888888_00000000UL); + + /// Fully-opaque light gray — matches Kotlin's Color.LightGray (#FFCCCCCC). + public static Color LightGray => new(0xFFCCCCCC_00000000UL); + + /// Fully-opaque dark gray — matches Kotlin's Color.DarkGray (#FF444444). + public static Color DarkGray => new(0xFF444444_00000000UL); + + /// + /// Sentinel "no color specified" value — matches Kotlin's + /// Color.Unspecified. Many Compose APIs treat this as + /// "fall back to the parent / theme default". + /// + public static Color Unspecified => new(0x10UL); + + /// + public bool Equals(Color other) => PackedValue == other.PackedValue; + + /// + public override bool Equals(object? obj) => obj is Color c && Equals(c); + + /// + public override int GetHashCode() => PackedValue.GetHashCode(); + + /// Equality operator. + public static bool operator ==(Color left, Color right) => left.Equals(right); + + /// Inequality operator. + public static bool operator !=(Color left, Color right) => !left.Equals(right); + + /// + public override string ToString() + { + if (PackedValue == Unspecified.PackedValue) + return "Color.Unspecified"; + if ((PackedValue & 0xFFFFFFFFUL) != 0) + return $"Color(0x{PackedValue:X16})"; + return $"Color(0x{A:X2}{R:X2}{G:X2}{B:X2})"; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Column.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Column.cs new file mode 100644 index 000000000..b6fc9d6eb --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Column.cs @@ -0,0 +1,102 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation Column composable — lays its children out +/// vertically. Mirror of ; same collection-init shape: +/// +/// new Column { new Text("A"), new Text("B") } +/// +/// Pass an optional to control how children +/// are distributed along the vertical axis: +/// +/// new Column(verticalArrangement: Arrangement.SpacedBy(8)) { /* … */ } +/// +/// +public sealed class Column : ComposableContainer +{ + readonly Arrangement? _verticalArrangement; + readonly Alignment.Horizontal? _horizontalAlignment; + + /// + /// Lay out children top-to-bottom with Compose's default + /// vertical arrangement (Arrangement.Top) and default + /// horizontal alignment (Alignment.Start). + /// + public Column() : this(null, null) { } + + /// + /// Lay out children top-to-bottom using + /// to distribute them along + /// the vertical axis. Pass (or use the + /// parameterless ctor) for Compose's default. + /// + /// + /// One of the static factories. Must wrap + /// a vertical-capable Kotlin arrangement + /// (, , + /// , , + /// , , + /// or ). Passing a + /// horizontal-only arrangement (, + /// ) throws + /// . + /// + public Column(Arrangement? verticalArrangement) : this(verticalArrangement, null) { } + + /// + /// Lay out children top-to-bottom using + /// and + /// . Either may be + /// to keep Compose's default for that axis + /// (Arrangement.Top / Alignment.Start). + /// + /// + /// One of the static factories — see the + /// single-argument constructor for the full list of permitted + /// values. + /// + /// + /// One of the singletons + /// (, + /// , + /// ). Controls how children + /// narrower than the Column's measured width are placed along its + /// horizontal axis. + /// + public Column(Arrangement? verticalArrangement, Alignment.Horizontal? horizontalAlignment) + { + if (verticalArrangement is not null && verticalArrangement.Vertical is null) + { + throw new ArgumentException( + $"{nameof(verticalArrangement)} must wrap a vertical " + + $"or horizontal-or-vertical Compose Arrangement; got a " + + $"horizontal-only value (e.g. Arrangement.Start / Arrangement.End).", + nameof(verticalArrangement)); + } + + _verticalArrangement = verticalArrangement; + _horizontalAlignment = horizontalAlignment; + } + + public override void Render(IComposer composer) + { + var vertical = _verticalArrangement?.Vertical; + var horizontal = _horizontalAlignment?.Java; + var modifier = BuildModifier(); + + int defaults = (int)ColumnDefault.All; + if (modifier is not null) defaults &= ~(int)ColumnDefault.Modifier; + if (vertical is not null) defaults &= ~(int)ColumnDefault.VerticalArrangement; + if (horizontal is not null) defaults &= ~(int)ColumnDefault.HorizontalAlignment; + + var content = ComposableLambdas.Wrap3(composer, (scope, c) => + { + using var __scopeFrame = RenderContext.PushScope(scope, ScopeKind.Column); + RenderChildren(c); + }); + + ComposeBridges.Column(modifier, vertical, horizontal, content, defaults, composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableContainer.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableContainer.cs new file mode 100644 index 000000000..4ea679bcd --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableContainer.cs @@ -0,0 +1,114 @@ +using System.Collections; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Base class for container composables that take a single content +/// lambda holding zero or more children. Implements +/// + so +/// C# collection-initializer syntax +/// (new Column { new Text("Hi"), new Text("There") }) compiles. +/// +public abstract class ComposableContainer : ComposableNode, IEnumerable +{ + readonly List _children = new(); + + public void Add(ComposableNode? child) + { + if (child is not null) + _children.Add(child); + } + + /// + /// Collection-initializer overload that lets callers drop a + /// composer-aware builder lambda inline as a child: + /// + /// new Column + /// { + /// c => new Text(c.ColorScheme().OnSurface.ToString()), + /// } + /// + /// The lambda is wrapped in a node, so it + /// runs once per composition pass and may return + /// to skip rendering. This overload exists alongside the implicit + /// conversion on because C# can only target- + /// type a bare lambda when the call-site parameter is itself a + /// delegate — the implicit operator is otherwise invisible. + /// + public void Add(Func builder) + { + ArgumentNullException.ThrowIfNull(builder); + _children.Add(new Composed(builder)); + } + + /// + /// Collection-initializer overload that lets callers set + /// inline alongside children: + /// new Column { Modifier.Padding(16), new Text("Hi") } + /// (C# disallows mixing object-initializer assignments with + /// collection-initializer items in the same braces, so the modifier + /// is set via Add instead.) + /// + public void Add(Modifier modifier) => Modifier = modifier; + IEnumerator IEnumerable.GetEnumerator() => _children.GetEnumerator(); + + /// Accessor for derived Render impls. + protected IReadOnlyList Children => _children; + + /// + /// Renders this container's children sequentially into + /// , wrapping each child in a per-position + /// StartReplaceableGroup whose key combines the sibling index + /// and the child's runtime . Without per- + /// position groups, three sibling SegmentedButtons (same C# + /// call site → same group key) rely on Compose's positional + /// disambiguation, which combined with lambda-identity churn + /// elsewhere can land Reuse/Move ops at the wrong tree index. + /// Without the type component, a sibling that swaps to a different + /// subclass at the same position + /// (e.g. tab navigation flipping a PullToRefreshBox for a + /// HorizontalUncontainedCarousel) would re-enter the prior + /// occupant's group and read its slot-table entries, throwing + /// ClassCastException from inside rememberSaveable + /// when the prior slot held an incompatible type. Same-typed + /// siblings at the same position keep their identity and slot + /// state intact — that is intentional Compose positional identity. + /// + protected void RenderChildren(IComposer composer) + { + for (int i = 0; i < _children.Count; i++) + { + var child = _children[i]; + composer.StartReplaceableGroup(HashCode.Combine(i, child.GetType())); + try { child.Render(composer); } + finally { composer.EndReplaceableGroup(); } + } + } + + /// + /// Renders this container's children sequentially while publishing + /// the per-child row position via + /// + SetIndex. Child + /// composables (e.g. ) read + /// and + /// to compute + /// Kotlin defaults that depend on their position in the row + /// (start/end shape, etc.). Each child still gets the same + /// per-position StartReplaceableGroup key as + /// — see that method for the + /// positional-identity rationale. + /// + private protected void RenderChildrenIndexed(IComposer composer) + { + using var rows = RenderContext.PushRow(_children.Count); + for (int i = 0; i < _children.Count; i++) + { + rows.SetIndex(i); + var child = _children[i]; + composer.StartReplaceableGroup(HashCode.Combine(i, child.GetType())); + try { child.Render(composer); } + finally { composer.EndReplaceableGroup(); } + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda0.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda0.cs new file mode 100644 index 000000000..b53f716dd --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda0.cs @@ -0,0 +1,23 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function0<Unit> — zero-arg callbacks (Button.onClick, etc.). +/// A [Register]'d Java.Lang.Object ACW so Compose's bytecode-typed +/// lambda parameter accepts it. +/// +[Register("net/compose/ComposableLambda0")] +internal sealed class ComposableLambda0 : Java.Lang.Object, IFunction0 +{ + readonly Action _body; + public ComposableLambda0(Action body) => _body = body; + + // Kotlin Function0 contractually returns Unit.INSTANCE. Returning + // Java null happens to work today because every Compose call site we + // touch discards the result, but a future compose-compiler / K2 emit + // may chain `.let { }` / `.also { }` on the result and add an + // Intrinsics.checkNotNullExpressionValue. See issue #43. + public Java.Lang.Object Invoke() { _body(); return Kotlin.Unit.Instance!; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda0Int.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda0Int.cs new file mode 100644 index 000000000..03e734afc --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda0Int.cs @@ -0,0 +1,23 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function0<Integer> — a no-arg Kotlin lambda returning a boxed +/// . Used for state-holder factories +/// like rememberCarouselState(itemCount: () -> Int) / +/// rememberLazyListState(initialFirstVisibleItemIndex: () -> Int) +/// that re-read the count on every measure pass. +/// +/// Separate from , which returns +/// Kotlin.Unit (the JNI shape for onClick-style callbacks). +/// +[Register("net/compose/ComposableLambda0Int")] +internal sealed class ComposableLambda0Int : Java.Lang.Object, IFunction0 +{ + readonly Func _body; + public ComposableLambda0Int(Func body) => _body = body; + + public Java.Lang.Object Invoke() => Java.Lang.Integer.ValueOf(_body()); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda1.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda1.cs new file mode 100644 index 000000000..69324cf6e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda1.cs @@ -0,0 +1,24 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function1<T, Unit> — single-arg callbacks (TextField's +/// onValueChange, onCheckedChange, etc.). The body receives the raw +/// Java arg and is responsible for unboxing it. +/// +[Register("net/compose/ComposableLambda1")] +internal sealed class ComposableLambda1 : Java.Lang.Object, IFunction1 +{ + readonly Action _body; + public ComposableLambda1(Action body) => _body = body; + + // Kotlin Function1 contractually returns Unit.INSTANCE. See + // ComposableLambda0 / issue #43 for the rationale. + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + _body(p0); + return Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda2.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda2.cs new file mode 100644 index 000000000..ee4098b93 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda2.cs @@ -0,0 +1,27 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function2<Composer, Integer, Unit> — top-level composition + +/// theme/scope content. p0 is the composer, p1 is +/// $changed. +/// +[Register("net/compose/ComposableLambda2")] +internal sealed class ComposableLambda2 : Java.Lang.Object, IFunction2 +{ + readonly Action _body; + public ComposableLambda2(Action body) => _body = body; + + // Kotlin Function2 contractually returns + // Unit.INSTANCE. See ComposableLambda0 / issue #43 for the rationale. + public Java.Lang.Object Invoke(Java.Lang.Object? p0, Java.Lang.Object? p1) + { + ArgumentNullException.ThrowIfNull(p0); + var composer = Android.Runtime.Extensions.JavaCast(p0); + _body(composer); + return Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda3.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda3.cs new file mode 100644 index 000000000..a50e1c3bc --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda3.cs @@ -0,0 +1,53 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function3<Scope, Composer, Integer, Unit> — Column/Row/Box/Button +/// content. p0 is the receiver scope (RowScope/ColumnScope), +/// p1 is the composer, p2 is $changed. +/// +/// Three ctors: +/// +/// Action<IComposer> discards the scope — +/// used wherever children don't need to know it (Column, Box, +/// Button). +/// Action<IntPtr, IComposer> receives the +/// raw scope handle, used by container composables whose children +/// are extension-receiver composables (RowScope.NavigationBarItem). +/// The scope is published via so the +/// child Render can read it. +/// Action<Java.Lang.Object?, IComposer> +/// receives the boxed p0 as a . +/// Used by value-typed Function3 slots — e.g. +/// Crossfade's content: @Composable (T) -> Unit +/// where p0 is the boxed targetState rather than a scope +/// receiver — so the body can unbox to the user-facing +/// T. +/// +/// +[Register("net/compose/ComposableLambda3")] +internal sealed class ComposableLambda3 : Java.Lang.Object, IFunction3 +{ + readonly Action _body; + + public ComposableLambda3(Action body) + : this((Java.Lang.Object? _, IComposer c) => body(c)) { } + + public ComposableLambda3(Action body) + : this((Java.Lang.Object? p0, IComposer c) => body(p0?.Handle ?? IntPtr.Zero, c)) { } + + public ComposableLambda3(Action body) => _body = body; + + // Kotlin Function3 contractually returns + // Unit.INSTANCE. See ComposableLambda0 / issue #43 for the rationale. + public Java.Lang.Object Invoke(Java.Lang.Object? p0, Java.Lang.Object? p1, Java.Lang.Object? p2) + { + ArgumentNullException.ThrowIfNull(p1); + var composer = Android.Runtime.Extensions.JavaCast(p1); + _body(p0, composer); + return Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda4.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda4.cs new file mode 100644 index 000000000..04b1a1d27 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambda4.cs @@ -0,0 +1,30 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function4<Scope, T, Composer, Integer, Unit> — the shape Compose's +/// LazyListScope.items and LazyGridScope.items use for +/// their per-item content lambda. p0 is the lazy item / grid item +/// scope (raw ), p1 is the boxed +/// per-item value (an index for the +/// count-based overload, or the boxed user item for the list-based one), +/// p2 is the composer, p3 is $changed. +/// +[Register("net/compose/ComposableLambda4")] +internal sealed class ComposableLambda4 : Java.Lang.Object, IFunction4 +{ + readonly Action _body; + + public ComposableLambda4(Action body) => _body = body; + + public Java.Lang.Object? Invoke(Java.Lang.Object? p0, Java.Lang.Object? p1, Java.Lang.Object? p2, Java.Lang.Object? p3) + { + ArgumentNullException.ThrowIfNull(p2); + var composer = Android.Runtime.Extensions.JavaCast(p2); + _body(p0?.Handle ?? IntPtr.Zero, p1, composer); + return Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambdas.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambdas.cs new file mode 100644 index 000000000..0534c8a8e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableLambdas.cs @@ -0,0 +1,205 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.Runtime.Internal; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Helpers that wrap / +/// / +/// @Composable content lambdas through one of Compose's two factory +/// functions so the runtime owns lambda identity across recompositions. +/// +/// Two factories exist and they are not interchangeable: +/// +/// ComposableLambdaKt.ComposableLambda(composer, +/// key, tracked, block) — used by / +/// . +/// Stores/retrieves the wrapper from the current composition's slot +/// table at the given key; requires an *active* composer. Use for +/// content slots that are evaluated synchronously during the outer +/// composition pass (e.g. topBar, title, button +/// content). +/// ComposableLambdaKt.ComposableLambdaInstance( +/// key, tracked, block) — used by and +/// by ComposeExtensions.SetContent. Allocates a fresh wrapper +/// each call without touching any composer; safe to invoke from +/// outside composition. Use for content that runs at measure time +/// or in a subcomposition (e.g. LazyListScope.items / +/// LazyGridScope.items itemContent, which Compose +/// realizes inside the lazy list's +/// rememberLazyListItemProviderLambda long after the outer +/// Render has returned). +/// +/// +/// Convention: never construct +/// / / +/// directly inside a Render +/// method — always route through / +/// / +/// . The non-@Composable adapters +/// / +/// (onClick / onValueChange callbacks, plus the LazyListScope / +/// LazyGridScope DSL builders which are *not* @Composable) are NOT +/// wrapped — they run outside composition and would crash inside +/// Compose's restart-group machinery. +/// +internal static class ComposableLambdas +{ + /// + /// Wrap an as an identity-stable + /// (the Function2<Composer, Int, Unit> + /// @Composable content-slot shape used by topBar, + /// bottomBar, title, icon, etc.). + /// + public static IFunction2 Wrap2( + IComposer composer, + Action body, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => (IFunction2)ComposableLambdaKt.ComposableLambda( + composer, SourceLocationKey.Compute(line, file), tracked: true, + block: new ComposableLambda2(body)); + + /// + /// Wrap an as an identity-stable + /// (the Function3<Scope, Composer, Int, Unit> + /// @Composable content-slot shape used by Column / Row / + /// Box / Button bodies). The scope receiver is discarded. + /// + public static IFunction3 Wrap3( + IComposer composer, + Action body, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => (IFunction3)ComposableLambdaKt.ComposableLambda( + composer, SourceLocationKey.Compute(line, file), tracked: true, + block: new ComposableLambda3(body)); + + /// + /// Wrap an as an + /// identity-stable that receives the raw + /// scope handle (RowScope, ColumnScope, SegmentedButtonRowScope, + /// etc.) — for containers whose children need to forward the scope + /// to a scope-extension Kotlin static (e.g. + /// RowScope.NavigationBarItem). + /// + public static IFunction3 Wrap3( + IComposer composer, + Action body, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => (IFunction3)ComposableLambdaKt.ComposableLambda( + composer, SourceLocationKey.Compute(line, file), tracked: true, + block: new ComposableLambda3(body)); + + /// + /// Wrap an as an identity-stable + /// that receives the boxed p0 as a + /// — the value-typed Function3 shape + /// used by Crossfade's + /// content: @Composable (T) -> Unit, where p0 is + /// the boxed targetState (not a scope receiver). The body unboxes + /// it back to T. + /// + /// + /// Distinct method name (rather than a third Wrap3 + /// overload) so existing call sites that pass + /// (_, c) => ... aren't ambiguous between the + /// Action<IntPtr, IComposer> and + /// Action<Java.Lang.Object?, IComposer> shapes. + /// + public static IFunction3 Wrap3WithValue( + IComposer composer, + Action body, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => (IFunction3)ComposableLambdaKt.ComposableLambda( + composer, SourceLocationKey.Compute(line, file), tracked: true, + block: new ComposableLambda3(body)); + + /// + /// Build an identity-stable wrapper for the + /// Function4<LazyItemScope, Int, Composer, Int, Unit> + /// @Composable shape used by LazyListScope.items and + /// LazyGridScope.items itemContent. p0 is the + /// lazy item scope handle, p1 is the boxed item index, + /// p2 is the composer. + /// + /// Uses ComposableLambdaInstance (not + /// ComposableLambda) because the lazy list DSL builder runs + /// at measure time inside the list's + /// rememberLazyListItemProviderLambda, *not* inside the outer + /// Render's composition pass. There is no active composer at + /// the call site, so we can't look up a slot-table entry — the + /// Instance factory allocates a fresh wrapper without one, + /// which is exactly what Kotlin's inline + /// LazyListScope.items(...) extension expands to. + /// + public static IFunction4 Instantiate4( + Action body, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => (IFunction4)ComposableLambdaKt.ComposableLambdaInstance( + key: SourceLocationKey.Compute(line, file), tracked: true, + block: new ComposableLambda4(body)); + + /// + /// Build an identity-stable wrapper for the + /// Function3<NavBackStackEntry, Composer, Int, Unit> + /// @Composable shape used by NavHost's per-route + /// composable("route") { ... } destination content. p0 + /// is the current + /// handle, p1 is the destination's composer, p2 is + /// $changed. + /// + /// Uses ComposableLambdaInstance (not + /// ComposableLambda) because the navigation builder DSL runs + /// once at NavHost graph construction, but the destination's + /// content lambda is invoked LATER inside the route's own + /// subcomposition (every time the user navigates to the route, or + /// the entry recomposes). The outer NavHost composer captured at + /// graph-build time is no longer active by then, so we can't look + /// up a slot-table entry — the Instance factory allocates a + /// fresh wrapper without one, exactly mirroring what Kotlin's + /// composable("route") { ... } expands to. + /// + public static IFunction3 InstantiateNavComposable( + Action body, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => (IFunction3)ComposableLambdaKt.ComposableLambdaInstance( + key: HashCode.Combine(line, file), tracked: true, + block: new ComposableLambda3(body)); + + /// + /// Wrap an Action<IntPtr, Java.Lang.Object?, IComposer> + /// as an identity-stable for the + /// Function4<Scope, Int, Composer, Int, Unit> @Composable + /// shape used by Material 3 carousel content slots + /// (HorizontalUncontainedCarousel, etc.). + /// scope is the raw scope receiver handle (e.g. + /// CarouselItemScope), indexBoxed is the boxed + /// item index, composer is + /// the active composer for the item's composition. + /// + /// Uses ComposableLambda (not + /// ComposableLambdaInstance) because the carousel's + /// content parameter is a direct @Composable lambda on the + /// outer carousel call — the Kotlin compiler would wrap it via + /// composableLambda(composer, ...) in the OUTER composition's + /// slot table even though the lambda body itself eventually runs + /// inside the carousel's pager subcomposition. Mirrors + /// / + /// for the 4-arg shape. + /// + public static IFunction4 Wrap4( + IComposer composer, + Action body, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => (IFunction4)ComposableLambdaKt.ComposableLambda( + composer, SourceLocationKey.Compute(line, file), tracked: true, + block: new ComposableLambda4(body)); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableNode.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableNode.cs new file mode 100644 index 000000000..051f4fa39 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposableNode.cs @@ -0,0 +1,178 @@ +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI; + +namespace AndroidX.Compose; + +/// +/// Base type of every composable in the AndroidX.Compose tree-style facade. +/// +/// A is a passive AST node: building it +/// (new Text("Hi"), new Column { ... }) does NOT call into +/// Compose. The activity walks the tree and calls +/// during composition, threading the +/// through container nodes to their children. +/// +/// This is the C# moral equivalent of Kotlin Compose's IR transform — +/// the composer is explicit at the implementation layer (honest mirror +/// of ComposerParamTransformer), invisible at the user layer. +/// +public abstract class ComposableNode +{ + /// + /// Optional chain applied to this + /// composable. For leaf composables, set via object-initializer: + /// new Text("Hi") { Modifier = Modifier.Padding(8) } + /// For container composables (which use collection-initializer syntax), + /// add the modifier inline alongside the children: + /// new Column { Modifier.Padding(16), new Text("Hi") } + /// Composables without a Kotlin modifier parameter + /// (e.g. ) ignore this property. + /// + public Modifier? Modifier { get; set; } + + Modifier? _prepended; + Modifier? _appended; + + // PaddingValues handle handed to this node by a parent layout + // (e.g. Scaffold) via Render(IComposer, IntPtr). The next call + // to BuildModifier on this node prepends a Modifier.padding(values) + // op for it. Per-instance so descendants don't inherit it; + // save/restored by the virtual so re-entrant renders nest cleanly. + // See issue #46. + IntPtr _contentPadding; + + /// + /// Set the to prepend at the + /// START of this node's modifier chain on the next call to + /// . Each call REPLACES any prior + /// prepended modifier — use + /// at the call site to combine multiple ops into one. + /// + /// Intended use: a parent layout that needs to pass a runtime + /// modifier into a child without inserting a wrapper layout node. + /// For the specific case of forwarding a PaddingValues + /// handle from to its body, prefer the + /// internal Render(IComposer, IntPtr) overload — it skips + /// the managed wrapper altogether + /// (see issue #46). + /// + /// Caveat: the injected modifier is silently dropped if the child's + /// Render never calls (i.e. + /// composables whose Kotlin counterpart has no modifier + /// parameter — , the drawer sheets). + /// Replace-semantics keeps the stored modifier bounded to one ref + /// even when the child never consumes it. + /// + /// Ordering: when both a prepended modifier and a runtime + /// contentPadding (from Render(IComposer, IntPtr)) + /// are present on the same node, the content padding runs OUTSIDE + /// the prepended op — i.e. + /// contentPadding → prepended → Modifier → appended. + /// + public void PrependModifier(Modifier modifier) + { + ArgumentNullException.ThrowIfNull(modifier); + _prepended = modifier; + } + + /// + /// Set the to append at the END + /// of this node's modifier chain on the next call to + /// . Mirror of + /// — see that method for semantics, + /// caveats, and replace-vs-accumulate notes. + /// + public void AppendModifier(Modifier modifier) + { + ArgumentNullException.ThrowIfNull(modifier); + _appended = modifier; + } + + /// + /// Materialize for the JNI call, folding in + /// any pending / + /// contributions and clearing them so the next composition starts + /// fresh. When this node was rendered via the + /// Render(IComposer, IntPtr) overload, also prepends a + /// Modifier.padding(contentPadding) op to the chain without + /// allocating a managed wrapper (issue #46). + /// Returns null when the combined chain is empty — + /// callers should leave the Kotlin $default bit set so + /// Compose substitutes its real default. + /// + internal IModifier? BuildModifier() + { + var prepended = _prepended; + var appended = _appended; + _prepended = null; + _appended = null; + + // Consume the PaddingValues handle stashed by + // Render(IComposer, IntPtr); the wrapper restores the prior + // value in its finally block so nested renders nest cleanly + // without leaking. + IntPtr contentPadding = _contentPadding; + _contentPadding = IntPtr.Zero; + + Modifier? combined = prepended; + if (Modifier is not null) + combined = combined is null ? Modifier : combined.Then(Modifier); + if (appended is not null) + combined = combined is null ? appended : combined.Then(appended); + + if (contentPadding == IntPtr.Zero) + return combined?.Build(); + return (combined ?? Modifier.Companion).Build(contentPadding); + } + + /// + /// Compose this node's contribution into . + /// + /// The C# moral equivalent of an @Composable function body + /// in Kotlin: anything that runs at composition time — emitting + /// child nodes, reading , + /// calling , + /// etc. — happens here. To compose a child node from inside a + /// custom override, build it and call child.Render(composer) + /// directly; for richer container shapes derive from + /// and use RenderChildren. + /// + /// Must be invoked with the live handed to + /// the enclosing composition pass — calling it outside a + /// composition has no useful effect (and will fault when child + /// composables read snapshot state). + /// + public abstract void Render(IComposer composer); + + /// + /// Render this node as the body of a parent layout that supplies a + /// runtime PaddingValues handle (e.g. + /// 's content lambda). The default + /// implementation stashes in a + /// per-instance slot and delegates to the regular + /// ; the next call to + /// on this same node consumes the + /// handle and prepends a Modifier.padding(contentPadding) + /// op directly via JNI — no per-measure managed + /// allocations. + /// + /// Container facades whose Kotlin counterpart accepts a + /// contentPadding parameter directly (, + /// , the lazy grids) can override this to + /// forward the handle into the binding instead of materializing a + /// Modifier.padding(values) chain. See issue #46. + /// + internal virtual void Render(IComposer composer, IntPtr contentPadding) + { + var prev = _contentPadding; + _contentPadding = contentPadding; + try + { + Render(composer); + } + finally + { + _contentPadding = prev; + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeBridges.cs new file mode 100644 index 000000000..e16303a16 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeBridges.cs @@ -0,0 +1,4281 @@ +using Android.Runtime; +using AndroidX.Compose.Foundation.Layout; +using AndroidX.Compose.Foundation.Lazy.Grid; +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI; +using AndroidX.Compose.UI.State; +using Kotlin.Jvm.Functions; +using Kotlin.Ranges; +using Painter = AndroidX.Compose.UI.Graphics.Painter.Painter; + +namespace AndroidX.Compose; + +// Raw-JNI bridges to Compose functions the .NET-for-Android binding generator +// can't see (Compose @Composable functions don't get $default sibling overloads +// — the trailing $default bitmask lives on the regular method). The bodies +// for every method tagged with [ComposeBridge] are emitted by +// AndroidX.Compose.SourceGenerators.ComposeBridgeGenerator from the attribute +// metadata + (for $default-bearing signatures) the matching [ComposeDefaults] +// enum names. Plain Kotlin static helpers — no Composer, no $default — don't +// need a [ComposeDefaults] declaration. +// +// A few helpers stay hand-written: ModifierHandle (a managed-side conversion +// from the IModifier wrapper to a raw handle) and ModifierCompanionInstance +// (a static field lookup, not a method invocation). +internal static partial class ComposeBridges +{ + // Convert a managed Modifier wrapper (from `Modifier.Build()`) to a + // raw JNI handle, or IntPtr.Zero when null. Each bridge that takes + // a modifier param uses this + KeepAlive's the wrapper across the + // JNI call so its handle stays alive. + internal static IntPtr ModifierHandle(IModifier? modifier) => + modifier is null ? IntPtr.Zero : ((Java.Lang.Object)modifier).Handle; + + // androidx.compose.ui.Modifier$Companion.$$INSTANCE — the empty + // Modifier that every chain builds on top of. Cached as a global + // ref so the chain builder doesn't pay the FindClass + + // GetStaticObjectField cost on every recomposition. + static IntPtr s_modifierCompanionInstance; + + internal static unsafe IntPtr ModifierCompanionInstance() + { + if (s_modifierCompanionInstance == IntPtr.Zero) + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/Modifier$Companion"); + IntPtr fid = JNIEnv.GetStaticFieldID(cls, "$$INSTANCE", "Landroidx/compose/ui/Modifier$Companion;"); + IntPtr local = JNIEnv.GetStaticObjectField(cls, fid); + s_modifierCompanionInstance = JNIEnv.NewGlobalRef(local); + JNIEnv.DeleteLocalRef(local); + } + // Returning a NEW local ref each call so callers can DeleteLocalRef + // it uniformly while walking the op chain. + return JNIEnv.NewLocalRef(s_modifierCompanionInstance); + } + + // androidx.compose.foundation.lazy.grid.GridCells$Adaptive — the + // only constructor takes a Dp (`@JvmInline value class Dp(val value: Float)`), + // mangled to `(F)V` in bytecode, and the binder strips it. + // The source generator's constructor shape emits FindClass + + // GetMethodID("") + NewObject + Java.Lang.Object.GetObject + // with TransferLocalRef. `GridCells.Fixed(int)` doesn't need a + // bridge (no inline-class mangling); the binder exposes its ctor + // directly. + [ComposeBridge( + Class = "androidx/compose/foundation/lazy/grid/GridCells$Adaptive", + JvmName = "", + Signature = "(F)V")] + internal static partial IGridCells GridCellsAdaptive(float minSizeDp); + + // androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells$Adaptive + // and StaggeredGridCells$FixedSize — same Dp inline-class story as + // GridCells$Adaptive above. StaggeredGridCells.Fixed(int) is + // unaffected by inline-class mangling and the binder exposes its + // ctor directly, so no bridge is needed for it. + [ComposeBridge( + Class = "androidx/compose/foundation/lazy/staggeredgrid/StaggeredGridCells$Adaptive", + JvmName = "", + Signature = "(F)V")] + internal static partial AndroidX.Compose.Foundation.Lazy.Staggeredgrid.IStaggeredGridCells + StaggeredGridCellsAdaptive(float minSizeDp); + + [ComposeBridge( + Class = "androidx/compose/foundation/lazy/staggeredgrid/StaggeredGridCells$FixedSize", + JvmName = "", + Signature = "(F)V")] + internal static partial AndroidX.Compose.Foundation.Lazy.Staggeredgrid.IStaggeredGridCells + StaggeredGridCellsFixedSize(float sizeDp); + + // androidx.compose.foundation.layout.PaddingKt — the Dp-taking + // overloads have hashed JVM names from the inline-class compiler + // mangling (`@JvmInline value class Dp(val value: Float)`). Bodies + // are emitted by the source generator's plain-static shape. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/PaddingKt", + JvmName = "padding-3ABfNKs", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierPaddingAll(IntPtr modifier, float dp); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/PaddingKt", + JvmName = "padding-VpY3zN4", + Signature = "(Landroidx/compose/ui/Modifier;FF)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierPaddingHV(IntPtr modifier, float horizontal, float vertical); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/PaddingKt", + JvmName = "padding-qDBjuR0", + Signature = "(Landroidx/compose/ui/Modifier;FFFF)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierPaddingLTRB(IntPtr modifier, float start, float top, float end, float bottom); + + // PaddingKt.padding(Modifier, PaddingValues) — unmangled because + // PaddingValues is a regular interface, not a `value class`. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/PaddingKt", + JvmName = "padding", + Signature = "(Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierPaddingValues(IntPtr modifier, IntPtr paddingValues); + + // androidx.compose.foundation.layout.SizeKt — fillMax* take a plain + // Float fraction, not Dp, so the JVM names are NOT mangled. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "fillMaxWidth", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierFillMaxWidth(IntPtr modifier, float fraction); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "fillMaxHeight", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierFillMaxHeight(IntPtr modifier, float fraction); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "fillMaxSize", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierFillMaxSize(IntPtr modifier, float fraction); + + // androidx.compose.foundation.layout.SizeKt — Dp-taking sizing + // modifiers. The JVM names are mangled like padding's because + // `Dp` is an inline class (`@JvmInline value class Dp(val value: Float)`). + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "height-3ABfNKs", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierHeight(IntPtr modifier, float dp); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "width-3ABfNKs", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierWidth(IntPtr modifier, float dp); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "size-3ABfNKs", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierSizeAll(IntPtr modifier, float dp); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "size-VpY3zN4", + Signature = "(Landroidx/compose/ui/Modifier;FF)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierSizeWH(IntPtr modifier, float width, float height); + + // androidx.compose.foundation.layout.WindowInsetsPadding_androidKt — + // Modifier extensions that read WindowInsets from CompositionLocals + // and apply them as padding. Take only Modifier (no Dp), so JVM + // names are unmangled. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "safeDrawingPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierSafeDrawingPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "systemBarsPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierSystemBarsPadding(IntPtr modifier); + + // androidx.compose.material3.InteractiveComponentSizeKt.minimumInteractiveComponentSize() — + // declared in `InteractiveComponentSize.kt`, so the synthetic + // Kotlin file-class is `InteractiveComponentSizeKt`. Reserves at + // least 48.dp in both dimensions to keep touch targets accessible + // on icon-only buttons and compact rows. No Composer, no $default. + [ComposeBridge( + Class = "androidx/compose/material3/InteractiveComponentSizeKt", + JvmName = "minimumInteractiveComponentSize", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierMinimumInteractiveComponentSize(IntPtr modifier); + + // androidx.compose.foundation.shape.RoundedCornerShapeKt.RoundedCornerShape-0680j_4(Dp). + // Mangled because the lone arg is @JvmInline value class Dp (Float). + // Plain-static shape: no receiver, no $default, no Composer. + [ComposeBridge( + Class = "androidx/compose/foundation/shape/RoundedCornerShapeKt", + JvmName = "RoundedCornerShape-0680j_4", + Signature = "(F)Landroidx/compose/foundation/shape/RoundedCornerShape;")] + internal static partial IntPtr RoundedCornerShape(float dp); + + // androidx.compose.foundation.shape.RoundedCornerShapeKt.RoundedCornerShape(Dp, Dp, Dp, Dp). + // The 4-arg Dp overload keeps a clean name because no Float-arg 4-arg + // overload exists to collide with — only the 1-arg shape has the + // Dp/Float overload ambiguity that forces inline-class mangling. + [ComposeBridge( + Class = "androidx/compose/foundation/shape/RoundedCornerShapeKt", + JvmName = "RoundedCornerShape", + Signature = "(FFFF)Landroidx/compose/foundation/shape/RoundedCornerShape;")] + internal static partial IntPtr RoundedCornerShape4( + float topStart, float topEnd, float bottomEnd, float bottomStart); + + // androidx.compose.foundation.shape.RoundedCornerShapeKt.RoundedCornerShape(Int, Int, Int, Int) + // — independent percent-based radii per corner. Int isn't an inline + // class so no mangling; plain-static shape. + [ComposeBridge( + Class = "androidx/compose/foundation/shape/RoundedCornerShapeKt", + JvmName = "RoundedCornerShape", + Signature = "(IIII)Landroidx/compose/foundation/shape/RoundedCornerShape;")] + internal static partial IntPtr RoundedCornerShape4Percent( + int topStartPercent, int topEndPercent, int bottomEndPercent, int bottomStartPercent); + + // androidx.compose.ui.text.font.FontFamilyKt.FontFamily(Typeface) — wraps a platform + // android.graphics.Typeface into a Compose FontFamily. Plain-static interop: no receiver, + // no $default, no Composer, no value classes. Lets us load custom fonts (Montserrat/Karla, + // icon fonts) the facade otherwise can't construct (it only binds the system families). + [ComposeBridge( + Class = "androidx/compose/ui/text/font/AndroidTypeface_androidKt", + JvmName = "FontFamily", + Signature = "(Landroid/graphics/Typeface;)Landroidx/compose/ui/text/font/FontFamily;")] + internal static partial IntPtr FontFamilyFromTypeface(IntPtr typeface); + + // androidx.compose.ui.draw.ClipKt.clip(Modifier, Shape) — both args + // required, no $default and no name mangling. Plain-static shape + // with a Modifier extension receiver. + [ComposeBridge( + Class = "androidx/compose/ui/draw/ClipKt", + JvmName = "clip", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;)" + + "Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierClip(IntPtr modifier, IntPtr shape); + + // Convenience wrapper that composes RoundedCornerShape + ModifierClip + // and manages the intermediate Shape local ref. Listed as out of + // scope in #36 — the underlying JNI calls are both generator-driven + // now; only the two-step orchestration stays hand-written. + internal static IntPtr ModifierClipRoundedCorners(IntPtr modifier, float dp) + { + IntPtr shape = RoundedCornerShape(dp); + try + { + return ModifierClip(modifier, shape); + } + finally + { + if (shape != IntPtr.Zero) + JNIEnv.DeleteLocalRef(shape); + } + } + + // androidx.compose.ui.res.PainterResources_androidKt.painterResource — + // returns a NEW local Painter ref the caller is responsible for + // DeleteLocalRef'ing once it's been handed to the consuming + // Image/Icon JNI call. + // DeleteLocalRef'ing once it's been handed to the consuming + // Image/Icon JNI call. + [ComposeBridge( + Class = "androidx/compose/ui/res/PainterResources_androidKt", + JvmName = "painterResource", + Signature = "(ILandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/painter/Painter;")] + public static partial IntPtr PainterResource(int id, IComposer composer); + + // androidx.compose.foundation.layout.RowScope$DefaultImpls.weight$default — + // synthetic helper for `fun RowScope.Modifier.weight(weight, fill = true)`. + // Static method taking the dispatch receiver (RowScope) as arg 0, + // the extension receiver (Modifier) as arg 1, then weight/fill, the + // $default bitmask, and the synthetic marker. The bridge generator + // treats the first IntPtr (`rowScope`) as the receiver and binds it + // to args[0]; everything else flows through ModifierWeightDefault. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/RowScope$DefaultImpls", + JvmName = "weight$default", + Signature = "(Landroidx/compose/foundation/layout/RowScope;" + + "Landroidx/compose/ui/Modifier;FZILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierWeightDefault))] + internal static partial IntPtr RowScopeModifierWeight(IntPtr rowScope, IntPtr modifier, float weight, bool fill); + + // androidx.compose.foundation.layout.ColumnScope$DefaultImpls.weight$default — + // same shape as the RowScope helper above. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/ColumnScope$DefaultImpls", + JvmName = "weight$default", + Signature = "(Landroidx/compose/foundation/layout/ColumnScope;" + + "Landroidx/compose/ui/Modifier;FZILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierWeightDefault))] + internal static partial IntPtr ColumnScopeModifierWeight(IntPtr columnScope, IntPtr modifier, float weight, bool fill); + + // Source-generated bridges below. Each [ComposeBridge] partial + // declaration is paired with a matching [ComposeDefaults] in + // ComposeDefaults.cs; the generator reads bit positions and parameter + // names from the enum and emits the cache fields, lazy class/method + // ID resolution, JValue array fill, $default bitmask, and try/finally + // with GC.KeepAlive. + + // androidx.compose.material3.TextKt.Text--4IGK_g + [ComposeBridge( + Class = "androidx/compose/material3/TextKt", + JvmName = "Text--4IGK_g", + Signature = "(Ljava/lang/String;Landroidx/compose/ui/Modifier;JJ" + + "Landroidx/compose/ui/text/font/FontStyle;" + + "Landroidx/compose/ui/text/font/FontWeight;" + + "Landroidx/compose/ui/text/font/FontFamily;J" + + "Landroidx/compose/ui/text/style/TextDecoration;" + + "Landroidx/compose/ui/text/style/TextAlign;JIZII" + + "Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/text/TextStyle;" + + "Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(TextDefault))] + [ComposeFacade] + public static partial void Text( + string text, + IModifier? modifier, + Color? 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, + AndroidX.Compose.UI.Text.TextStyle? style, + IComposer composer); + + // androidx.compose.material3.TextKt.Text-IbK3jfQ — the + // AnnotatedString overload. Mangled JVM name because Kotlin + // value-class params (Color/TextUnit/TextDecoration/TextAlign) + // produce a `-XxxxXXX` suffix that strips the overload from the + // binding. Has one extra slot vs the string variant: a `Map` + // `inlineContent` between minLines and onTextLayout. Hand-paired + // with the AnnotatedText facade (Text-generated facade can't host a + // second constructor pointing at a different bridge); see + // AnnotatedText.cs. + [ComposeBridge( + Class = "androidx/compose/material3/TextKt", + JvmName = "Text-IbK3jfQ", + Signature = "(Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;JJ" + + "Landroidx/compose/ui/text/font/FontStyle;" + + "Landroidx/compose/ui/text/font/FontWeight;" + + "Landroidx/compose/ui/text/font/FontFamily;J" + + "Landroidx/compose/ui/text/style/TextDecoration;" + + "Landroidx/compose/ui/text/style/TextAlign;JIZII" + + "Ljava/util/Map;Lkotlin/jvm/functions/Function1;" + + "Landroidx/compose/ui/text/TextStyle;" + + "Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(AnnotatedTextDefault))] + public static partial void TextAnnotated( + AnnotatedString text, + IModifier? modifier, + Color? 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, + AndroidX.Compose.UI.Text.TextStyle? style, + IComposer composer); + + // androidx.compose.material3.ButtonKt.Button + [ComposeBridge( + Class = "androidx/compose/material3/ButtonKt", + JvmName = "Button", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material3/ButtonColors;" + + "Landroidx/compose/material3/ButtonElevation;Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ButtonDefault))] + [ComposeFacade] + public static partial void Button(IFunction0 onClick, IModifier? modifier, + Shape? shape, AndroidX.Compose.Material3.ButtonColors? colors, + PaddingValues? contentPadding, + IFunction3 content, IComposer composer); + + // androidx.compose.material3.ButtonKt.OutlinedButton — same Kotlin + // signature as Button (10 user params with shape/colors/elevation/ + // border/contentPadding/interactionSource), so it reuses ButtonDefault. + [ComposeBridge( + Class = "androidx/compose/material3/ButtonKt", + JvmName = "OutlinedButton", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material3/ButtonColors;" + + "Landroidx/compose/material3/ButtonElevation;Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ButtonDefault))] + [ComposeFacade] + public static partial void OutlinedButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, AndroidX.Compose.Material3.ButtonColors? colors, + PaddingValues? contentPadding, + IFunction3 content, IComposer composer); + + // androidx.compose.material3.ButtonKt.TextButton — same shape as Button. + [ComposeBridge( + Class = "androidx/compose/material3/ButtonKt", + JvmName = "TextButton", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material3/ButtonColors;" + + "Landroidx/compose/material3/ButtonElevation;Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ButtonDefault))] + [ComposeFacade] + public static partial void TextButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, AndroidX.Compose.Material3.ButtonColors? colors, + PaddingValues? contentPadding, + IFunction3 content, IComposer composer); + + // androidx.compose.material3.ButtonKt.ElevatedButton — same shape as Button. + [ComposeBridge( + Class = "androidx/compose/material3/ButtonKt", + JvmName = "ElevatedButton", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material3/ButtonColors;" + + "Landroidx/compose/material3/ButtonElevation;Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ButtonDefault))] + [ComposeFacade] + public static partial void ElevatedButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, AndroidX.Compose.Material3.ButtonColors? colors, + PaddingValues? contentPadding, + IFunction3 content, IComposer composer); + + // androidx.compose.material3.ButtonKt.FilledTonalButton — same shape as Button. + [ComposeBridge( + Class = "androidx/compose/material3/ButtonKt", + JvmName = "FilledTonalButton", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material3/ButtonColors;" + + "Landroidx/compose/material3/ButtonElevation;Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ButtonDefault))] + [ComposeFacade] + public static partial void FilledTonalButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, AndroidX.Compose.Material3.ButtonColors? colors, + PaddingValues? contentPadding, + IFunction3 content, IComposer composer); + + // androidx.compose.material3.IconButtonKt.IconButton + [ComposeBridge( + Class = "androidx/compose/material3/IconButtonKt", + JvmName = "IconButton", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/material3/IconButtonColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(IconButtonDefault))] + [ComposeFacade] + public static partial void IconButton(IFunction0 onClick, IModifier? modifier, + IFunction2 content, IComposer composer); + + // androidx.compose.material3.IconButtonKt.FilledIconButton — adds Shape + // before colors compared to plain IconButton (7 user params). + [ComposeBridge( + Class = "androidx/compose/material3/IconButtonKt", + JvmName = "FilledIconButton", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/IconButtonColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(FilledIconButtonDefault))] + [ComposeFacade] + public static partial void FilledIconButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, + IFunction2 content, IComposer composer); + + // androidx.compose.material3.IconButtonKt.FilledTonalIconButton — same + // signature as FilledIconButton. + [ComposeBridge( + Class = "androidx/compose/material3/IconButtonKt", + JvmName = "FilledTonalIconButton", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/IconButtonColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(FilledIconButtonDefault))] + [ComposeFacade] + public static partial void FilledTonalIconButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, + IFunction2 content, IComposer composer); + + // androidx.compose.material3.IconButtonKt.OutlinedIconButton — adds + // BorderStroke between colors and interactionSource (8 user params). + [ComposeBridge( + Class = "androidx/compose/material3/IconButtonKt", + JvmName = "OutlinedIconButton", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/IconButtonColors;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(OutlinedIconButtonDefault))] + [ComposeFacade] + public static partial void OutlinedIconButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, + IFunction2 content, IComposer composer); + + // androidx.compose.material3.IconButtonKt.IconToggleButton — toggle + // shape: leading boolean checked + Function1 onCheckedChange instead of + // a Function0 onClick (7 user params). + [ComposeBridge( + Class = "androidx/compose/material3/IconButtonKt", + JvmName = "IconToggleButton", + Signature = "(ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/material3/IconToggleButtonColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(IconToggleButtonDefault))] + [ComposeFacade] + public static partial void IconToggleButton(bool @checked, [Callback(typeof(bool))] IFunction1 onCheckedChange, + IModifier? modifier, IFunction2 content, + IComposer composer); + + // androidx.compose.material3.IconButtonKt.FilledIconToggleButton — adds + // Shape between enabled and colors compared to plain IconToggleButton + // (8 user params). + [ComposeBridge( + Class = "androidx/compose/material3/IconButtonKt", + JvmName = "FilledIconToggleButton", + Signature = "(ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/IconToggleButtonColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(FilledIconToggleButtonDefault))] + [ComposeFacade] + public static partial void FilledIconToggleButton(bool @checked, [Callback(typeof(bool))] IFunction1 onCheckedChange, + IModifier? modifier, Shape? shape, IFunction2 content, + IComposer composer); + + // androidx.compose.material3.IconButtonKt.FilledTonalIconToggleButton — + // same signature as FilledIconToggleButton. + [ComposeBridge( + Class = "androidx/compose/material3/IconButtonKt", + JvmName = "FilledTonalIconToggleButton", + Signature = "(ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/IconToggleButtonColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(FilledIconToggleButtonDefault))] + [ComposeFacade] + public static partial void FilledTonalIconToggleButton(bool @checked, [Callback(typeof(bool))] IFunction1 onCheckedChange, + IModifier? modifier, Shape? shape, IFunction2 content, + IComposer composer); + + // androidx.compose.material3.IconButtonKt.OutlinedIconToggleButton — + // adds BorderStroke between colors and interactionSource (9 user params). + [ComposeBridge( + Class = "androidx/compose/material3/IconButtonKt", + JvmName = "OutlinedIconToggleButton", + Signature = "(ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/IconToggleButtonColors;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(OutlinedIconToggleButtonDefault))] + [ComposeFacade] + public static partial void OutlinedIconToggleButton(bool @checked, [Callback(typeof(bool))] IFunction1 onCheckedChange, + IModifier? modifier, Shape? shape, IFunction2 content, + IComposer composer); + + // androidx.compose.material3.FloatingActionButtonKt.FloatingActionButton-X-z6DiA + [ComposeBridge( + Class = "androidx/compose/material3/FloatingActionButtonKt", + JvmName = "FloatingActionButton-X-z6DiA", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;JJ" + + "Landroidx/compose/material3/FloatingActionButtonElevation;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(FloatingActionButtonDefault))] + [ComposeFacade] + public static partial void FloatingActionButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, + Color? containerColor, Color? contentColor, + IFunction2 content, IComposer composer); + + // androidx.compose.material3.FloatingActionButtonKt.SmallFloatingActionButton-X-z6DiA + [ComposeBridge( + Class = "androidx/compose/material3/FloatingActionButtonKt", + JvmName = "SmallFloatingActionButton-X-z6DiA", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;JJ" + + "Landroidx/compose/material3/FloatingActionButtonElevation;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(SmallFloatingActionButtonDefault))] + [ComposeFacade] + public static partial void SmallFloatingActionButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, + IFunction2 content, IComposer composer); + + // androidx.compose.material3.FloatingActionButtonKt.LargeFloatingActionButton-X-z6DiA + [ComposeBridge( + Class = "androidx/compose/material3/FloatingActionButtonKt", + JvmName = "LargeFloatingActionButton-X-z6DiA", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;JJ" + + "Landroidx/compose/material3/FloatingActionButtonElevation;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(LargeFloatingActionButtonDefault))] + [ComposeFacade] + public static partial void LargeFloatingActionButton(IFunction0 onClick, IModifier? modifier, + Shape? shape, + IFunction2 content, IComposer composer); + + // androidx.compose.material3.FloatingActionButtonKt.ExtendedFloatingActionButton-ElI5-7k + // (icon + text + expanded multi-slot variant — the canonical animated extended FAB) + [ComposeBridge( + Class = "androidx/compose/material3/FloatingActionButtonKt", + JvmName = "ExtendedFloatingActionButton-ElI5-7k", + Signature = "(Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;JJ" + + "Landroidx/compose/material3/FloatingActionButtonElevation;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ExtendedFloatingActionButtonDefault))] + [ComposeFacade] + public static partial void ExtendedFloatingActionButton( + IFunction2 text, + IFunction2 icon, + IFunction0 onClick, + IModifier? modifier, + bool expanded, + Shape? shape, + Color? containerColor, Color? contentColor, + IComposer composer); + + // androidx.compose.material3.SurfaceKt.Surface-T9BRK9s (non-interactive) + [ComposeBridge( + Class = "androidx/compose/material3/SurfaceKt", + JvmName = "Surface-T9BRK9s", + Signature = "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Shape;JJFF" + + "Landroidx/compose/foundation/BorderStroke;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(SurfaceDefault))] + [ComposeFacade] + public static partial void Surface(IModifier? modifier, Shape? shape, Color? color, IFunction2 content, IComposer composer); + + // androidx.compose.foundation.ImageKt.Image (Painter overload) — all + // four `Image` Kotlin overloads share the JVM name `Image`, so the + // binder strips them. The Painter type itself isn't bound either + // (inline-class methods like getIntrinsicSize-NH-jbRc), so callers + // pass a raw IntPtr Painter handle obtained from + // ComposeBridges.PainterResource. + [ComposeBridge( + Class = "androidx/compose/foundation/ImageKt", + JvmName = "Image", + Signature = "(Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;" + + "Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;" + + "Landroidx/compose/ui/layout/ContentScale;F" + + "Landroidx/compose/ui/graphics/ColorFilter;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ImageDefault))] + [ComposeFacade] + public static partial void Image( + [PainterResource] Painter painter, + string? contentDescription, + IModifier? modifier, + Alignment? alignment, + ContentScale? contentScale, + float? alpha, + int defaults, + IComposer composer); + + // androidx.compose.material3.IconKt.Icon-ww6aTOc (Painter overload) — + // the Painter and ImageBitmap overloads share the mangled JVM name + // `Icon-ww6aTOc` with the bound ImageVector overload and are + // stripped. Painter handles come from ComposeBridges.PainterResource. + [ComposeBridge( + Class = "androidx/compose/material3/IconKt", + JvmName = "Icon-ww6aTOc", + Signature = "(Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;" + + "Landroidx/compose/ui/Modifier;JLandroidx/compose/runtime/Composer;II)V", + Defaults = typeof(IconPainterDefault))] + [ComposeFacade( + ClassName = "Icon", + SecondaryCtor = nameof(IconImageVector), + SecondaryDefaults = typeof(IconDefault))] + public static partial void IconPainter( + [PainterResource] Painter painter, + string? contentDescription, + IModifier? modifier, + Color? tint, + int defaults, + IComposer composer); + + // Phase 11 secondary — thin wrapper over the bound + // androidx.compose.material3.IconKt.Icon(ImageVector, ...) + // overload. Mirrors IconPainter's user-param shape but swaps the + // discriminator (Painter → ImageVector) so the generated Icon + // facade can dispatch by ctor. No [ComposeBridge] needed — the + // ImageVector overload is fully bound; the facade reaches it via + // SecondaryDefaults pointing at the IconDefault enum. + // + // `tint` is `Color?` (a registered Compose value type, lowered to + // the JNI `J` slot via the implicit `Color -> long` conversion) so + // the facade generator classifies it as OptionalValue and only + // clears the `$default` bit when the caller assigns a non-null + // Tint — otherwise Kotlin falls back to `LocalContentColor.current`. + // A non-nullable `long` (or `Color`) would unconditionally clear + // the bit and pass `0L` (transparent black) to Kotlin, breaking + // theme-inherited icon tinting. + public static void IconImageVector( + global::AndroidX.Compose.UI.Graphics.Vector.ImageVector imageVector, + string? contentDescription, + IModifier? modifier, + Color? tint, + int defaults, + IComposer composer) => + IconKt.Icon( + imageVector: imageVector, + contentDescription: contentDescription!, + modifier: modifier, + tint: tint.GetValueOrDefault(), + _composer: composer, + p5: 0, + _changed: defaults); + + // androidx.compose.material3.TextFieldKt.TextField (String overload) + const string TextFieldStringSig = + "(Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZZ" + + "Landroidx/compose/ui/text/TextStyle;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/ui/text/input/VisualTransformation;" + + "Landroidx/compose/foundation/text/KeyboardOptions;" + + "Landroidx/compose/foundation/text/KeyboardActions;ZII" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/TextFieldColors;" + + "Landroidx/compose/runtime/Composer;IIII)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/TextFieldKt", + JvmName = "TextField", + Signature = TextFieldStringSig, + Defaults = typeof(TextFieldDefault))] + public static partial void TextField( + string value, + [Callback(typeof(string))] IFunction1 onValueChange, + IModifier? modifier, + bool? enabled, + bool? readOnly, + AndroidX.Compose.UI.Text.TextStyle? textStyle, + IFunction2? label, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction2? prefix, + IFunction2? suffix, + IFunction2? supportingText, + bool? isError, + AndroidX.Compose.UI.Text.Input.IVisualTransformation? visualTransformation, + AndroidX.Compose.Foundation.Text.KeyboardOptions? keyboardOptions, + bool? singleLine, + int? maxLines, + int? minLines, + Shape? shape, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/OutlinedTextFieldKt", + JvmName = "OutlinedTextField", + Signature = TextFieldStringSig, + Defaults = typeof(TextFieldDefault))] + public static partial void OutlinedTextField( + string value, + [Callback(typeof(string))] IFunction1 onValueChange, + IModifier? modifier, + bool? enabled, + bool? readOnly, + AndroidX.Compose.UI.Text.TextStyle? textStyle, + IFunction2? label, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction2? prefix, + IFunction2? suffix, + IFunction2? supportingText, + bool? isError, + AndroidX.Compose.UI.Text.Input.IVisualTransformation? visualTransformation, + AndroidX.Compose.Foundation.Text.KeyboardOptions? keyboardOptions, + AndroidX.Compose.Foundation.Text.KeyboardActions? keyboardActions, + bool? singleLine, + int? maxLines, + int? minLines, + Shape? shape, + IComposer composer); + + // androidx.compose.foundation.text.BasicTextFieldKt.BasicTextField (String overload) — the + // un-decorated text field (no Material container or indicator line), used for a borderless + // input that blends into its surroundings. We expose only value/onValueChange/modifier/ + // textStyle/singleLine; everything else (cursorBrush, decorationBox, keyboard*, + // interactionSource, …) keeps its Kotlin default. 16 user params → III tail (two $changed + one + // $default mask). + const string BasicTextFieldStringSig = + "(Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZZ" + + "Landroidx/compose/ui/text/TextStyle;" + + "Landroidx/compose/foundation/text/KeyboardOptions;" + + "Landroidx/compose/foundation/text/KeyboardActions;ZII" + + "Landroidx/compose/ui/text/input/VisualTransformation;" + + "Lkotlin/jvm/functions/Function1;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/ui/graphics/Brush;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;III)V"; + + [ComposeBridge( + Class = "androidx/compose/foundation/text/BasicTextFieldKt", + JvmName = "BasicTextField", + Signature = BasicTextFieldStringSig, + Defaults = typeof(BasicTextFieldDefault))] + public static partial void BasicTextField( + string value, + [Callback(typeof(string))] IFunction1 onValueChange, + IModifier? modifier, + AndroidX.Compose.UI.Text.TextStyle? textStyle, + AndroidX.Compose.Foundation.Text.KeyboardOptions? keyboardOptions, + AndroidX.Compose.Foundation.Text.KeyboardActions? keyboardActions, + bool? singleLine, + IComposer composer); + + // androidx.compose.foundation.text.BasicTextFieldKt.BasicTextField (TextFieldValue overload) — + // identical shape to the String overload (BasicTextFieldDefault bit layout matches + // one-for-one) except slot 0 is androidx/compose/ui/text/input/TextFieldValue. Drives + // caret/selection programmatically (insert-at-cursor); the onValueChange hands back the + // full TextFieldValue (text + selection + composition). + const string BasicTextFieldValueSig = + "(Landroidx/compose/ui/text/input/TextFieldValue;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZZ" + + "Landroidx/compose/ui/text/TextStyle;" + + "Landroidx/compose/foundation/text/KeyboardOptions;" + + "Landroidx/compose/foundation/text/KeyboardActions;ZII" + + "Landroidx/compose/ui/text/input/VisualTransformation;" + + "Lkotlin/jvm/functions/Function1;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/ui/graphics/Brush;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;III)V"; + + [ComposeBridge( + Class = "androidx/compose/foundation/text/BasicTextFieldKt", + JvmName = "BasicTextField", + Signature = BasicTextFieldValueSig, + Defaults = typeof(BasicTextFieldDefault))] + public static partial void BasicTextFieldWithValue( + AndroidX.Compose.UI.Text.Input.TextFieldValue value, + IFunction1 onValueChange, + IModifier? modifier, + AndroidX.Compose.UI.Text.TextStyle? textStyle, + AndroidX.Compose.Foundation.Text.KeyboardOptions? keyboardOptions, + AndroidX.Compose.Foundation.Text.KeyboardActions? keyboardActions, + bool? singleLine, + IComposer composer); + + // androidx.compose.material3.TextFieldKt.TextField (TextFieldValue + // overload) and OutlinedTextFieldKt.OutlinedTextField (TextFieldValue + // overload). Same 23 user-param shape as the String overload — bit + // layout in TextFieldDefault matches one-for-one — except slot 0 is + // androidx/compose/ui/text/input/TextFieldValue instead of java/lang/String. + // Used by the TextField(MutableState) ctor to drive + // caret placement after programmatic edits (see issue #204). + const string TextFieldValueSig = + "(Landroidx/compose/ui/text/input/TextFieldValue;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZZ" + + "Landroidx/compose/ui/text/TextStyle;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/ui/text/input/VisualTransformation;" + + "Landroidx/compose/foundation/text/KeyboardOptions;" + + "Landroidx/compose/foundation/text/KeyboardActions;ZII" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/TextFieldColors;" + + "Landroidx/compose/runtime/Composer;IIII)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/TextFieldKt", + JvmName = "TextField", + Signature = TextFieldValueSig, + Defaults = typeof(TextFieldDefault))] + public static partial void TextFieldWithValue( + AndroidX.Compose.UI.Text.Input.TextFieldValue value, + IFunction1 onValueChange, + IModifier? modifier, + bool? enabled, + bool? readOnly, + AndroidX.Compose.UI.Text.TextStyle? textStyle, + IFunction2? label, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction2? prefix, + IFunction2? suffix, + IFunction2? supportingText, + bool? isError, + AndroidX.Compose.UI.Text.Input.IVisualTransformation? visualTransformation, + AndroidX.Compose.Foundation.Text.KeyboardOptions? keyboardOptions, + bool? singleLine, + int? maxLines, + int? minLines, + Shape? shape, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/OutlinedTextFieldKt", + JvmName = "OutlinedTextField", + Signature = TextFieldValueSig, + Defaults = typeof(TextFieldDefault))] + public static partial void OutlinedTextFieldWithValue( + AndroidX.Compose.UI.Text.Input.TextFieldValue value, + IFunction1 onValueChange, + IModifier? modifier, + bool? enabled, + bool? readOnly, + AndroidX.Compose.UI.Text.TextStyle? textStyle, + IFunction2? label, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction2? prefix, + IFunction2? suffix, + IFunction2? supportingText, + bool? isError, + AndroidX.Compose.UI.Text.Input.IVisualTransformation? visualTransformation, + AndroidX.Compose.Foundation.Text.KeyboardOptions? keyboardOptions, + AndroidX.Compose.Foundation.Text.KeyboardActions? keyboardActions, + bool? singleLine, + int? maxLines, + int? minLines, + Shape? shape, + IComposer composer); + + // androidx.compose.material3.SecureTextFieldKt.{SecureTextField,OutlinedSecureTextField}-XvU6IwQ. + // Both overloads have identical 23-user-param signatures: the + // TextFieldState peer, a Modifier, 19 object slots (TextStyle, + // TextFieldLabelPosition, six Function2/3 lambdas, InputTransformation, + // KeyboardOptions, KeyboardActionHandler, an onTextLayout Function2, + // Shape, TextFieldColors, PaddingValues, MutableInteractionSource), + // 2 booleans (enabled, isError), 1 int + 1 char for the inline-class + // textObfuscationMode/textObfuscationCharacter pair, then the trailing + // Composer + IIII (3 $changed groups + 1 $default int). Hashed JVM + // names come from the inline-class compiler mangling on + // textObfuscationMode (TextObfuscationMode is @JvmInline value class + // wrapping Int). + const string SecureTextFieldSig = + "(Landroidx/compose/foundation/text/input/TextFieldState;" + + "Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/text/TextStyle;" + + "Landroidx/compose/material3/TextFieldLabelPosition;" + + "Lkotlin/jvm/functions/Function3;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/foundation/text/input/InputTransformation;IC" + + "Landroidx/compose/foundation/text/KeyboardOptions;" + + "Landroidx/compose/foundation/text/input/KeyboardActionHandler;" + + "Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/TextFieldColors;" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;IIII)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/SecureTextFieldKt", + JvmName = "SecureTextField-XvU6IwQ", + Signature = SecureTextFieldSig, + Defaults = typeof(SecureTextFieldDefault))] + public static partial void SecureTextField( + IntPtr state, + IModifier? modifier, + bool enabled, + IFunction3? label, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction2? supportingText, + bool isError, + Shape? shape, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/SecureTextFieldKt", + JvmName = "OutlinedSecureTextField-XvU6IwQ", + Signature = SecureTextFieldSig, + Defaults = typeof(OutlinedSecureTextFieldDefault))] + public static partial void OutlinedSecureTextField( + IntPtr state, + IModifier? modifier, + bool enabled, + IFunction3? label, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction2? supportingText, + bool isError, + Shape? shape, + IComposer composer); + + // androidx.compose.material3.AndroidAlertDialog_androidKt.AlertDialog-Oix01E0 + [ComposeBridge( + Class = "androidx/compose/material3/AndroidAlertDialog_androidKt", + JvmName = "AlertDialog-Oix01E0", + Signature = "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;JJJJF" + + "Landroidx/compose/ui/window/DialogProperties;" + + "Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(AlertDialogDefault))] + [ComposeFacade] + public static partial void AlertDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IModifier? modifier, + IFunction2? dismissButton, + IFunction2? icon, + IFunction2? title, + IFunction2? text, + Shape? shape, + int defaults, + IComposer composer); + + // androidx.compose.material3.ModalBottomSheet_androidKt.ModalBottomSheet-dYc4hso + [ComposeBridge( + Class = "androidx/compose/material3/ModalBottomSheet_androidKt", + JvmName = "ModalBottomSheet-dYc4hso", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/SheetState;FLandroidx/compose/ui/graphics/Shape;JJFJ" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/ModalBottomSheetProperties;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(ModalBottomSheetDefault))] + [ComposeFacade(Scope = "Column")] + public static partial void ModalBottomSheet( + IFunction0 onDismissRequest, + IModifier? modifier, + [StateHolder(Remember = nameof(RememberSheetState), + StateType = typeof(SheetStateHolder), + SharedState = true)] + IntPtr sheetState, + IFunction2? dragHandle, + Shape? shape, + IFunction3 content, + int defaults, + IComposer composer); + + // androidx.compose.material3.BottomSheetScaffoldKt.BottomSheetScaffold-sdMYb0k + [ComposeBridge( + Class = "androidx/compose/material3/BottomSheetScaffoldKt", + JvmName = "BottomSheetScaffold-sdMYb0k", + Signature = "(Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/BottomSheetScaffoldState;FF" + + "Landroidx/compose/ui/graphics/Shape;JJFF" + + "Lkotlin/jvm/functions/Function2;Z" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;JJ" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(BottomSheetScaffoldDefault))] + public static partial void BottomSheetScaffold( + IFunction3 sheetContent, + IModifier? modifier, + IntPtr scaffoldState, + IFunction2? sheetDragHandle, + IFunction2? topBar, + IFunction3? snackbarHost, + IFunction3 content, + int defaults, + IComposer composer); + + // androidx.compose.material3.ScaffoldKt.Scaffold-TvnljyQ + [ComposeBridge( + Class = "androidx/compose/material3/ScaffoldKt", + JvmName = "Scaffold-TvnljyQ", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "IJJ" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ScaffoldDefault))] + public static partial void Scaffold( + IModifier? modifier, + IFunction2? topBar, + IFunction2? bottomBar, + IFunction2? snackbarHost, + IFunction2? floatingActionButton, + IFunction3 content, + int defaults, + IComposer composer); + + // androidx.compose.material3.DatePickerDialog_androidKt.DatePickerDialog-GmEhDVc + [ComposeBridge( + Class = "androidx/compose/material3/DatePickerDialog_androidKt", + JvmName = "DatePickerDialog-GmEhDVc", + Signature = "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;F" + + "Landroidx/compose/material3/DatePickerColors;" + + "Landroidx/compose/ui/window/DialogProperties;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(DatePickerDialogDefault))] + [ComposeFacade] + public static partial void DatePickerDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IModifier? modifier, + IFunction2? dismissButton, + [Slot("Body")] + IFunction3 content, + int defaults, + IComposer composer); + + // DateRangePickerDialog is just DatePickerDialog with a DateRangePicker + // body — same Kotlin composable, same $default bitmask. Wrapper-passthrough + // gives us a distinct C# facade name (and matching XML doc focus) without + // a second JNI bridge. + [ComposeFacade(Defaults = typeof(DatePickerDialogDefault))] + public static partial void DateRangePickerDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IModifier? modifier, + IFunction2? dismissButton, + [Slot("Body")] + IFunction3 content, + int defaults, + IComposer composer); + + public static partial void DateRangePickerDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IModifier? modifier, + IFunction2? dismissButton, + IFunction3 content, + int defaults, + IComposer composer) + => DatePickerDialog(onDismissRequest, confirmButton, modifier, dismissButton, content, defaults, composer); + + // androidx.compose.material3.TimePickerKt.TimePicker-mT9BvqQ + [ComposeBridge( + Class = "androidx/compose/material3/TimePickerKt", + JvmName = "TimePicker-mT9BvqQ", + Signature = "(Landroidx/compose/material3/TimePickerState;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/TimePickerColors;ILandroidx/compose/runtime/Composer;II)V", + Defaults = typeof(TimePickerDefault))] + [ComposeFacade] + public static partial void TimePicker( + [StateHolder(Remember = nameof(RememberTimePickerState), + StateType = typeof(TimePickerState), + SharedState = true)] IntPtr state, + IModifier? modifier, + int defaults, IComposer composer); + + // androidx.compose.material3.TimePickerKt.TimeInput + [ComposeBridge( + Class = "androidx/compose/material3/TimePickerKt", + JvmName = "TimeInput", + Signature = "(Landroidx/compose/material3/TimePickerState;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/TimePickerColors;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(TimeInputDefault))] + [ComposeFacade] + public static partial void TimeInput( + [StateHolder(Remember = nameof(RememberTimePickerState), + StateType = typeof(TimePickerState), + SharedState = true)] IntPtr state, + IModifier? modifier, + int defaults, IComposer composer); + + // androidx.compose.material3.TimePickerDialogKt.TimePickerDialog-FItCLgY + [ComposeBridge( + Class = "androidx/compose/material3/TimePickerDialogKt", + JvmName = "TimePickerDialog-FItCLgY", + Signature = "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/window/DialogProperties;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;J" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(TimePickerDialogDefault))] + [ComposeFacade] + public static partial void TimePickerDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IFunction2 dismissButton, + IModifier? modifier, + IFunction2? title, + IFunction2? modeToggleButton, + [Slot("Body")] + IFunction3 content, + int defaults, + IComposer composer); + + // androidx.compose.material3.TooltipKt.TooltipBox (7-user-param overload) + [ComposeBridge( + Class = "androidx/compose/material3/TooltipKt", + JvmName = "TooltipBox", + Signature = "(Landroidx/compose/ui/window/PopupPositionProvider;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/material3/TooltipState;" + + "Landroidx/compose/ui/Modifier;ZZ" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(TooltipBoxDefault))] + public static partial void TooltipBox( + IntPtr positionProvider, + IFunction3 tooltip, + IntPtr state, + IModifier? modifier, + IFunction2 content, + int defaults, + IComposer composer); + + // androidx.compose.material3.DatePickerKt.DatePicker + [ComposeBridge( + Class = "androidx/compose/material3/DatePickerKt", + JvmName = "DatePicker", + Signature = "(Landroidx/compose/material3/DatePickerState;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/DatePickerFormatter;" + + "Landroidx/compose/material3/DatePickerColors;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/ui/focus/FocusRequester;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(DatePickerDefault))] + [ComposeFacade] + public static partial void DatePicker( + [StateHolder(Remember = nameof(RememberDatePickerState), + StateType = typeof(DatePickerState))] + IntPtr state, + IModifier? modifier, + int defaults, + IComposer composer); + + // androidx.compose.material3.DatePickerKt.rememberDatePickerState-EU0dCGE + // + // Phase 4b parameterised Remember (issue #264). Every Kotlin user + // slot is surfaced — the bridge generator can't skip non-trailing + // slots. The auto-default-mask clears bit N for every non-null + // param the caller supplied; null leaves the Kotlin default in + // place (the matching `RememberDatePickerStateDefault` bit stays + // set). + // + // Wrapper-member resolution (StateType = DatePickerState): + // initialSelectedDateMillis -> InitialSelectedDateMillis (Java.Lang.Long?) + // initialDisplayedMonthMillis-> InitialDisplayedMonthMillis (Java.Lang.Long?) + // yearRange -> InitialYearRange (IntRange?) + // initialDisplayMode -> InitialDisplayMode (int?) + // selectableDates -> InitialSelectableDates (ISelectableDates?) + // + // The JNI slot for `Long`/`IntRange`/`SelectableDates` is `L…;`, + // so the C# bridge param types must be reference (boxed Long, + // IntRange, ISelectableDates). The `initialDisplayMode` slot is + // `I` so `int?` is correct (auto-mask passes 0 + sets the bit + // when caller leaves it null). + [ComposeBridge( + Class = "androidx/compose/material3/DatePickerKt", + JvmName = "rememberDatePickerState-EU0dCGE", + Signature = "(Ljava/lang/Long;Ljava/lang/Long;Lkotlin/ranges/IntRange;I" + + "Landroidx/compose/material3/SelectableDates;" + + "Landroidx/compose/runtime/Composer;II)Landroidx/compose/material3/DatePickerState;", + Defaults = typeof(RememberDatePickerStateDefault))] + public static partial IntPtr RememberDatePickerState( + Java.Lang.Long? initialSelectedDateMillis, + Java.Lang.Long? initialDisplayedMonthMillis, + IntRange? yearRange, + int? initialDisplayMode, + AndroidX.Compose.Material3.ISelectableDates? selectableDates, + IComposer composer); + + // androidx.compose.material3.DateRangePickerKt.DateRangePicker + [ComposeBridge( + Class = "androidx/compose/material3/DateRangePickerKt", + JvmName = "DateRangePicker", + Signature = "(Landroidx/compose/material3/DateRangePickerState;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/DatePickerFormatter;" + + "Landroidx/compose/material3/DatePickerColors;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/ui/focus/FocusRequester;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(DateRangePickerDefault))] + [ComposeFacade] + public static partial void DateRangePicker( + [StateHolder(Remember = nameof(RememberDateRangePickerState), + StateType = typeof(DateRangePickerState))] + IntPtr state, + IModifier? modifier, + int defaults, + IComposer composer); + + // androidx.compose.material3.DateRangePickerKt.rememberDateRangePickerState-IlFM19s + [ComposeBridge( + Class = "androidx/compose/material3/DateRangePickerKt", + JvmName = "rememberDateRangePickerState-IlFM19s", + Signature = "(Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lkotlin/ranges/IntRange;I" + + "Landroidx/compose/material3/SelectableDates;" + + "Landroidx/compose/runtime/Composer;II)Landroidx/compose/material3/DateRangePickerState;", + Defaults = typeof(RememberDateRangePickerStateDefault))] + public static partial IntPtr RememberDateRangePickerState(IComposer composer); + + // androidx.compose.material3.TimePickerKt.rememberTimePickerState + [ComposeBridge( + Class = "androidx/compose/material3/TimePickerKt", + JvmName = "rememberTimePickerState", + Signature = "(IIZLandroidx/compose/runtime/Composer;II)Landroidx/compose/material3/TimePickerState;", + Defaults = typeof(RememberTimePickerStateDefault))] + public static partial IntPtr RememberTimePickerState(int initialHour, int initialMinute, + bool is24Hour, IComposer composer); + + // androidx.compose.material3.NavigationDrawerKt.rememberDrawerState. + // The Kotlin function is bound natively so we go through it instead + // of duplicating the JNI plumbing here. The [ComposeFacade]-generated + // facade hands us the per-instance JCW veto adapter as + // confirmStateChange (stable JNI identity = stable `remember` key); + // initialValue comes from the wrapper's InitialValue property. The + // [ConfirmStateChange] attribute is read by the facade generator off + // this declaration when resolving the Remember parameter to a + // per-instance JCW field instead of a state-wrapper member. + public static IntPtr RememberDrawerState( + DrawerValue initialValue, + [ConfirmStateChange(typeof(DrawerValue), + AdapterType = typeof(DrawerValueConfirmStateChange))] + IFunction1 confirmStateChange, + IComposer composer) + { + var state = AndroidX.Compose.Material3.NavigationDrawerKt.RememberDrawerState( + initialValue: initialValue, + confirmStateChange: confirmStateChange, + _composer: composer, + p3: 0, + _changed: 0); + return ((Java.Lang.Object)state).Handle; + } + + // androidx.compose.material3.WideNavigationRailStateKt.rememberWideNavigationRailState. + // No confirmStateChange callback; the rail's state is driven entirely + // from C# via the wrapper's InitialValue property. Kotlin function is + // bound natively — wrapper-passthrough only. + public static IntPtr RememberWideNavigationRailState( + WideNavigationRailValue initialValue, + IComposer composer) + { + var state = AndroidX.Compose.Material3.WideNavigationRailStateKt.RememberWideNavigationRailState( + initialValue: initialValue, + _composer: composer, + p2: 0, + _changed: 0); + return ((Java.Lang.Object)state).Handle; + } + + // androidx.compose.material3.ModalBottomSheetKt.rememberModalBottomSheetState. + // Bound binding — wrapper-passthrough only. SkipPartiallyExpanded + // comes from the SheetStateHolder wrapper's like-named property + // (Phase 4b parameterised Remember). The veto adapter is wired by + // the facade generator: it reads [ConfirmStateChange] off this + // declaration and allocates a per-instance JCW field whose JNI + // identity stays stable across recompositions (so `remember` cache + // key holds and the sheet keeps its position). + public static IntPtr RememberSheetState( + bool skipPartiallyExpanded, + [ConfirmStateChange(typeof(global::AndroidX.Compose.Material3.SheetValue), + AdapterType = typeof(SheetValueConfirmStateChange), + PropertyName = "ConfirmValueChange")] + IFunction1 confirmValueChange, + IComposer composer) + { + var state = AndroidX.Compose.Material3.ModalBottomSheetKt.RememberModalBottomSheetState( + skipPartiallyExpanded: skipPartiallyExpanded, + confirmValueChange: confirmValueChange, + _composer: composer, + p3: 0, + _changed: 0); + return ((Java.Lang.Object)state).Handle; + } + + // androidx.compose.material3.TooltipKt.rememberTooltipState + [ComposeBridge( + Class = "androidx/compose/material3/TooltipKt", + JvmName = "rememberTooltipState", + Signature = "(ZZLandroidx/compose/foundation/MutatorMutex;Landroidx/compose/runtime/Composer;II)Landroidx/compose/material3/TooltipState;", + Defaults = typeof(RememberTooltipStateDefault))] + public static partial IntPtr RememberTooltipState(bool isPersistent, IComposer composer); + + // androidx.compose.material3.TooltipDefaults.INSTANCE.rememberPlainTooltipPositionProvider-kHDZbjc + // Instance method on a Kotlin object singleton. + [ComposeBridge( + Class = "androidx/compose/material3/TooltipDefaults", + JvmName = "rememberPlainTooltipPositionProvider-kHDZbjc", + Signature = "(FLandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/window/PopupPositionProvider;", + Defaults = typeof(RememberPlainTooltipPositionProviderDefault), + InstanceField = "INSTANCE")] + public static partial IntPtr RememberPlainTooltipPositionProvider(IComposer composer); + + // androidx.compose.material3.pulltorefresh.PullToRefreshKt.PullToRefreshBox + [ComposeBridge( + Class = "androidx/compose/material3/pulltorefresh/PullToRefreshKt", + JvmName = "PullToRefreshBox", + Signature = "(ZLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/pulltorefresh/PullToRefreshState;" + + "Landroidx/compose/ui/Alignment;Lkotlin/jvm/functions/Function3;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(PullToRefreshBoxDefault))] + [ComposeFacade] + public static partial void PullToRefreshBox( + bool isRefreshing, + IFunction0 onRefresh, + [StateHolder(Remember = nameof(RememberPullToRefreshState), + StateType = typeof(PullToRefreshState))] + IntPtr state, + IModifier? modifier, + IFunction3 content, + int defaults, + IComposer composer); + + // androidx.compose.material3.pulltorefresh.PullToRefreshKt.rememberPullToRefreshState + // No-arg @Composable; signature has only the trailing _changed I slot + // (no $default), so omit Defaults entirely per the bridge generator + // contract (see CN2005). + [ComposeBridge( + Class = "androidx/compose/material3/pulltorefresh/PullToRefreshKt", + JvmName = "rememberPullToRefreshState", + Signature = "(Landroidx/compose/runtime/Composer;I)" + + "Landroidx/compose/material3/pulltorefresh/PullToRefreshState;")] + public static partial IntPtr RememberPullToRefreshState(IComposer composer); + + // androidx.compose.material3.CardKt.Card (non-clickable) + const string CardSig = + "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/CardColors;Landroidx/compose/material3/CardElevation;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/CardKt", + JvmName = "Card", + Signature = CardSig, + Defaults = typeof(CardDefault))] + [ComposeFacade] + public static partial void Card(IModifier? modifier, Shape? shape, IFunction3 content, IComposer composer); + + // androidx.compose.material3.CardKt.Card — the CLICKABLE overload (the gold's + // tappable cards, e.g. JetNews PostCardPopular). Same JvmName; resolved by signature. + [ComposeBridge( + Class = "androidx/compose/material3/CardKt", + JvmName = "Card", + Signature = "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;Landroidx/compose/material3/CardColors;" + + "Landroidx/compose/material3/CardElevation;Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ClickableCardDefault))] + [ComposeFacade] + public static partial void ClickableCard(IFunction0 onClick, IModifier? modifier, Shape? shape, IFunction3 content, IComposer composer); + + // androidx.compose.material3.CardKt.OutlinedCard (same shape as Card) + [ComposeBridge( + Class = "androidx/compose/material3/CardKt", + JvmName = "OutlinedCard", + Signature = CardSig, + Defaults = typeof(CardDefault))] + [ComposeFacade] + public static partial void OutlinedCard(Shape? shape, IFunction3 content, IComposer composer); + + // androidx.compose.material3.CardKt.ElevatedCard (no border) + [ComposeBridge( + Class = "androidx/compose/material3/CardKt", + JvmName = "ElevatedCard", + Signature = "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/CardColors;Landroidx/compose/material3/CardElevation;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ElevatedCardDefault))] + [ComposeFacade] + public static partial void ElevatedCard(Shape? shape, IFunction3 content, IComposer composer); + + const string AssistChipSig = + "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/ChipColors;Landroidx/compose/material3/ChipElevation;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;III)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/ChipKt", + JvmName = "AssistChip", + Signature = AssistChipSig, + Defaults = typeof(AssistChipDefault))] + [ComposeFacade] + public static partial void AssistChip( + IFunction0 onClick, + IFunction2 label, + IModifier? modifier, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + Shape? shape, + int defaults, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/ChipKt", + JvmName = "ElevatedAssistChip", + Signature = AssistChipSig, + Defaults = typeof(AssistChipDefault))] + [ComposeFacade] + public static partial void ElevatedAssistChip( + IFunction0 onClick, + IFunction2 label, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + Shape? shape, + int defaults, + IComposer composer); + + const string FilterChipSig = + "(ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/SelectableChipColors;" + + "Landroidx/compose/material3/SelectableChipElevation;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;III)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/ChipKt", + JvmName = "FilterChip", + Signature = FilterChipSig, + Defaults = typeof(FilterChipDefault))] + [ComposeFacade] + public static partial void FilterChip( + bool selected, + IFunction0 onClick, + IFunction2 label, + IModifier? modifier, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + Shape? shape, + int defaults, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/ChipKt", + JvmName = "ElevatedFilterChip", + Signature = FilterChipSig, + Defaults = typeof(FilterChipDefault))] + [ComposeFacade] + public static partial void ElevatedFilterChip( + bool selected, + IFunction0 onClick, + IFunction2 label, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + Shape? shape, + int defaults, + IComposer composer); + + // androidx.compose.material3.ChipKt.InputChip + [ComposeBridge( + Class = "androidx/compose/material3/ChipKt", + JvmName = "InputChip", + Signature = "(ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/SelectableChipColors;" + + "Landroidx/compose/material3/SelectableChipElevation;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(InputChipDefault))] + [ComposeFacade] + public static partial void InputChip( + bool selected, + IFunction0 onClick, + IFunction2 label, + IModifier? modifier, + IFunction2? leadingIcon, + IFunction2? avatar, + IFunction2? trailingIcon, + Shape? shape, + int defaults, + IComposer composer); + + const string SuggestionChipSig = + "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/ChipColors;Landroidx/compose/material3/ChipElevation;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;II)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/ChipKt", + JvmName = "SuggestionChip", + Signature = SuggestionChipSig, + Defaults = typeof(SuggestionChipDefault))] + [ComposeFacade] + public static partial void SuggestionChip( + IFunction0 onClick, + IFunction2 label, + IModifier? modifier, + IFunction2? icon, + Shape? shape, + int defaults, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/ChipKt", + JvmName = "ElevatedSuggestionChip", + Signature = SuggestionChipSig, + Defaults = typeof(SuggestionChipDefault))] + [ComposeFacade] + public static partial void ElevatedSuggestionChip( + IFunction0 onClick, + IFunction2 label, + IFunction2? icon, + Shape? shape, + int defaults, + IComposer composer); + + // androidx.compose.material3.NavigationBarKt.NavigationBar-HsRjFd4 + [ComposeBridge( + Class = "androidx/compose/material3/NavigationBarKt", + JvmName = "NavigationBar-HsRjFd4", + Signature = "(Landroidx/compose/ui/Modifier;JJF" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(NavigationBarDefault))] + [ComposeFacade(Scope = "Row")] + public static partial void NavigationBar(IModifier? modifier, IFunction3 content, IComposer composer); + + // androidx.compose.material3.NavigationBarKt.NavigationBarItem (RowScope receiver) + [ComposeBridge( + Class = "androidx/compose/material3/NavigationBarKt", + JvmName = "NavigationBarItem", + Signature = "(Landroidx/compose/foundation/layout/RowScope;Z" + + "Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/material3/NavigationBarItemColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(NavigationBarItemDefault))] + [ComposeFacade] + public static partial void NavigationBarItem( + IntPtr rowScope, + bool selected, + IFunction0 onClick, + IFunction2 icon, + IModifier? modifier, + IFunction2? label, + bool enabled = true, + bool alwaysShowLabel = true, + int defaults = 0, + IComposer composer = null!); + + // androidx.compose.material3.NavigationRailKt.NavigationRail-qi6gXK8 + [ComposeBridge( + Class = "androidx/compose/material3/NavigationRailKt", + JvmName = "NavigationRail-qi6gXK8", + Signature = "(Landroidx/compose/ui/Modifier;JJ" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(NavigationRailDefault))] + [ComposeFacade] + public static partial void NavigationRail(IModifier? modifier, IFunction3 content, IComposer composer); + + // androidx.compose.material3.NavigationRailKt.NavigationRailItem + [ComposeBridge( + Class = "androidx/compose/material3/NavigationRailKt", + JvmName = "NavigationRailItem", + Signature = "(ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/material3/NavigationRailItemColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(NavigationRailItemDefault))] + [ComposeFacade] + public static partial void NavigationRailItem( + bool selected, + IFunction0 onClick, + IFunction2 icon, + IModifier? modifier, + IFunction2? label, + bool? alwaysShowLabel, + int defaults, + IComposer composer); + + // androidx.compose.material3.NavigationDrawerKt.NavigationDrawerItem + // (no JVM mangling — no @JvmInline value-class params). label is the + // required content slot, icon and badge are optional. shape, colors, + // and interactionSource are kept defaulted (their $default bits stay + // set; Kotlin supplies its real defaults). + [ComposeBridge( + Class = "androidx/compose/material3/NavigationDrawerKt", + JvmName = "NavigationDrawerItem", + Signature = "(Lkotlin/jvm/functions/Function2;Z" + + "Lkotlin/jvm/functions/Function0;" + + "Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/NavigationDrawerItemColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(NavigationDrawerItemDefault))] + [ComposeFacade] + public static partial void NavigationDrawerItem( + IFunction2 label, + bool selected, + IFunction0 onClick, + IModifier? modifier, + IFunction2? icon, + IFunction2? badge, + int defaults, + IComposer composer); + + // 3 drawer-sheet variants share the same DrawerSheetDefault enum + signature. + const string DrawerSheetSig = + "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Shape;JJF" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/NavigationDrawerKt", + JvmName = "ModalDrawerSheet-afqeVBk", + Signature = DrawerSheetSig, + Defaults = typeof(DrawerSheetDefault))] + [ComposeFacade(DefaultColorFromTheme = "surfaceContainerLow")] + public static partial void ModalDrawerSheet(IFunction3 content, Shape? shape, long drawerContainerColor, IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/NavigationDrawerKt", + JvmName = "DismissibleDrawerSheet-afqeVBk", + Signature = DrawerSheetSig, + Defaults = typeof(DrawerSheetDefault))] + [ComposeFacade(DefaultColorFromTheme = "surface")] + public static partial void DismissibleDrawerSheet(IFunction3 content, Shape? shape, long drawerContainerColor, IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/NavigationDrawerKt", + JvmName = "PermanentDrawerSheet-afqeVBk", + Signature = DrawerSheetSig, + Defaults = typeof(DrawerSheetDefault))] + [ComposeFacade(DefaultColorFromTheme = "surface")] + public static partial void PermanentDrawerSheet(IFunction3 content, Shape? shape, long drawerContainerColor, IComposer composer); + + // androidx.compose.material3.SegmentedButtonKt.SegmentedButton + // (SingleChoiceSegmentedButtonRowScope receiver, longer 11-param overload + // with PaddingValues). The Kt method has 4 same-named overloads, so the + // binder strips them all. + const string SingleChoiceSegmentedButtonSig = + "(Landroidx/compose/material3/SingleChoiceSegmentedButtonRowScope;Z" + + "Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/material3/SegmentedButtonColors;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/runtime/Composer;III)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/SegmentedButtonKt", + JvmName = "SegmentedButton", + Signature = SingleChoiceSegmentedButtonSig, + Defaults = typeof(SingleChoiceSegmentedButtonDefault))] + public static partial void SingleChoiceSegmentedButton( + IntPtr singleChoiceScope, + bool selected, + IFunction0 onClick, + IntPtr shape, + IFunction2 label, + IModifier? modifier, + IFunction2? icon, + int defaults, + IComposer composer); + + // androidx.compose.material3.SegmentedButtonKt.SegmentedButton + // (MultiChoiceSegmentedButtonRowScope receiver, longer 11-param overload). + const string MultiChoiceSegmentedButtonSig = + "(Landroidx/compose/material3/MultiChoiceSegmentedButtonRowScope;Z" + + "Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/material3/SegmentedButtonColors;" + + "Landroidx/compose/foundation/BorderStroke;" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/runtime/Composer;III)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/SegmentedButtonKt", + JvmName = "SegmentedButton", + Signature = MultiChoiceSegmentedButtonSig, + Defaults = typeof(MultiChoiceSegmentedButtonDefault))] + public static partial void MultiChoiceSegmentedButton( + IntPtr multiChoiceScope, + bool @checked, + IFunction1 onCheckedChange, + IntPtr shape, + IFunction2 label, + IModifier? modifier, + IFunction2? icon, + int defaults, + IComposer composer); + + // androidx.compose.material3.SegmentedButtonDefaults.itemShape — INSTANCE + // method on the SegmentedButtonDefaults Kotlin `object` singleton, returns + // the rounded-corner Shape for a segment given its position in the row. + // The Material3 binding strips the surrounding SegmentedButtonDefaults + // helpers (probably because some sibling members use inline-class types), + // so we go through the source generator's InstanceField shape — that walks + // the static `INSTANCE` field once, caches a global ref, then calls the + // method via CallObjectMethod. The auto-defaults logic on the generator + // sees `baseShape` is in [ComposeDefaults] but missing from the user-facing + // params, so it sets bit 2 ($default for baseShape) automatically — making + // Kotlin substitute SegmentedButtonDefaults.getBaseShape() (the theme's + // default rounded-corner shape). + [ComposeBridge( + Class = "androidx/compose/material3/SegmentedButtonDefaults", + JvmName = "itemShape", + Signature = "(IILandroidx/compose/foundation/shape/CornerBasedShape;" + + "Landroidx/compose/runtime/Composer;II)" + + "Landroidx/compose/ui/graphics/Shape;", + InstanceField = "INSTANCE", + Defaults = typeof(SegmentedButtonItemShapeDefault))] + internal static partial IntPtr ItemShape(int index, int count, IComposer composer); + + // androidx.compose.material3.WideNavigationRailKt.WideNavigationRail. + // No mangled hash (no inline-class types), but the binder still strips + // it — likely because it's @ExperimentalMaterial3ExpressiveApi-annotated. + [ComposeBridge( + Class = "androidx/compose/material3/WideNavigationRailKt", + JvmName = "WideNavigationRail", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/WideNavigationRailState;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/WideNavigationRailColors;" + + "Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/foundation/layout/Arrangement$Vertical;" + + "Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(WideNavigationRailDefault))] + [ComposeFacade] + public static partial void WideNavigationRail(IModifier? modifier, IFunction2 content, IComposer composer); + + // androidx.compose.material3.WideNavigationRailKt.ModalWideNavigationRail-k3FuEkE. + // The "-k3FuEkE" hash comes from the @JvmInline value-class Dp param + // (collapsedShadowElevation). 12 user params + 3 trailing ints + // ($changed, $changed1, $default). The raw JNI bridge takes the full + // surface; the [ComposeFacade]-wrapped partial below hides + // `hideOnCollapse` (always true) so the public facade matches the + // visibility-toggle pattern documented on the facade class. + [ComposeBridge( + Class = "androidx/compose/material3/WideNavigationRailKt", + JvmName = "ModalWideNavigationRail-k3FuEkE", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/WideNavigationRailState;Z" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/WideNavigationRailColors;" + + "Lkotlin/jvm/functions/Function2;F" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/foundation/layout/Arrangement$Vertical;" + + "Landroidx/compose/material3/ModalWideNavigationRailProperties;" + + "Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(ModalWideNavigationRailDefault))] + internal static partial void ModalWideNavigationRailRaw( + IModifier? modifier, + IntPtr state, + bool hideOnCollapse, + IFunction2? header, + IFunction2 content, + int defaults, + IComposer composer); + + // Phase 8 wrapper-passthrough for the public ModalWideNavigationRail + // facade. Hides `hideOnCollapse` (always true — the C# visibility- + // toggle pattern owns mount/unmount), so the auto-mask never + // computes the HideOnCollapse bit. We pre-clear it here before + // forwarding, so Kotlin substitutes the body-supplied `true` rather + // than its own default. + [ComposeFacade(Container = true, Defaults = typeof(ModalWideNavigationRailDefault))] + public static partial void ModalWideNavigationRail( + IModifier? modifier, + [StateHolder(Remember = nameof(RememberWideNavigationRailState), + StateType = typeof(WideNavigationRailState))] IntPtr state, + IFunction2? header, + IFunction2 content, + int defaults, + IComposer composer); + + public static partial void ModalWideNavigationRail( + IModifier? modifier, + IntPtr state, + IFunction2? header, + IFunction2 content, + int defaults, + IComposer composer) + { + // Clear the HideOnCollapse bit so Kotlin uses our hardcoded + // `true` rather than its own default (which is `false`). + defaults &= ~(int)ModalWideNavigationRailDefault.HideOnCollapse; + ModalWideNavigationRailRaw( + modifier: modifier, + state: state, + hideOnCollapse: true, + header: header, + content: content, + defaults: defaults, + composer: composer); + } + + // Phase 8 wrapper-passthroughs for the navigation-drawer family. + // Kotlin functions are all bound natively (no hash mangling on the + // public overload), so we just forward to `NavigationDrawerKt.*` + // and let the bridge generator stay out of it. Each bridge declares + // the facade ctor slots the user actually controls (drawer, modifier, + // optional state, content) and the bound binding fills in the + // hard-coded Kotlin params (gesturesEnabled: true, scrimColor: 0L + // — left at the Kotlin default sentinel via the bit mask). The + // facade-side $default mask is computed by the [ComposeFacade] + // generator from the matching [ComposeDefaults] declaration and + // forwarded into _changed. + + [ComposeFacade(Defaults = typeof(ModalNavigationDrawerDefault))] + public static partial void ModalNavigationDrawer( + [Slot("Drawer")] IFunction2 drawerContent, + IModifier? modifier, + [StateHolder(Remember = nameof(RememberDrawerState), + StateType = typeof(DrawerStateHolder), + SharedState = true)] IntPtr drawerState, + [Slot("Content")] IFunction2 content, + bool gesturesEnabled = true, + int defaults = 0, + IComposer composer = null!); + + public static partial void ModalNavigationDrawer( + IFunction2 drawerContent, IModifier? modifier, IntPtr drawerState, + IFunction2 content, bool gesturesEnabled, int defaults, IComposer composer) + { + // The bound binding takes a typed DrawerState; reconstitute it + // from the JNI handle the [StateHolder] handed us. + var stateObj = Java.Lang.Object.GetObject( + drawerState, Android.Runtime.JniHandleOwnership.DoNotTransfer)!; + AndroidX.Compose.Material3.NavigationDrawerKt.ModalNavigationDrawer( + drawerContent: drawerContent, + modifier: modifier, + drawerState: stateObj, + // The facade always supplies a value (caller's argument or the + // `gesturesEnabled = true` ctor default) and clears the matching + // bit in `_changed` so Kotlin uses our value. + gesturesEnabled: gesturesEnabled, + scrimColor: 0L, + content: content, + _composer: composer, + p7: 0, + _changed: defaults); + } + + [ComposeFacade(Defaults = typeof(DismissibleNavigationDrawerDefault))] + public static partial void DismissibleNavigationDrawer( + [Slot("Drawer")] IFunction2 drawerContent, + IModifier? modifier, + [StateHolder(Remember = nameof(RememberDrawerState), + StateType = typeof(DrawerStateHolder), + SharedState = true)] IntPtr drawerState, + [Slot("Content")] IFunction2 content, + bool gesturesEnabled = true, + int defaults = 0, + IComposer composer = null!); + + public static partial void DismissibleNavigationDrawer( + IFunction2 drawerContent, IModifier? modifier, IntPtr drawerState, + IFunction2 content, bool gesturesEnabled, int defaults, IComposer composer) + { + var stateObj = Java.Lang.Object.GetObject( + drawerState, Android.Runtime.JniHandleOwnership.DoNotTransfer)!; + AndroidX.Compose.Material3.NavigationDrawerKt.DismissibleNavigationDrawer( + drawerContent: drawerContent, + modifier: modifier, + drawerState: stateObj, + gesturesEnabled: gesturesEnabled, + content: content, + _composer: composer, + p6: 0, + _changed: defaults); + } + + [ComposeFacade(Defaults = typeof(PermanentNavigationDrawerDefault))] + public static partial void PermanentNavigationDrawer( + [Slot("Drawer")] IFunction2 drawerContent, + IModifier? modifier, + [Slot("Content")] IFunction2 content, + int defaults, + IComposer composer); + + public static partial void PermanentNavigationDrawer( + IFunction2 drawerContent, IModifier? modifier, + IFunction2 content, int defaults, IComposer composer) + { + AndroidX.Compose.Material3.NavigationDrawerKt.PermanentNavigationDrawer( + drawerContent: drawerContent, + modifier: modifier, + content: content, + _composer: composer, + p4: 0, + _changed: defaults); + } + + // Modifier-chain extensions. These are non-@Composable Kotlin + // extension functions on Modifier; their JNI signatures end in + // `I L` (the $default bitmask plus a synthetic-overload + // Object marker, which Kotlin always passes as null). The bridge + // generator emits the marker slot automatically. + + // androidx.compose.foundation.BackgroundKt.background-bw27NRU$default — + // (Modifier, Color, Shape). Color is mangled because it's a + // @JvmInline value class (ULong). The C# wrapper always supplies + // color; shape is optional (null → Kotlin default of RectangleShape). + [ComposeBridge( + Class = "androidx/compose/foundation/BackgroundKt", + JvmName = "background-bw27NRU$default", + Signature = "(Landroidx/compose/ui/Modifier;J" + + "Landroidx/compose/ui/graphics/Shape;ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierBackgroundDefault))] + internal static partial IntPtr ModifierBackground(IntPtr modifier, long color, IntPtr? shape); + + // androidx.compose.foundation.BackgroundKt.background$default — + // (Modifier, Brush, Shape, float alpha). Not mangled (no value-class + // params), but the bound C# overload requires non-null Shape and + // alpha — and we want callers to be able to omit either, so we go + // through the synthetic $default sibling and let auto-mask handle + // the bits. alpha is always supplied (caller's default is 1f); + // shape is auto-cleared when supplied. + [ComposeBridge( + Class = "androidx/compose/foundation/BackgroundKt", + JvmName = "background$default", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Brush;" + + "Landroidx/compose/ui/graphics/Shape;FILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierBackgroundBrushDefault))] + internal static partial IntPtr ModifierBackgroundBrush( + IntPtr modifier, AndroidX.Compose.UI.Graphics.Brush brush, Shape? shape, float alpha); + + // androidx.compose.foundation.BorderKt.border-xT4_qwU$default — + // (Modifier, Dp width, Color, Shape). Both width and color are + // mangled inline-class params. Shape is optional (null → Kotlin + // default of RectangleShape). + [ComposeBridge( + Class = "androidx/compose/foundation/BorderKt", + JvmName = "border-xT4_qwU$default", + Signature = "(Landroidx/compose/ui/Modifier;FJ" + + "Landroidx/compose/ui/graphics/Shape;ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierBorderDefault))] + internal static partial IntPtr ModifierBorder(IntPtr modifier, float width, long color, IntPtr? shape); + + // androidx.compose.foundation.BorderKt.border-ziNgDLE — + // (Modifier, Dp width, Brush, Shape). width is mangled because Dp + // is a @JvmInline value class. Unlike the Color overload, the Brush + // overload declares NO default for shape, so the AAR ships no + // $default synthetic — bind the real method and have the caller + // always supply a shape (Shape.Rectangle when it wants "none"). + [ComposeBridge( + Class = "androidx/compose/foundation/BorderKt", + JvmName = "border-ziNgDLE", + Signature = "(Landroidx/compose/ui/Modifier;F" + + "Landroidx/compose/ui/graphics/Brush;" + + "Landroidx/compose/ui/graphics/Shape;)" + + "Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierBorderBrush( + IntPtr modifier, float width, AndroidX.Compose.UI.Graphics.Brush brush, Shape shape); + + // androidx.compose.foundation.ClickableKt.clickable-XHw0xAI$default — + // (Modifier, Boolean enabled, String onClickLabel, Role role, + // Function0 onClick). Returns a Modifier directly — the lambda is + // wrapped via composed { ... } internally so no Composer is needed. + // The C# wrapper supplies onClick; enabled/onClickLabel/role are + // left to Kotlin's defaults. + [ComposeBridge( + Class = "androidx/compose/foundation/ClickableKt", + JvmName = "clickable-XHw0xAI$default", + Signature = "(Landroidx/compose/ui/Modifier;ZLjava/lang/String;" + + "Landroidx/compose/ui/semantics/Role;" + + "Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierClickableDefault))] + internal static partial IntPtr ModifierClickable(IntPtr modifier, IFunction0 onClick); + + // androidx.compose.foundation.ScrollKt.verticalScroll$default — + // non-@Composable Modifier extension. Kotlin params after the + // receiver: state, enabled, flingBehavior?, reverseScrolling. The + // C# wrapper always supplies state/enabled/reverseScrolling and + // leaves flingBehavior to Kotlin's default + // (ScrollableDefaults.flingBehavior()). + [ComposeBridge( + Class = "androidx/compose/foundation/ScrollKt", + JvmName = "verticalScroll$default", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/foundation/ScrollState;Z" + + "Landroidx/compose/foundation/gestures/FlingBehavior;Z" + + "ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierVerticalScrollDefault))] + internal static partial IntPtr ModifierVerticalScroll( + IntPtr modifier, IntPtr state, bool enabled, bool reverseScrolling); + + // androidx.compose.foundation.ScrollKt.horizontalScroll$default — + // same shape as ModifierVerticalScroll above. + [ComposeBridge( + Class = "androidx/compose/foundation/ScrollKt", + JvmName = "horizontalScroll$default", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/foundation/ScrollState;Z" + + "Landroidx/compose/foundation/gestures/FlingBehavior;Z" + + "ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierHorizontalScrollDefault))] + internal static partial IntPtr ModifierHorizontalScroll( + IntPtr modifier, IntPtr state, bool enabled, bool reverseScrolling); + + // androidx.compose.foundation.layout.SizeKt — ranged size constraints. + // All Dp params are `@JvmInline value class Dp(val value: Float)` + // hence the mangled JVM names. Each bridge takes Dp? per parameter so + // a `null` value leaves the corresponding $default bit set and Kotlin + // substitutes `Dp.Unspecified` (no constraint), letting callers + // express one-sided constraints like `Modifier.WidthIn(min: 100)`. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "widthIn-VpY3zN4$default", + Signature = "(Landroidx/compose/ui/Modifier;FFILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierWidthInDefault))] + internal static partial IntPtr ModifierWidthIn(IntPtr modifier, Dp? min, Dp? max); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "heightIn-VpY3zN4$default", + Signature = "(Landroidx/compose/ui/Modifier;FFILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierHeightInDefault))] + internal static partial IntPtr ModifierHeightIn(IntPtr modifier, Dp? min, Dp? max); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "sizeIn-qDBjuR0$default", + Signature = "(Landroidx/compose/ui/Modifier;FFFFILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierSizeInDefault))] + internal static partial IntPtr ModifierSizeIn(IntPtr modifier, Dp? minWidth, Dp? minHeight, Dp? maxWidth, Dp? maxHeight); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "defaultMinSize-VpY3zN4$default", + Signature = "(Landroidx/compose/ui/Modifier;FFILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierDefaultMinSizeDefault))] + internal static partial IntPtr ModifierDefaultMinSize(IntPtr modifier, Dp? minWidth, Dp? minHeight); + + // androidx.compose.foundation.layout.SizeKt — required size variants. + // requiredSize / requiredWidth / requiredHeight bypass parent + // constraints (clip-out is permitted). No $default — both args are + // mandatory positional. Plain-static shape, no auto-mask. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "requiredSize-3ABfNKs", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierRequiredSizeAll(IntPtr modifier, float dp); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "requiredSize-VpY3zN4", + Signature = "(Landroidx/compose/ui/Modifier;FF)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierRequiredSizeWH(IntPtr modifier, float width, float height); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "requiredWidth-3ABfNKs", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierRequiredWidth(IntPtr modifier, float dp); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "requiredHeight-3ABfNKs", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierRequiredHeight(IntPtr modifier, float dp); + + // androidx.compose.foundation.layout.SizeKt — wrapContent variants. + // Each takes an Alignment (or Alignment.Horizontal / Vertical) and + // an `unbounded` boolean. We always supply the boolean (bit cleared) + // and leave alignment to Kotlin's default (Center / CenterHorizontally + // / CenterVertically — bit kept set). Names are not mangled (no + // inline-class params). + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "wrapContentSize$default", + Signature = "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;ZILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierWrapContentSizeDefault))] + internal static partial IntPtr ModifierWrapContentSize(IntPtr modifier, bool unbounded); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "wrapContentWidth$default", + Signature = "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment$Horizontal;ZILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierWrapContentWidthDefault))] + internal static partial IntPtr ModifierWrapContentWidth(IntPtr modifier, bool unbounded); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/SizeKt", + JvmName = "wrapContentHeight$default", + Signature = "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment$Vertical;ZILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierWrapContentHeightDefault))] + internal static partial IntPtr ModifierWrapContentHeight(IntPtr modifier, bool unbounded); + + // androidx.compose.foundation.layout.AspectRatioKt.aspectRatio$default — + // (Modifier, Float ratio, Boolean matchHeightConstraintsFirst). Both + // params are non-inline so the JVM name is unmangled. Both bits are + // cleared when the caller supplies values (the auto-mask treats + // non-nullable primitives as unconditional clears). + [ComposeBridge( + Class = "androidx/compose/foundation/layout/AspectRatioKt", + JvmName = "aspectRatio$default", + Signature = "(Landroidx/compose/ui/Modifier;FZILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierAspectRatioDefault))] + internal static partial IntPtr ModifierAspectRatio(IntPtr modifier, float ratio, bool matchHeightConstraintsFirst); + + // androidx.compose.foundation.layout.OffsetKt — Dp-Dp offset shifts + // a composable's draw position by (x, y) without affecting its parent + // layout slot. `offset` is layout-direction-aware (start/end on RTL); + // `absoluteOffset` is always absolute (left/right). Both mangled + // because of @JvmInline value class Dp. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/OffsetKt", + JvmName = "offset-VpY3zN4$default", + Signature = "(Landroidx/compose/ui/Modifier;FFILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierOffsetDefault))] + internal static partial IntPtr ModifierOffset(IntPtr modifier, Dp? x, Dp? y); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/OffsetKt", + JvmName = "absoluteOffset-VpY3zN4$default", + Signature = "(Landroidx/compose/ui/Modifier;FFILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierAbsoluteOffsetDefault))] + internal static partial IntPtr ModifierAbsoluteOffset(IntPtr modifier, Dp? x, Dp? y); + + // androidx.compose.ui.ZIndexModifierKt.zIndex(Modifier, Float) — sets + // the draw order within a parent layout (higher z draws later, on + // top). No $default. Plain-static, unmangled. + [ComposeBridge( + Class = "androidx/compose/ui/ZIndexModifierKt", + JvmName = "zIndex", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierZIndex(IntPtr modifier, float z); + + // androidx.compose.ui.draw.AlphaKt.alpha(Modifier, Float) — sets the + // composable's alpha (0 = invisible, 1 = opaque). Forces a graphics + // layer; cheap to animate. + [ComposeBridge( + Class = "androidx/compose/ui/draw/AlphaKt", + JvmName = "alpha", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierAlpha(IntPtr modifier, float alpha); + + // androidx.compose.ui.draw.RotateKt.rotate(Modifier, Float) — rotates + // around the composable's center by `degrees`. + [ComposeBridge( + Class = "androidx/compose/ui/draw/RotateKt", + JvmName = "rotate", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierRotate(IntPtr modifier, float degrees); + + // androidx.compose.ui.draw.ScaleKt.scale(Modifier, Float) — uniform + // scale around the composable's center. + [ComposeBridge( + Class = "androidx/compose/ui/draw/ScaleKt", + JvmName = "scale", + Signature = "(Landroidx/compose/ui/Modifier;F)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierScaleUniform(IntPtr modifier, float scale); + + // androidx.compose.ui.draw.ScaleKt.scale(Modifier, Float, Float) — + // independent X/Y scale. Distinct C# name avoids the partial-method + // overload collision; the JVM resolves to the (M,F,F) overload. + [ComposeBridge( + Class = "androidx/compose/ui/draw/ScaleKt", + JvmName = "scale", + Signature = "(Landroidx/compose/ui/Modifier;FF)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierScaleXY(IntPtr modifier, float scaleX, float scaleY); + + // androidx.compose.ui.draw.ShadowKt.shadow-ziNgDLE$default — + // (Modifier, Dp elevation, Shape shape, Boolean clip). Mangled because + // elevation is @JvmInline value class Dp. The C# wrapper exposes + // elevation + shape; Kotlin's default for `clip` is + // `elevation > 0.dp` which we honor by leaving that bit set. + [ComposeBridge( + Class = "androidx/compose/ui/draw/ShadowKt", + JvmName = "shadow-ziNgDLE$default", + Signature = "(Landroidx/compose/ui/Modifier;F" + + "Landroidx/compose/ui/graphics/Shape;ZILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierShadowDefault))] + internal static partial IntPtr ModifierShadow(IntPtr modifier, float elevation, IntPtr? shape); + + // androidx.compose.ui.graphics.GraphicsLayerModifierKt.graphicsLayer-sKFY_QE$default — + // (Modifier, F scaleX, F scaleY, F alpha, F translationX, F + // translationY, F shadowElevation, F rotationX, F rotationY, + // F rotationZ, F cameraDistance, J transformOrigin, Shape shape, + // Z clip). 13 user params, all defaultable. Mangled because + // `transformOrigin` is `@JvmInline value class TransformOrigin` + // packed as a `long`. The C# wrapper uses `float?`/`long?`/ + // `IntPtr?`/`bool?` so the auto-mask clears each bit when a + // value is supplied. This is the simplest of the three + // graphicsLayer overloads — it omits the `RenderEffect`, + // `ambientShadowColor`, `spotShadowColor`, and + // `compositingStrategy` parameters introduced in later + // versions. Most user code only touches alpha / rotation / + // scale, which this overload covers. + [ComposeBridge( + Class = "androidx/compose/ui/graphics/GraphicsLayerModifierKt", + JvmName = "graphicsLayer-sKFY_QE$default", + Signature = "(Landroidx/compose/ui/Modifier;FFFFFFFFFFJ" + + "Landroidx/compose/ui/graphics/Shape;Z" + + "ILjava/lang/Object;)Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierGraphicsLayerDefault))] + internal static partial IntPtr ModifierGraphicsLayer( + IntPtr modifier, + float? scaleX, + float? scaleY, + float? alpha, + float? translationX, + float? translationY, + float? shadowElevation, + float? rotationX, + float? rotationY, + float? rotationZ, + float? cameraDistance, + long? transformOrigin, + IntPtr? shape, + bool? clip); + + // androidx.compose.foundation.layout.WindowInsetsPadding_androidKt — + // additional inset-padding helpers. Same shape as the existing + // safeDrawingPadding / systemBarsPadding bridges. + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "imePadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierImePadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "navigationBarsPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierNavigationBarsPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "statusBarsPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierStatusBarsPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "displayCutoutPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierDisplayCutoutPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "captionBarPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierCaptionBarPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "mandatorySystemGesturesPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierMandatorySystemGesturesPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "safeContentPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierSafeContentPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "safeGesturesPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierSafeGesturesPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "systemGesturesPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierSystemGesturesPadding(IntPtr modifier); + + [ComposeBridge( + Class = "androidx/compose/foundation/layout/WindowInsetsPadding_androidKt", + JvmName = "waterfallPadding", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierWaterfallPadding(IntPtr modifier); + + // androidx.compose.ui.platform.TestTagKt.testTag(Modifier, String) — + // attaches a test tag for UI testing frameworks. + [ComposeBridge( + Class = "androidx/compose/ui/platform/TestTagKt", + JvmName = "testTag", + Signature = "(Landroidx/compose/ui/Modifier;Ljava/lang/String;)" + + "Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierTestTag(IntPtr modifier, string tag); + + // androidx.compose.foundation.FocusableKt.focusable$default — + // non-@Composable Modifier extension. Kotlin params after the + // receiver: enabled (Boolean), interactionSource (MutableInteractionSource). + // The C# wrapper always supplies enabled; interactionSource is left + // to Kotlin's default (Compose allocates one per-call). + [ComposeBridge( + Class = "androidx/compose/foundation/FocusableKt", + JvmName = "focusable$default", + Signature = "(Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "ILjava/lang/Object;)Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierFocusableDefault))] + internal static partial IntPtr ModifierFocusable(IntPtr modifier, bool enabled); + + // androidx.compose.foundation.FocusableKt.focusGroup(Modifier) — + // groups focusable descendants so two-dimensional focus search + // treats them as a single unit. No defaults. + [ComposeBridge( + Class = "androidx/compose/foundation/FocusableKt", + JvmName = "focusGroup", + Signature = "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierFocusGroup(IntPtr modifier); + + // androidx.compose.ui.focus.FocusChangedModifierKt.onFocusChanged — + // (Modifier, Function1). No defaults — the + // listener is always supplied by the caller. + [ComposeBridge( + Class = "androidx/compose/ui/focus/FocusChangedModifierKt", + JvmName = "onFocusChanged", + Signature = "(Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;)" + + "Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierOnFocusChanged(IntPtr modifier, IFunction1 onFocusChanged); + + // androidx.compose.ui.focus.FocusRequesterModifierKt.focusRequester — + // (Modifier, FocusRequester). No defaults. + [ComposeBridge( + Class = "androidx/compose/ui/focus/FocusRequesterModifierKt", + JvmName = "focusRequester", + Signature = "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/focus/FocusRequester;)" + + "Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierFocusRequester(IntPtr modifier, IntPtr focusRequester); + + // androidx.compose.foundation.ClickableKt.combinedClickable-cJG_KMw$default — + // the no-MutableInteractionSource overload. 7 Kotlin params after + // the receiver: enabled, onClickLabel, role, onLongClickLabel, + // onLongClick, onDoubleClick, onClick. C# wrapper always supplies + // onClick (bit 6 cleared); other bits are toggled per-call based + // on which arguments the caller passed. + [ComposeBridge( + Class = "androidx/compose/foundation/ClickableKt", + JvmName = "combinedClickable-cJG_KMw$default", + Signature = "(Landroidx/compose/ui/Modifier;ZLjava/lang/String;" + + "Landroidx/compose/ui/semantics/Role;Ljava/lang/String;" + + "Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;" + + "Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierCombinedClickableDefault))] + internal static partial IntPtr ModifierCombinedClickable( + IntPtr modifier, + IFunction0? onLongClick, + IFunction0? onDoubleClick, + IFunction0 onClick); + + // androidx.compose.foundation.selection.SelectableKt.selectable-XHw0xAI$default — + // 4 Kotlin params after the receiver: selected, enabled, role, onClick. + // C# wrapper always supplies selected + onClick. + [ComposeBridge( + Class = "androidx/compose/foundation/selection/SelectableKt", + JvmName = "selectable-XHw0xAI$default", + Signature = "(Landroidx/compose/ui/Modifier;ZZ" + + "Landroidx/compose/ui/semantics/Role;" + + "Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierSelectableDefault))] + internal static partial IntPtr ModifierSelectable( + IntPtr modifier, bool selected, IFunction0 onClick); + + // androidx.compose.foundation.selection.ToggleableKt.toggleable-XHw0xAI$default — + // 4 Kotlin params after the receiver: value (Bool), enabled, role, + // onValueChange (Function1). C# wrapper always + // supplies value + onValueChange. + [ComposeBridge( + Class = "androidx/compose/foundation/selection/ToggleableKt", + JvmName = "toggleable-XHw0xAI$default", + Signature = "(Landroidx/compose/ui/Modifier;ZZ" + + "Landroidx/compose/ui/semantics/Role;" + + "Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierToggleableDefault))] + internal static partial IntPtr ModifierToggleable( + IntPtr modifier, bool value, IFunction1 onValueChange); + + // androidx.compose.foundation.gestures.DraggableKt.draggable$default — + // non-@Composable Modifier extension. 8 Kotlin params after the + // receiver: state, orientation, enabled, interactionSource, + // startDragImmediately, onDragStarted, onDragStopped, + // reverseDirection. The C# wrapper always supplies state / + // orientation / enabled (bits 0/1/2 cleared); the other five slots + // (interactionSource + the two suspend Function3s + the two + // booleans) stay defaulted in v1. + [ComposeBridge( + Class = "androidx/compose/foundation/gestures/DraggableKt", + JvmName = "draggable$default", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/foundation/gestures/DraggableState;" + + "Landroidx/compose/foundation/gestures/Orientation;Z" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;Z" + + "Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Z" + + "ILjava/lang/Object;)Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierDraggableDefault))] + internal static partial IntPtr ModifierDraggable( + IntPtr modifier, IntPtr state, IntPtr orientation, bool enabled); + + // androidx.compose.ui.semantics.SemanticsModifierKt.semantics$default — + // 2 Kotlin params after the receiver: mergeDescendants (Bool), + // properties (Function1). The C# + // wrapper always supplies the properties lambda. + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsModifierKt", + JvmName = "semantics$default", + Signature = "(Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)" + + "Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierSemanticsDefault))] + internal static partial IntPtr ModifierSemantics( + IntPtr modifier, bool mergeDescendants, IFunction1 properties); + + // androidx.compose.ui.semantics.SemanticsModifierKt.clearAndSetSemantics — + // (Modifier, Function1). No defaults. + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsModifierKt", + JvmName = "clearAndSetSemantics", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function1;)" + + "Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierClearAndSetSemantics(IntPtr modifier, IFunction1 properties); + + // androidx.compose.foundation.draganddrop.DragAndDropTargetKt.dragAndDropTarget — + // non-@Composable Modifier extension. Both Kotlin params are required + // (no $default). `shouldStartDragAndDrop` is a `(DragAndDropEvent) -> Boolean` + // predicate that Compose calls once per drag-start to decide whether + // this target should participate; `target` is the per-instance + // `DragAndDropTarget` JCW whose `OnDrop` (and the other no-op + // callbacks) the runtime fires while the drag is in progress. + [ComposeBridge( + Class = "androidx/compose/foundation/draganddrop/DragAndDropTargetKt", + JvmName = "dragAndDropTarget", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function1;" + + "Landroidx/compose/ui/draganddrop/DragAndDropTarget;)" + + "Landroidx/compose/ui/Modifier;")] + internal static partial IntPtr ModifierDragAndDropTarget( + IntPtr modifier, IFunction1 shouldStartDragAndDrop, DragAndDropTarget target); + + // androidx.compose.ui.input.nestedscroll.NestedScrollModifierKt.nestedScroll$default — + // non-@Composable Modifier extension. 2 Kotlin params after the + // receiver: connection (NestedScrollConnection — always supplied by + // the C# wrapper), dispatcher (NestedScrollDispatcher — left + // defaulted so Kotlin allocates its own). + [ComposeBridge( + Class = "androidx/compose/ui/input/nestedscroll/NestedScrollModifierKt", + JvmName = "nestedScroll$default", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/input/nestedscroll/NestedScrollConnection;" + + "Landroidx/compose/ui/input/nestedscroll/NestedScrollDispatcher;" + + "ILjava/lang/Object;)Landroidx/compose/ui/Modifier;", + Defaults = typeof(ModifierNestedScrollDefault))] + internal static partial IntPtr ModifierNestedScroll( + IntPtr modifier, + AndroidX.Compose.UI.Input.NestedScroll.INestedScrollConnection connection); + + // androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior — + // @Composable instance method on the singleton. 4 user params; the + // C# wrapper supplies only `state` and leaves `canScroll`, + // `snapAnimationSpec`, `flingAnimationSpec` to Kotlin's defaults. + [ComposeBridge( + Class = "androidx/compose/material3/TopAppBarDefaults", + JvmName = "enterAlwaysScrollBehavior", + Signature = "(Landroidx/compose/material3/TopAppBarState;" + + "Lkotlin/jvm/functions/Function0;" + + "Landroidx/compose/animation/core/AnimationSpec;" + + "Landroidx/compose/animation/core/DecayAnimationSpec;" + + "Landroidx/compose/runtime/Composer;II)" + + "Landroidx/compose/material3/TopAppBarScrollBehavior;", + InstanceField = "INSTANCE", + Defaults = typeof(TopAppBarEnterAlwaysScrollBehaviorDefault))] + internal static partial IntPtr TopAppBarDefaultsEnterAlwaysScrollBehavior( + TopAppBarState state, + IComposer composer); + + // androidx.compose.material3.TopAppBarDefaults.exitUntilCollapsedScrollBehavior — + // same shape as enterAlwaysScrollBehavior above. + [ComposeBridge( + Class = "androidx/compose/material3/TopAppBarDefaults", + JvmName = "exitUntilCollapsedScrollBehavior", + Signature = "(Landroidx/compose/material3/TopAppBarState;" + + "Lkotlin/jvm/functions/Function0;" + + "Landroidx/compose/animation/core/AnimationSpec;" + + "Landroidx/compose/animation/core/DecayAnimationSpec;" + + "Landroidx/compose/runtime/Composer;II)" + + "Landroidx/compose/material3/TopAppBarScrollBehavior;", + InstanceField = "INSTANCE", + Defaults = typeof(TopAppBarExitUntilCollapsedScrollBehaviorDefault))] + internal static partial IntPtr TopAppBarDefaultsExitUntilCollapsedScrollBehavior( + TopAppBarState state, + IComposer composer); + + // BoxScope / RowScope / ColumnScope `align` and `matchParentSize` + // are abstract interface methods on the scope class (no static + // $default helper exists — the methods themselves don't have + // defaults). The [ComposeBridge] generator's static/ctor/instance- + // field shapes don't fit, so we hand-write the JNI lookup + + // CallObjectMethod here. The scope handle comes from + // RenderContext.CurrentScope, set by the enclosing Box/Row/Column + // facade for the duration of its content lambda. + static IntPtr s_boxScopeClass; + static IntPtr s_boxScopeAlignMethodId; + static IntPtr s_boxScopeMatchParentSizeMethodId; + + internal static unsafe IntPtr BoxScopeAlign(IntPtr boxScope, IntPtr modifier, IntPtr alignment) + { + if (s_boxScopeAlignMethodId == IntPtr.Zero) + { + s_boxScopeClass = JNIEnv.FindClass("androidx/compose/foundation/layout/BoxScope"); + s_boxScopeAlignMethodId = JNIEnv.GetMethodID( + s_boxScopeClass, "align", + "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;)Landroidx/compose/ui/Modifier;"); + } + var args = stackalloc JValue[2]; + args[0] = new JValue(modifier); + args[1] = new JValue(alignment); + return JNIEnv.CallObjectMethod(boxScope, s_boxScopeAlignMethodId, args); + } + + internal static unsafe IntPtr BoxScopeMatchParentSize(IntPtr boxScope, IntPtr modifier) + { + if (s_boxScopeMatchParentSizeMethodId == IntPtr.Zero) + { + if (s_boxScopeClass == IntPtr.Zero) + s_boxScopeClass = JNIEnv.FindClass("androidx/compose/foundation/layout/BoxScope"); + s_boxScopeMatchParentSizeMethodId = JNIEnv.GetMethodID( + s_boxScopeClass, "matchParentSize", + "(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier;"); + } + var args = stackalloc JValue[1]; + args[0] = new JValue(modifier); + return JNIEnv.CallObjectMethod(boxScope, s_boxScopeMatchParentSizeMethodId, args); + } + + static IntPtr s_rowScopeClass; + static IntPtr s_rowScopeAlignMethodId; + + internal static unsafe IntPtr RowScopeAlignVertical(IntPtr rowScope, IntPtr modifier, IntPtr verticalAlignment) + { + if (s_rowScopeAlignMethodId == IntPtr.Zero) + { + s_rowScopeClass = JNIEnv.FindClass("androidx/compose/foundation/layout/RowScope"); + s_rowScopeAlignMethodId = JNIEnv.GetMethodID( + s_rowScopeClass, "align", + "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment$Vertical;)Landroidx/compose/ui/Modifier;"); + } + var args = stackalloc JValue[2]; + args[0] = new JValue(modifier); + args[1] = new JValue(verticalAlignment); + return JNIEnv.CallObjectMethod(rowScope, s_rowScopeAlignMethodId, args); + } + + static IntPtr s_columnScopeClass; + static IntPtr s_columnScopeAlignMethodId; + + internal static unsafe IntPtr ColumnScopeAlignHorizontal(IntPtr columnScope, IntPtr modifier, IntPtr horizontalAlignment) + { + if (s_columnScopeAlignMethodId == IntPtr.Zero) + { + s_columnScopeClass = JNIEnv.FindClass("androidx/compose/foundation/layout/ColumnScope"); + s_columnScopeAlignMethodId = JNIEnv.GetMethodID( + s_columnScopeClass, "align", + "(Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment$Horizontal;)Landroidx/compose/ui/Modifier;"); + } + var args = stackalloc JValue[2]; + args[0] = new JValue(modifier); + args[1] = new JValue(horizontalAlignment); + return JNIEnv.CallObjectMethod(columnScope, s_columnScopeAlignMethodId, args); + } + + // androidx.compose.ui.focus.FocusRequester.requestFocus()V — + // the no-arg overload the binding doesn't surface (it only exposed + // the parameterised `requestFocus-3ESFkO8(int):Boolean`). Wrapped + // hand-written because [ComposeBridge] doesn't support instance + // method calls. + static IntPtr s_focusRequesterClass; + static IntPtr s_focusRequesterRequestFocusMethodId; + + internal static void FocusRequesterRequestFocus(IntPtr focusRequester) + { + if (s_focusRequesterRequestFocusMethodId == IntPtr.Zero) + { + s_focusRequesterClass = JNIEnv.FindClass("androidx/compose/ui/focus/FocusRequester"); + s_focusRequesterRequestFocusMethodId = JNIEnv.GetMethodID( + s_focusRequesterClass, "requestFocus", "()V"); + } + JNIEnv.CallVoidMethod(focusRequester, s_focusRequesterRequestFocusMethodId); + } + + // androidx.compose.ui.semantics.SemanticsPropertiesKt.setContentDescription( + // SemanticsPropertyReceiver, String) — called from inside the + // Function1 body built by Modifier.Semantics(string)/.ClearAndSetSemantics(string). + // Static helper, no $default — wrap with the bridge generator. + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsPropertiesKt", + JvmName = "setContentDescription", + Signature = "(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;" + + "Ljava/lang/String;)V")] + internal static partial void SemanticsSetContentDescription(IntPtr receiver, string description); + + // androidx.compose.ui.semantics.SemanticsPropertiesKt.setRole-kuIjeqM( + // SemanticsPropertyReceiver, int) — JVM-mangled because the + // Kotlin source signature takes a `Role` value class whose + // underlying type is `int`. Same lambda-receiver invocation shape + // as SetContentDescription. The int comes from + // . + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsPropertiesKt", + JvmName = "setRole-kuIjeqM", + Signature = "(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;I)V")] + internal static partial void SemanticsSetRole(IntPtr receiver, int role); + + // androidx.compose.ui.semantics.SemanticsPropertiesKt.setSelected( + // SemanticsPropertyReceiver, boolean) — toggles the "selected" + // state read by TalkBack on grouped selection nodes (tabs, list + // items). No mangling — no value class params. No $default. + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsPropertiesKt", + JvmName = "setSelected", + Signature = "(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Z)V")] + internal static partial void SemanticsSetSelected(IntPtr receiver, bool selected); + + // androidx.compose.ui.semantics.SemanticsPropertiesKt.setStateDescription( + // SemanticsPropertyReceiver, String) — short state hint TalkBack + // reads after the content description ("expanded", "3 of 5"). Same + // shape as setContentDescription. + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsPropertiesKt", + JvmName = "setStateDescription", + Signature = "(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;" + + "Ljava/lang/String;)V")] + internal static partial void SemanticsSetStateDescription(IntPtr receiver, string description); + + // androidx.compose.ui.semantics.SemanticsPropertiesKt.heading( + // SemanticsPropertyReceiver) — tags the node as a heading for + // TalkBack. Compose's Foundation/Material baseline `heading()` is + // a single boolean (no level), matching MAUI's + // `SemanticHeadingLevel != None` semantic and ViewCompat's + // `setAccessibilityHeading(true)`. No mangling, no $default. + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsPropertiesKt", + JvmName = "heading", + Signature = "(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)V")] + internal static partial void SemanticsSetHeading(IntPtr receiver); + + // androidx.compose.ui.semantics.SemanticsProperties_androidKt.setTestTagsAsResourceId( + // SemanticsPropertyReceiver, boolean) — opts the subtree into + // surfacing `Modifier.testTag(...)` as AccessibilityNodeInfo's + // `viewIdResourceName`. UIAutomator / Espresso / Appium-Android + // read elements from there for `By.id(...)` lookups, so MAUI + // `AutomationId` only round-trips to Appium when this flag is + // set somewhere on (or above) the testTag-carrying node. + // + // Lives on the Android-specific `SemanticsProperties_androidKt` + // file (not the common `SemanticsPropertiesKt`) because the + // property is `actual` only on the JVM target. Marked + // `@ExperimentalComposeUiApi` upstream but stable enough for + // production — Google uses it in Jetcaster, Now in Android, and + // it's been the canonical Compose-↔-Espresso/UIAutomator bridge + // since 2021. Boolean isn't a value class so no name mangling. + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsProperties_androidKt", + JvmName = "setTestTagsAsResourceId", + Signature = "(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;Z)V")] + internal static partial void SemanticsSetTestTagsAsResourceId(IntPtr receiver, bool value); + + // androidx.compose.ui.semantics.SemanticsPropertiesKt.onClick$default( + // SemanticsPropertyReceiver, String?, Function0?, + // int $default, Object marker) — registers a labelled + // accessibility click action. Two defaultable Kotlin params: + // label (bit 0, may be null = use platform default label) and + // action (bit 1, always supplied by the C# wrapper). Auto-mask + // shape: `string? label` lowers to the label bit; `IFunction0 + // action` is suppressed via `!action` in the defaults enum. + [ComposeBridge( + Class = "androidx/compose/ui/semantics/SemanticsPropertiesKt", + JvmName = "onClick$default", + Signature = "(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;" + + "Ljava/lang/String;Lkotlin/jvm/functions/Function0;" + + "ILjava/lang/Object;)V", + Defaults = typeof(SemanticsOnClickDefault))] + internal static partial void SemanticsOnClick(IntPtr receiver, string? label, IFunction0 action); + + // Shape factories — additional overloads of RoundedCornerShape / + // CutCornerShape beyond the existing Dp variant. The Int (percent) + // overloads aren't mangled because Int isn't an inline class. The + // Dp variant of CutCornerShape is mangled. + [ComposeBridge( + Class = "androidx/compose/foundation/shape/RoundedCornerShapeKt", + JvmName = "RoundedCornerShape", + Signature = "(I)Landroidx/compose/foundation/shape/RoundedCornerShape;")] + internal static partial IntPtr RoundedCornerShapePercent(int percent); + + [ComposeBridge( + Class = "androidx/compose/foundation/shape/CutCornerShapeKt", + JvmName = "CutCornerShape-0680j_4", + Signature = "(F)Landroidx/compose/foundation/shape/CutCornerShape;")] + internal static partial IntPtr CutCornerShapeDp(float dp); + + [ComposeBridge( + Class = "androidx/compose/foundation/shape/CutCornerShapeKt", + JvmName = "CutCornerShape", + Signature = "(I)Landroidx/compose/foundation/shape/CutCornerShape;")] + internal static partial IntPtr CutCornerShapePercent(int percent); + + // androidx.compose.material3.AppBarKt — TopAppBar / CenterAlignedTopAppBar + // share the `-GHTll3U` shape (extra `expandedHeight: Dp` vs. the older + // unmangled overload). 8 user params: title, modifier, navigationIcon, + // actions, expandedHeight, windowInsets, colors, scrollBehavior. + const string TopAppBarSig = + "(Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;F" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/TopAppBarColors;" + + "Landroidx/compose/material3/TopAppBarScrollBehavior;" + + "Landroidx/compose/runtime/Composer;II)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "TopAppBar-GHTll3U", + Signature = TopAppBarSig, + Defaults = typeof(TopAppBarDefault))] + [ComposeFacade( + BranchOn = "Subtitle", + AlternateBridge = nameof(TopAppBarWithSubtitle))] + public static partial void TopAppBar( + IFunction2 title, + IModifier? modifier, + IFunction2? navigationIcon, + IFunction3? actions, + ITopAppBarScrollBehavior? scrollBehavior, + int defaults, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "CenterAlignedTopAppBar-GHTll3U", + Signature = TopAppBarSig, + Defaults = typeof(TopAppBarDefault))] + [ComposeFacade] + public static partial void CenterAlignedTopAppBar( + IFunction2 title, + IModifier? modifier, + IFunction2? navigationIcon, + IFunction3? actions, + ITopAppBarScrollBehavior? scrollBehavior, + int defaults, + IComposer composer); + + // MediumTopAppBar / LargeTopAppBar share `-oKE7A98` (two-row variants + // take BOTH `collapsedHeight` and `expandedHeight` Dp). 9 user params. + const string TwoRowsTopAppBarSig = + "(Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;FF" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/TopAppBarColors;" + + "Landroidx/compose/material3/TopAppBarScrollBehavior;" + + "Landroidx/compose/runtime/Composer;II)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "MediumTopAppBar-oKE7A98", + Signature = TwoRowsTopAppBarSig, + Defaults = typeof(TwoRowsTopAppBarDefault))] + [ComposeFacade( + BranchOn = "Subtitle", + AlternateBridge = nameof(MediumFlexibleTopAppBar))] + public static partial void MediumTopAppBar( + IFunction2 title, + IModifier? modifier, + IFunction2? navigationIcon, + IFunction3? actions, + ITopAppBarScrollBehavior? scrollBehavior, + int defaults, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "LargeTopAppBar-oKE7A98", + Signature = TwoRowsTopAppBarSig, + Defaults = typeof(TwoRowsTopAppBarDefault))] + [ComposeFacade( + BranchOn = "Subtitle", + AlternateBridge = nameof(LargeFlexibleTopAppBar))] + public static partial void LargeTopAppBar( + IFunction2 title, + IModifier? modifier, + IFunction2? navigationIcon, + IFunction3? actions, + ITopAppBarScrollBehavior? scrollBehavior, + int defaults, + IComposer composer); + + // androidx.compose.material3.TabRowKt — TabRow / PrimaryTabRow / + // SecondaryTabRow all share `-pAZo6Ak`. 7 user params: selectedTabIndex, + // modifier, containerColor, contentColor, indicator, divider, tabs. + // (Primary/Secondary's indicator Function3 has a TabIndicatorScope + // receiver; TabRow's has List — irrelevant to the + // descriptor since both compile to Function3.) + const string TabRowSig = + "(ILandroidx/compose/ui/Modifier;JJ" + + "Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/TabRowKt", + JvmName = "TabRow-pAZo6Ak", + Signature = TabRowSig, + Defaults = typeof(TabRowDefault))] + [ComposeFacade] + public static partial void TabRow( + int selectedTabIndex, + IModifier? modifier, + IFunction2 tabs, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/TabRowKt", + JvmName = "PrimaryTabRow-pAZo6Ak", + Signature = TabRowSig, + Defaults = typeof(TabRowDefault))] + [ComposeFacade] + public static partial void PrimaryTabRow( + int selectedTabIndex, + IModifier? modifier, + IFunction2 tabs, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/TabRowKt", + JvmName = "SecondaryTabRow-pAZo6Ak", + Signature = TabRowSig, + Defaults = typeof(TabRowDefault))] + [ComposeFacade] + public static partial void SecondaryTabRow( + int selectedTabIndex, + IModifier? modifier, + IFunction2 tabs, + IComposer composer); + + // androidx.compose.material3.TabRowKt.ScrollableTabRow-sKfQg0A. + // 8 user params: same as TabRow plus a leading `edgePadding: Dp`. + [ComposeBridge( + Class = "androidx/compose/material3/TabRowKt", + JvmName = "ScrollableTabRow-sKfQg0A", + Signature = "(ILandroidx/compose/ui/Modifier;JJF" + + "Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ScrollableTabRowDefault))] + [ComposeFacade] + public static partial void ScrollableTabRow( + int selectedTabIndex, + IModifier? modifier, + IFunction2 tabs, + IComposer composer); + + // androidx.compose.material3.TabKt.Tab-wqdebIU (text/icon overload). + // 9 user params: selected, onClick, modifier, enabled, text, icon, + // selectedContentColor, unselectedContentColor, interactionSource. + [ComposeBridge( + Class = "androidx/compose/material3/TabKt", + JvmName = "Tab-wqdebIU", + Signature = "(ZLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;JJ" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(TabDefault))] + [ComposeFacade] + public static partial void Tab( + bool selected, + IFunction0 onClick, + IModifier? modifier, + IFunction2? text, + IFunction2? icon, + int defaults, + IComposer composer); + + // androidx.compose.material3.TabKt.LeadingIconTab-wqdebIU. + // 9 user params: selected, onClick, text, icon, modifier, enabled, + // selectedContentColor, unselectedContentColor, interactionSource. + // Note text and icon are REQUIRED (no Kotlin default), unlike Tab. + [ComposeBridge( + Class = "androidx/compose/material3/TabKt", + JvmName = "LeadingIconTab-wqdebIU", + Signature = "(ZLkotlin/jvm/functions/Function0;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;ZJJ" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(LeadingIconTabDefault))] + [ComposeFacade] + public static partial void LeadingIconTab( + bool selected, + IFunction0 onClick, + IFunction2 text, + IFunction2 icon, + IModifier? modifier, + IComposer composer); + + // androidx.compose.material3.SnackbarKt.Snackbar-eQBnUkQ. 10 user + // params: modifier, action, dismissAction, actionOnNewLine, shape, + // 4 colors, content. Bit 9 (content) always provided. + [ComposeBridge( + Class = "androidx/compose/material3/SnackbarKt", + JvmName = "Snackbar-eQBnUkQ", + Signature = "(Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/ui/graphics/Shape;JJJJ" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(SnackbarDefault))] + [ComposeFacade] + public static partial void Snackbar( + IModifier? modifier, + IFunction2? action, + IFunction2? dismissAction, + [Slot("Body")] IFunction2 content, + int defaults, + IComposer composer); + + // androidx.compose.material3.SnackbarHostKt.SnackbarHost — UNMANGLED + // (no inline-class params). 3 user params: hostState, modifier, + // snackbar. Bits 0 (hostState) and 2 (snackbar) always provided. + [ComposeBridge( + Class = "androidx/compose/material3/SnackbarHostKt", + JvmName = "SnackbarHost", + Signature = "(Landroidx/compose/material3/SnackbarHostState;" + + "Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(SnackbarHostDefault))] + public static partial void SnackbarHost( + IntPtr hostState, + IModifier? modifier, + IFunction3 snackbar, + IComposer composer); + + // androidx.compose.material3.BadgeKt.Badge-eopBjH0. 4 user params: + // modifier, containerColor, contentColor, content (RowScope-receiver + // Function3). Bit 3 (content) always provided. + [ComposeBridge( + Class = "androidx/compose/material3/BadgeKt", + JvmName = "Badge-eopBjH0", + Signature = "(Landroidx/compose/ui/Modifier;JJ" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(BadgeDefault))] + [ComposeFacade(Scope = "Row")] + public static partial void Badge(IModifier? modifier, IFunction3 content, IComposer composer); + + // androidx.compose.material3.BadgeKt.BadgedBox — UNMANGLED. 3 user + // params: badge (BoxScope-receiver Function3), modifier, content + // (BoxScope-receiver Function3). Bits 0 (badge) and 2 (content) + // always provided. + [ComposeBridge( + Class = "androidx/compose/material3/BadgeKt", + JvmName = "BadgedBox", + Signature = "(Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(BadgedBoxDefault))] + [ComposeFacade] + public static partial void BadgedBox( + IFunction3 badge, + IModifier? modifier, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.ListItemKt.ListItem-HXNGIdc. 9 user + // params: headlineContent, modifier, overlineContent, supportingContent, + // leadingContent, trailingContent, colors, tonalElevation, + // shadowElevation. Bit 0 (headlineContent) always provided. + [ComposeBridge( + Class = "androidx/compose/material3/ListItemKt", + JvmName = "ListItem-HXNGIdc", + Signature = "(Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/material3/ListItemColors;FF" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ListItemDefault))] + [ComposeFacade] + public static partial void ListItem( + [Slot("Headline")] IFunction2 headlineContent, + IModifier? modifier, + [Slot("Overline")] IFunction2? overlineContent, + [Slot("Supporting")] IFunction2? supportingContent, + [Slot("Leading")] IFunction2? leadingContent, + [Slot("Trailing")] IFunction2? trailingContent, + int defaults, + IComposer composer); + + // androidx.compose.material3.AndroidMenu_androidKt.DropdownMenu-IlH_yew — + // the menu *container* that anchors the (already-bound) DropdownMenuItem. + // 12 user params: expanded, onDismissRequest, modifier, offset (DpOffset + // packed in J), scrollState, properties, shape, containerColor (J), + // tonalElevation (Dp F), shadowElevation (Dp F), border, content + // (ColumnScope receiver Function3). Bits 0/1/11 (expanded, onDismissRequest, + // content) always provided. + [ComposeBridge( + Class = "androidx/compose/material3/AndroidMenu_androidKt", + JvmName = "DropdownMenu-IlH_yew", + Signature = "(ZLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;J" + + "Landroidx/compose/foundation/ScrollState;" + + "Landroidx/compose/ui/window/PopupProperties;" + + "Landroidx/compose/ui/graphics/Shape;JFF" + + "Landroidx/compose/foundation/BorderStroke;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(DropdownMenuDefault))] + [ComposeFacade] + public static partial void DropdownMenu( + bool expanded, + IFunction0 onDismissRequest, + IModifier? modifier, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.ExposedDropdownMenuKt.ExposedDropdownMenuBox — + // textfield-anchored menu container. 4 user params: expanded, onExpandedChange + // (Function1), modifier, content (Function3 with + // ExposedDropdownMenuBoxScope receiver). Bits 0/1/3 always provided. + // The Kt method itself is NOT mangled (no inline-class params), but the + // binder still strips the trailing `$default` int, so we go through JNI. + [ComposeBridge( + Class = "androidx/compose/material3/ExposedDropdownMenuKt", + JvmName = "ExposedDropdownMenuBox", + Signature = "(ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ExposedDropdownMenuBoxDefault))] + [ComposeFacade(Scope = "Other")] + public static partial void ExposedDropdownMenuBox( + bool expanded, + [Callback(typeof(bool))] IFunction1 onExpandedChange, + IModifier? modifier, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.ExposedDropdownMenuBoxScope.ExposedDropdownMenu + // (the simplest unmangled overload — boolean + Function0 + Modifier + + // ScrollState + Function3). This is + // an INSTANCE method on the abstract scope class (not a static helper, not + // a Kotlin object singleton), so the [ComposeBridge] generator's static / + // ctor / InstanceField shapes don't fit — hand-written raw JNI in the + // wrapper-passthrough body below. + // + // The receiver is whichever scope the enclosing ExposedDropdownMenuBox + // pushed onto RenderContext (auto-bound by the facade generator because + // the param name ends in "Scope"). We do NOT cache the receiver — + // Compose hands a fresh scope per recomposition. + static IntPtr s_exposedDropdownMenuBoxScopeClass; + static IntPtr s_exposedDropdownMenuMethodId; + + [ComposeFacade(Defaults = typeof(ExposedDropdownMenuDefault))] + public static unsafe partial void ExposedDropdownMenu( + IntPtr exposedDropdownMenuBoxScope, + bool expanded, + IFunction0 onDismissRequest, + IModifier? modifier, + IFunction3 content, + int defaults, + IComposer composer); + + public static unsafe partial void ExposedDropdownMenu( + IntPtr exposedDropdownMenuBoxScope, + bool expanded, + IFunction0 onDismissRequest, + IModifier? modifier, + IFunction3 content, + int defaults, + IComposer composer) + { + if (exposedDropdownMenuBoxScope == IntPtr.Zero) + throw new InvalidOperationException( + "ExposedDropdownMenu must be rendered inside an ExposedDropdownMenuBox " + + "so it can resolve the menu-anchor scope."); + + if (s_exposedDropdownMenuMethodId == IntPtr.Zero) + { + // JNIEnv.FindClass on .NET for Android returns a long-lived + // global handle the runtime caches — passing it to + // DeleteLocalRef trips CheckJNI ("expected Local but found + // Global"). Cache it directly, matching every generated bridge. + s_exposedDropdownMenuBoxScopeClass = JNIEnv.FindClass( + "androidx/compose/material3/ExposedDropdownMenuBoxScope"); + s_exposedDropdownMenuMethodId = JNIEnv.GetMethodID( + s_exposedDropdownMenuBoxScopeClass, + "ExposedDropdownMenu", + "(ZLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/foundation/ScrollState;Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;II)V"); + } + + IntPtr modifierHandle = ModifierHandle(modifier); + var args = stackalloc JValue[8]; + args[0] = new JValue(expanded); + args[1] = new JValue(((Java.Lang.Object)onDismissRequest).Handle); + args[2] = new JValue(modifierHandle); + args[3] = new JValue(IntPtr.Zero); // scrollState — defaulted + args[4] = new JValue(((Java.Lang.Object)content).Handle); + args[5] = new JValue(((Java.Lang.Object)composer).Handle); + args[6] = new JValue(0); // $changed + args[7] = new JValue(defaults); + + try + { + JNIEnv.CallVoidMethod(exposedDropdownMenuBoxScope, s_exposedDropdownMenuMethodId, args); + } + finally + { + GC.KeepAlive(onDismissRequest); + GC.KeepAlive(modifier); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.SearchBarKt.rememberSearchBarState — + // factory @Composable that produces a `SearchBarState`. 3 user params: + // initialValue (enum), animationSpecForExpand, animationSpecForCollapse. + // The wrapper defaults all three (initialValue defaults to Collapsed). + [ComposeBridge( + Class = "androidx/compose/material3/SearchBarKt", + JvmName = "rememberSearchBarState", + Signature = "(Landroidx/compose/material3/SearchBarValue;" + + "Landroidx/compose/animation/core/AnimationSpec;" + + "Landroidx/compose/animation/core/AnimationSpec;" + + "Landroidx/compose/runtime/Composer;II)" + + "Landroidx/compose/material3/SearchBarState;", + Defaults = typeof(RememberSearchBarStateDefault))] + public static partial IntPtr RememberSearchBarState(IComposer composer); + + // androidx.compose.material3.SearchBarKt.SearchBar-nbWgWpA — the + // state-based collapsed search bar. 7 user params: state, inputField + // (Function2), modifier, shape, colors, inputFieldHeight (Dp F), + // floatingHeight (Dp F). Bits 0/1 (state, inputField) always provided. + [ComposeBridge( + Class = "androidx/compose/material3/SearchBarKt", + JvmName = "SearchBar-nbWgWpA", + Signature = "(Landroidx/compose/material3/SearchBarState;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/SearchBarColors;FF" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(SearchBarDefault))] + public static partial void SearchBar( + IntPtr state, + IFunction2 inputField, + IModifier? modifier, + IComposer composer); + + // androidx.compose.material3.SearchBarKt.TopSearchBar-qKj4JfE — like + // SearchBar but draws as a scrollable top bar (adds WindowInsets + + // SearchBarScrollBehavior). 9 user params; bits 0/1 always provided. + [ComposeBridge( + Class = "androidx/compose/material3/SearchBarKt", + JvmName = "TopSearchBar-qKj4JfE", + Signature = "(Landroidx/compose/material3/SearchBarState;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/SearchBarColors;FF" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/SearchBarScrollBehavior;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(TopSearchBarDefault))] + public static partial void TopSearchBar( + IntPtr state, + IFunction2 inputField, + IModifier? modifier, + IComposer composer); + + // androidx.compose.material3.SearchBarKt.ExpandedDockedSearchBar-qKj4JfE — + // the expanded popup half of a docked SearchBar pair. Renders only when + // the SearchBarState is Expanded. 9 user params: state, inputField, + // modifier, shape, colors, inputFieldHeight, floatingHeight, properties, + // content (ColumnScope receiver Function3). Bits 0/1/8 always provided. + [ComposeBridge( + Class = "androidx/compose/material3/SearchBarKt", + JvmName = "ExpandedDockedSearchBar-qKj4JfE", + Signature = "(Landroidx/compose/material3/SearchBarState;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/SearchBarColors;FF" + + "Landroidx/compose/ui/window/PopupProperties;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ExpandedDockedSearchBarDefault))] + public static partial void ExpandedDockedSearchBar( + IntPtr state, + IFunction2 inputField, + IModifier? modifier, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.SearchBarKt.ExpandedFullScreenSearchBar-_UtchM0 — + // the full-screen popup half of a SearchBar pair (Dialog-based). 10 + // user params: state, inputField, modifier, shape, colors, inputFieldHeight, + // floatingHeight, windowInsets (Function2 producing WindowInsets), + // properties (DialogProperties), content. Bits 0/1/9 always provided. + [ComposeBridge( + Class = "androidx/compose/material3/SearchBarKt", + JvmName = "ExpandedFullScreenSearchBar-_UtchM0", + Signature = "(Landroidx/compose/material3/SearchBarState;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/SearchBarColors;FF" + + "Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/window/DialogProperties;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(ExpandedFullScreenSearchBarDefault))] + public static partial void ExpandedFullScreenSearchBar( + IntPtr state, + IFunction2 inputField, + IModifier? modifier, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.SearchBarKt.DockedSearchBar-EQC0FA8 — the + // boolean-state docked variant. Deprecated in M3 1.4 in favor of the + // state-based `SearchBar` + `ExpandedDockedSearchBar` pair, but still + // ships in 1.4.0.3 and is the only standalone "DockedSearchBar" entry + // point — the new state-based design has no separate `DockedSearchBar` + // composable. 9 user params: inputField (Function2), expanded (Z), + // onExpandedChange (Function1), modifier, shape, colors, + // tonalElevation (Dp F), shadowElevation (Dp F), content (Function3 + // ColumnScope receiver). Bits 0/1/2/8 always provided. + [ComposeBridge( + Class = "androidx/compose/material3/SearchBarKt", + JvmName = "DockedSearchBar-EQC0FA8", + Signature = "(Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;" + + "Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/SearchBarColors;FF" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(DockedSearchBarDefault))] + public static partial void DockedSearchBar( + IFunction2 inputField, + bool expanded, + IFunction1 onExpandedChange, + IModifier? modifier, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.SearchBarKt.DockedSearchBar-eWTbjVg — the + // even older query-based docked search bar. Self-contained input field + // (no separate inputField slot); the caller drives `query` text and the + // `active` boolean directly, with `placeholder` / `leadingIcon` / + // `trailingIcon` as composable slots and `content` as the docked + // results list. Deprecated in M3 1.2 in favor of the boolean-state + // overload above, and again in M3 1.4 in favor of the state-based + // `SearchBar` + `ExpandedDockedSearchBar` pair, but still ships in + // 1.4.0.3 — and it's what compose-samples/Reply uses for the inbox + // search affordance (see issue #165). 16 user params, packed into two + // `$changed` ints + one `$default` (trailing `III`). Bits 0/1/2/3/4 + // (query, onQueryChange, onSearch, active, onActiveChange) and bit 15 + // (content) are always provided. Bits 10–14 (shape, colors, + // tonalElevation, shadowElevation, interactionSource) are unmapped + // and stay defaulted by Kotlin; bit 6 (enabled) likewise defaults to + // Kotlin's `true`. + [ComposeBridge( + Class = "androidx/compose/material3/SearchBarKt", + JvmName = "DockedSearchBar-eWTbjVg", + Signature = "(Ljava/lang/String;" + + "Lkotlin/jvm/functions/Function1;" + + "Lkotlin/jvm/functions/Function1;Z" + + "Lkotlin/jvm/functions/Function1;" + + "Landroidx/compose/ui/Modifier;Z" + + "Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/SearchBarColors;FF" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;III)V", + Defaults = typeof(DockedSearchBarWithQueryDefault))] + public static partial void DockedSearchBarWithQuery( + string query, + IFunction1 onQueryChange, + IFunction1 onSearch, + bool active, + IFunction1 onActiveChange, + IModifier? modifier, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.SearchBarDefaults.InputField — state-based + // overload. The state-aware InputField is what wires focus/click events + // to SearchBarState (animateToExpanded/Collapsed), so this is what makes + // tapping the bar actually expand the popup. Instance method on the + // Kotlin SearchBarDefaults singleton — InstanceField walks the static + // INSTANCE field once, caches a global ref, then calls via CallVoidMethod. + // + // 18 user params (textFieldState, searchBarState, onSearch, modifier, + // enabled, autoFocus, textStyle, placeholder, leadingIcon, trailingIcon, + // prefix, suffix, inputTransformation, outputTransformation, scrollState, + // shape, colors, interactionSource). Bits 0-2 always provided + // (textFieldState, searchBarState, onSearch); we expose modifier + + // placeholder + leadingIcon + trailingIcon and let the rest default. + [ComposeBridge( + Class = "androidx/compose/material3/SearchBarDefaults", + JvmName = "InputField", + Signature = "(Landroidx/compose/foundation/text/input/TextFieldState;" + + "Landroidx/compose/material3/SearchBarState;" + + "Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZZ" + + "Landroidx/compose/ui/text/TextStyle;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/foundation/text/input/InputTransformation;" + + "Landroidx/compose/foundation/text/input/OutputTransformation;" + + "Landroidx/compose/foundation/ScrollState;" + + "Landroidx/compose/ui/graphics/Shape;" + + "Landroidx/compose/material3/TextFieldColors;" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Landroidx/compose/runtime/Composer;III)V", + InstanceField = "INSTANCE", + Defaults = typeof(SearchBarDefaultsInputFieldDefault))] + public static partial void SearchBarDefaultsInputField( + IntPtr textFieldState, + IntPtr searchBarState, + IFunction1 onSearch, + IModifier? modifier, + IFunction2? placeholder, + IFunction2? leadingIcon, + IFunction2? trailingIcon, + IComposer composer); + + // androidx.compose.material3.AppBarKt.TopAppBar-cJHQLPU (subtitle + // overload). 10 user params: title, subtitle, modifier, navigationIcon, + // actions, titleHorizontalAlignment, expandedHeight, windowInsets, + // colors, scrollBehavior. + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "TopAppBar-cJHQLPU", + Signature = "(Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Alignment$Horizontal;F" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/TopAppBarColors;" + + "Landroidx/compose/material3/TopAppBarScrollBehavior;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(TopAppBarSubtitleDefault))] + public static partial void TopAppBarWithSubtitle( + IFunction2 title, + IFunction2 subtitle, + IModifier? modifier, + IFunction2? navigationIcon, + IFunction3? actions, + ITopAppBarScrollBehavior? scrollBehavior, + int defaults, + IComposer composer); + + // androidx.compose.material3.AppBarKt.{Medium,Large}FlexibleTopAppBar-eXZ4JBQ + // share the descriptor (trailing `III` because 11 params * 3 bits per + // $changed slot exceeds the 31 bits in one int). 11 user params: + // title, modifier, subtitle, navigationIcon, actions, + // titleHorizontalAlignment, collapsedHeight, expandedHeight, + // windowInsets, colors, scrollBehavior. + const string FlexibleTopAppBarSig = + "(Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Alignment$Horizontal;FF" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/TopAppBarColors;" + + "Landroidx/compose/material3/TopAppBarScrollBehavior;" + + "Landroidx/compose/runtime/Composer;III)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "MediumFlexibleTopAppBar-eXZ4JBQ", + Signature = FlexibleTopAppBarSig, + Defaults = typeof(FlexibleTopAppBarDefault))] + [ComposeFacade] + public static partial void MediumFlexibleTopAppBar( + IFunction2 title, + IModifier? modifier, + IFunction2? subtitle, + IFunction2? navigationIcon, + IFunction3? actions, + ITopAppBarScrollBehavior? scrollBehavior, + int defaults, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "LargeFlexibleTopAppBar-eXZ4JBQ", + Signature = FlexibleTopAppBarSig, + Defaults = typeof(FlexibleTopAppBarDefault))] + [ComposeFacade] + public static partial void LargeFlexibleTopAppBar( + IFunction2 title, + IModifier? modifier, + IFunction2? subtitle, + IFunction2? navigationIcon, + IFunction3? actions, + ITopAppBarScrollBehavior? scrollBehavior, + int defaults, + IComposer composer); + + // androidx.compose.material3.AppBarKt.BottomAppBar-qhFBPw4 (actions + + // FAB + scrollBehavior — the most flexible of the four BottomAppBar + // overloads). 9 user params: actions (RowScope-receiver Function3), + // modifier, floatingActionButton, containerColor, contentColor, + // tonalElevation, contentPadding, windowInsets, scrollBehavior. + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "BottomAppBar-qhFBPw4", + Signature = "(Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Modifier;" + + "Lkotlin/jvm/functions/Function2;JJF" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/BottomAppBarScrollBehavior;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(BottomAppBarDefault))] + [ComposeFacade(Scope = "Row")] + public static partial void BottomAppBar( + IFunction3 actions, + IModifier? modifier, + IFunction2? floatingActionButton, + IComposer composer); + + // androidx.compose.material3.AppBarKt.FlexibleBottomAppBar-wBhsO_E. + // 9 user params: modifier, containerColor, contentColor, contentPadding, + // horizontalArrangement, expandedHeight, windowInsets, scrollBehavior, + // content (RowScope-receiver Function3). Bit 8 (content) always provided. + [ComposeBridge( + Class = "androidx/compose/material3/AppBarKt", + JvmName = "FlexibleBottomAppBar-wBhsO_E", + Signature = "(Landroidx/compose/ui/Modifier;JJ" + + "Landroidx/compose/foundation/layout/PaddingValues;" + + "Landroidx/compose/foundation/layout/Arrangement$Horizontal;F" + + "Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/BottomAppBarScrollBehavior;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(FlexibleBottomAppBarDefault))] + [ComposeFacade(Scope = "Row")] + public static partial void FlexibleBottomAppBar( + IModifier? modifier, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.TabRowKt.{Primary,Secondary}ScrollableTabRow-qhFBPw4 + // (simpler overload of -cx2KkNY without the TabIndicatorScope-aware + // indicator/divider Function2 wrap — both share the descriptor). + // 9 user params: selectedTabIndex, modifier, scrollState (optional — + // null lets Kotlin allocate the default ScrollState), containerColor, + // contentColor, edgePadding, indicator, divider, tabs. + const string PrimaryScrollableTabRowSig = + "(ILandroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;JJF" + + "Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V"; + + [ComposeBridge( + Class = "androidx/compose/material3/TabRowKt", + JvmName = "PrimaryScrollableTabRow-qhFBPw4", + Signature = PrimaryScrollableTabRowSig, + Defaults = typeof(PrimaryScrollableTabRowDefault))] + public static partial void PrimaryScrollableTabRow( + int selectedTabIndex, + IModifier? modifier, + IntPtr? scrollState, + IFunction2 tabs, + IComposer composer); + + [ComposeBridge( + Class = "androidx/compose/material3/TabRowKt", + JvmName = "SecondaryScrollableTabRow-qhFBPw4", + Signature = PrimaryScrollableTabRowSig, + Defaults = typeof(PrimaryScrollableTabRowDefault))] + public static partial void SecondaryScrollableTabRow( + int selectedTabIndex, + IModifier? modifier, + IntPtr? scrollState, + IFunction2 tabs, + IComposer composer); + + // androidx.compose.material3.TabKt.Tab-bogVsAg (ColumnScope content + // lambda overload — alternative to Tab-wqdebIU). 8 user params: + // selected, onClick, modifier, enabled, selectedContentColor, + // unselectedContentColor, interactionSource, content. + [ComposeBridge( + Class = "androidx/compose/material3/TabKt", + JvmName = "Tab-bogVsAg", + Signature = "(ZLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;ZJJ" + + "Landroidx/compose/foundation/interaction/MutableInteractionSource;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(TabContentDefault))] + [ComposeFacade(ClassName = "CustomTab", Scope = "Column")] + public static partial void TabContent( + bool selected, + IFunction0 onClick, + IModifier? modifier, + IFunction3 content, + IComposer composer); + + // androidx.compose.material3.SnackbarKt.Snackbar-sDKtq54 (SnackbarData + // overload — fed by SnackbarHost's content lambda when state has + // queued data). 9 user params: snackbarData, modifier, actionOnNewLine, + // shape, containerColor, contentColor, actionColor, actionContentColor, + // dismissActionContentColor. Bit 0 (snackbarData) always provided — + // we take the raw JNI handle so SnackbarHost can forward Function3's + // p0 (a Java.Lang.Object SnackbarData) directly. + [ComposeBridge( + Class = "androidx/compose/material3/SnackbarKt", + JvmName = "Snackbar-sDKtq54", + Signature = "(Landroidx/compose/material3/SnackbarData;" + + "Landroidx/compose/ui/Modifier;Z" + + "Landroidx/compose/ui/graphics/Shape;JJJJJ" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(SnackbarFromDataDefault))] + public static partial void SnackbarFromData( + IntPtr snackbarData, + IModifier? modifier, + IComposer composer); + + // Phase 8 — wrapper-passthrough facades. These are [ComposeFacade]-only + // partial methods with hand-written bodies that delegate to a bound + // binding. They eliminate per-facade boilerplate (ComposableLambda + // wrapping, modifier handling, $default mask) by routing through the + // ComposeFacadeGenerator. The wrapper itself is just one explicit + // call to the binding — no overload resolution magic, every arg + // hand-picked. + + [ComposeFacade(Defaults = typeof(BoxDefault), Scope = "Box")] + public static partial void Box(IModifier? modifier, IFunction3 content, int defaults, IComposer composer); + + public static partial void Box(IModifier? modifier, IFunction3 content, int defaults, IComposer composer) + => BoxKt.Box( + modifier: modifier, + contentAlignment: null, + propagateMinConstraints: false, + content: content, + _composer: composer, + p5: 0, + _changed: defaults); + + // Internal forwarder for Column. The public-facing facade + // (Column.cs) is hand-written because the [ComposeFacade] generator + // can't surface a typed Arrangement? ctor parameter; it owns the + // $default mask and forwards the chosen verticalArrangement here. + internal static void Column( + IModifier? modifier, + AndroidX.Compose.Foundation.Layout.Arrangement.IVertical? verticalArrangement, + IAlignmentHorizontal? horizontalAlignment, + IFunction3 content, + int defaults, + IComposer composer) + => ColumnKt.Column( + modifier: modifier, + verticalArrangement: verticalArrangement, + horizontalAlignment: horizontalAlignment, + content: content, + _composer: composer, + p5: 0, + _changed: defaults); + + // Internal forwarder for Row — see the Column helper above for why + // this isn't an auto-generated facade. Row.cs supplies the typed + // horizontalArrangement / verticalAlignment and matching $default mask. + internal static void Row( + IModifier? modifier, + AndroidX.Compose.Foundation.Layout.Arrangement.IHorizontal? horizontalArrangement, + IAlignmentVertical? verticalAlignment, + IFunction3 content, + int defaults, + IComposer composer) + => RowKt.Row( + modifier: modifier, + horizontalArrangement: horizontalArrangement, + verticalAlignment: verticalAlignment, + content: content, + _composer: composer, + p5: 0, + _changed: defaults); + + // Spacer has no Kotlin $default — the modifier param is required. + // The wrapper materialises Modifier.Companion when the caller leaves + // it null so the binding receives a non-null receiver. + [ComposeFacade] + public static partial void Spacer(IModifier? modifier, IComposer composer); + + public static partial void Spacer(IModifier? modifier, IComposer composer) + => SpacerKt.Spacer( + modifier ?? Java.Lang.Object.GetObject( + ModifierCompanionInstance(), + JniHandleOwnership.TransferLocalRef)!, + composer, + 0); + + [ComposeFacade(Defaults = typeof(CheckboxDefault))] + public static partial void Checkbox( + bool @checked, + [Callback(typeof(bool))] + IFunction1 onCheckedChange, + IModifier? modifier, + bool enabled = true, + AndroidX.Compose.Material3.CheckboxColors? colors = null, + int defaults = 0, + IComposer composer = null!); + + public static partial void Checkbox(bool @checked, IFunction1 onCheckedChange, IModifier? modifier, bool enabled, AndroidX.Compose.Material3.CheckboxColors? colors, int defaults, IComposer composer) + => CheckboxKt.Checkbox( + @checked: @checked, + onCheckedChange: onCheckedChange, + modifier: modifier, + enabled: enabled, + colors: colors, + interactionSource: null, + _composer: composer, + p7: 0, + _changed: defaults); + + [ComposeFacade(Defaults = typeof(SwitchDefault))] + public static partial void Switch( + bool @checked, + [Callback(typeof(bool))] + IFunction1 onCheckedChange, + IModifier? modifier, + bool enabled = true, + AndroidX.Compose.Material3.SwitchColors? colors = null, + int defaults = 0, + IComposer composer = null!); + + public static partial void Switch(bool @checked, IFunction1 onCheckedChange, IModifier? modifier, bool enabled, AndroidX.Compose.Material3.SwitchColors? colors, int defaults, IComposer composer) + => SwitchKt.Switch( + @checked: @checked, + onCheckedChange: onCheckedChange, + modifier: modifier, + thumbContent: null, + enabled: enabled, + colors: colors, + interactionSource: null, + _composer: composer, + p8: 0, + _changed: defaults); + + [ComposeFacade(Defaults = typeof(RadioButtonDefault))] + public static partial void RadioButton( + bool selected, + IFunction0 onClick, + IModifier? modifier, + bool enabled = true, + int defaults = 0, + IComposer composer = null!); + + public static partial void RadioButton(bool selected, IFunction0 onClick, IModifier? modifier, bool enabled, int defaults, IComposer composer) + => RadioButtonKt.RadioButton( + selected: selected, + onClick: onClick, + modifier: modifier, + enabled: enabled, + colors: null, + interactionSource: null, + _composer: composer, + p7: 0, + _changed: defaults); + + [ComposeFacade(Defaults = typeof(SliderDefault))] + public static partial void Slider( + float value, + [Callback(typeof(float))] + IFunction1 onValueChange, + IModifier? modifier, + IClosedFloatingPointRange? valueRange, + SliderColors? colors, + bool enabled = true, + int steps = 0, + int defaults = 0, + IComposer composer = null!); + + // Slot rename pattern: C# `p5` is the real Kotlin `steps` Int; C# named + // `steps:` is the JVM `$changed` recomposition int; `_changed` is `$default`. + // Cross-check with RangeSlider above, which uses the same SliderKt + // overload (both pass `steps: 0` for $changed and route the user-facing + // steps through `p5:`). + public static partial void Slider(float value, IFunction1 onValueChange, IModifier? modifier, IClosedFloatingPointRange? valueRange, SliderColors? colors, bool enabled, int steps, int defaults, IComposer composer) + => SliderKt.Slider( + value: value, + onValueChange: onValueChange, + modifier: modifier, + enabled: enabled, + valueRange: valueRange, + p5: steps, + onValueChangeFinished: null, + colors: colors, + interactionSource: null, + _composer: composer, + steps: 0, + _changed: defaults); + + // FlowRow / FlowColumn — Phase 8 wrapper-passthrough facades. The + // simpler 7-Kotlin-param overloads (no FlowRowOverflow / FlowColumnOverflow + // slot) are bound directly. The Int params `maxItemsInEachRow` / + // `maxLines` (resp. `maxItemsInEachColumn` / `maxLines`) can't be + // safely auto-masked from a nullable C# slot, so v1 leaves their + // $default bits set and passes 0 — Kotlin substitutes Int.MAX_VALUE. + // The binder rename pattern: the C# `p4` param is the actual Kotlin + // `maxItemsInEachRow`/`maxItemsInEachColumn` Int, and the C# named + // `maxItemsInEachRow`/`maxItemsInEachColumn` is the Kotlin `maxLines` + // Int. The `maxLines` slot in C# is the JVM `$changed` int; `_changed` + // is `$default`. + [ComposeFacade(Defaults = typeof(FlowRowDefault), Scope = "Row")] + public static partial void FlowRow(IModifier? modifier, IFunction3 content, int defaults, IComposer composer); + + public static partial void FlowRow(IModifier? modifier, IFunction3 content, int defaults, IComposer composer) + => FlowLayoutKt.FlowRow( + modifier: modifier, + horizontalArrangement: null, + verticalArrangement: null, + itemVerticalAlignment: null, + p4: 0, + maxItemsInEachRow: 0, + content: content, + _composer: composer, + maxLines: 0, + _changed: defaults); + + [ComposeFacade(Defaults = typeof(FlowColumnDefault), Scope = "Column")] + public static partial void FlowColumn(IModifier? modifier, IFunction3 content, int defaults, IComposer composer); + + public static partial void FlowColumn(IModifier? modifier, IFunction3 content, int defaults, IComposer composer) + => FlowLayoutKt.FlowColumn( + modifier: modifier, + verticalArrangement: null, + horizontalArrangement: null, + itemHorizontalAlignment: null, + p4: 0, + maxItemsInEachColumn: 0, + content: content, + _composer: composer, + maxLines: 0, + _changed: defaults); + + // WideNavigationRailKt.WideNavigationRailItem-pli-t6k. Bound C# wrapper + // has misnamed trailing params: `iconPosition` is actually $changed, + // `_changed` is actually $default. The mid-list `int p7` is the real + // iconPosition slot (not user-exposed). + [ComposeFacade(Defaults = typeof(WideNavigationRailItemDefault))] + public static partial void WideNavigationRailItem( + bool selected, + IFunction0 onClick, + IFunction2 icon, + IFunction2? label, + IModifier? modifier, + bool enabled = true, + int defaults = 0, + IComposer composer = null!); + + public static partial void WideNavigationRailItem(bool selected, IFunction0 onClick, IFunction2 icon, IFunction2? label, IModifier? modifier, bool enabled, int defaults, IComposer composer) + => AndroidX.Compose.Material3.WideNavigationRailKt.WideNavigationRailItem( + selected: selected, + onClick: onClick, + icon: icon, + label: label, + railExpanded: false, + modifier: modifier, + enabled: enabled, + p7: 0, + colors: null, + interactionSource: null, + _composer: composer, + iconPosition: 0, + _changed: defaults); + + [ComposeFacade(Defaults = typeof(TriStateCheckboxDefault))] + public static partial void TriStateCheckbox( + ToggleableState state, + IFunction0 onClick, + IModifier? modifier, + bool enabled = true, + int defaults = 0, + IComposer composer = null!); + + public static partial void TriStateCheckbox(ToggleableState state, IFunction0 onClick, IModifier? modifier, bool enabled, int defaults, IComposer composer) + => AndroidX.Compose.Material3.CheckboxKt.TriStateCheckbox( + state: state, + onClick: onClick, + modifier: modifier, + enabled: enabled, + colors: null, + interactionSource: null, + _composer: composer, + p7: 0, + _changed: defaults); + + // Phase 8 wrapper-passthrough for the segmented-button row + // containers. The generator's IndexedChildren=true emits the + // PushRow + per-child SetIndex publishing required by the child + // SegmentedButton's ItemShape computation. Inner call is the bound + // binding directly (the binder exposes the *Kt static for these + // unmangled overloads), so the facade must supply the $default mask + // explicitly via the trailing `int defaults` slot. + [ComposeFacade(Defaults = typeof(SegmentedButtonRowDefault), Scope = "Other", IndexedChildren = true)] + public static partial void SingleChoiceSegmentedButtonRow( + IModifier? modifier, + IFunction3 content, + int defaults, + IComposer composer); + + public static partial void SingleChoiceSegmentedButtonRow(IModifier? modifier, IFunction3 content, int defaults, IComposer composer) + => SegmentedButtonKt.SingleChoiceSegmentedButtonRow( + modifier: modifier, + space: 0f, + content: content, + _composer: composer, + p4: 0, + _changed: defaults); + + [ComposeFacade(Defaults = typeof(SegmentedButtonRowDefault), Scope = "Other", IndexedChildren = true)] + public static partial void MultiChoiceSegmentedButtonRow( + IModifier? modifier, + IFunction3 content, + int defaults, + IComposer composer); + + public static partial void MultiChoiceSegmentedButtonRow(IModifier? modifier, IFunction3 content, int defaults, IComposer composer) + => SegmentedButtonKt.MultiChoiceSegmentedButtonRow( + modifier: modifier, + space: 0f, + content: content, + _composer: composer, + p4: 0, + _changed: defaults); + + // Phase 8 wrapper-passthrough for the scrollable tab rows. The + // existing 5-param [ComposeBridge] overloads above (search for + // `PrimaryScrollableTabRow-qhFBPw4`) expose an `IntPtr? scrollState` + // slot the facade always passes as null — the bound binding's + // auto-mask handles the corresponding $default bit. These 4-param + // overloads forward to those bridges; the generator emits the + // facade class as a same-name overload selection at compile time + // (no `ClassName` redirect needed). + [ComposeFacade] + public static partial void PrimaryScrollableTabRow( + int selectedTabIndex, + IModifier? modifier, + IFunction2 tabs, + IComposer composer); + + public static partial void PrimaryScrollableTabRow(int selectedTabIndex, IModifier? modifier, IFunction2 tabs, IComposer composer) + => PrimaryScrollableTabRow( + selectedTabIndex: selectedTabIndex, + modifier: modifier, + scrollState: null, + tabs: tabs, + composer: composer); + + [ComposeFacade] + public static partial void SecondaryScrollableTabRow( + int selectedTabIndex, + IModifier? modifier, + IFunction2 tabs, + IComposer composer); + + public static partial void SecondaryScrollableTabRow(int selectedTabIndex, IModifier? modifier, IFunction2 tabs, IComposer composer) + => SecondaryScrollableTabRow( + selectedTabIndex: selectedTabIndex, + modifier: modifier, + scrollState: null, + tabs: tabs, + composer: composer); + + // androidx.compose.foundation.text.selection.SelectionContainerKt.SelectionContainer — + // `(modifier=Modifier, content)`. The binder exposes the Kt class + // and the 5-arg `_composer:/p3:/_changed:` overload directly, so + // this is a Phase 8 wrapper-passthrough. The auto-mask clears + // SelectionContainerDefault.Modifier when the caller supplies one. + [ComposeFacade(Defaults = typeof(SelectionContainerDefault))] + public static partial void SelectionContainer( + IModifier? modifier, + IFunction2 content, + int defaults, + IComposer composer); + + public static partial void SelectionContainer(IModifier? modifier, IFunction2 content, int defaults, IComposer composer) + => AndroidX.Compose.Foundation.Text.Selection.SelectionContainerKt.SelectionContainer( + modifier: modifier, + content: content, + _composer: composer, + p3: 0, + _changed: defaults); + + // androidx.compose.foundation.text.selection.SelectionContainerKt.DisableSelection — + // `(content)`. No optional params → no `$default` slot, so no + // `Defaults` enum and no `int defaults` user param (mirrors the + // Spacer wrapper above). Nests inside a SelectionContainer to opt + // a subtree out of selection. + [ComposeFacade] + public static partial void DisableSelection( + IFunction2 content, + IComposer composer); + + public static partial void DisableSelection(IFunction2 content, IComposer composer) + => AndroidX.Compose.Foundation.Text.Selection.SelectionContainerKt.DisableSelection( + content: content, + _composer: composer, + _changed: 0); + + // Tracking dotnet/android-libraries#1444: the binder strips every + // @Composable static from `Xamarin.AndroidX.Navigation.Compose`'s + // *Kt wrapper classes (NavHostKt, NavGraphBuilderKt, + // NavHostControllerKt, ...). We bridge the three we need — + // NavHost, NavGraphBuilder.composable, and rememberNavController. + + // androidx.navigation.compose.NavHostControllerKt.rememberNavController. + // Kotlin signature: `rememberNavController(vararg navigators: Navigator<*>): NavHostController`. + // Hand-written rather than [ComposeBridge] because the bridge + // generator has no path for "construct an empty Java array of + // Navigator and pass it as the varargs argument". Empty + // `Navigator[]` is cached as a global ref so we don't allocate one + // per recomposition. + static IntPtr s_rememberNavController_class; + static IntPtr s_rememberNavController_method; + static IntPtr s_rememberNavController_emptyArray; + + internal static unsafe AndroidX.Navigation.NavHostController RememberNavController(IComposer composer) + { + if (s_rememberNavController_method == IntPtr.Zero) + { + s_rememberNavController_class = JNIEnv.FindClass("androidx/navigation/compose/NavHostControllerKt"); + s_rememberNavController_method = JNIEnv.GetStaticMethodID( + s_rememberNavController_class, + "rememberNavController", + "([Landroidx/navigation/Navigator;Landroidx/compose/runtime/Composer;I)Landroidx/navigation/NavHostController;"); + + IntPtr navigatorClass = JNIEnv.FindClass("androidx/navigation/Navigator"); + IntPtr local = JNIEnv.NewObjectArray(0, navigatorClass, IntPtr.Zero); + s_rememberNavController_emptyArray = JNIEnv.NewGlobalRef(local); + JNIEnv.DeleteLocalRef(local); + } + + try + { + JValue* args = stackalloc JValue[3]; + args[0] = new JValue(s_rememberNavController_emptyArray); + args[1] = new JValue(((Java.Lang.Object)composer).Handle); + args[2] = new JValue(0); // $changed — Compose substitutes its own + IntPtr handle = JNIEnv.CallStaticObjectMethod( + s_rememberNavController_class, s_rememberNavController_method, args); + return Java.Lang.Object.GetObject( + handle, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + GC.KeepAlive(composer); + } + } + + // androidx.navigation.compose.NavHostKt.NavHost (string-route overload). + // Kotlin: `NavHost(navController, startDestination, modifier=Modifier, + // route=null, builder)`. Trailing `II` on the JNI sig = $changed + + // $default. The auto-mask drives the $default bits — modifier and + // route are nullable so the generator clears their bits only when + // the caller supplies a non-null value. + [ComposeBridge( + Class = "androidx/navigation/compose/NavHostKt", + JvmName = "NavHost", + Signature = "(Landroidx/navigation/NavHostController;Ljava/lang/String;" + + "Landroidx/compose/ui/Modifier;Ljava/lang/String;" + + "Lkotlin/jvm/functions/Function1;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(NavHostDefault))] + internal static partial void NavHost( + AndroidX.Navigation.NavHostController navController, + string startDestination, + IModifier? modifier, + string? route, + IFunction1 builder, + IComposer composer); + + // androidx.navigation.compose.NavGraphBuilderKt.composable$default + // (string-route overload). Kotlin extension on NavGraphBuilder: + // `NavGraphBuilder.composable(route, arguments=emptyList(), + // deepLinks=emptyList(), content)`. NOT @Composable itself — + // invoked synchronously inside NavHost's builder lambda. The + // trailing `IL...;` on the JNI sig is `$default` + the synthetic + // marker the bridge generator fills with `IntPtr.Zero` for any + // `*$default` overload. The first IntPtr (navGraphBuilder) is the + // extension receiver; route/content are required (suppressed bits); + // arguments and deepLinks are nullable lists with empty defaults. + [ComposeBridge( + Class = "androidx/navigation/compose/NavGraphBuilderKt", + JvmName = "composable$default", + Signature = "(Landroidx/navigation/NavGraphBuilder;Ljava/lang/String;" + + "Ljava/util/List;Ljava/util/List;" + + "Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V", + Defaults = typeof(NavComposableDefault))] + internal static partial void NavGraphBuilderComposable( + IntPtr navGraphBuilder, + string route, + Java.Lang.Object? arguments, + Java.Lang.Object? deepLinks, + IFunction3 content); + + static IntPtr s_modifierPointerInput_class; + static IntPtr s_modifierPointerInput_method; + + // androidx.compose.ui.input.pointer.SuspendingPointerInputFilterKt + // .pointerInput(Modifier, Object key1, PointerInputEventHandler): Modifier + // + // Hand-written because the [ComposeBridge] generator doesn't know + // about non-bound interface types like IPointerInputEventHandler. + // Returns a fresh local ref to the chained Modifier; the caller + // (Modifier.Append's lambda) DeleteLocalRefs the previous one. + internal static unsafe IntPtr ModifierPointerInput( + IntPtr modifier, IntPtr key, IntPtr handler) + { + if (s_modifierPointerInput_method == IntPtr.Zero) + { + s_modifierPointerInput_class = JNIEnv.FindClass( + "androidx/compose/ui/input/pointer/SuspendingPointerInputFilterKt"); + s_modifierPointerInput_method = JNIEnv.GetStaticMethodID( + s_modifierPointerInput_class, + "pointerInput", + "(Landroidx/compose/ui/Modifier;Ljava/lang/Object;" + + "Landroidx/compose/ui/input/pointer/PointerInputEventHandler;)" + + "Landroidx/compose/ui/Modifier;"); + } + + JValue* args = stackalloc JValue[3]; + args[0] = new JValue(modifier); + args[1] = new JValue(key); + args[2] = new JValue(handler); + return JNIEnv.CallStaticObjectMethod( + s_modifierPointerInput_class, s_modifierPointerInput_method, args); + } + + static IntPtr s_pointerInputHandler_class; + static IntPtr s_pointerInputHandler_ctor; + + // net.compose.PointerInputEventHandlerImpl.(Function2) + // + // Allocates the Java-side helper (shipped via ) + // that implements the bound but otherwise-unreachable + // PointerInputEventHandler interface by forwarding invoke(scope, + // continuation) to the supplied Function2 handle. + // + // Returns a fresh local ref to the new PointerInputEventHandlerImpl + // instance; caller owns it (typically pass straight through to + // ModifierPointerInput, whose CallStaticObjectMethod frame pop + // reclaims it). + internal static unsafe IntPtr NewPointerInputEventHandler(IntPtr function2) + { + if (s_pointerInputHandler_ctor == IntPtr.Zero) + { + s_pointerInputHandler_class = JNIEnv.FindClass( + "net/compose/PointerInputEventHandlerImpl"); + s_pointerInputHandler_ctor = JNIEnv.GetMethodID( + s_pointerInputHandler_class, + "", + "(Lkotlin/jvm/functions/Function2;)V"); + } + + JValue* args = stackalloc JValue[1]; + args[0] = new JValue(function2); + return JNIEnv.NewObject( + s_pointerInputHandler_class, s_pointerInputHandler_ctor, args); + } + + // androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner.current + // — `@Composable @ReadOnlyComposable` getter on a Kotlin object + // singleton. We need this to read the current ViewModelStoreOwner + // (NavBackStackEntry inside a NavHost destination, or the + // ComponentActivity at the root) so ComposeExtensions.ViewModel can + // hand the right owner to ViewModelProvider. The binding exposes + // the singleton (LocalViewModelStoreOwner.Instance) but not the + // @Composable getter, so we go through InstanceField + JNI. + // Returns null when no owner is installed; the caller throws. + [ComposeBridge( + Class = "androidx/lifecycle/viewmodel/compose/LocalViewModelStoreOwner", + JvmName = "getCurrent", + Signature = "(Landroidx/compose/runtime/Composer;I)Landroidx/lifecycle/ViewModelStoreOwner;", + InstanceField = "INSTANCE")] + public static partial IntPtr LocalViewModelStoreOwnerCurrent(IComposer composer); + + // androidx.activity.compose.BackHandlerKt.BackHandler — Kotlin + // signature `BackHandler(enabled: Boolean = true, onBack: () -> Unit)`. + // Bridged via raw JNI because `Xamarin.AndroidX.Activity.Compose` + // ships an empty stub DLL (no types bound). The trailing `II)V` on + // the JNI sig is `$changed, $default` — the primary @Composable + // overload, not a `*$default` synthetic. Compose's BackHandler + // wraps `onBack` in `rememberUpdatedState` so identity churn from + // a fresh ComposableLambda0 per recomposition is safe; the + // OnBackPressedDispatcher registration is keyed on lifecycle owner. + [ComposeBridge( + Class = "androidx/activity/compose/BackHandlerKt", + JvmName = "BackHandler", + Signature = "(ZLkotlin/jvm/functions/Function0;" + + "Landroidx/compose/runtime/Composer;II)V", + Defaults = typeof(BackHandlerDefault))] + [ComposeFacade] + public static partial void BackHandler( + IFunction0 onBack, + bool enabled, + IComposer composer); +} + diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeDefaults.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeDefaults.cs new file mode 100644 index 000000000..27abc8833 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeDefaults.cs @@ -0,0 +1,1205 @@ +// `$default` is a Kotlin compiler convention: every @Composable function +// takes a trailing `int $default` bitmask where bit N == 1 means +// "parameter N was NOT provided; substitute the default". Bit positions +// are positional in the Kotlin source order — there's no runtime API +// to query them. +// +// `ColumnDefault` and `MaterialThemeDefault` are *generated* by +// AndroidX.Compose.SourceGenerators from the generic `[ComposeDefaults]` +// attribute below — the binder exposes those Kt classes, so the +// generator can read parameter names off the longest overload. +// +// The other enums 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). For extension-receiver functions +// (NavigationBarItem takes a RowScope receiver), the receiver is NOT +// part of the $default bitmask — start the name list at the first +// user-facing parameter. +// +// When dotnet/java-interop#1440 lands and exposes the inline-class +// overloads, the declarative attributes can be replaced with +// `[ComposeDefaults("Button", "ButtonDefault")]` etc. and +// this comment can be deleted. + +using AndroidX.Compose.Foundation.Lazy; +using AndroidX.Compose.Foundation.Lazy.Grid; +using AndroidX.Compose.Material3; +using AndroidX.Compose; + +// Hand-rolled declarative form (instead of generic form) so the +// ComposeFacadeGenerator can see the bit names — generic-form enums +// are emitted by another source generator in the same pass and aren't +// visible cross-generator. Bits and names match what the generic form +// would have produced. +[assembly: ComposeDefaults("ColumnDefault", "modifier", "verticalArrangement", "horizontalAlignment", "!content")] +[assembly: ComposeDefaults("RowDefault", "modifier", "horizontalArrangement", "verticalAlignment", "!content")] +[assembly: ComposeDefaults("BoxDefault", "modifier", "contentAlignment", "propagateMinConstraints", "!content")] + +// androidx.compose.foundation.layout.BoxWithConstraintsKt — same shape +// as Box, but the content lambda receives a BoxWithConstraintsScope. +[assembly: ComposeDefaults("BoxWithConstraintsDefault", "modifier", "contentAlignment", "propagateMinConstraints", "!content")] + +// androidx.compose.foundation.text.selection.SelectionContainerKt.SelectionContainer — +// `(modifier=Modifier, content)`. Only `modifier` is defaultable; +// `content` is always provided. `DisableSelection(content)` takes no +// optional params, so no `$default` slot and no enum is needed for it. +[assembly: ComposeDefaults("SelectionContainerDefault", "modifier", "!content")] + +// androidx.compose.foundation.layout.FlowLayoutKt — the simpler +// FlowRow / FlowColumn overloads (no FlowRowOverflow / FlowColumnOverflow +// slot) lower to 7 user params + content. The trailing `maxItemsInEachRow` +// / `maxLines` (resp. `maxItemsInEachColumn` / `maxLines`) Ints can't be +// auto-masked from a nullable C# slot, so the v1 facade leaves both bits +// set and lets Kotlin substitute Int.MAX_VALUE. +[assembly: ComposeDefaults("FlowRowDefault", + "modifier", "horizontalArrangement", "verticalArrangement", + "itemVerticalAlignment", "maxItemsInEachRow", "maxLines", "!content")] +[assembly: ComposeDefaults("FlowColumnDefault", + "modifier", "verticalArrangement", "horizontalArrangement", + "itemHorizontalAlignment", "maxItemsInEachColumn", "maxLines", "!content")] +[assembly: ComposeDefaults("HorizontalDivider", "HorizontalDividerDefault")] +[assembly: ComposeDefaults("VerticalDivider", "VerticalDividerDefault")] +[assembly: ComposeDefaults("Icon", "IconDefault")] +[assembly: ComposeDefaults("MaterialTheme", "MaterialThemeDefault")] + +// androidx.compose.animation.AnimatedVisibilityKt.AnimatedVisibility(bool, ...) — +// declarative form (not generic) because AnimatedVisibilityKt has multiple +// overloads (Boolean, MutableTransitionState, Transition extension, +// RowScope/ColumnScope extensions) and the generic form picks the *longest* +// overload, which is not necessarily the (boolean visible, ...) one we call. +// Pinning the parameter list here keeps the bit positions in lockstep with +// the bytecode of the specific overload `AnimatedVisibility.Render` invokes. +[assembly: ComposeDefaults("AnimatedVisibilityDefault", + "!visible", "modifier", "enter", "exit", "label", "!content")] + +// androidx.compose.material3.Shapes — synthetic-default constructor, +// 5 CornerBasedShape slots. Hand-rolled JNI (ShapesBridges.cs) calls +// the ctor; this enum names the bits so MaterialTheme.BuildShapes can +// build the $default mask without magic numbers. +[assembly: ComposeDefaults("ShapesDefault", + "extraSmall", "small", "medium", "large", "extraLarge")] + +// androidx.compose.material3.ColorScheme — synthetic-default +// lightColorScheme / darkColorScheme factories, 48 Color slots in +// Kotlin source order. Hand-rolled JNI (ColorSchemeBridges.cs) calls +// the pair-of-int factory; this enum names the bits so +// MaterialTheme.BuildColorScheme can build the $default mask without +// magic numbers. The slot count (48 > 31) triggers the generator's +// long-backed enum + Split helper (see #178), and BuildColorScheme +// calls .Split() to get the (mask0, mask1) pair Kotlin expects. +[assembly: ComposeDefaults("ColorSchemeDefault", + "primary", "onPrimary", + "primaryContainer", "onPrimaryContainer", + "inversePrimary", + "secondary", "onSecondary", + "secondaryContainer", "onSecondaryContainer", + "tertiary", "onTertiary", + "tertiaryContainer", "onTertiaryContainer", + "background", "onBackground", + "surface", "onSurface", + "surfaceVariant", "onSurfaceVariant", + "surfaceTint", + "inverseSurface", "inverseOnSurface", + "error", "onError", + "errorContainer", "onErrorContainer", + "outline", "outlineVariant", + "scrim", + "surfaceBright", + "surfaceContainer", "surfaceContainerHigh", + "surfaceContainerHighest","surfaceContainerLow", + "surfaceContainerLowest", + "surfaceDim", + "primaryFixed", "primaryFixedDim", + "onPrimaryFixed", "onPrimaryFixedVariant", + "secondaryFixed", "secondaryFixedDim", + "onSecondaryFixed", "onSecondaryFixedVariant", + "tertiaryFixed", "tertiaryFixedDim", + "onTertiaryFixed", "onTertiaryFixedVariant")] + +// androidx.compose.material3.Typography — synthetic-default +// constructor, 15 TextStyle slots in Kotlin source order. +// Hand-rolled JNI (TypographyBridges.cs) calls the ctor; this enum +// names the bits so MaterialTheme.BuildTypography can build the +// $default mask without magic numbers. +[assembly: ComposeDefaults("TypographyDefault", + "displayLarge", "displayMedium", "displaySmall", + "headlineLarge", "headlineMedium", "headlineSmall", + "titleLarge", "titleMedium", "titleSmall", + "bodyLarge", "bodyMedium", "bodySmall", + "labelLarge", "labelMedium", "labelSmall")] + +// androidx.compose.foundation.lazy.LazyDslKt — the LazyColumn / LazyRow +// @Composable functions take no inline-class params (modifier/state/etc. +// are all reference types or plain bools), so the binder exposes the +// Kt class and the generic generator path Just Works. 9 optional params +// per overload (modifier through overscrollEffect; content lambda is +// skipped as IFunction1). +[assembly: ComposeDefaults("LazyColumn", "LazyColumnDefault")] +[assembly: ComposeDefaults("LazyRow", "LazyRowDefault")] + +// androidx.compose.foundation.lazy.grid.LazyGridDslKt — same story for +// LazyVerticalGrid / LazyHorizontalGrid, but with a required first +// `columns` / `rows` IGridCells param. The facade always supplies that +// param and clears the bit (mirror of how Icon clears +// IconDefault.ImageVector / IconDefault.ContentDescription). +[assembly: ComposeDefaults("LazyVerticalGrid", "LazyVerticalGridDefault")] +[assembly: ComposeDefaults("LazyHorizontalGrid", "LazyHorizontalGridDefault")] + +// androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridDslKt — +// LazyVerticalStaggeredGrid / LazyHorizontalStaggeredGrid take a required +// first `columns` / `rows` IStaggeredGridCells param (Bit 0 is always +// cleared; the facade always provides it). The remaining params are all +// optional; bit positions follow Kotlin source order. +[assembly: ComposeDefaults("LazyVerticalStaggeredGridDefault", + "columns", "modifier", "state", "contentPadding", "reverseLayout", + "verticalItemSpacing", "horizontalArrangement", "flingBehavior", + "userScrollEnabled", "overscrollEffect", "!content")] +[assembly: ComposeDefaults("LazyHorizontalStaggeredGridDefault", + "rows", "modifier", "state", "contentPadding", "reverseLayout", + "verticalArrangement", "horizontalItemSpacing", "flingBehavior", + "userScrollEnabled", "overscrollEffect", "!content")] + +// androidx.compose.foundation.pager.PagerKt — HorizontalPager / +// VerticalPager take a required first PagerState (always provided by the +// facade) and a required trailing pageContent IFunction4 (also always +// provided). The 13 optional params in between are all candidates for +// the $default mask. +[assembly: ComposeDefaults("HorizontalPagerDefault", + "!state", "modifier", "contentPadding", "pageSize", + "beyondViewportPageCount", "pageSpacing", "verticalAlignment", + "flingBehavior", "userScrollEnabled", "reverseLayout", "key", + "pageNestedScrollConnection", "snapPosition", "overscrollEffect", + "!pageContent")] +[assembly: ComposeDefaults("VerticalPagerDefault", + "!state", "modifier", "contentPadding", "pageSize", + "beyondViewportPageCount", "pageSpacing", "horizontalAlignment", + "flingBehavior", "userScrollEnabled", "reverseLayout", "key", + "pageNestedScrollConnection", "snapPosition", "overscrollEffect", + "!pageContent")] + +// androidx.compose.foundation.ImageKt.Image (Painter overload): all four +// `Image` Kotlin overloads share the JVM name `Image` and only differ by +// first-param type, so the binder strips them all. 7 user params; bit 0 +// (painter) is always provided by the Image facade. +[assembly: ComposeDefaults("ImageDefault", + "!painter", "contentDescription", "modifier", "alignment", + "contentScale", "alpha", "colorFilter")] + +// androidx.compose.material3.IconKt.Icon-ww6aTOc (Painter overload): +// the Painter and ImageBitmap overloads collide with the bound +// ImageVector overload (same mangled JVM name `Icon-ww6aTOc`) and are +// stripped from the binding. 4 user params; bit 0 (painter) is always +// provided by the Icon facade. ContentDescription/Modifier/Tint are +// optional. A separate enum is required because the matching bridge +// generator needs Kotlin parameter names (the `IconDefault` enum is +// produced via the generic `[ComposeDefaults]` form, which the +// bridge generator doesn't read). +[assembly: ComposeDefaults("IconPainterDefault", + "!painter", "contentDescription", "modifier", "tint")] + +// androidx.compose.material3.ButtonKt.Button: 10 user params, +// bit 0 = onClick, bit 9 = content (both always provided). +[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). +[assembly: ComposeDefaults("TextDefault", + "!text", "modifier", "color", "fontSize", "fontStyle", + "fontWeight", "fontFamily", "letterSpacing", "decoration", "align", + "lineHeight", "overflow", "softWrap", "maxLines", "minLines", + "onTextLayout", "style")] + +// androidx.compose.material3.TextKt.Text-IbK3jfQ — the AnnotatedString +// overload. Same shape as TextDefault but with the extra `inlineContent` +// Map slot between minLines and onTextLayout, for a total of 18 bits. +// bit 0 = text (always provided); inlineContent, onTextLayout, style +// are never exposed by the AnnotatedText facade so the auto-mask leaves +// their default bits set. +[assembly: ComposeDefaults("AnnotatedTextDefault", + "!text", "modifier", "color", "fontSize", "fontStyle", + "fontWeight", "fontFamily", "letterSpacing", "decoration", "align", + "lineHeight", "overflow", "softWrap", "maxLines", "minLines", + "inlineContent", "onTextLayout", "style")] + +// androidx.compose.material3.IconButtonKt.IconButton: 6 user params, +// bit 0 = onClick, bit 5 = content (both provided). +[assembly: ComposeDefaults("IconButtonDefault", + "!onClick", "modifier", "enabled", "colors", "interactionSource", "!content")] + +// androidx.compose.material3.IconButtonKt.{FilledIconButton,FilledTonalIconButton}: +// 7 user params, bit 0 = onClick, bit 6 = content (both provided). +// Filled and FilledTonal variants share the same Kotlin signature so they +// reuse one enum. +[assembly: ComposeDefaults("FilledIconButtonDefault", + "!onClick", "modifier", "enabled", "shape", "colors", + "interactionSource", "!content")] + +// androidx.compose.material3.IconButtonKt.OutlinedIconButton: 8 user +// params, bit 0 = onClick, bit 7 = content (both provided). +[assembly: ComposeDefaults("OutlinedIconButtonDefault", + "!onClick", "modifier", "enabled", "shape", "colors", "border", + "interactionSource", "!content")] + +// androidx.compose.material3.IconButtonKt.IconToggleButton: 7 user params, +// bits 0 (checked), 1 (onCheckedChange), 6 (content) always provided. +[assembly: ComposeDefaults("IconToggleButtonDefault", + "!checked", "!onCheckedChange", "modifier", "enabled", "colors", + "interactionSource", "!content")] + +// androidx.compose.material3.IconButtonKt.{FilledIconToggleButton,FilledTonalIconToggleButton}: +// 8 user params, bits 0 (checked), 1 (onCheckedChange), 7 (content) +// always provided. Filled and FilledTonal share one enum. +[assembly: ComposeDefaults("FilledIconToggleButtonDefault", + "!checked", "!onCheckedChange", "modifier", "enabled", "shape", + "colors", "interactionSource", "!content")] + +// androidx.compose.material3.IconButtonKt.OutlinedIconToggleButton: +// 9 user params, bits 0 (checked), 1 (onCheckedChange), 8 (content) +// always provided. +[assembly: ComposeDefaults("OutlinedIconToggleButtonDefault", + "!checked", "!onCheckedChange", "modifier", "enabled", "shape", + "colors", "border", "interactionSource", "!content")] + +// androidx.compose.material3.FloatingActionButtonKt.FloatingActionButton-X-z6DiA: +// 8 user params, bit 0 = onClick, bit 7 = content (both provided). +[assembly: ComposeDefaults("FloatingActionButtonDefault", + "!onClick", "modifier", "shape", "containerColor", "contentColor", + "elevation", "interactionSource", "!content")] + +// androidx.compose.material3.FloatingActionButtonKt.SmallFloatingActionButton-X-z6DiA: +// same shape as FloatingActionButton. +[assembly: ComposeDefaults("SmallFloatingActionButtonDefault", + "!onClick", "modifier", "shape", "containerColor", "contentColor", + "elevation", "interactionSource", "!content")] + +// androidx.compose.material3.FloatingActionButtonKt.LargeFloatingActionButton-X-z6DiA: +// same shape as FloatingActionButton. +[assembly: ComposeDefaults("LargeFloatingActionButtonDefault", + "!onClick", "modifier", "shape", "containerColor", "contentColor", + "elevation", "interactionSource", "!content")] + +// androidx.compose.material3.FloatingActionButtonKt.ExtendedFloatingActionButton-ElI5-7k: +// 10 user params; text/icon/onClick/expanded always provided by the caller. +[assembly: ComposeDefaults("ExtendedFloatingActionButtonDefault", + "!text", "!icon", "!onClick", "modifier", "!expanded", "shape", + "containerColor", "contentColor", "elevation", "interactionSource")] + +// androidx.compose.material3.SurfaceKt.Surface-T9BRK9s (non-interactive): +// 8 user params, only bit 7 = content provided. +[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 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.ModalBottomSheet_androidKt.ModalBottomSheet-dYc4hso: +// 13 user params; bits 0 (onDismissRequest), 2 (sheetState), 12 (content) always provided. +[assembly: ComposeDefaults("ModalBottomSheetDefault", + "!onDismissRequest", "modifier", "!sheetState", "sheetMaxWidth", "shape", + "containerColor", "contentColor", "tonalElevation", "scrimColor", "dragHandle", + "windowInsets", "properties", "!content")] + +// androidx.compose.material3.ScaffoldKt.Scaffold-TvnljyQ: +// 10 user params; bit 9 (content) always provided. Optional slot bits +// 1 (topBar), 2 (bottomBar), 3 (snackbarHost), 4 (floatingActionButton) +// are toggled per-call by Scaffold.Render. +[assembly: ComposeDefaults("ScaffoldDefault", + "modifier", "topBar", "bottomBar", "snackbarHost", "floatingActionButton", + "floatingActionButtonPosition", "containerColor", "contentColor", + "contentWindowInsets", "!content")] + +// androidx.compose.material3.BottomSheetScaffoldKt.BottomSheetScaffold-sdMYb0k: +// 17 user params; bits 0 (sheetContent), 2 (scaffoldState), 16 (content) always provided. +[assembly: ComposeDefaults("BottomSheetScaffoldDefault", + "!sheetContent", "modifier", "!scaffoldState", "sheetPeekHeight", "sheetMaxWidth", + "sheetShape", "sheetContainerColor", "sheetContentColor", "sheetTonalElevation", + "sheetShadowElevation", "sheetDragHandle", "sheetSwipeEnabled", "topBar", + "snackbarHost", "containerColor", "contentColor", "!content")] + +// androidx.compose.material3.DatePickerDialog_androidKt.DatePickerDialog-GmEhDVc: +// 9 user params; bits 0 (onDismissRequest), 1 (confirmButton), 8 (content) always provided. +[assembly: ComposeDefaults("DatePickerDialogDefault", + "!onDismissRequest", "!confirmButton", "modifier", "dismissButton", "shape", + "tonalElevation", "colors", "properties", "!content")] + +// androidx.compose.material3.DatePickerKt.DatePicker: +// 8 user params; bit 0 (state) always provided. +[assembly: ComposeDefaults("DatePickerDefault", + "!state", "modifier", "dateFormatter", "colors", "title", + "headline", "showModeToggle", "requestFocus")] + +// androidx.compose.material3.DateRangePickerKt.DateRangePicker: +// 8 user params; bit 0 (state) always provided. +[assembly: ComposeDefaults("DateRangePickerDefault", + "!state", "modifier", "dateFormatter", "colors", "title", + "headline", "showModeToggle", "requestFocus")] + +// androidx.compose.material3.TimePickerKt.TimePicker-mT9BvqQ: +// 4 user params; bit 0 (state) always provided. +[assembly: ComposeDefaults("TimePickerDefault", + "!state", "modifier", "colors", "layoutType")] + +// androidx.compose.material3.TimePickerKt.TimeInput: +// 3 user params; bit 0 (state) always provided. +[assembly: ComposeDefaults("TimeInputDefault", + "!state", "modifier", "colors")] + +// androidx.compose.material3.TimePickerDialogKt.TimePickerDialog-FItCLgY: +// 10 user params; bits 0 (onDismissRequest), 1 (confirmButton), +// 2 (dismissButton), 9 (content) always provided. +[assembly: ComposeDefaults("TimePickerDialogDefault", + "!onDismissRequest", "!confirmButton", "!dismissButton", "modifier", "properties", + "title", "modeToggleButton", "shape", "containerColor", "!content")] + +// androidx.compose.material3.TooltipKt.TooltipBox (7-param overload): +// 7 user params; bits 0 (positionProvider), 1 (tooltip), 2 (state), +// 6 (content) always provided. +[assembly: ComposeDefaults("TooltipBoxDefault", + "!positionProvider", "!tooltip", "!state", "modifier", "focusable", + "enableUserInput", "!content")] + +// androidx.compose.material3.TextFieldKt.TextField (String overload) AND +// OutlinedTextFieldKt.OutlinedTextField (String overload): 23 user params, +// bit 0 = value, bit 1 = onValueChange (both provided). +[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")] + +// androidx.compose.foundation.text.BasicTextFieldKt.BasicTextField (String overload): 16 user +// params, bit 0 = value, bit 1 = onValueChange (both provided through value/callback). +[assembly: ComposeDefaults("BasicTextFieldDefault", + "!value", "!onValueChange", "modifier", "enabled", "readOnly", + "textStyle", "keyboardOptions", "keyboardActions", "singleLine", "maxLines", + "minLines", "visualTransformation", "onTextLayout", "interactionSource", "cursorBrush", + "decorationBox")] + +// androidx.compose.material3.SecureTextFieldKt.SecureTextField-XvU6IwQ AND +// OutlinedSecureTextField-XvU6IwQ: 23 user params, bit 0 = state (always +// provided through the SecureTextFieldState wrapper). Two enums (one per +// JVM function) so each [ComposeBridge] can disambiguate via its own +// `Defaults` typeof — the bit layout is identical between filled and +// outlined variants. +[assembly: ComposeDefaults("SecureTextFieldDefault", + "!state", + "modifier", "enabled", "textStyle", "labelPosition", + "label", "placeholder", "leadingIcon", "trailingIcon", + "prefix", "suffix", "supportingText", "isError", + "inputTransformation", "textObfuscationMode", "textObfuscationCharacter", + "keyboardOptions", "onKeyboardAction", "onTextLayout", + "shape", "colors", "contentPadding", "interactionSource")] + +[assembly: ComposeDefaults("OutlinedSecureTextFieldDefault", + "!state", + "modifier", "enabled", "textStyle", "labelPosition", + "label", "placeholder", "leadingIcon", "trailingIcon", + "prefix", "suffix", "supportingText", "isError", + "inputTransformation", "textObfuscationMode", "textObfuscationCharacter", + "keyboardOptions", "onKeyboardAction", "onTextLayout", + "shape", "colors", "contentPadding", "interactionSource")] + +// androidx.compose.material3.CardKt.Card (non-clickable): 6 user params, +// bit 5 = content provided. Also reused by OutlinedCard — it takes the +// same 6 params in the same order. +[assembly: ComposeDefaults("CardDefault", + "modifier", "shape", "colors", "elevation", "border", "!content")] + +// androidx.compose.material3.CardKt.Card (CLICKABLE overload): 9 user params, +// bits 0 (onClick) and 8 (content) always provided. +[assembly: ComposeDefaults("ClickableCardDefault", + "!onClick", "modifier", "enabled", "shape", "colors", + "elevation", "border", "interactionSource", "!content")] + +// androidx.compose.material3.CardKt.ElevatedCard (non-clickable): 5 user +// params (no border) — bit 4 = content provided. +[assembly: ComposeDefaults("ElevatedCardDefault", + "modifier", "shape", "colors", "elevation", "!content")] + +// androidx.compose.material3.NavigationDrawerKt.{Modal,Dismissible,Permanent}DrawerSheet-afqeVBk: +// all three have identical 7-param signatures (modifier, shape, +// drawerContainerColor, drawerContentColor, tonalElevation, windowInsets, +// content). Bit 6 (content) always provided. +[assembly: ComposeDefaults("DrawerSheetDefault", + "modifier", "shape", "drawerContainerColor", "drawerContentColor", + "tonalElevation", "windowInsets", "!content")] + +// androidx.compose.material3.NavigationDrawerKt.ModalNavigationDrawer. +// 6 Kotlin user params. The facade ALWAYS supplies drawerContent (Drawer +// slot), drawerState (Phase 4b StateHolder auto-creates a wrapper), and +// content (Content slot); modifier is the only auto-mask-controlled bit. +// gesturesEnabled and scrimColor stay set so Kotlin substitutes its +// defaults (true / DrawerDefaults.scrimColor); the wrapper-passthrough +// body passes dummy values that Kotlin ignores when the bit is set. +[assembly: ComposeDefaults("ModalNavigationDrawerDefault", + "!drawerContent", "modifier", "!drawerState", "gesturesEnabled", + "scrimColor", "!content")] + +// androidx.compose.material3.NavigationDrawerKt.DismissibleNavigationDrawer. +// 5 Kotlin user params (same as ModalNavigationDrawer minus scrimColor — +// the dismissible drawer has no scrim). +[assembly: ComposeDefaults("DismissibleNavigationDrawerDefault", + "!drawerContent", "modifier", "!drawerState", "gesturesEnabled", + "!content")] + +// androidx.compose.material3.NavigationDrawerKt.PermanentNavigationDrawer. +// 3 Kotlin user params — no state holder (drawer is always visible), no +// gesturesEnabled (can't be swiped closed), no scrimColor (no scrim). +[assembly: ComposeDefaults("PermanentNavigationDrawerDefault", + "!drawerContent", "modifier", "!content")] + +// androidx.compose.material3.ChipKt.AssistChip: 11 user params, +// bit 0 = onClick, bit 1 = label (both always provided). +// Optional slot bits 4 (LeadingIcon) and 5 (TrailingIcon) are toggled +// per-call by AssistChip.Render. +[assembly: ComposeDefaults("AssistChipDefault", + "!onClick", "!label", "modifier", "enabled", "leadingIcon", "trailingIcon", + "shape", "colors", "elevation", "border", "interactionSource")] + +// androidx.compose.material3.ChipKt.FilterChip: 12 user params, +// bits 0 = selected, 1 = onClick, 2 = label (all always provided). +[assembly: ComposeDefaults("FilterChipDefault", + "!selected", "!onClick", "!label", "modifier", "enabled", + "leadingIcon", "trailingIcon", "shape", "colors", "elevation", + "border", "interactionSource")] + +// androidx.compose.material3.ChipKt.InputChip: 13 user params, +// bits 0 = selected, 1 = onClick, 2 = label (all always provided). +[assembly: ComposeDefaults("InputChipDefault", + "!selected", "!onClick", "!label", "modifier", "enabled", + "leadingIcon", "avatar", "trailingIcon", "shape", "colors", + "elevation", "border", "interactionSource")] + +// androidx.compose.material3.ChipKt.SuggestionChip: 10 user params, +// bit 0 = onClick, bit 1 = label (both always provided). +[assembly: ComposeDefaults("SuggestionChipDefault", + "!onClick", "!label", "modifier", "enabled", "icon", + "shape", "colors", "elevation", "border", "interactionSource")] + +// androidx.compose.material3.NavigationBarKt.NavigationBar-HsRjFd4: +// 6 user params, bit 5 = content provided. +[assembly: ComposeDefaults("NavigationBarDefault", + "modifier", "containerColor", "contentColor", "tonalElevation", + "windowInsets", "!content")] + +// androidx.compose.material3.NavigationBarKt.NavigationBarItem: 9 user +// params after the RowScope receiver (the receiver is not part of the +// $default bitmask). Bits 0 = selected, 1 = onClick, 2 = icon (all +// always provided). The optional Label slot is toggled by +// NavigationBarItem.Render. +[assembly: ComposeDefaults("NavigationBarItemDefault", + "!selected", "!onClick", "!icon", "modifier", "enabled", "label", + "alwaysShowLabel", "colors", "interactionSource")] + +// androidx.compose.material3.NavigationDrawerKt.NavigationDrawerItem: +// 9 user params (no extension receiver). Bits 0 = label, 1 = selected, +// 2 = onClick are required and always provided by the caller; the +// optional Icon and Badge slots are toggled per-call by +// NavigationDrawerItem.Render via the auto-mask. shape, colors, and +// interactionSource stay defaulted (Kotlin defaults applied). +[assembly: ComposeDefaults("NavigationDrawerItemDefault", + "!label", "!selected", "!onClick", "modifier", "icon", "badge", + "shape", "colors", "interactionSource")] + +// androidx.compose.foundation.BackgroundKt.background-bw27NRU$default — +// non-@Composable Modifier extension. Mangled because Color is a +// @JvmInline value class (ULong). Bit 0 = color (always supplied by +// the C# wrapper), bit 1 = shape (Compose substitutes RectangleShape +// when defaulted). +[assembly: ComposeDefaults("ModifierBackgroundDefault", "!color", "shape")] + +// androidx.compose.foundation.BackgroundKt.background$default — +// Brush-typed overload. Bit 0 = brush (always supplied), bit 1 = shape +// (Compose substitutes RectangleShape when defaulted), bit 2 = alpha +// (always supplied; the C# wrapper defaults to 1f to match Kotlin). +[assembly: ComposeDefaults("ModifierBackgroundBrushDefault", + "!brush", "shape", "!alpha")] + +// androidx.compose.foundation.BorderKt.border-xT4_qwU$default — +// non-@Composable Modifier extension. Both width (Dp) and color +// (Color) are @JvmInline value classes, hence the mangled name. +// Bits 0/1 = width/color (always supplied), bit 2 = shape. +[assembly: ComposeDefaults("ModifierBorderDefault", "!width", "!color", "shape")] + +// androidx.compose.foundation.ClickableKt.clickable-XHw0xAI$default — +// non-@Composable Modifier extension. Bit 3 (onClick) is always +// supplied by the C# wrapper; bits 0/1/2 (enabled/onClickLabel/role) +// are left to Kotlin's defaults. +[assembly: ComposeDefaults("ModifierClickableDefault", + "enabled", "onClickLabel", "role", "!onClick")] + +// androidx.compose.foundation.ClickableKt.combinedClickable-cJG_KMw$default — +// non-@Composable Modifier extension (no MutableInteractionSource overload). +// 7 Kotlin params after the receiver. The C# wrapper always supplies +// onClick (bit 6 always cleared); enabled / onClickLabel / role / onLongClickLabel +// are left to Kotlin's defaults; the optional onLongClick / onDoubleClick +// slots are auto-cleared per-call when the caller passes a non-null +// IFunction0 (Kotlin requires nullability here — a null callback DOES +// substitute Kotlin's default of "ignore that gesture"). +[assembly: ComposeDefaults("ModifierCombinedClickableDefault", + "enabled", "onClickLabel", "role", "onLongClickLabel", + "onLongClick", "onDoubleClick", "!onClick")] + +// androidx.compose.foundation.selection.SelectableKt.selectable-XHw0xAI$default — +// 4 Kotlin params after the receiver: selected, enabled, role, onClick. +// C# wrapper always supplies selected + onClick. +[assembly: ComposeDefaults("ModifierSelectableDefault", + "!selected", "enabled", "role", "!onClick")] + +// androidx.compose.foundation.selection.ToggleableKt.toggleable-XHw0xAI$default — +// 4 Kotlin params after the receiver: value, enabled, role, onValueChange. +// C# wrapper always supplies value + onValueChange. +[assembly: ComposeDefaults("ModifierToggleableDefault", + "!value", "enabled", "role", "!onValueChange")] + +// androidx.compose.foundation.FocusableKt.focusable$default — 2 Kotlin +// params after the receiver: enabled (always supplied by C#) and +// interactionSource (left to Kotlin's default). +[assembly: ComposeDefaults("ModifierFocusableDefault", + "!enabled", "interactionSource")] + +// androidx.compose.ui.semantics.SemanticsModifierKt.semantics$default — +// 2 Kotlin params after the receiver: mergeDescendants (always supplied +// by the C# wrapper, the `Semantics(string)` helper hard-codes `false`) +// and properties (the configuration Function1, always supplied). +[assembly: ComposeDefaults("ModifierSemanticsDefault", + "mergeDescendants", "!properties")] + +// androidx.compose.ui.semantics.SemanticsPropertiesKt.onClick$default — +// 2 Kotlin params after the receiver: label (String?, optional — caller +// may omit to get the platform-default label) and action (Function0, +// always supplied by the C# wrapper). +[assembly: ComposeDefaults("SemanticsOnClickDefault", + "label", "!action")] + +// androidx.compose.foundation.gestures.DraggableKt.draggable$default — +// non-@Composable Modifier extension. 8 Kotlin params after the +// receiver: state, orientation, enabled, interactionSource, +// startDragImmediately, onDragStarted, onDragStopped, reverseDirection. +// C# wrapper supplies state/orientation/enabled (bits 0/1/2 cleared); +// the remaining five slots fall through to Kotlin's defaults. +[assembly: ComposeDefaults("ModifierDraggableDefault", + "!state", "!orientation", "!enabled", + "interactionSource", "startDragImmediately", + "onDragStarted", "onDragStopped", "reverseDirection")] + +// androidx.compose.foundation.ScrollKt.verticalScroll$default — +// non-@Composable Modifier extension. 4 Kotlin params after the +// receiver: state, enabled, flingBehavior, reverseScrolling. The C# +// wrapper always supplies state/enabled/reverseScrolling (bits 0/1/3 +// cleared) and leaves flingBehavior to Kotlin's default (bit 2 set, +// substitutes ScrollableDefaults.flingBehavior()). +[assembly: ComposeDefaults("ModifierVerticalScrollDefault", + "!state", "!enabled", "flingBehavior", "!reverseScrolling")] + +// androidx.compose.foundation.ScrollKt.horizontalScroll$default — +// same shape as ModifierVerticalScrollDefault above. +[assembly: ComposeDefaults("ModifierHorizontalScrollDefault", + "!state", "!enabled", "flingBehavior", "!reverseScrolling")] + +// androidx.compose.foundation.layout.{Row,Column}Scope$DefaultImpls.weight$default — +// non-@Composable scope extension. Shared by both Row and Column +// weight bridges since the helper signatures are identical apart from +// the dispatch-receiver class. The bridge generator treats the first +// IntPtr (rowScope / columnScope) as the JNI receiver (bound to +// args[0], NOT a $default bit). The remaining Kotlin parameters in +// declaration order are: the extension receiver Modifier (bit 0, +// always supplied), weight (bit 1, always supplied), fill (bit 2, +// optional — Kotlin's default is true). Our wrapper always passes +// fill explicitly, so the generator clears bit 2 too and the helper +// uses our value. +[assembly: ComposeDefaults("ModifierWeightDefault", + "!modifier", "!weight", "fill")] + +// androidx.navigation.compose.NavHostKt.NavHost — Kotlin signature +// `NavHost(navController, startDestination, modifier=Modifier, +// route=null, builder)`. Bits 0/1/4 are required params (always +// supplied) so they're suppressed; bits 2 (modifier) and 3 (route) +// are optional and the auto-mask in the bridge generator clears them +// when the caller provides a non-null value. +[assembly: ComposeDefaults("NavHostDefault", + "!navController", "!startDestination", "modifier", "route", "!builder")] + +// androidx.navigation.compose.NavGraphBuilderKt.composable — Kotlin +// extension `NavGraphBuilder.composable(route, arguments=emptyList(), +// deepLinks=emptyList(), content)`. The receiver is NOT in the +// $default bitmask. Bit 0 (route) and bit 3 (content) are required, +// so suppressed; bits 1/2 (arguments/deepLinks) are optional and +// driven by the auto-mask — passing null leaves the bit set so +// Kotlin substitutes its emptyList() default. +[assembly: ComposeDefaults("NavComposableDefault", + "!route", "arguments", "deepLinks", "!content")] + +// androidx.compose.foundation.layout.SizeKt — ranged size constraints. +// Each bit corresponds to one Dp parameter. C# bridges declare each +// param as `Dp?`, so the auto-mask clears the bit only when the user +// supplies a non-null value (otherwise Kotlin substitutes +// `Dp.Unspecified` — no constraint on that side). +[assembly: ComposeDefaults("ModifierWidthInDefault", "min", "max")] +[assembly: ComposeDefaults("ModifierHeightInDefault", "min", "max")] +[assembly: ComposeDefaults("ModifierSizeInDefault", + "minWidth", "minHeight", "maxWidth", "maxHeight")] +[assembly: ComposeDefaults("ModifierDefaultMinSizeDefault", + "minWidth", "minHeight")] + +// androidx.compose.foundation.layout.SizeKt — wrapContent variants. +// Bit 0 = align (left to Kotlin's default — Center, etc.; the +// param has no C# slot, generator emits IntPtr.Zero into the JNI +// arg and the bit stays set). Bit 1 = unbounded — always supplied +// by the C# wrapper, so the auto-mask must clear the bit; do NOT +// prefix with `!` (that would skip auto-mask clearing and Kotlin +// would always use its `false` default). +[assembly: ComposeDefaults("ModifierWrapContentSizeDefault", + "align", "unbounded")] +[assembly: ComposeDefaults("ModifierWrapContentWidthDefault", + "align", "unbounded")] +[assembly: ComposeDefaults("ModifierWrapContentHeightDefault", + "align", "unbounded")] + +// androidx.compose.foundation.layout.AspectRatioKt.aspectRatio$default — +// (Modifier, Float ratio, Boolean matchHeightConstraintsFirst). Bit 0 +// = ratio (always supplied), bit 1 = matchHeightConstraintsFirst (cleared +// when the C# caller supplies a value; default false matches Kotlin). +[assembly: ComposeDefaults("ModifierAspectRatioDefault", + "!ratio", "matchHeightConstraintsFirst")] + +// androidx.compose.foundation.layout.OffsetKt.offset / absoluteOffset — +// (Modifier, Dp x, Dp y). Both Dp params are nullable in the C# +// wrapper; null leaves Kotlin's default of 0.dp on that axis. +[assembly: ComposeDefaults("ModifierOffsetDefault", "x", "y")] +[assembly: ComposeDefaults("ModifierAbsoluteOffsetDefault", "x", "y")] + +// androidx.compose.ui.draw.ShadowKt.shadow-ziNgDLE$default — +// (Modifier, Dp elevation, Shape shape, Boolean clip). Bit 0 = +// elevation (always supplied), bit 1 = shape (auto-mask honors +// null), bit 2 = clip (left to Kotlin's default of +// `elevation > 0.dp`). +[assembly: ComposeDefaults("ModifierShadowDefault", + "!elevation", "shape", "clip")] + +// androidx.compose.ui.graphics.GraphicsLayerModifierKt.graphicsLayer-sKFY_QE$default — +// 13 user params, all defaultable. Caller passes null to skip, +// non-null to override. +[assembly: ComposeDefaults("ModifierGraphicsLayerDefault", + "scaleX", "scaleY", "alpha", + "translationX", "translationY", "shadowElevation", + "rotationX", "rotationY", "rotationZ", + "cameraDistance", "transformOrigin", "shape", "clip")] + +// androidx.compose.material3.NavigationRailKt.NavigationRail-qi6gXK8: +// 6 user params, bit 5 = content provided. +[assembly: ComposeDefaults("NavigationRailDefault", + "modifier", "containerColor", "contentColor", "header", + "windowInsets", "!content")] + +// androidx.compose.material3.NavigationRailKt.NavigationRailItem: +// 9 user params; bits 0 = selected, 1 = onClick, 2 = icon +// (all always provided). +[assembly: ComposeDefaults("NavigationRailItemDefault", + "!selected", "!onClick", "!icon", "modifier", "enabled", "label", + "alwaysShowLabel", "colors", "interactionSource")] + +// androidx.compose.material3.DatePickerKt.rememberDatePickerState-EU0dCGE: +// 5 user params, all defaulted by the wrapper (which exposes none). +[assembly: ComposeDefaults("RememberDatePickerStateDefault", + "initialSelectedDateMillis", "initialDisplayedMonthMillis", "yearRange", + "initialDisplayMode", "selectableDates")] + +// androidx.compose.material3.DateRangePickerKt.rememberDateRangePickerState-IlFM19s: +// 6 user params, all defaulted by the wrapper (which exposes none). +[assembly: ComposeDefaults("RememberDateRangePickerStateDefault", + "initialSelectedStartDateMillis", "initialSelectedEndDateMillis", + "initialDisplayedMonthMillis", "yearRange", + "initialDisplayMode", "selectableDates")] + +// androidx.compose.material3.TimePickerKt.rememberTimePickerState: +// 3 user params, all always provided by the wrapper. +[assembly: ComposeDefaults("RememberTimePickerStateDefault", + "!initialHour", "!initialMinute", "!is24Hour")] + +// androidx.compose.material3.TooltipKt.rememberTooltipState: +// 3 user params; `isPersistent` always provided, the other two +// (`initialIsVisible`, `mutatorMutex`) are defaulted. +[assembly: ComposeDefaults("RememberTooltipStateDefault", + "initialIsVisible", "!isPersistent", "mutatorMutex")] + +// androidx.compose.material3.TooltipDefaults.rememberPlainTooltipPositionProvider-kHDZbjc: +// 1 user param (`spacing`), defaulted by the wrapper. +[assembly: ComposeDefaults("RememberPlainTooltipPositionProviderDefault", + "spacing")] + +// androidx.compose.material3.SegmentedButtonKt.{Single,Multi}ChoiceSegmentedButtonRow-uFdPcIQ: +// 3 user params, bit 2 (content) always provided. Both row variants +// share the same signature so they reuse one enum. The JNI descriptor +// is `(...;Composer;II)V` — two trailing `I` slots, so the row HAS a +// `$default` slot (3 user params → 1 `$changed` group → trailingInts=2 +// > expectedChangedSlots=1). The bound binding's parameter names are +// misleading: the binder labels position 4 as `p4` ($changed) and +// position 5 as `_changed` ($default). Call sites pass this enum's +// mask to the `_changed:` named arg, which is positionally `$default`. +[assembly: ComposeDefaults("SegmentedButtonRowDefault", + "modifier", "space", "!content")] + +// androidx.compose.material3.SegmentedButtonKt.SegmentedButton (longer +// 11-param overload with PaddingValues) — SingleChoiceSegmentedButtonRowScope +// receiver. Bits 0 (selected), 1 (onClick), 2 (shape), 10 (label) always +// provided — `shape` has no Kotlin default expression so its $default bit +// is a no-op; the caller must pass a real Shape (resolved via +// SegmentedButtonDefaults.itemShape). Bit 9 (icon) is optional and +// toggled per-call by SegmentedButton.Render. +[assembly: ComposeDefaults("SingleChoiceSegmentedButtonDefault", + "!selected", "!onClick", "!shape", "modifier", "enabled", + "colors", "border", "contentPadding", "interactionSource", + "icon", "!label")] + +// androidx.compose.material3.SegmentedButtonKt.SegmentedButton (longer +// 11-param overload with PaddingValues) — MultiChoiceSegmentedButtonRowScope +// receiver. Bits 0 (checked), 1 (onCheckedChange), 2 (shape), 10 (label) +// always provided. Bit 9 (icon) is optional and toggled per-call. +[assembly: ComposeDefaults("MultiChoiceSegmentedButtonDefault", + "!checked", "!onCheckedChange", "!shape", "modifier", "enabled", + "colors", "border", "contentPadding", "interactionSource", + "icon", "!label")] + +// androidx.compose.material3.SegmentedButtonDefaults.itemShape: 3 user +// params on the SegmentedButtonDefaults Kotlin `object` instance method. +// `index` and `count` are always provided; `baseShape` is omitted from +// the C# user-facing bridge, so the source generator auto-sets bit 2 to +// have Kotlin substitute SegmentedButtonDefaults.getBaseShape() (the +// theme's default rounded-corner shape). +[assembly: ComposeDefaults("SegmentedButtonItemShapeDefault", + "!index", "!count", "baseShape")] + +// androidx.compose.material3.WideNavigationRailKt.WideNavigationRail. +// 8 user params; bit 7 (content) always provided. The optional `header` +// slot (bit 4) is a member of the enum so a future overload of the +// facade can toggle it; today WideNavigationRail.Render always leaves +// it as a default ($default bit set). +[assembly: ComposeDefaults("WideNavigationRailDefault", + "modifier", "state", "shape", "colors", "header", + "windowInsets", "arrangement", "!content")] + +// androidx.compose.material3.WideNavigationRailKt.WideNavigationRailItem-pli-t6k: +// 10 user params; bits 0 (selected), 1 (onClick), 2 (icon) always provided. +[assembly: ComposeDefaults("WideNavigationRailItemDefault", + "!selected", "!onClick", "!icon", "label", "railExpanded", + "modifier", "enabled", "iconPosition", "colors", "interactionSource")] + +// androidx.compose.material3.WideNavigationRailKt.ModalWideNavigationRail-k3FuEkE: +// 12 user params; bits 1 (state) and 11 (content) always provided by the +// facade. The facade also always sets `hideOnCollapse=true`, but we keep +// that bit as an enum member because future overloads may toggle it. +[assembly: ComposeDefaults("ModalWideNavigationRailDefault", + "modifier", "!state", "hideOnCollapse", "collapsedShape", "expandedShape", + "colors", "header", "collapsedShadowElevation", "windowInsets", "arrangement", + "properties", "!content")] + +// androidx.compose.material3.ProgressIndicatorKt.LinearProgressIndicator-rIrjwxo +// (indeterminate, no progress callback). 5 user params, all optional. +[assembly: ComposeDefaults("LinearProgressIndicatorDefault", + "modifier", "color", "trackColor", "strokeCap", "gapSize")] + +// androidx.compose.material3.ProgressIndicatorKt.LinearProgressIndicator-2cYBFYY +// (deprecated determinate float-progress overload). 5 user params: +// progress is always provided so bit 0 stays cleared via the `!` +// prefix; modifier/color/trackColor/strokeCap are optional. +[assembly: ComposeDefaults("LinearProgressIndicatorDeterminateDefault", + "!progress", "modifier", "color", "trackColor", "strokeCap")] + +// androidx.compose.material3.ProgressIndicatorKt.CircularProgressIndicator-4lLiAd8 +// (indeterminate). 6 user params, all optional. +[assembly: ComposeDefaults("CircularProgressIndicatorDefault", + "modifier", "color", "strokeWidth", "trackColor", "strokeCap", "gapSize")] + +// androidx.compose.material3.CheckboxKt.Checkbox: 6 user params, +// bit 0 = checked (always provided), bit 1 = onCheckedChange (always +// provided — Function1, the user passes a callback). +[assembly: ComposeDefaults("CheckboxDefault", + "!checked", "!onCheckedChange", "modifier", "enabled", + "colors", "interactionSource")] + +// androidx.compose.material3.CheckboxKt.TriStateCheckbox: 6 user params, +// bit 0 = state (always provided). onClick is Function0? defaulting to +// null in Kotlin; the facade always provides a callback. +[assembly: ComposeDefaults("TriStateCheckboxDefault", + "!state", "!onClick", "modifier", "enabled", + "colors", "interactionSource")] + +// androidx.compose.material3.RadioButtonKt.RadioButton: 6 user params, +// bit 0 = selected (always provided). onClick is Function0?, the facade +// always provides a callback. +[assembly: ComposeDefaults("RadioButtonDefault", + "!selected", "!onClick", "modifier", "enabled", + "colors", "interactionSource")] + +// androidx.compose.material3.SwitchKt.Switch: 7 user params, +// bit 0 = checked (always provided), bit 1 = onCheckedChange (always +// provided). bit 3 = thumbContent is Function2? with Kotlin default +// null; the facade doesn't expose it, so the bit stays set in `All` +// and Kotlin substitutes the default null at the call site. +[assembly: ComposeDefaults("SwitchDefault", + "!checked", "!onCheckedChange", "modifier", "thumbContent", + "enabled", "colors", "interactionSource")] + +// androidx.compose.material3.SwitchDefaults.colors(...): 16-slot +// $default mask covering thumb/track/border/icon for each of the +// checked, unchecked, disabled-checked, and disabled-unchecked +// states. Used by `composer.SwitchColors(...)` to flip individual +// bits as caller-supplied overrides arrive. Order matches the +// bytecode lowering (every parameter is a `@JvmInline value class +// Color` lowered to `long`, so the 16-slot factory is fully bound). +[assembly: ComposeDefaults("SwitchColorsDefault", + "checkedThumbColor", "checkedTrackColor", + "checkedBorderColor", "checkedIconColor", + "uncheckedThumbColor", "uncheckedTrackColor", + "uncheckedBorderColor", "uncheckedIconColor", + "disabledCheckedThumbColor", "disabledCheckedTrackColor", + "disabledCheckedBorderColor", "disabledCheckedIconColor", + "disabledUncheckedThumbColor", "disabledUncheckedTrackColor", + "disabledUncheckedBorderColor", "disabledUncheckedIconColor")] + +// androidx.compose.material3.SliderDefaults.colors(...): 10-slot +// $default mask covering thumb / activeTrack / activeTick / +// inactiveTrack / inactiveTick plus the four disabled siblings. +// Used by `composer.SliderColors(...)` to flip bits as caller- +// supplied overrides arrive. Order matches the bytecode lowering +// of the parameterised `colors-q0g_0yA(JJJJJJJJJJ;Composer;III)` +// factory. +[assembly: ComposeDefaults("SliderColorsDefault", + "thumbColor", "activeTrackColor", "activeTickColor", + "inactiveTrackColor", "inactiveTickColor", + "disabledThumbColor", "disabledActiveTrackColor", + "disabledActiveTickColor", "disabledInactiveTrackColor", + "disabledInactiveTickColor")] + +// androidx.compose.material3.SliderKt.Slider (simple float overload): +// 9 user params; bits 0 (value) and 1 (onValueChange) always provided. +// The longer overload with Function3 thumb/track slots has non-null +// Kotlin defaults that can't be safely substituted, so we lock in this +// simpler shape via the declarative form. +[assembly: ComposeDefaults("SliderDefault", + "!value", "!onValueChange", "modifier", "enabled", "valueRange", + "steps", "onValueChangeFinished", "colors", "interactionSource")] + +// androidx.compose.material3.SliderKt.RangeSlider (simple +// ClosedFloatingPointRange overload): 8 user params; bits 0 (value) +// and 1 (onValueChange) always provided. +[assembly: ComposeDefaults("RangeSliderDefault", + "!value", "!onValueChange", "modifier", "enabled", "valueRange", + "steps", "onValueChangeFinished", "colors")] + +// androidx.compose.material3.AppBarKt.{TopAppBar,CenterAlignedTopAppBar}-GHTll3U: +// 8 user params; bit 0 (title) always provided. Optional slot bits 2 +// (NavigationIcon) and 3 (Actions) are toggled per-call by the facades. +[assembly: ComposeDefaults("TopAppBarDefault", + "!title", "modifier", "navigationIcon", "actions", "expandedHeight", + "windowInsets", "colors", "scrollBehavior")] + +// androidx.compose.material3.AppBarKt.{Medium,Large}TopAppBar-oKE7A98: +// 9 user params; bit 0 (title) always provided. Two-row variants take +// both collapsedHeight and expandedHeight as Dp. +[assembly: ComposeDefaults("TwoRowsTopAppBarDefault", + "!title", "modifier", "navigationIcon", "actions", "collapsedHeight", + "expandedHeight", "windowInsets", "colors", "scrollBehavior")] + +// androidx.compose.material3.TabRowKt.{TabRow,PrimaryTabRow,SecondaryTabRow}-pAZo6Ak: +// 7 user params; bits 0 (selectedTabIndex) and 6 (tabs) always provided. +[assembly: ComposeDefaults("TabRowDefault", + "!selectedTabIndex", "modifier", "containerColor", "contentColor", + "indicator", "divider", "!tabs")] + +// androidx.compose.material3.TabRowKt.ScrollableTabRow-sKfQg0A: +// 8 user params; bits 0 (selectedTabIndex) and 7 (tabs) always provided. +[assembly: ComposeDefaults("ScrollableTabRowDefault", + "!selectedTabIndex", "modifier", "containerColor", "contentColor", + "edgePadding", "indicator", "divider", "!tabs")] + +// androidx.compose.material3.TabKt.Tab-wqdebIU: 9 user params; bits 0 +// (selected) and 1 (onClick) always provided. text/icon are optional +// slots toggled per-call by Tab.Render. +[assembly: ComposeDefaults("TabDefault", + "!selected", "!onClick", "modifier", "enabled", "text", "icon", + "selectedContentColor", "unselectedContentColor", "interactionSource")] + +// androidx.compose.material3.TabKt.LeadingIconTab-wqdebIU: 9 user params; +// bits 0 (selected), 1 (onClick), 2 (text), 3 (icon) all always provided +// — Kotlin requires text and icon (no defaults). +[assembly: ComposeDefaults("LeadingIconTabDefault", + "!selected", "!onClick", "!text", "!icon", "modifier", "enabled", + "selectedContentColor", "unselectedContentColor", "interactionSource")] + +// androidx.compose.material3.SnackbarKt.Snackbar-eQBnUkQ: 10 user params; +// bit 9 (content) always provided. action/dismissAction are optional +// slots toggled per-call by Snackbar.Render. +[assembly: ComposeDefaults("SnackbarDefault", + "modifier", "action", "dismissAction", "actionOnNewLine", "shape", + "containerColor", "contentColor", "actionColor", "actionContentColor", + "!content")] + +// androidx.compose.material3.SnackbarHostKt.SnackbarHost: 3 user params; +// bits 0 (hostState) and 2 (snackbar) always provided. +[assembly: ComposeDefaults("SnackbarHostDefault", + "!hostState", "modifier", "!snackbar")] + +// androidx.compose.material3.BadgeKt.Badge-eopBjH0: 4 user params; bit +// 3 (content) always provided. +[assembly: ComposeDefaults("BadgeDefault", + "modifier", "containerColor", "contentColor", "!content")] + +// androidx.compose.material3.BadgeKt.BadgedBox: 3 user params; bits 0 +// (badge) and 2 (content) always provided. +[assembly: ComposeDefaults("BadgedBoxDefault", + "!badge", "modifier", "!content")] + +// androidx.compose.material3.ListItemKt.ListItem-HXNGIdc: 9 user params; +// bit 0 (headlineContent) always provided. The four optional slot +// Function2 params (overline, supporting, leading, trailing) are +// toggled per-call by ListItem.Render. +[assembly: ComposeDefaults("ListItemDefault", + "!headlineContent", "modifier", "overlineContent", "supportingContent", + "leadingContent", "trailingContent", "colors", "tonalElevation", + "shadowElevation")] + +// androidx.compose.material3.AndroidMenu_androidKt.DropdownMenu-IlH_yew: +// 12 user params; bits 0 (expanded), 1 (onDismissRequest), 11 (content) +// always provided. +[assembly: ComposeDefaults("DropdownMenuDefault", + "!expanded", "!onDismissRequest", "modifier", "offset", "scrollState", + "properties", "shape", "containerColor", "tonalElevation", "shadowElevation", + "border", "!content")] + +// androidx.compose.material3.SearchBarKt.rememberSearchBarState: 3 user +// params; all defaulted by the wrapper (which exposes none). +[assembly: ComposeDefaults("RememberSearchBarStateDefault", + "initialValue", "animationSpecForExpand", "animationSpecForCollapse")] + +// androidx.compose.material3.SearchBarKt.SearchBar-nbWgWpA (state-based): +// 7 user params; bits 0 (state), 1 (inputField) always provided. +[assembly: ComposeDefaults("SearchBarDefault", + "!state", "!inputField", "modifier", "shape", "colors", + "inputFieldHeight", "floatingHeight")] + +// androidx.compose.material3.SearchBarKt.TopSearchBar-qKj4JfE: +// 9 user params; bits 0 (state), 1 (inputField) always provided. +[assembly: ComposeDefaults("TopSearchBarDefault", + "!state", "!inputField", "modifier", "shape", "colors", + "inputFieldHeight", "floatingHeight", "windowInsets", "scrollBehavior")] + +// androidx.compose.material3.SearchBarKt.ExpandedDockedSearchBar-qKj4JfE: +// 9 user params; bits 0 (state), 1 (inputField), 8 (content) always provided. +[assembly: ComposeDefaults("ExpandedDockedSearchBarDefault", + "!state", "!inputField", "modifier", "shape", "colors", + "inputFieldHeight", "floatingHeight", "properties", "!content")] + +// androidx.compose.material3.SearchBarKt.ExpandedFullScreenSearchBar-_UtchM0: +// 10 user params; bits 0 (state), 1 (inputField), 9 (content) always provided. +[assembly: ComposeDefaults("ExpandedFullScreenSearchBarDefault", + "!state", "!inputField", "modifier", "shape", "colors", + "inputFieldHeight", "floatingHeight", "windowInsets", "properties", "!content")] + +// androidx.compose.material3.SearchBarDefaults.InputField (state-based): +// 18 user params. Bits 0 (textFieldState), 1 (searchBarState), +// 2 (onSearch) are always provided by the SearchBarInputField facade — +// onSearch defaults to a no-op ComposableLambda1 so Kotlin never invokes +// a null callback on IME Search. +[assembly: ComposeDefaults("SearchBarDefaultsInputFieldDefault", + "!textFieldState", "!searchBarState", "!onSearch", + "modifier", "enabled", "autoFocus", "textStyle", + "placeholder", "leadingIcon", "trailingIcon", "prefix", "suffix", + "inputTransformation", "outputTransformation", "scrollState", + "shape", "colors", "interactionSource")] + +// androidx.compose.material3.AndroidMenu_androidKt.DropdownMenuItem: 9 user +// params; bits 0 (text), 1 (onClick) always provided. Declarative form used +// (instead of generic) so leadingIcon and trailingIcon get enum members the +// facade can clear when the caller supplies those optional slots. +[assembly: ComposeDefaults("DropdownMenuItemDefault", + "!text", "!onClick", "modifier", "leadingIcon", "trailingIcon", + "enabled", "colors", "contentPadding", "interactionSource")] + +// androidx.compose.material3.ExposedDropdownMenuKt.ExposedDropdownMenuBox: +// 4 user params; bits 0 (expanded), 1 (onExpandedChange), 3 (content) +// always provided. +[assembly: ComposeDefaults("ExposedDropdownMenuBoxDefault", + "!expanded", "!onExpandedChange", "modifier", "!content")] + +// androidx.compose.material3.ExposedDropdownMenuBoxScope.ExposedDropdownMenu +// (the unmangled instance-method overload): 5 user params; +// bits 0 (expanded), 1 (onDismissRequest), 4 (content) always provided. +// ScrollState (bit 3) is intentionally exposed so callers can keep the +// menu's scroll position in sync with their own state holder. +[assembly: ComposeDefaults("ExposedDropdownMenuDefault", + "!expanded", "!onDismissRequest", "modifier", "scrollState", "!content")] + +// androidx.compose.material3.SearchBarKt.DockedSearchBar-EQC0FA8 (boolean- +// state, deprecated): 9 user params; bits 0 (inputField), 1 (expanded), +// 2 (onExpandedChange), 8 (content) always provided. +[assembly: ComposeDefaults("DockedSearchBarDefault", + "!inputField", "!expanded", "!onExpandedChange", "modifier", "shape", + "colors", "tonalElevation", "shadowElevation", "!content")] + +// androidx.compose.material3.SearchBarKt.DockedSearchBar-eWTbjVg (query- +// based, even older / deprecated): 16 user params; bits 0–4 (query, +// onQueryChange, onSearch, active, onActiveChange) and bit 15 (content) +// always provided. The unmapped slots (enabled, shape, colors, +// tonalElevation, shadowElevation, interactionSource) are not exposed on +// the facade and stay defaulted by Kotlin. +[assembly: ComposeDefaults("DockedSearchBarWithQueryDefault", + "!query", "!onQueryChange", "!onSearch", "!active", "!onActiveChange", + "modifier", "enabled", "placeholder", "leadingIcon", "trailingIcon", + "shape", "colors", "tonalElevation", "shadowElevation", "interactionSource", + "!content")] + +// androidx.compose.material3.AppBarKt.TopAppBar-cJHQLPU (subtitle overload): +// 10 user params; bits 0 (title) and 1 (subtitle) always provided. +// Optional slot bits 3 (NavigationIcon) and 4 (Actions) are toggled +// per-call by TopAppBar.Render when Subtitle is set. +[assembly: ComposeDefaults("TopAppBarSubtitleDefault", + "!title", "!subtitle", "modifier", "navigationIcon", "actions", + "titleHorizontalAlignment", "expandedHeight", "windowInsets", + "colors", "scrollBehavior")] + +// androidx.compose.material3.AppBarKt.{Medium,Large}FlexibleTopAppBar-eXZ4JBQ: +// 11 user params; bit 0 (title) always provided. Optional slot bits +// 2 (Subtitle), 3 (NavigationIcon), 4 (Actions) toggled per-call. +// 11 params * 3 bits/param > 31, so the bytecode emits two `$changed` +// ints in addition to `$default` (trailing `III`). +[assembly: ComposeDefaults("FlexibleTopAppBarDefault", + "!title", "modifier", "subtitle", "navigationIcon", "actions", + "titleHorizontalAlignment", "collapsedHeight", "expandedHeight", + "windowInsets", "colors", "scrollBehavior")] + +// androidx.compose.material3.AppBarKt.BottomAppBar-qhFBPw4 (RowScope actions +// + optional FAB + scrollBehavior overload — the most flexible of the four +// `BottomAppBar` overloads; the older variants are intentionally not bound): +// 9 user params; bit 0 (actions) always provided. Optional FAB slot +// (bit 2) toggled per-call by BottomAppBar.Render. +[assembly: ComposeDefaults("BottomAppBarDefault", + "!actions", "modifier", "floatingActionButton", "containerColor", + "contentColor", "tonalElevation", "contentPadding", "windowInsets", + "scrollBehavior")] + +// androidx.compose.material3.AppBarKt.FlexibleBottomAppBar-wBhsO_E: +// 9 user params; bit 8 (content) always provided. +[assembly: ComposeDefaults("FlexibleBottomAppBarDefault", + "modifier", "containerColor", "contentColor", "contentPadding", + "horizontalArrangement", "expandedHeight", "windowInsets", + "scrollBehavior", "!content")] + +// androidx.compose.material3.TabRowKt.{Primary,Secondary}ScrollableTabRow-qhFBPw4: +// 9 user params; bits 0 (selectedTabIndex) and 8 (tabs) always provided. +[assembly: ComposeDefaults("PrimaryScrollableTabRowDefault", + "!selectedTabIndex", "modifier", "scrollState", "containerColor", + "contentColor", "edgePadding", "indicator", "divider", "!tabs")] + +// androidx.compose.material3.TabKt.Tab-bogVsAg (ColumnScope content +// overload — alternative to the text/icon `Tab-wqdebIU`): 8 user +// params; bits 0 (selected), 1 (onClick), 7 (content) always provided. +[assembly: ComposeDefaults("TabContentDefault", + "!selected", "!onClick", "modifier", "enabled", "selectedContentColor", + "unselectedContentColor", "interactionSource", "!content")] + +// androidx.compose.material3.SnackbarKt.Snackbar-sDKtq54 (SnackbarData +// overload — the one normally rendered inside SnackbarHost's default +// content lambda): 9 user params; bit 0 (snackbarData) always provided. +[assembly: ComposeDefaults("SnackbarFromDataDefault", + "!snackbarData", "modifier", "actionOnNewLine", "shape", "containerColor", + "contentColor", "actionColor", "actionContentColor", + "dismissActionContentColor")] + +// androidx.compose.material3.pulltorefresh.PullToRefreshKt.PullToRefreshBox: +// 7 user params. The [ComposeFacade] generator surfaces ctor params for +// isRefreshing / onRefresh (bits 0/1) and a state holder for bit 3 (via +// the [StateHolder] attribute on the bridge), so those three bits are +// suppressed ("!" prefix — consume the bit position, emit no enum +// member). Bits 4/5 (contentAlignment / indicator) are not exposed by +// the bridge — JNI passes null for those slots and the bits MUST be +// set in $default so Kotlin substitutes its defaults instead of +// invoking a null Function3 (NPE). Keeping them as enum members lets +// `All` include those bits; nothing surfaced to callers ever clears +// them. Bit 6 (content) is the container content slot, always +// provided by RenderChildren. +[assembly: ComposeDefaults("PullToRefreshBoxDefault", + "!isRefreshing", "!onRefresh", "modifier", "!state", "contentAlignment", + "indicator", "!content")] + +// androidx.activity.compose.BackHandlerKt.BackHandler — Kotlin +// signature `BackHandler(enabled: Boolean = true, onBack: () -> Unit)`. +// Bit 0 (enabled) is optional with a Kotlin default of true; the C# +// facade always supplies a bool, so the auto-mask clears the bit and +// Kotlin uses our value. Bit 1 (onBack) is required (no Kotlin +// default), `!`-suppressed so the bit stays clear and the caller's +// lambda is always invoked. +[assembly: ComposeDefaults("BackHandlerDefault", "enabled", "!onBack")] + +// androidx.compose.ui.input.nestedscroll.NestedScrollModifierKt.nestedScroll$default — +// non-@Composable Modifier extension. 2 Kotlin params after the +// receiver: connection (NestedScrollConnection — always provided by the +// C# wrapper), dispatcher (NestedScrollDispatcher — optional; null → +// Kotlin allocates a new dispatcher internally). +[assembly: ComposeDefaults("ModifierNestedScrollDefault", "!connection", "dispatcher")] + +// androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior — +// instance method on the INSTANCE singleton. 4 user params: state +// (always provided), canScroll (defaultable), snapAnimationSpec +// (defaultable), flingAnimationSpec (defaultable). The C# wrapper +// supplies only `state` and leaves the other three to Kotlin's +// defaults so a single-arg call replicates Kotlin's idiomatic +// `enterAlwaysScrollBehavior(state)`. +[assembly: ComposeDefaults("TopAppBarEnterAlwaysScrollBehaviorDefault", + "!state", "canScroll", "snapAnimationSpec", "flingAnimationSpec")] + +// androidx.compose.material3.TopAppBarDefaults.exitUntilCollapsedScrollBehavior — +// same shape as enterAlwaysScrollBehavior above. +[assembly: ComposeDefaults("TopAppBarExitUntilCollapsedScrollBehaviorDefault", + "!state", "canScroll", "snapAnimationSpec", "flingAnimationSpec")] + +// androidx.compose.foundation.ScrollState.animateScrollTo$default — +// suspend extension with one defaultable param. Bit 0 = `value` (always +// provided, `!`-prefixed); bit 1 = `animationSpec` (null → Kotlin +// substitutes SpringSpec()). Receiver (ScrollState) and Continuation +// are NOT in the defaults — they're shape parameters, not defaultable. +[assembly: ComposeDefaults("ScrollStateAnimateScrollToDefault", + "!value", "animationSpec")] + +// androidx.compose.foundation.gestures.TapGestureDetectorKt.detectTapGestures$default — +// suspend extension with four defaultable callback params. All four +// callbacks are nullable; the auto-default-mask leaves a bit set when +// the caller passes null so Kotlin substitutes its real default +// (null for the nullables, NoPressGesture for onPress). Receiver +// (PointerInputScope) and Continuation are NOT in the defaults. +[assembly: ComposeDefaults("DetectTapGesturesDefault", + "onDoubleTap", "onLongPress", "onPress", "onTap")] + +// androidx.compose.foundation.gestures.DragGestureDetectorKt.detectDragGestures$default — +// suspend extension with three defaultable callback params +// (onDragStart / onDragEnd / onDragCancel — all `() -> Unit` style +// no-op defaults) plus a required onDrag. Receiver +// (PointerInputScope) and Continuation are NOT in the defaults; the +// required onDrag (the only Function2 slot) is excluded too. +[assembly: ComposeDefaults("DetectDragGesturesDefault", + "onDragStart", "onDragEnd", "onDragCancel", "!onDrag")] + +// androidx.compose.foundation.gestures.TransformGestureDetectorKt.detectTransformGestures$default — +// only `panZoomLock` is defaultable (false), but it's a primitive +// boolean (auto-mask can't flip its bit because primitives are +// never null). We always pass it explicitly, so suppress the enum +// entry. onGesture is required. +[assembly: ComposeDefaults("DetectTransformGesturesDefault", + "!panZoomLock", "!onGesture")] diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ButtonDefaults.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ButtonDefaults.cs new file mode 100644 index 000000000..758676e61 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ButtonDefaults.cs @@ -0,0 +1,73 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Static factories mirroring Kotlin's +/// androidx.compose.material3.ButtonDefaults singleton — each +/// returns a configured for a particular +/// override pattern. Surfaced as extensions so +/// call sites read composer.ButtonColors(containerColor: ...). +/// +/// Useful when you want to override a subset of the four color slots +/// (containerColor, contentColor, +/// disabledContainerColor, disabledContentColor) without +/// hand-rolling a full . Any slot left +/// null falls back to the value +/// ButtonDefaults.buttonColors() would return at the current +/// MaterialTheme — exactly what Kotlin's +/// buttonColors.copy(...) ergonomic does. +/// +public static partial class ComposeExtensions +{ + /// + /// Mirrors Kotlin's + /// ButtonDefaults.buttonColors(containerColor = …, contentColor = …, + /// disabledContainerColor = …, disabledContentColor = …). + /// + /// The Compose lowering takes a packed Color (a long) + /// per slot plus a $default bitmask flagging which slots the + /// caller actually supplied — that's the bridge's p5 arg. + /// Each non-null argument here clears the corresponding bit + /// so Compose adopts your color; null leaves the bit set so + /// Kotlin's default (the theme's color-scheme slot) wins. + /// + public static ButtonColors ButtonColors( + this IComposer composer, + long? containerColor = null, + long? contentColor = null, + long? disabledContainerColor = null, + long? disabledContentColor = null, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + { + ArgumentNullException.ThrowIfNull(composer); + + // Default mask: every bit set = "use Kotlin's default for that + // slot". Clear a bit when the caller passes a value. + int defaults = 0b1111; + if (containerColor is not null) defaults &= ~0b0001; + if (contentColor is not null) defaults &= ~0b0010; + if (disabledContainerColor is not null) defaults &= ~0b0100; + if (disabledContentColor is not null) defaults &= ~0b1000; + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + return AndroidX.Compose.Material3.ButtonDefaults.Instance.ButtonColors( + containerColor ?? 0L, + contentColor ?? 0L, + disabledContainerColor ?? 0L, + disabledContentColor ?? 0L, + composer, + defaults, + 0); + } + finally + { + composer.EndReplaceableGroup(); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CheckboxDefaults.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CheckboxDefaults.cs new file mode 100644 index 000000000..4a5b017df --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CheckboxDefaults.cs @@ -0,0 +1,72 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Static factories mirroring Kotlin's +/// androidx.compose.material3.CheckboxDefaults singleton — +/// returns a configured for a particular +/// override pattern. Surfaced as extensions so +/// call sites read composer.CheckboxColors(checkedColor: ...). +/// +public static partial class ComposeExtensions +{ + /// + /// Mirrors Kotlin's + /// CheckboxDefaults.colors(checkedColor = …, uncheckedColor = …, + /// checkmarkColor = …) for the three slots most consumers want + /// to override. + /// + /// The Kotlin colors(...) factory takes a + /// $default bitmask, but the C# binder strips it because + /// every parameter is the @JvmInline value class Color. We + /// therefore call the parameterless colors(composer, _changed) + /// to obtain the theme defaults and build the result via the bound + /// CheckboxColors.copy(...) overload — which is the only + /// twelve-long entry point that survives the binder strip. + /// + /// Each non-null argument substitutes for the + /// corresponding default; null falls back to the value from + /// the current MaterialTheme. + /// applies to both the box fill and the box border so the checked + /// chrome stays internally consistent. + /// + public static CheckboxColors CheckboxColors( + this IComposer composer, + long? checkedColor = null, + long? uncheckedColor = null, + long? checkmarkColor = null, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + { + ArgumentNullException.ThrowIfNull(composer); + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + var d = AndroidX.Compose.Material3.CheckboxDefaults.Instance.Colors(composer, 0); + if (checkedColor is null && uncheckedColor is null && checkmarkColor is null) + return d; + + return d.Copy( + checkedCheckmarkColor: checkmarkColor ?? d.CheckedCheckmarkColor, + uncheckedCheckmarkColor: d.UncheckedCheckmarkColor, + checkedBoxColor: checkedColor ?? d.CheckedBoxColor, + uncheckedBoxColor: uncheckedColor ?? d.UncheckedBoxColor, + disabledCheckedBoxColor: d.DisabledCheckedBoxColor, + disabledUncheckedBoxColor: d.DisabledUncheckedBoxColor, + disabledIndeterminateBoxColor: d.DisabledIndeterminateBoxColor, + checkedBorderColor: checkedColor ?? d.CheckedBorderColor, + uncheckedBorderColor: uncheckedColor ?? d.UncheckedBorderColor, + disabledBorderColor: d.DisabledBorderColor, + disabledUncheckedBorderColor: d.DisabledUncheckedBorderColor, + disabledIndeterminateBorderColor: d.DisabledIndeterminateBorderColor); + } + finally + { + composer.EndReplaceableGroup(); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CollectAsStateWithLifecycle.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CollectAsStateWithLifecycle.cs new file mode 100644 index 000000000..0382afb8e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CollectAsStateWithLifecycle.cs @@ -0,0 +1,148 @@ +using AndroidX.Compose.Runtime; +using AndroidX.Lifecycle.Compose; +using Xamarin.KotlinX.Coroutines.Flow; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + // ---- Flow.collectAsStateWithLifecycle ---- + // + // C# parity of androidx.lifecycle.compose.collectAsStateWithLifecycle — + // the standard bridge from a Kotlin StateFlow/Flow (typically + // exposed by a ViewModel) into a Compose State that pauses + // collection while the hosting LifecycleOwner is below the requested + // minimum active state (default: STARTED — so collection pauses while + // the activity is stopped/destroyed). + // + // We call the bound FlowExtKt overloads directly (no [ComposeBridge] + // needed). The trailing two ints on each binding are Kotlin's + // ($changed, $default) convention; we pass 0 for $changed (no info) + // and set the bits for the parameters we omit so Kotlin substitutes + // its source-level defaults — Lifecycle.State.STARTED for + // minActiveState and EmptyCoroutineContext for context. Bits index + // Kotlin source parameters only; the extension receiver is invisible + // to the bitmask. + // + // The bound IFlow / IStateFlow interfaces are non-generic on the .NET + // side (Kotlin's type parameter is erased through JNI), so the + // managed is a caller-asserted contract. CollectedState uses + // MutableState.FromJava to unbox the latest value — it will throw + // on a mismatch. + + /// + /// C# parity of Kotlin's + /// StateFlow<T>.collectAsStateWithLifecycle(...): + /// returns a read-only Compose that mirrors + /// the latest value emitted by while the + /// composition's (resolved via + /// ) is at least at + /// Lifecycle.State.STARTED. Reading the returned state's + /// inside a composition subscribes the + /// surrounding scope so it recomposes when a new value is published. + /// + /// + /// Caller-asserted element type of the underlying Kotlin + /// StateFlow<T>. Mismatch (e.g. asking for + /// <int> on a flow that actually emits strings) is + /// caught lazily inside the getter. + /// + /// + /// The source Kotlin kotlinx.coroutines.flow.StateFlow. + /// + /// + /// The active composer — supplied automatically when call sites use + /// the standard Render(IComposer composer) entry point. + /// + /// + /// Defaults to Lifecycle.State.STARTED + + /// EmptyCoroutineContext. To opt into a different active state + /// or coroutine context, call + /// + /// directly. + /// + public static CollectedState CollectAsStateWithLifecycle( + this IStateFlow stateFlow, + IComposer composer) + { + ArgumentNullException.ThrowIfNull(stateFlow); + ArgumentNullException.ThrowIfNull(composer); + + // $default bits index Kotlin source params only (the receiver is + // invisible to the bitmask). Source params are + // (lifecycleOwner, minActiveState, context) = bits 0, 1, 2. + // We supply lifecycleOwner (bit 0=0) and let Kotlin substitute + // the defaults for minActiveState and context (bits 1+2=1). + const int defaults = 0b110; // = 6 + + var state = FlowExtKt.CollectAsStateWithLifecycle( + stateFlow, + LocalLifecycleOwner.Current(composer), + minActiveState: null, + context: null, + composer, + p5: 0, // $changed: no info + _changed: defaults); + return new CollectedState(state); + } + + /// + /// C# parity of Kotlin's + /// Flow<T>.collectAsStateWithLifecycle(initialValue, ...): + /// returns a read-only Compose seeded with + /// that mirrors every value emitted + /// by while the composition's + /// (resolved via + /// ) is at least at + /// Lifecycle.State.STARTED. + /// + /// + /// Caller-asserted element type of the underlying Kotlin + /// Flow<T>. Mismatch (e.g. asking for <int> + /// on a flow that actually emits strings) is caught lazily inside + /// the getter. + /// + /// + /// The source Kotlin kotlinx.coroutines.flow.Flow. + /// + /// + /// Initial value observed before the flow emits its first item. + /// + /// + /// The active composer — supplied automatically when call sites use + /// the standard Render(IComposer composer) entry point. + /// + /// + /// Defaults to Lifecycle.State.STARTED + + /// EmptyCoroutineContext. To opt into a different active state + /// or coroutine context, call + /// + /// directly. + /// + public static CollectedState CollectAsStateWithLifecycle( + this IFlow flow, + T initialValue, + IComposer composer) + { + ArgumentNullException.ThrowIfNull(flow); + ArgumentNullException.ThrowIfNull(composer); + + // $default bits index Kotlin source params only. Source params are + // (initialValue, lifecycleOwner, minActiveState, context) = + // bits 0, 1, 2, 3. We supply initialValue + lifecycleOwner + // (bits 0+1=0) and let Kotlin substitute the defaults for + // minActiveState and context (bits 2+3=1). + const int defaults = 0b1100; // = 12 + + var state = FlowExtKt.CollectAsStateWithLifecycle( + flow, + MutableState.ToJava(initialValue), + LocalLifecycleOwner.Current(composer), + minActiveState: null, + context: null, + composer, + p6: 0, // $changed: no info + _changed: defaults); + return new CollectedState(state); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CurrentWindowAdaptiveInfo.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CurrentWindowAdaptiveInfo.cs new file mode 100644 index 000000000..4838d1654 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.CurrentWindowAdaptiveInfo.cs @@ -0,0 +1,59 @@ +using AndroidX.Compose.Material3.Adaptive; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + /// + /// C# parity of Kotlin's currentWindowAdaptiveInfo() @Composable + /// from androidx.compose.material3.adaptive. Returns the live + /// for the host window — exposes both the + /// upstream + /// (with IsWidthAtLeastBreakpoint / IsHeightAtLeastBreakpoint + /// + the standard 600 / 840 / 480 / 900 dp breakpoint constants) and the + /// describing foldable / tabletop state. + /// + /// Use this to branch between phone / tablet / desktop layouts inside + /// a composable: + /// + /// + /// var info = composer.CurrentWindowAdaptiveInfo(); + /// if (info.WindowSizeClass.IsWidthAtLeastBreakpoint( + /// AndroidX.Window.Core.Layout.WindowSizeClass.WidthDpMediumLowerBound)) + /// return ListDetailScreen.Build(...); + /// else + /// return PhoneScreen.Build(...); + /// + /// + /// Re-reads on every recomposition Compose drives, so layout code + /// that branches off the returned reacts + /// naturally to rotation, multi-window resize, fold / unfold, and + /// activity-embedded surface changes. + /// + /// The composer for the active composition. + /// + /// When true, snaps width buckets at the additional 1200 dp + /// (large) and 1600 dp (extra-large) breakpoints in addition to the + /// standard 600 / 840 bounds. Mirrors the Kotlin parameter + /// of the same name; defaults to false for parity with the + /// no-argument Kotlin call site. + /// + /// + /// The bound Kotlin would have returned — + /// not a managed wrapper. Pass it straight to any binding API that takes + /// one (e.g. NavigationSuiteScaffold). + /// + public static WindowAdaptiveInfo CurrentWindowAdaptiveInfo( + this IComposer composer, + bool supportLargeAndXLargeWidth = false) + { + ArgumentNullException.ThrowIfNull(composer); + + return WindowAdaptiveInfoKt.CurrentWindowAdaptiveInfo( + supportLargeAndXLargeWidth, + composer, + p2: 0, + _changed: 0); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Effects.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Effects.cs new file mode 100644 index 000000000..395719855 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Effects.cs @@ -0,0 +1,176 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + /// + /// Compose's SideEffect { … }: runs + /// on every successful recomposition, after the composition + /// has been applied. Use it to publish managed-side state into + /// objects that aren't managed by Compose (e.g. logging, analytics, + /// pushing a value into a non-Compose Android API). + /// + public static void SideEffect(this IComposer composer, Action effect) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(effect); + EffectsKt.SideEffect(new ComposableLambda0(effect), composer, _changed: 0); + } + + /// + /// Compose's DisposableEffect(key1) { … onDispose { … } }: + /// runs the first time this call site + /// is composed (and again whenever changes), + /// and calls the returned cleanup on + /// key change or when the call site leaves the composition. + /// + public static void DisposableEffect( + this IComposer composer, + object? key1, + Func effect) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(effect); + EffectsKt.DisposableEffect( + ComposeBridges.BoxKey(key1), + new DisposableEffectBody(effect), + composer, + _changed: 0); + } + + /// + /// Two-key overload of + /// . + /// + public static void DisposableEffect( + this IComposer composer, + object? key1, + object? key2, + Func effect) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(effect); + EffectsKt.DisposableEffect( + ComposeBridges.BoxKey(key1), + ComposeBridges.BoxKey(key2), + new DisposableEffectBody(effect), + composer, + _changed: 0); + } + + /// + /// Three-key overload of + /// . + /// + public static void DisposableEffect( + this IComposer composer, + object? key1, + object? key2, + object? key3, + Func effect) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(effect); + EffectsKt.DisposableEffect( + ComposeBridges.BoxKey(key1), + ComposeBridges.BoxKey(key2), + ComposeBridges.BoxKey(key3), + new DisposableEffectBody(effect), + composer, + _changed: 0); + } + + /// + /// Compose's LaunchedEffect(key1) { … }: launches the C# + /// as a coroutine the first time this call + /// site is composed (and again whenever + /// changes). The previous launch is cancelled on key change or + /// when the call site leaves the composition. + /// + public static void LaunchedEffect( + this IComposer composer, + object? key1, + Func body) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(body); + EffectsKt.LaunchedEffect( + ComposeBridges.BoxKey(key1), + new LaunchedEffectBody(body), + composer, + _changed: 0); + } + + /// + /// Two-key overload of + /// . + /// + public static void LaunchedEffect( + this IComposer composer, + object? key1, + object? key2, + Func body) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(body); + EffectsKt.LaunchedEffect( + ComposeBridges.BoxKey(key1), + ComposeBridges.BoxKey(key2), + new LaunchedEffectBody(body), + composer, + _changed: 0); + } + + /// + /// Three-key overload of + /// . + /// + public static void LaunchedEffect( + this IComposer composer, + object? key1, + object? key2, + object? key3, + Func body) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(body); + EffectsKt.LaunchedEffect( + ComposeBridges.BoxKey(key1), + ComposeBridges.BoxKey(key2), + ComposeBridges.BoxKey(key3), + new LaunchedEffectBody(body), + composer, + _changed: 0); + } + + /// + /// Bridges Compose's snapshotFlow { producer() } into an + /// . Every time any + /// / + /// read inside is written to and the + /// surrounding snapshot is applied, the producer re-runs on Compose's + /// main dispatcher and the new value flows through the returned + /// async sequence. + /// + public static IAsyncEnumerable SnapshotFlow(Func producer) + { + ArgumentNullException.ThrowIfNull(producer); + return new SnapshotFlowEnumerable(producer); + } + + /// + /// Convenience factory for a remembered + /// seeded with + /// . The flow is cached for the + /// life of the composition slot, just like + /// . + /// + public static MutableStateFlow MutableStateFlowOf( + this IComposer composer, + T initialValue, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => composer.Remember(() => new MutableStateFlow(initialValue), line, file); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Lazy.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Lazy.cs new file mode 100644 index 000000000..09ca527ee --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Lazy.cs @@ -0,0 +1,80 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + /// + /// Compose's rememberDraggableState(onDelta): build a + /// whose underlying Kotlin handle is + /// cached in the active composer's slot table for the lifetime of + /// this call site. Pair with + /// + /// — the returned state is the value to hand to that modifier. + /// + public static DraggableState RememberDraggableState( + this IComposer composer, + Action onDelta, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(onDelta); + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + var jcw = new ComposableLambda1(boxed => + { + var f = boxed as Java.Lang.Float + ?? throw new InvalidCastException( + $"Expected java.lang.Float in DraggableState.onDelta; got '{boxed?.Class?.Name ?? "null"}'."); + onDelta(f.FloatValue()); + }); + var jvm = AndroidX.Compose.Foundation.Gestures.DraggableKt.RememberDraggableState(jcw, composer, 0) + ?? throw new InvalidOperationException( + "DraggableKt.RememberDraggableState returned null."); + return new DraggableState(jvm); + } + finally + { + composer.EndReplaceableGroup(); + } + } + + /// + /// Compose's rememberLazyListState(initialFirstVisibleItemIndex, + /// initialFirstVisibleItemScrollOffset): returns a + /// that survives recompositions, cached + /// in the active composer's slot table for the lifetime of this + /// call site. + /// + public static LazyListState RememberLazyListState( + this IComposer composer, + int initialFirstVisibleItemIndex = 0, + int initialFirstVisibleItemScrollOffset = 0, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + { + ArgumentNullException.ThrowIfNull(composer); + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + var jvm = AndroidX.Compose.Foundation.Lazy.LazyListStateKt.RememberLazyListState( + p0: 0, + initialFirstVisibleItemIndex: initialFirstVisibleItemIndex, + _composer: composer, + initialFirstVisibleItemScrollOffset: initialFirstVisibleItemScrollOffset, + _changed: 0) + ?? throw new InvalidOperationException( + "LazyListStateKt.RememberLazyListState returned null."); + return new LazyListState(jvm); + } + finally + { + composer.EndReplaceableGroup(); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ProduceState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ProduceState.cs new file mode 100644 index 000000000..a66760d6f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ProduceState.cs @@ -0,0 +1,109 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + /// + /// C# parity for Kotlin's + /// produceState(initialValue, vararg keys) { producer }: + /// remembers a seeded with + /// . Starts + /// the first time this call site enters the composition. The + /// producer receives the state to write to plus a + /// that fires when this call site + /// leaves the composition. + /// + public static MutableState ProduceState( + this IComposer composer, + T initialValue, + Func, CancellationToken, Task> producer, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => ProduceStateCore(composer, initialValue, producer, keys: null, line, file); + + /// + /// Keyed produceState(initial, key1) { producer }: cancels + /// the running producer and starts a fresh one whenever + /// changes (structural equality). + /// + public static MutableState ProduceState( + this IComposer composer, + T initialValue, + object? key1, + Func, CancellationToken, Task> producer, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => ProduceStateCore(composer, initialValue, producer, new[] { key1 }, line, file); + + /// Keyed produceState(initial, key1, key2) { producer }. + public static MutableState ProduceState( + this IComposer composer, + T initialValue, + object? key1, + object? key2, + Func, CancellationToken, Task> producer, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => ProduceStateCore(composer, initialValue, producer, new[] { key1, key2 }, line, file); + + /// Keyed produceState(initial, key1, key2, key3) { producer }. + public static MutableState ProduceState( + this IComposer composer, + T initialValue, + object? key1, + object? key2, + object? key3, + Func, CancellationToken, Task> producer, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => ProduceStateCore(composer, initialValue, producer, new[] { key1, key2, key3 }, line, file); + + /// + /// Array-form keyed produceState(initial, vararg keys) { producer }. + /// + public static MutableState ProduceStateKeyed( + this IComposer composer, + T initialValue, + object?[] keys, + Func, CancellationToken, Task> producer, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => ProduceStateCore(composer, initialValue, producer, keys ?? throw new ArgumentNullException(nameof(keys)), line, file); + + static MutableState ProduceStateCore( + IComposer composer, + T initialValue, + Func, CancellationToken, Task> producer, + object?[]? keys, + int line, + string file) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(producer); + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + // The slot value MUST be the IRememberObserver itself — + // Compose only inspects what UpdateRememberedValue is + // handed for the IRememberObserver interface. Nesting it + // inside RememberHolder would silently break the + // OnRemembered/OnForgotten/OnAbandoned hooks. + if (composer.RememberedValue() is ProduceStateScope existing) + { + if (!RememberHolder.KeysEqual(existing.Keys, keys)) + existing.Restart(keys); + return existing.State; + } + var scope = new ProduceStateScope(initialValue, producer, keys); + composer.UpdateRememberedValue(scope); + return scope.State; + } + finally + { + composer.EndReplaceableGroup(); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Remember.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Remember.cs new file mode 100644 index 000000000..d5ce3cdb6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Remember.cs @@ -0,0 +1,254 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + /// + /// Compose's remember { factory() }, backed by the active + /// composer's slot table. Returns the value of + /// the first time this call site is reached, then the cached value on + /// subsequent recompositions. + /// + /// The slot is keyed by 's FNV-1a hash + /// of the file path mixed with the line number — a stable identifier + /// derived from / + /// fill-ins, deterministic + /// across process restarts (unlike , + /// which is per-process randomized) so the saveable-state registry + /// can match the recomputed key to its stored value on restore. + /// + /// Wrapped in StartReplaceableGroup / EndReplaceableGroup + /// so the slot belongs to its own group; sibling positional grouping + /// (see ) then keeps + /// repeated helper calls in a parent container from sharing slots. + /// + public static T Remember( + this IComposer composer, + Func factory, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberCore(composer, factory, keys: null, line, file); + + /// + /// Keyed remember(key1) { factory() }: the cached value is + /// re-created whenever changes (structural + /// equality via ). + /// + public static T Remember( + this IComposer composer, + Func factory, + object? key1, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberCore(composer, factory, new[] { key1 }, line, file); + + /// Keyed remember(key1, key2) { factory() }. + public static T Remember( + this IComposer composer, + Func factory, + object? key1, + object? key2, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberCore(composer, factory, new[] { key1, key2 }, line, file); + + /// Keyed remember(key1, key2, key3) { factory() }. + public static T Remember( + this IComposer composer, + Func factory, + object? key1, + object? key2, + object? key3, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberCore(composer, factory, new[] { key1, key2, key3 }, line, file); + + /// + /// Array-form keyed remember(vararg keys) { factory() }. + /// Use when the caller has more than three keys or already has the + /// keys in an array. + /// + public static T RememberKeyed( + this IComposer composer, + Func factory, + object?[] keys, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberCore(composer, factory, keys ?? throw new ArgumentNullException(nameof(keys)), line, file); + + static T RememberCore(IComposer composer, Func factory, object?[]? keys, int line, string file) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(factory); + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + if (composer.RememberedValue() is RememberHolder existing + && RememberHolder.KeysEqual(existing.Keys, keys)) + { + return (T)existing.Value!; + } + var value = factory(); + composer.UpdateRememberedValue(new RememberHolder(value, keys)); + return value; + } + finally + { + composer.EndReplaceableGroup(); + } + } + + /// + /// Compose's rememberSaveable { factory() }: like + /// , but the + /// cached value also survives process death and activity + /// recreation (e.g. rotation when the activity doesn't override + /// android:configChanges) via Compose's + /// SaveableStateRegistry, which serialises into the + /// activity's saved-instance . + /// + /// Mirrors Kotlin's single rememberSaveable<T> entry + /// point — the same call works for scalar values + /// (int, long, float, double, + /// bool, string, plus any other type + /// can box / unbox) and for + /// state-holder wrappers (, + /// ). + /// + public static T RememberSaveable( + this IComposer composer, + Func factory, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberSaveableCore(composer, factory, keys: null, line, file); + + /// + /// Keyed rememberSaveable(key1) { factory() }: keys flow into + /// Kotlin's inputs array so Compose's saveable registry + /// invalidates the cached value when any key changes. + /// + public static T RememberSaveable( + this IComposer composer, + Func factory, + object? key1, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberSaveableCore(composer, factory, new[] { key1 }, line, file); + + /// Keyed rememberSaveable(key1, key2) { factory() }. + public static T RememberSaveable( + this IComposer composer, + Func factory, + object? key1, + object? key2, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberSaveableCore(composer, factory, new[] { key1, key2 }, line, file); + + /// Keyed rememberSaveable(key1, key2, key3) { factory() }. + public static T RememberSaveable( + this IComposer composer, + Func factory, + object? key1, + object? key2, + object? key3, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberSaveableCore(composer, factory, new[] { key1, key2, key3 }, line, file); + + /// + /// Array-form keyed rememberSaveable(vararg inputs) { factory() }. + /// + public static T RememberSaveableKeyed( + this IComposer composer, + Func factory, + object?[] keys, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => RememberSaveableCore(composer, factory, keys ?? throw new ArgumentNullException(nameof(keys)), line, file); + + static T RememberSaveableCore(IComposer composer, Func factory, object?[]? keys, int line, string file) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(factory); + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + if (typeof(IMutableStateWrapper).IsAssignableFrom(typeof(T))) + return RememberSaveableWrapper(composer, factory, keys); + + return RememberSaveableScalar(composer, factory, keys); + } + finally + { + composer.EndReplaceableGroup(); + } + } + + static T RememberSaveableScalar(IComposer composer, Func factory, object?[]? keys) + { + var inputs = ComposeBridges.BuildKeysArray(keys, out var ownsInputs); + var jcw = new ObjectFunction0(() => MutableState.ToJava(factory())); + var handle = ComposeBridges.RememberSaveableSimple( + inputs, + jcw, + composer, + changed: 0); + try + { + if (handle == IntPtr.Zero) + return default!; + var boxed = Java.Lang.Object.GetObject( + handle, Android.Runtime.JniHandleOwnership.DoNotTransfer); + return MutableState.FromJava(boxed); + } + finally + { + if (handle != IntPtr.Zero) + Android.Runtime.JNIEnv.DeleteLocalRef(handle); + if (ownsInputs && inputs != IntPtr.Zero) + Android.Runtime.JNIEnv.DeleteLocalRef(inputs); + } + } + + static T RememberSaveableWrapper(IComposer composer, Func factory, object?[]? keys) + { + // Cache the C# wrapper across recompositions so we don't + // allocate a fresh facade + Kotlin IMutableState on every + // render. Nested Remember opens its own replaceable group; + // nesting is fine — Compose's slot table handles it. + var wrapper = composer.Remember(factory) + ?? throw new InvalidOperationException( + $"RememberSaveable<{typeof(T).Name}>: factory returned null."); + var iwrap = (IMutableStateWrapper)wrapper; + + var inputs = ComposeBridges.BuildKeysArray(keys, out var ownsInputs); + var jcw = new ObjectFunction0(() => (Java.Lang.Object)iwrap.State); + var handle = ComposeBridges.RememberSaveableMutableState( + inputs, + ComposeBridges.SaverAutoSaver(), + jcw, + composer, + changed: 0); + try + { + if (handle == IntPtr.Zero) + throw new InvalidOperationException( + $"RememberSaveable<{typeof(T).Name}>: rememberSaveable returned null."); + iwrap.State = Java.Lang.Object.GetObject( + handle, Android.Runtime.JniHandleOwnership.DoNotTransfer)!; + return wrapper; + } + finally + { + if (handle != IntPtr.Zero) + Android.Runtime.JNIEnv.DeleteLocalRef(handle); + if (ownsInputs && inputs != IntPtr.Zero) + Android.Runtime.JNIEnv.DeleteLocalRef(inputs); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Resources.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Resources.cs new file mode 100644 index 000000000..b94bf4d54 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Resources.cs @@ -0,0 +1,196 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Graphics.Painter; +using AndroidX.Compose.UI.Res; + +namespace AndroidX.Compose; + +/// +/// Composable resource accessors — the C# parity of Kotlin's +/// stringResource / colorResource / dimensionResource / +/// painterResource family from androidx.compose.ui.res. +/// Every method is an extension on so the active +/// composer is passed explicitly — exactly the $composer shape +/// Kotlin's IR rewrite uses. +/// +public static partial class ComposeExtensions +{ + /// + /// Load a string resource. Mirrors Kotlin's + /// stringResource(@StringRes id: Int): String. + /// + public static string StringResource(this IComposer composer, int id) + { + ArgumentNullException.ThrowIfNull(composer); + return StringResources_androidKt.StringResource(id, composer, _changed: 0); + } + + /// + /// Load a string resource with String.format-style arguments. + /// Mirrors Kotlin's + /// stringResource(@StringRes id: Int, vararg formatArgs: Any): String. + /// + public static string StringResource(this IComposer composer, int id, params object?[] formatArgs) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(formatArgs); + var (boxed, owned) = BoxArgs(formatArgs); + try + { + return StringResources_androidKt.StringResource(id, boxed, composer, _changed: 0); + } + finally + { + DisposeOwned(boxed, owned); + } + } + + /// + /// Load a string-array resource. Mirrors Kotlin's + /// stringArrayResource(@ArrayRes id: Int): Array<String>. + /// + public static string[] StringArrayResource(this IComposer composer, int id) + { + ArgumentNullException.ThrowIfNull(composer); + return StringResources_androidKt.StringArrayResource(id, composer, _changed: 0); + } + + /// + /// Load a plurals resource. Mirrors Kotlin's + /// pluralStringResource(@PluralsRes id: Int, count: Int): String. + /// + public static string PluralStringResource(this IComposer composer, int id, int count) + { + ArgumentNullException.ThrowIfNull(composer); + return StringResources_androidKt.PluralStringResource(id, count, composer, _changed: 0); + } + + /// + /// Load a plurals resource with String.format-style arguments. Mirrors + /// Kotlin's pluralStringResource(@PluralsRes id: Int, count: Int, vararg formatArgs: Any): String. + /// + public static string PluralStringResource(this IComposer composer, int id, int count, params object?[] formatArgs) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(formatArgs); + var (boxed, owned) = BoxArgs(formatArgs); + try + { + return StringResources_androidKt.PluralStringResource(id, count, boxed, composer, _changed: 0); + } + finally + { + DisposeOwned(boxed, owned); + } + } + + /// + /// Load an integer resource. Mirrors Kotlin's + /// integerResource(@IntegerRes id: Int): Int. + /// + public static int IntegerResource(this IComposer composer, int id) + { + ArgumentNullException.ThrowIfNull(composer); + return PrimitiveResources_androidKt.IntegerResource(id, composer, _changed: 0); + } + + /// + /// Load an integer-array resource. Mirrors Kotlin's + /// integerArrayResource(@ArrayRes id: Int): IntArray. + /// + public static int[] IntegerArrayResource(this IComposer composer, int id) + { + ArgumentNullException.ThrowIfNull(composer); + return PrimitiveResources_androidKt.IntegerArrayResource(id, composer, _changed: 0); + } + + /// + /// Load a boolean resource. Mirrors Kotlin's + /// booleanResource(@BoolRes id: Int): Boolean. + /// + public static bool BooleanResource(this IComposer composer, int id) + { + ArgumentNullException.ThrowIfNull(composer); + return PrimitiveResources_androidKt.BooleanResource(id, composer, _changed: 0); + } + + /// + /// Load a dimension resource as a . Mirrors Kotlin's + /// dimensionResource(@DimenRes id: Int): Dp. + /// + public static Dp DimensionResource(this IComposer composer, int id) + { + ArgumentNullException.ThrowIfNull(composer); + float raw = PrimitiveResources_androidKt.DimensionResource(id, composer, _changed: 0); + return new Dp(raw); + } + + /// + /// Load a color resource as a packed Compose Color long. Mirrors + /// Kotlin's colorResource(@ColorRes id: Int): Color. + /// + public static long ColorResource(this IComposer composer, int id) + { + ArgumentNullException.ThrowIfNull(composer); + return ColorResources_androidKt.ColorResource(id, composer, _changed: 0); + } + + /// + /// Load a drawable resource as a . Mirrors Kotlin's + /// painterResource(@DrawableRes id: Int): Painter. + /// + public static Painter PainterResource(this IComposer composer, int id) + { + ArgumentNullException.ThrowIfNull(composer); + IntPtr handle = ComposeBridges.PainterResource(id, composer); + return Java.Lang.Object.GetObject(handle, JniHandleOwnership.TransferLocalRef) + ?? throw new InvalidOperationException( + $"painterResource({id}) returned a null Painter handle."); + } + + // Convert a C# format-args array into a Java.Lang.Object[] suitable for + // crossing JNI. Returns a parallel bool[] flagging which entries we + // allocated ourselves so DisposeOwned can release them after the call. + static (Java.Lang.Object[] boxed, bool[] owned) BoxArgs(object?[] args) + { + var boxed = new Java.Lang.Object[args.Length]; + var owned = new bool[args.Length]; + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case null: + boxed[i] = null!; + owned[i] = false; + break; + case Java.Lang.Object o: + boxed[i] = o; + owned[i] = false; + break; + case string s: boxed[i] = new Java.Lang.String(s); owned[i] = true; break; + case bool b: boxed[i] = Java.Lang.Boolean.ValueOf(b)!; owned[i] = true; break; + case char c: boxed[i] = Java.Lang.Character.ValueOf(c)!; owned[i] = true; break; + case sbyte sb: boxed[i] = Java.Lang.Byte.ValueOf(sb)!; owned[i] = true; break; + case byte by: boxed[i] = Java.Lang.Short.ValueOf((short)by)!; owned[i] = true; break; + case short sh: boxed[i] = Java.Lang.Short.ValueOf(sh)!; owned[i] = true; break; + case ushort us: boxed[i] = Java.Lang.Integer.ValueOf(us)!; owned[i] = true; break; + case int ii: boxed[i] = Java.Lang.Integer.ValueOf(ii)!; owned[i] = true; break; + case uint ui: boxed[i] = Java.Lang.Long.ValueOf(ui)!; owned[i] = true; break; + case long l: boxed[i] = Java.Lang.Long.ValueOf(l)!; owned[i] = true; break; + case ulong ul: boxed[i] = Java.Lang.Long.ValueOf(unchecked((long)ul))!; owned[i] = true; break; + case float f: boxed[i] = Java.Lang.Float.ValueOf(f)!; owned[i] = true; break; + case double d: boxed[i] = Java.Lang.Double.ValueOf(d)!; owned[i] = true; break; + default: boxed[i] = new Java.Lang.String(args[i]!.ToString() ?? string.Empty); owned[i] = true; break; + } + } + return (boxed, owned); + } + + static void DisposeOwned(Java.Lang.Object[] boxed, bool[] owned) + { + for (int i = 0; i < boxed.Length; i++) + { + if (owned[i]) boxed[i]?.Dispose(); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SetContent.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SetContent.cs new file mode 100644 index 000000000..cb3d7ee3e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SetContent.cs @@ -0,0 +1,78 @@ +using Android.Util; +using AndroidX.Activity; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.Runtime.Internal; +using AndroidX.Compose.UI.Platform; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + const string TAG = "AndroidX.Compose"; + + /// + /// Sets the activity's content view to a Compose composition that runs + /// on each pass and renders the returned + /// tree. C# parity of Kotlin's + /// ComponentActivity.setContent { ... } extension (which the + /// binder strips because its body is a @Composable lambda). + /// + /// + /// protected override void OnCreate(Bundle? state) + /// { + /// base.OnCreate(state); + /// this.EnableEdgeToEdge(); + /// this.SetContent(c => + /// { + /// var count = c.MutableStateOf(0); + /// return new MaterialTheme { new Text($"Count: {count}") }; + /// }); + /// } + /// + /// + public static void SetContent( + this ComponentActivity activity, + Func content) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(content); + var view = new ComposeView(activity); + view.SetContent(content); + activity.SetContentView(view); + Log.Debug(TAG, "ComponentActivity content set"); + } + + /// + /// Installs as the composition driving this + /// — the View-hierarchy entry point. Use when + /// you're hosting Compose inside an existing Android View tree + /// (XML layouts, fragments, RecyclerView cells) instead of via + /// . + /// Mirrors Kotlin's ComposeView.setContent { ... } extension. + /// + public static void SetContent( + this ComposeView view, + Func content) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(content); + view.SetContent(ComposableLambdaKt.ComposableLambdaInstance( + key: -1, + tracked: false, + block: new ComposableLambda2(composer => content(composer).Render(composer)))); + } + + /// + /// Opts the window into edge-to-edge — transparent system bars, + /// content draws under the status / nav bar, inset padding becomes the + /// caller's responsibility. Mirrors Kotlin's + /// ComponentActivity.enableEdgeToEdge(). Call after + /// base.OnCreate and before + /// . + /// + public static void EnableEdgeToEdge(this ComponentActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + EdgeToEdge.Enable(activity); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SliderDefaults.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SliderDefaults.cs new file mode 100644 index 000000000..24e3d7ba7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SliderDefaults.cs @@ -0,0 +1,101 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Static factories mirroring Kotlin's +/// androidx.compose.material3.SliderDefaults singleton — builds +/// a with one or more slot overrides. Surfaced +/// as an extension so call sites read +/// composer.SliderColors(thumbColor: ...). +/// +/// Useful for overriding a subset of the ten color slots (thumbColor, +/// activeTrackColor, activeTickColor, inactiveTrackColor, +/// inactiveTickColor, plus the four disabled siblings) without +/// hand-rolling a full . Any slot left +/// null falls back to the value +/// SliderDefaults.colors() would return at the current +/// MaterialTheme. +/// +public static partial class ComposeExtensions +{ + /// + /// Mirrors Kotlin's + /// SliderDefaults.colors(thumbColor = …, activeTrackColor = …, …). + /// + /// The Compose lowering takes a packed Color (a long) + /// per slot plus a $default bitmask flagging which slots the + /// caller actually supplied. Each non-null argument here + /// clears the corresponding bit so Compose adopts your color; + /// null leaves the bit set so Kotlin's default (the theme's + /// color-scheme slot) wins. + /// + public static SliderColors SliderColors( + this IComposer composer, + long? thumbColor = null, + long? activeTrackColor = null, + long? activeTickColor = null, + long? inactiveTrackColor = null, + long? inactiveTickColor = null, + long? disabledThumbColor = null, + long? disabledActiveTrackColor = null, + long? disabledActiveTickColor = null, + long? disabledInactiveTrackColor = null, + long? disabledInactiveTickColor = null, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + { + ArgumentNullException.ThrowIfNull(composer); + + // Default mask: every bit set = "use Kotlin's default for that + // slot". Clear a bit when the caller passes a value. The enum + // is generated from the declarative `[assembly: ComposeDefaults]` + // entry; member ordering matches the slot order on the bound + // `SliderDefaults.colors(...)` overload. + var defaults = SliderColorsDefault.All; + if (thumbColor is not null) defaults &= ~SliderColorsDefault.ThumbColor; + if (activeTrackColor is not null) defaults &= ~SliderColorsDefault.ActiveTrackColor; + if (activeTickColor is not null) defaults &= ~SliderColorsDefault.ActiveTickColor; + if (inactiveTrackColor is not null) defaults &= ~SliderColorsDefault.InactiveTrackColor; + if (inactiveTickColor is not null) defaults &= ~SliderColorsDefault.InactiveTickColor; + if (disabledThumbColor is not null) defaults &= ~SliderColorsDefault.DisabledThumbColor; + if (disabledActiveTrackColor is not null) defaults &= ~SliderColorsDefault.DisabledActiveTrackColor; + if (disabledActiveTickColor is not null) defaults &= ~SliderColorsDefault.DisabledActiveTickColor; + if (disabledInactiveTrackColor is not null) defaults &= ~SliderColorsDefault.DisabledInactiveTrackColor; + if (disabledInactiveTickColor is not null) defaults &= ~SliderColorsDefault.DisabledInactiveTickColor; + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + // Kotlin signature: colors-q0g_0yA(JJJJJJJJJJ;Composer;III) → + // ten longs, $changed, $changed1, $default. Last trailing + // int (`_changed1`) is $default per the SliderKt.Slider / + // ProgressIndicatorKt convention. (Even if the mask is + // wrong, Color.Unspecified packs to 0L and Compose's color + // resolver falls back to theme — Kotlin's belt-and-braces + // default semantics — so unset slots still pick up the + // current MaterialTheme color.) + return AndroidX.Compose.Material3.SliderDefaults.Instance.Colors( + thumbColor ?? 0L, + activeTrackColor ?? 0L, + activeTickColor ?? 0L, + inactiveTrackColor ?? 0L, + inactiveTickColor ?? 0L, + disabledThumbColor ?? 0L, + disabledActiveTrackColor ?? 0L, + disabledActiveTickColor ?? 0L, + disabledInactiveTrackColor ?? 0L, + disabledInactiveTickColor ?? 0L, + composer, + p11: 0, + _changed: 0, + _changed1: (int)defaults); + } + finally + { + composer.EndReplaceableGroup(); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.State.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.State.cs new file mode 100644 index 000000000..4d8c3c69c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.State.cs @@ -0,0 +1,156 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + /// + /// Construct an androidx.compose.ui.text.input.TextFieldValue + /// — the text + caret-selection + IME-composition triple that drives + /// the + /// overload. Hand-bridged because the Kotlin primary ctor is stripped + /// from the binding (the selection: TextRange param is a + /// @JvmInline value class; see issue #204). + /// + public static AndroidX.Compose.UI.Text.Input.TextFieldValue NewTextFieldValue( + string text = "", + long selection = 0L, + AndroidX.Compose.UI.Text.TextRange? composition = null) + => ComposeBridges.NewTextFieldValueImpl(text, selection, composition); + + /// + /// Convenience overload: composer.NewTextFieldValue(...) for + /// call sites already inside a composable that prefer the same + /// dotted-on-the-composer shape as . + /// The composer is unused — this just forwards to + /// + /// — but it keeps demos consistent with the "everything Compose-related + /// hangs off c" idiom. Outside composition (button callbacks, + /// ViewModels) call the static form directly, exactly as Kotlin's + /// TextFieldValue("") ctor isn't @Composable. + /// + public static AndroidX.Compose.UI.Text.Input.TextFieldValue NewTextFieldValue( + this IComposer composer, + string text = "", + long selection = 0L, + AndroidX.Compose.UI.Text.TextRange? composition = null) + => NewTextFieldValue(text, selection, composition); + + /// + /// Compose's derivedStateOf { calculation() }: returns a + /// read-only whose value is lazily + /// computed by . Compose tracks which + /// state values reads and only re-runs + /// it when one of them changes. Cache the returned instance via + /// so it + /// survives recomposition. + /// + public static DerivedState DerivedStateOf(Func calculation) + { + ArgumentNullException.ThrowIfNull(calculation); + var jcw = new ObjectFunction0(() => MutableState.ToJava(calculation())); + var state = SnapshotStateKt.DerivedStateOf(jcw); + return new DerivedState(state); + } + + /// + /// Convenience overload: composer.DerivedStateOf(() => ...) for + /// call sites that prefer the dotted-on-the-composer shape. The + /// composer is unused — this just forwards to + /// . Wrap the result in + /// to cache + /// it across recompositions. + /// + public static DerivedState DerivedStateOf(this IComposer composer, Func calculation) + => DerivedStateOf(calculation); + + // ---- MutableStateOf ---- + // + // Single name; five overloads. C# overload resolution prefers a + // non-generic match over a generic one, so: + // + // c.MutableStateOf(0) → int overload → MutableNumberState + // c.MutableStateOf(1000L) → long overload → MutableNumberState + // c.MutableStateOf(0.5f) → float overload → MutableNumberState + // c.MutableStateOf(Math.PI) → double overload → MutableNumberState + // c.MutableStateOf("Ada") → generic → MutableState + // c.MutableStateOf(false) → generic → MutableState + // + // Numeric overloads return MutableNumberState so `count++` / `--` work + // at the call site without the caller picking a different factory name. + + /// + /// Allocate (once per call site) a + /// holding , cached in the composer's slot + /// table by . + /// C# parity of Kotlin's remember { mutableStateOf(initial) }. + /// + /// + /// For int, long, float, and double + /// arguments, C# overload resolution prefers the non-generic numeric + /// overloads of + /// etc., so the caller receives a + /// and can write count++ + /// directly. Use explicit type args (e.g. c.MutableStateOf<long?>(null)) + /// when the literal can't infer . + /// + public static MutableState MutableStateOf( + this IComposer composer, + T initial, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => composer.Remember(() => new MutableState(initial), line, file); + + /// + /// int-typed overload: backed by Compose's + /// fast path (no Java.Lang.Integer + /// allocation per read/write) and returns + /// so the caller can write + /// count++. + /// + public static MutableNumberState MutableStateOf( + this IComposer composer, + int initial, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => composer.Remember(() => new MutableNumberState(initial), line, file); + + /// + /// long-typed overload: backed by Compose's + /// fast path and returns + /// . + /// + public static MutableNumberState MutableStateOf( + this IComposer composer, + long initial, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => composer.Remember(() => new MutableNumberState(initial), line, file); + + /// + /// float-typed overload: backed by Compose's + /// fast path and returns + /// . + /// + public static MutableNumberState MutableStateOf( + this IComposer composer, + float initial, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => composer.Remember(() => new MutableNumberState(initial), line, file); + + /// + /// double-typed overload: Compose has no + /// MutableDoubleState primitive, so this falls through to the + /// boxed path — but still returns + /// so the caller can write x++ / x.Value /= 2 + /// idiomatically. + /// + public static MutableNumberState MutableStateOf( + this IComposer composer, + double initial, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + => composer.Remember(() => new MutableNumberState(initial), line, file); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SwitchDefaults.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SwitchDefaults.cs new file mode 100644 index 000000000..7f51da82d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.SwitchDefaults.cs @@ -0,0 +1,82 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Static factory mirroring Kotlin's +/// androidx.compose.material3.SwitchDefaults.colors(...). Returns +/// a overriding only the slots the caller +/// supplies; the rest fall back to the current MaterialTheme. +/// +public static partial class ComposeExtensions +{ + /// + /// Build a with the four "common" + /// override slots — thumb / track for both the checked and + /// unchecked states — wired to the bound parameterised + /// SwitchDefaults.colors(c1..c16, composer, $default, $changed, $changed1) + /// overload. + /// + /// Every slot left null stays "use Kotlin's default"; + /// the corresponding bit in the $default bitmask stays set + /// and Kotlin substitutes the theme's colorScheme entry. The + /// 16 slot order matches the bytecode lowering of the + /// @JvmInline value class Color parameters: thumb / track / + /// border / icon — checked, unchecked, disabled-checked, + /// disabled-unchecked. + /// + public static SwitchColors SwitchColors( + this IComposer composer, + long? checkedThumbColor = null, + long? checkedTrackColor = null, + long? uncheckedThumbColor = null, + long? uncheckedTrackColor = null, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + { + ArgumentNullException.ThrowIfNull(composer); + + // Default mask: every bit set = "use Kotlin's default for that + // slot". Clear a bit when the caller passes a value. The enum + // is generated from the declarative `[assembly: ComposeDefaults]` + // entry; member ordering matches the slot order on the bound + // `colors(...)` overload. + var defaults = SwitchColorsDefault.All; + if (checkedThumbColor is not null) defaults &= ~SwitchColorsDefault.CheckedThumbColor; + if (checkedTrackColor is not null) defaults &= ~SwitchColorsDefault.CheckedTrackColor; + if (uncheckedThumbColor is not null) defaults &= ~SwitchColorsDefault.UncheckedThumbColor; + if (uncheckedTrackColor is not null) defaults &= ~SwitchColorsDefault.UncheckedTrackColor; + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + return AndroidX.Compose.Material3.SwitchDefaults.Instance.Colors( + checkedThumbColor: checkedThumbColor ?? 0L, + checkedTrackColor: checkedTrackColor ?? 0L, + checkedBorderColor: 0L, + checkedIconColor: 0L, + uncheckedThumbColor: uncheckedThumbColor ?? 0L, + uncheckedTrackColor: uncheckedTrackColor ?? 0L, + uncheckedBorderColor: 0L, + uncheckedIconColor: 0L, + disabledCheckedThumbColor: 0L, + disabledCheckedTrackColor: 0L, + disabledCheckedBorderColor: 0L, + disabledCheckedIconColor: 0L, + disabledUncheckedThumbColor: 0L, + disabledUncheckedTrackColor: 0L, + disabledUncheckedBorderColor: 0L, + disabledUncheckedIconColor: 0L, + _composer: composer, + p17: (int)defaults, + _changed: 0, + _changed1: 0); + } + finally + { + composer.EndReplaceableGroup(); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Theme.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Theme.cs new file mode 100644 index 000000000..4e51cc8a7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.Theme.cs @@ -0,0 +1,44 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; +using BindingMaterialTheme = AndroidX.Compose.Material3.MaterialTheme; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + /// + /// Read the active from the current + /// ancestor. Mirror of Kotlin's + /// MaterialTheme.colorScheme. Must be called from inside a + /// composable's Render body. + /// + public static ColorScheme ColorScheme(this IComposer composer) + { + ArgumentNullException.ThrowIfNull(composer); + return BindingMaterialTheme.Instance.GetColorScheme(composer, 0); + } + + /// + /// Read the active from the current + /// ancestor. Mirror of Kotlin's + /// MaterialTheme.typography. Must be called from inside a + /// composable's Render body. + /// + public static Typography Typography(this IComposer composer) + { + ArgumentNullException.ThrowIfNull(composer); + return BindingMaterialTheme.Instance.GetTypography(composer, 0); + } + + /// + /// Read the active from the current + /// ancestor. Mirror of Kotlin's + /// MaterialTheme.shapes. Must be called from inside a + /// composable's Render body. + /// + public static Shapes Shapes(this IComposer composer) + { + ArgumentNullException.ThrowIfNull(composer); + return BindingMaterialTheme.Instance.GetShapes(composer, 0); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.TopAppBarDefaults.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.TopAppBarDefaults.cs new file mode 100644 index 000000000..8da14a3c6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.TopAppBarDefaults.cs @@ -0,0 +1,119 @@ +using System.Runtime.CompilerServices; +using Android.Runtime; +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Static factories mirroring Kotlin's +/// androidx.compose.material3.TopAppBarDefaults singleton — each +/// returns an configured for a +/// particular collapse strategy. Surfaced as +/// extensions so call sites read +/// composer.PinnedScrollBehavior(state). +/// +public static partial class ComposeExtensions +{ + /// + /// Mirrors Kotlin's rememberTopAppBarState(...) — allocates + /// a via + /// + /// so the same instance survives recompositions, with the three + /// initial offsets surfaced as named C# parameters with Kotlin's + /// defaults. + /// + public static TopAppBarState RememberTopAppBarState( + this IComposer composer, + float initialHeightOffsetLimit = float.NegativeInfinity, + float initialHeightOffset = 0f, + float initialContentOffset = 0f, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") => + composer.Remember( + () => new TopAppBarState( + initialHeightOffsetLimit, initialHeightOffset, initialContentOffset), + line, file); + + /// + /// Mirrors Kotlin's + /// TopAppBarDefaults.pinnedScrollBehavior(state). The bar + /// stays in place but tracks the content's scroll offset on + /// so it can raise its surface elevation + /// when content scrolls under it. + /// + public static ITopAppBarScrollBehavior PinnedScrollBehavior( + this IComposer composer, + TopAppBarState state, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(state); + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + // canScroll: null → Kotlin's `() -> true` default; bit 1 in + // `$default` flags that we're omitting it. + return AndroidX.Compose.Material3.TopAppBarDefaults.Instance + .PinnedScrollBehavior(state, canScroll: null, composer, 0, 0b10); + } + finally + { + composer.EndReplaceableGroup(); + } + } + + /// + /// Mirrors Kotlin's + /// TopAppBarDefaults.enterAlwaysScrollBehavior(state). + /// + public static ITopAppBarScrollBehavior EnterAlwaysScrollBehavior( + this IComposer composer, + TopAppBarState state, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") => + InvokeScrollBehavior(composer, state, line, file, ComposeBridges.TopAppBarDefaultsEnterAlwaysScrollBehavior); + + /// + /// Mirrors Kotlin's + /// TopAppBarDefaults.exitUntilCollapsedScrollBehavior(state). + /// + public static ITopAppBarScrollBehavior ExitUntilCollapsedScrollBehavior( + this IComposer composer, + TopAppBarState state, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") => + InvokeScrollBehavior(composer, state, line, file, ComposeBridges.TopAppBarDefaultsExitUntilCollapsedScrollBehavior); + + static ITopAppBarScrollBehavior InvokeScrollBehavior( + IComposer composer, + TopAppBarState state, int line, string file, + Func bridge) + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(state); + + composer.StartReplaceableGroup(SourceLocationKey.Compute(line, file)); + try + { + IntPtr handle = bridge(state, composer); + try + { + return Java.Lang.Object.GetObject( + handle, JniHandleOwnership.TransferLocalRef)!; + } + catch + { + if (handle != IntPtr.Zero) + JNIEnv.DeleteLocalRef(handle); + throw; + } + } + finally + { + composer.EndReplaceableGroup(); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ViewModel.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ViewModel.cs new file mode 100644 index 000000000..2736710f9 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.ViewModel.cs @@ -0,0 +1,138 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +public static partial class ComposeExtensions +{ + /// + /// Acquires (or creates on first composition) the + /// for this call site — the C# + /// parity of Kotlin's + /// androidx.lifecycle.viewmodel.compose.viewModel<T>(…). + /// + /// A subclass. + /// The active composer. + /// + /// Constructs the view model the first time the host's + /// sees this + /// call site's storage key. + /// + /// Auto-populated; do not pass. + /// Auto-populated; do not pass. + /// + /// The view model is owned by the nearest + /// on + /// LocalViewModelStoreOwner — the host + /// at the root, + /// or the current + /// inside a + /// . + /// + public static T ViewModel( + this IComposer composer, + Func factory, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + where T : ViewModel + => ViewModelCore(composer, factory, keys: null, line, file); + + /// + /// Keyed viewModel(key1): the storage key includes the + /// stringified . + /// + public static T ViewModel( + this IComposer composer, + Func factory, + object? key1, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + where T : ViewModel + => ViewModelCore(composer, factory, new[] { key1 }, line, file); + + /// Keyed viewModel(key1, key2). + public static T ViewModel( + this IComposer composer, + Func factory, + object? key1, + object? key2, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + where T : ViewModel + => ViewModelCore(composer, factory, new[] { key1, key2 }, line, file); + + /// Keyed viewModel(key1, key2, key3). + public static T ViewModel( + this IComposer composer, + Func factory, + object? key1, + object? key2, + object? key3, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + where T : ViewModel + => ViewModelCore(composer, factory, new[] { key1, key2, key3 }, line, file); + + /// + /// Array-form keyed viewModel(vararg keys). + /// + public static T ViewModelKeyed( + this IComposer composer, + Func factory, + object?[] keys, + [CallerLineNumber] int line = 0, + [CallerFilePath] string file = "") + where T : ViewModel + => ViewModelCore(composer, factory, keys ?? throw new ArgumentNullException(nameof(keys)), line, file); + + static T ViewModelCore(IComposer composer, Func factory, object?[]? keys, int line, string file) + where T : ViewModel + { + ArgumentNullException.ThrowIfNull(composer); + ArgumentNullException.ThrowIfNull(factory); + + var ownerHandle = ComposeBridges.LocalViewModelStoreOwnerCurrent(composer); + if (ownerHandle == IntPtr.Zero) + { + throw new InvalidOperationException( + "ViewModel requires LocalViewModelStoreOwner to be set. " + + "Call from inside ComponentActivity.SetContent or a NavHost destination so the host owner is in scope."); + } + var owner = Java.Lang.Object.GetObject( + ownerHandle, Android.Runtime.JniHandleOwnership.TransferLocalRef) + ?? throw new InvalidOperationException( + "LocalViewModelStoreOwner.current returned a non-IViewModelStoreOwner handle."); + + var key = BuildViewModelKey(typeof(T), file, line, keys); + var modelClass = Java.Lang.Class.FromType(typeof(T)); + var lambdaFactory = new LambdaViewModelFactory(factory); + var provider = new AndroidX.Lifecycle.ViewModelProvider(owner, lambdaFactory); + try + { + var vm = provider.Get(key, modelClass); + return (T)vm; + } + finally + { + lambdaFactory.Dispose(); + } + } + + static string BuildViewModelKey(Type type, string file, int line, object?[]? keys) + { + var sb = new System.Text.StringBuilder("net.compose:") + .Append(type.FullName) + .Append(':').Append(file) + .Append(':').Append(line); + if (keys is { Length: > 0 }) + { + for (int i = 0; i < keys.Length; i++) + { + sb.Append('|'); + if (keys[i] is { } k) sb.Append(k); + else sb.Append(""); + } + } + return sb.ToString(); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.cs new file mode 100644 index 000000000..4db393901 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeExtensions.cs @@ -0,0 +1,32 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Top-level composition utilities — the C# parity of every helper Kotlin's +/// IR rewrite would normally inject through the implicit $composer +/// parameter on a @Composable function. +/// +/// Every method that needs the active composer takes +/// as its receiver (this IComposer composer) so call sites read +/// composer.Remember(...), composer.LaunchedEffect(...), +/// composer.StringResource(R.string.foo) — exactly the +/// $composer.remember(...) shape Compose ships under the hood. The +/// composer is always handed to the body explicitly by the +/// / / +/// adapters, by +/// overrides, and by the +/// +/// entry point — no ambient [ThreadStatic] lookup, no base class +/// required. +/// +/// Plain statics that don't read a slot (, +/// , …) also expose this IComposer +/// convenience overloads so call sites inside a composable can keep the +/// same composer.X(...) shape. The static form stays callable from +/// non-composable contexts (button callbacks, ViewModels) — exactly the +/// way Kotlin's TextFieldValue("") ctor isn't @Composable. +/// +public static partial class ComposeExtensions +{ +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeTextMeasure.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeTextMeasure.cs new file mode 100644 index 000000000..950de5a5b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ComposeTextMeasure.cs @@ -0,0 +1,61 @@ +#nullable enable +namespace AndroidX.Compose; + +/// +/// Measures text with Compose's OWN layout engine (TextMeasurer → TextLayoutResult), +/// the same engine that paints a Text. This is the React-Native model — measure and render +/// with one engine — so the reported first baseline equals the rendered one exactly (no +/// StaticLayout/Compose cross-engine seam). Used by the layout engine for baseline alignment. +/// +public static class ComposeTextMeasure +{ + // (probe removed — the full API is directly callable, no JNI bridge needed) + static global::AndroidX.Compose.UI.Text.TextMeasurer? _measurer; + static global::AndroidX.Compose.UI.Unit.IDensity? _density; + + static global::AndroidX.Compose.UI.Text.TextMeasurer Measurer(global::Android.Content.Context ctx) + { + if (_measurer is not null) + return _measurer; + var resolver = global::AndroidX.Compose.UI.Text.Font.FontFamilyResolver_androidKt.CreateFontFamilyResolver(ctx); + _density = global::AndroidX.Compose.UI.Unit.AndroidDensity_androidKt.Density(ctx); + _measurer = new global::AndroidX.Compose.UI.Text.TextMeasurer( + resolver, _density, global::AndroidX.Compose.UI.Unit.LayoutDirection.Ltr, 64); + return _measurer; + } + + /// + /// First-line baseline (Dp from the top) of as Compose lays it out with a + /// style matching the rendered Text — so it equals the drawn baseline. Build the style to + /// mirror the renderer: same font size / family / weight / line height, zero letter spacing. + /// + public static double FirstBaselineDp( + global::Android.Content.Context ctx, float density, string? text, + Sp fontSize, FontFamily? fontFamily, FontWeight? fontWeight, Sp lineHeight, double maxWidthDp) + { + var measurer = Measurer(ctx); + var style = new TextStyle + { + FontSize = fontSize, + FontFamily = fontFamily, + FontWeight = fontWeight, + LetterSpacing = Sp.Zero, + LineHeight = lineHeight, + }.Build(); + + // Compose's Infinity sentinel (Int.MaxValue) marks an unbounded dimension without consuming + // packing bits; a large *finite* maxHeight would overflow the Constraints bit budget. + int widthPx = (maxWidthDp > 0 && !double.IsInfinity(maxWidthDp)) + ? (int)System.Math.Ceiling(maxWidthDp * density) + : int.MaxValue; + long constraints = global::AndroidX.Compose.UI.Unit.ConstraintsKt.Constraints(0, widthPx, 0, int.MaxValue); + var resolver = global::AndroidX.Compose.UI.Text.Font.FontFamilyResolver_androidKt.CreateFontFamilyResolver(ctx); + + var result = measurer.Measure( + text ?? string.Empty, style, + overflow: 1 /* Clip */, softWrap: true, maxLines: int.MaxValue, + constraints, global::AndroidX.Compose.UI.Unit.LayoutDirection.Ltr, _density!, resolver, false); + + return result.FirstBaseline / density; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Composed.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Composed.cs new file mode 100644 index 000000000..6dd0864e6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Composed.cs @@ -0,0 +1,108 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Composer-aware that defers tree +/// construction until runs. The wrapped +/// receives the active +/// so the builder can read snapshot-state +/// values, call , +/// or invoke any other API that takes an . +/// +/// +/// +/// Use this where you'd otherwise need to subclass +/// and override +/// — for example a +/// sample-side helper that needs to thread the composer into a static +/// builder method: +/// +/// +/// new Composed(c => +/// { +/// var scheme = c.ColorScheme(); +/// return new Text("Hello") +/// { +/// Color = Color.FromArgb(scheme.OnSurfaceVariant), +/// }; +/// }) +/// +/// +/// The wrapped builder is invoked once per composition pass; the +/// returned subtree is then rendered with the same +/// instance. The builder is not memoized — if +/// you need to cache a subtree across recompositions, wrap the +/// per-render allocation behind composer.Remember(...). +/// +/// +/// Returning from the builder is allowed and +/// renders nothing. +/// +/// +public sealed class Composed : ComposableNode +{ + readonly Func _builder; + + /// + /// Create a composer-aware wrapper around . + /// + public Composed(Func builder) + { + ArgumentNullException.ThrowIfNull(builder); + _builder = builder; + } + + /// + /// Implicit conversion from a composer-aware builder lambda to a + /// , so callers can drop the explicit + /// new Composed(...) wrapper whenever the target type is + /// known to be : + /// + /// Composed node = c => new Text(c.ColorScheme().OnSurface.ToString()); + /// + /// + /// Note: C# only considers user-defined implicit conversions when the + /// expression already has a target type that is a delegate + /// ( here). A bare lambda assigned to + /// still needs an explicit + /// new Composed(...) wrapper — collection-initializer support + /// for the bare-lambda form is provided separately via + /// . + /// + /// + public static implicit operator Composed(Func builder) => + new(builder); + + /// + public override void Render(IComposer composer) + { + var node = _builder(composer); + node?.Render(composer); + } + + /// + /// Forward a parent-supplied PaddingValues handle (e.g. from + /// 's content lambda) through to the node the + /// builder produces, so the inner node's BuildModifier picks + /// it up as if it had been the body itself. + /// + /// The base implementation stashes the + /// handle on the receiving node and consumes it in + /// BuildModifier — but never calls + /// BuildModifier on itself; it just delegates to the + /// builder's result. Without this override the padding is dropped + /// on the floor and the inner content renders behind the top / + /// bottom bars. + /// + internal override void Render(IComposer composer, IntPtr contentPadding) + { + var node = _builder(composer); + if (node is null) + return; + if (contentPadding == IntPtr.Zero) + node.Render(composer); + else + node.Render(composer, contentPadding); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CompositionLocal.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CompositionLocal.cs new file mode 100644 index 000000000..098f018bf --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CompositionLocal.cs @@ -0,0 +1,179 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Type-inferred entry point for creating +/// instances. Mirrors Kotlin's top-level compositionLocalOf and +/// staticCompositionLocalOf functions: +/// +/// public static readonly CompositionLocal<MyTheme> LocalMyTheme = +/// CompositionLocal.StaticOf(() => MyTheme.Default); +/// +/// +public static class CompositionLocal +{ + /// + /// Create a static composition local. Readers do not + /// recompose when the provided value changes — instead, the entire + /// content lambda of the enclosing + /// is recomposed. Use this + /// for values that rarely change (theme tokens, density-like + /// configuration) since it avoids the per-reader bookkeeping cost + /// of the dynamic variant. + /// + public static CompositionLocal StaticOf(Func defaultFactory) => + CompositionLocal.StaticOf(defaultFactory); + + /// + /// Create a dynamic composition local backed by + /// structuralEqualityPolicy. Only the composables that + /// actually read are + /// invalidated when the provided value changes (by + /// ) — the rest of the + /// provider's subtree is skipped. + /// + public static CompositionLocal Of(Func defaultFactory) => + CompositionLocal.Of(defaultFactory); +} + +/// +/// Typed wrapper around Kotlin's +/// androidx.compose.runtime.ProvidableCompositionLocal. A +/// composition local is a slot in the composition that propagates a +/// value down the tree implicitly — readers pick up the nearest +/// enclosing 's value, or the +/// default if none is in scope. +/// +/// Instances are usually created once (a static readonly +/// field) via or +/// , then provided per-subtree +/// with and read inside a composable's +/// Render method via +/// . +/// +/// Boxing. Reference values that already derive from +/// are passed through unchanged; the +/// built-in primitive / string / numeric types are forwarded +/// to the corresponding java.lang.* box. Any other managed +/// type is wrapped in an internal ManagedBox JCW so pure-C# +/// POCOs (records, classes, structs) work without inheriting from +/// . +/// +public sealed class CompositionLocal +{ + readonly ProvidableCompositionLocal _peer; + + internal CompositionLocal(ProvidableCompositionLocal peer) => + _peer = peer; + + /// + /// Create a static composition local. Prefer the + /// type-inferred + /// entry point — this overload only exists for callers that want + /// to spell the type argument explicitly. + /// + public static CompositionLocal StaticOf(Func defaultFactory) + { + ArgumentNullException.ThrowIfNull(defaultFactory); + var peer = CompositionLocalKt.StaticCompositionLocalOf( + new ObjectFunction0(() => ToJava(defaultFactory()))); + return new CompositionLocal(peer); + } + + /// + /// Create a dynamic composition local backed by + /// structuralEqualityPolicy. Prefer the type-inferred + /// entry point. + /// + public static CompositionLocal Of(Func defaultFactory) + { + ArgumentNullException.ThrowIfNull(defaultFactory); + var peer = CompositionLocalKt.CompositionLocalOf( + SnapshotStateKt.StructuralEqualityPolicy(), + new ObjectFunction0(() => ToJava(defaultFactory()))); + return new CompositionLocal(peer); + } + + /// + /// Read the local's value for the current composition position, + /// equivalent to Kotlin's MyLocal.current. Pass the + /// handed to the enclosing + /// Render method. + /// + public T Current(IComposer composer) + { + ArgumentNullException.ThrowIfNull(composer); + return FromJava(_peer.GetCurrent(composer, 0)); + } + + /// + /// Pair this local with for installation + /// by a . Equivalent to + /// Kotlin's infix local provides value syntax. + /// + public ProvidedValue Provides(T value) => + new(_peer.Provides(ToJava(value)!)); + + // Cached per-T: when T is Nullable (e.g. long?, int?, bool?), this + // is typeof(U) so the primitive dispatch in FromJava/ToJava treats + // `long?` the same as `long`. For all other T, it's just typeof(T). + static readonly Type s_effectiveType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + static Java.Lang.Object? ToJava(T value) + { + if (value is null) return null; + // Boxing a non-null Nullable to object yields a boxed U, not a + // boxed Nullable, so one switch handles both T == long and + // T == long?. + object boxed = value!; + return boxed switch + { + Java.Lang.Object o => o, + string s => new Java.Lang.String(s), + bool b => Java.Lang.Boolean.ValueOf(b), + char c => Java.Lang.Character.ValueOf(c), + sbyte sb => Java.Lang.Byte.ValueOf(sb), + byte by => Java.Lang.Short.ValueOf((short)by), + short sh => Java.Lang.Short.ValueOf(sh), + ushort us => Java.Lang.Integer.ValueOf(us), + int i => Java.Lang.Integer.ValueOf(i), + uint ui => Java.Lang.Long.ValueOf(ui), + long l => Java.Lang.Long.ValueOf(l), + ulong ul => Java.Lang.Long.ValueOf(unchecked((long)ul)), + float f => Java.Lang.Float.ValueOf(f), + double d => Java.Lang.Double.ValueOf(d), + _ => new ManagedBox(boxed), + }; + } + + static T FromJava(Java.Lang.Object? value) + { + if (value is null) return default!; + if (value is ManagedBox box) return (T)box.Value!; + if (value is T t) return t; + // Use the underlying type when T is Nullable so `long?` + // dispatches into the same branch as `long`. `(T)(object)5L` + // unboxes directly into Nullable for T == long?. + var type = s_effectiveType; + if (type == typeof(string)) return (T)(object)value.ToString()!; + if (type == typeof(bool)) return (T)(object)((Java.Lang.Boolean)value).BooleanValue(); + if (type == typeof(char)) return (T)(object)((Java.Lang.Character)value).CharValue(); + if (type == typeof(sbyte)) return (T)(object)((Java.Lang.Byte)value).ByteValue(); + if (type == typeof(byte)) return (T)(object)(byte)((Java.Lang.Short)value).ShortValue(); + if (type == typeof(short)) return (T)(object)((Java.Lang.Short)value).ShortValue(); + if (type == typeof(ushort)) return (T)(object)(ushort)((Java.Lang.Integer)value).IntValue(); + if (type == typeof(int)) return (T)(object)((Java.Lang.Integer)value).IntValue(); + if (type == typeof(uint)) return (T)(object)(uint)((Java.Lang.Long)value).LongValue(); + if (type == typeof(long)) return (T)(object)((Java.Lang.Long)value).LongValue(); + if (type == typeof(ulong)) return (T)(object)unchecked((ulong)((Java.Lang.Long)value).LongValue()); + if (type == typeof(float)) return (T)(object)((Java.Lang.Float)value).FloatValue(); + if (type == typeof(double)) return (T)(object)((Java.Lang.Double)value).DoubleValue(); + throw new InvalidCastException( + $"CompositionLocal<{typeof(T).Name}>: don't know how to unwrap a " + + $"{value.GetType().Name}. Built-in composition locals always return " + + "their own bound types; for user-defined locals the value must be a " + + "Java.Lang.Object subclass, a built-in primitive/string, or wrapped in " + + "ManagedBox (handled automatically by Provides)."); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CompositionLocalProvider.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CompositionLocalProvider.cs new file mode 100644 index 000000000..02fd3d767 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CompositionLocalProvider.cs @@ -0,0 +1,83 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Container composable that installs one or more +/// s for the duration of its child +/// composition. Mirrors Kotlin's +/// CompositionLocalProvider(*values) { ... }: +/// +/// new CompositionLocalProvider { +/// LocalMyTheme.Provides(customTheme), +/// LocalContext.Provides(scopedContext), +/// new Text("inherits theme + context"), +/// } +/// +/// +/// Ordering rule. All entries +/// in a collection initializer must come before any +/// children. Mixing them would suggest +/// the provider only applies to the children that follow it visually, +/// but in fact a single provider call installs every value for the +/// whole subtree. Adding a child first and then a provided value +/// throws to keep the source +/// layout honest. +/// +public sealed class CompositionLocalProvider : ComposableContainer +{ + readonly List _provided = new(); + + /// + /// Collection-initializer hook for . + /// Must precede every child in the initializer block; see the + /// type-level remarks for why. + /// + public void Add(ProvidedValue value) + { + if (value is null) return; + if (Children.Count != 0) + throw new InvalidOperationException( + "CompositionLocalProvider: every ProvidedValue must be listed " + + "before any ComposableNode child. The provided values apply " + + "to the entire child subtree, not only the children that " + + "follow them in source order."); + _provided.Add(value); + } + + public override void Render(IComposer composer) + { + if (_provided.Count == 0) + { + // Nothing to install — render children transparently so an + // empty provider behaves like a plain group. + RenderChildren(composer); + return; + } + + var content = ComposableLambdas.Wrap2(composer, c => RenderChildren(c)); + + if (_provided.Count == 1) + { + // Use the single-value overload — it's the common case for + // theme overrides and is cheaper than allocating a 1-length + // array. + CompositionLocalKt.CompositionLocalProvider( + value: _provided[0].Peer, + content: content, + _composer: composer, + _changed: 0); + return; + } + + var array = new AndroidX.Compose.Runtime.ProvidedValue[_provided.Count]; + for (int i = 0; i < _provided.Count; i++) + array[i] = _provided[i].Peer; + + CompositionLocalKt.CompositionLocalProvider( + values: array, + content: content, + _composer: composer, + _changed: 0); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ContentScale.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ContentScale.cs new file mode 100644 index 000000000..f9b105c77 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ContentScale.cs @@ -0,0 +1,84 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around the androidx.compose.ui.layout.ContentScale +/// singletons exposed by Kotlin's ContentScale.Companion object. +/// Compose's ContentScale is a real Kotlin interface (not a +/// @JvmInline value class) — same plumbing story as +/// , generated from the +/// below. The +/// Xamarin.AndroidX.Compose.UI binding currently doesn't surface +/// a managed IContentScale type, so we resolve the companion +/// getters via JNI directly. +/// +/// Pass instances to facades that accept an optional contentScale +/// slot (e.g. ) to control how source content is +/// scaled to fit its destination bounds. Left unset (null) on those +/// facades, Kotlin's default (ContentScale.Fit) applies. +/// +[ComposeCompanion("androidx/compose/ui/layout/ContentScale")] +public sealed partial class ContentScale : Java.Lang.Object +{ + ContentScale(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + /// + /// ContentScale.Fit — scale the source uniformly (maintaining + /// the aspect ratio) so both dimensions fit within the destination. + /// Comparable to ImageView.ScaleType.FIT_CENTER. + /// + [ComposeCompanionGetter("getFit")] + public static partial ContentScale Fit { get; } + + /// + /// ContentScale.Crop — scale the source uniformly so it fully + /// fills the destination, cropping any overflow. Comparable to + /// ImageView.ScaleType.CENTER_CROP. + /// + [ComposeCompanionGetter("getCrop")] + public static partial ContentScale Crop { get; } + + /// + /// ContentScale.FillHeight — scale the source uniformly to + /// match the destination's height; the width may overflow or be + /// clipped depending on the parent. + /// + [ComposeCompanionGetter("getFillHeight")] + public static partial ContentScale FillHeight { get; } + + /// + /// ContentScale.FillWidth — scale the source uniformly to + /// match the destination's width; the height may overflow or be + /// clipped depending on the parent. + /// + [ComposeCompanionGetter("getFillWidth")] + public static partial ContentScale FillWidth { get; } + + /// + /// ContentScale.FillBounds — scale the source non-uniformly + /// to exactly match the destination bounds. Distorts the aspect + /// ratio. Comparable to ImageView.ScaleType.FIT_XY. + /// + [ComposeCompanionGetter("getFillBounds")] + public static partial ContentScale FillBounds { get; } + + /// + /// ContentScale.Inside — like when the + /// source is larger than the destination; otherwise the source is + /// drawn at its intrinsic size (no upscaling). Comparable to + /// ImageView.ScaleType.CENTER_INSIDE. + /// + [ComposeCompanionGetter("getInside")] + public static partial ContentScale Inside { get; } + + /// + /// ContentScale.None — draw the source at its intrinsic size + /// (no scaling). Comparable to ImageView.ScaleType.CENTER. + /// + // ContentScale.getNone() returns the concrete subtype FixedScale, not the + // ContentScale base — so override the JNI return descriptor to match. + [ComposeCompanionGetter("getNone", ReturnDescriptor = "Landroidx/compose/ui/layout/FixedScale;")] + public static partial ContentScale None { get; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Crossfade.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Crossfade.cs new file mode 100644 index 000000000..2dbd77f7b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Crossfade.cs @@ -0,0 +1,97 @@ +using AndroidX.Compose.Animation; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Crossfade — fades between content keyed by +/// . When changes, +/// the content for the previous and new states are both rendered for +/// the duration of the crossfade animation while one fades out and +/// the other fades in. +/// +/// +/// The state type. Must be one of the -supported +/// boxable types: a primitive (bool, int, long, +/// float, double, string, char, the byte +/// / short / unsigned numeric variants), or a +/// subclass. Other reference types are +/// not supported because there is no clean Java box for them — wrap +/// such state in a key type the user controls (e.g. an +/// int page index) and render the actual content in the +/// callback. +/// +/// +/// Mirror of the bound (targetState: T, ...) overload of +/// androidx.compose.animation.CrossfadeKt.Crossfade — no JNI +/// bridge needed; every parameter except targetState and +/// content defaults to Kotlin's value. +/// The content callback receives the state +/// value Compose is currently rendering — typically the +/// after settling, but during a crossfade +/// transition it is invoked once per active state (the previous and +/// the new value). Make sure the callback returns the right node for +/// the value it receives, not just for the current +/// . +/// +/// +/// +/// var page = Remember(() => new MutableNumberState<int>(0)); +/// new Crossfade<int>( +/// targetState: page.Value, +/// content: i => new Text($"Page {i}")) +/// +/// +public sealed class Crossfade : ComposableNode +{ + readonly T _targetState; + readonly Func _content; + + /// Build a Crossfade keyed on with per-state content. + /// The state value that drives the fade. + /// Builds the to display for a given state value. Invoked once per active state during a crossfade transition. + public Crossfade(T targetState, Func content) + { + _targetState = targetState; + ArgumentNullException.ThrowIfNull(content); + _content = content; + } + + /// The state value passed to the underlying Crossfade. + public T TargetState => _targetState; + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var boxed = MutableState.ToJava(_targetState); + + // p0 carries the boxed state value (Function3). + // Unbox back to T so the caller's builder receives a typed value + // matching whatever Compose is rendering for this pass — could be + // the previous OR new target during a crossfade transition. + var content = ComposableLambdas.Wrap3WithValue(composer, (p0, c) => + { + T value = MutableState.FromJava(p0); + _content(value).Render(c); + }); + + // $default bit positions (Kotlin source order): + // 0 = targetState (always provided → cleared) + // 1 = modifier (cleared when caller set it) + // 2 = animationSpec (left set → use Compose default tween) + // 3 = label (left set → use Compose default) + // 4 = content (always provided → cleared) + int defaults = (1 << 2) | (1 << 3); + if (modifier is null) defaults |= 1 << 1; + + CrossfadeKt.Crossfade( + targetState: boxed, + modifier: modifier, + animationSpec: null, + label: null, + content: content, + _composer: composer, + p6: 0, // $changed + _changed: defaults); // $default + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CustomTab.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CustomTab.cs new file mode 100644 index 000000000..64ed74cea --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/CustomTab.cs @@ -0,0 +1,16 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 Tab with fully custom content (the Tab-bogVsAg +/// overload that takes a ColumnScope-receiver content lambda — useful +/// when the text/icon slots on the regular don't fit +/// your layout, e.g. multi-line labels or a leading dot indicator): +/// +/// new CustomTab(selected: tab == 0, onClick: () => tab.Value = 0) +/// { +/// new Column { new Text("Inbox"), new Text("5 unread") }, +/// } +/// +/// Children are stacked vertically inside the tab's ColumnScope. +/// +public sealed partial class CustomTab; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePicker.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePicker.cs new file mode 100644 index 000000000..5254aadbb --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePicker.cs @@ -0,0 +1,17 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 DatePicker. Place inside 's +/// body. Pass an explicit to +/// read the selection from a button callback; if none is supplied a +/// fresh state is created internally and the selection is unobservable. +/// +/// +/// The generated Render() calls +/// ComposeBridges.RememberDatePickerState to obtain the JVM +/// state handle, populates the wrapper's Jvm field on first +/// render (so subsequent property reads on the wrapper hit the live +/// state), and forwards the handle into the +/// androidx.compose.material3.DatePickerKt.DatePicker composable. +/// +public sealed partial class DatePicker; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePickerDialog.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePickerDialog.cs new file mode 100644 index 000000000..d7bc9788c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePickerDialog.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 DatePickerDialog. The DatePickerState +/// builder (rememberDatePickerState-EU0dCGE) is mangled and +/// stripped from the binding, so we resolve it through JNI inside the +/// facade. Place a +/// (or any composable) inside Body. +/// +public sealed partial class DatePickerDialog; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePickerState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePickerState.cs new file mode 100644 index 000000000..2a1f2b49e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DatePickerState.cs @@ -0,0 +1,152 @@ +using AndroidX.Compose.Material3; +using Kotlin.Ranges; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for / +/// . The underlying JVM +/// androidx.compose.material3.DatePickerState is created lazily +/// the first time a bound to this state is +/// rendered; reads/writes to before +/// that point are no-ops/fallbacks. +/// +/// +/// Typical usage — Remember a state instance, pass it to a +/// , and read the picked value from the +/// dialog's ConfirmButton.OnClick: +/// +/// var pickerState = Remember(() => new DatePickerState()); +/// +/// new DatePickerDialog(onDismissRequest: ...) +/// { +/// ConfirmButton = new Button(onClick: () => +/// { +/// var ms = pickerState.SelectedDateMillis; +/// // ms is the picked date as Unix epoch milliseconds (UTC). +/// }) +/// { new Text("OK") }, +/// Body = new DatePicker(pickerState), +/// } +/// +/// +/// The constructor's optional parameters seed the underlying state on +/// first composition (via the Phase 4b RememberDatePickerState +/// bridge). They're read once when the JVM state is first allocated; +/// mutating / +/// / +/// after binding has no effect unless the host re-keys the surrounding +/// composer.Remember(...) to force a fresh allocation. +/// +public sealed class DatePickerState +{ + internal IDatePickerState? Jvm; + + /// + /// Constructs an empty state holder. The underlying JVM + /// DatePickerState is allocated on first + /// render with all values left at Kotlin's defaults. + /// + public DatePickerState() + { + } + + /// + /// Constructs a state holder seeded with the supplied initial values. + /// All parameters are optional — passing null keeps Kotlin's + /// default for that slot (auto-default-mask leaves the matching + /// $default bit set). + /// + /// Initial selection as Unix + /// epoch milliseconds (UTC), or null for "no initial selection". + /// Inclusive range of selectable years + /// shown in the year-grid. null keeps Kotlin's default + /// (1900–2100). + /// Per-day enable/disable + /// adapter. null keeps Kotlin's default (every date + /// selectable). + public DatePickerState( + long? initialSelectedDateMillis = null, + IntRange? initialYearRange = null, + ISelectableDates? initialSelectableDates = null) + { + if (initialSelectedDateMillis is long ms) + InitialSelectedDateMillis = Java.Lang.Long.ValueOf(ms); + InitialYearRange = initialYearRange; + InitialSelectableDates = initialSelectableDates; + } + + /// + /// Initial selection as a boxed + /// (Kotlin's Long?). Read by the Phase 4b + /// RememberDatePickerState bridge on first composition; + /// mutating after binding has no effect (the live value lives in + /// ). Most callers should use the + /// long? constructor parameter instead of touching this + /// directly. + /// + public Java.Lang.Long? InitialSelectedDateMillis { get; set; } + + /// + /// First-of-month milliseconds for the initial month shown by the + /// picker. Kotlin's initialDisplayedMonthMillis slot — + /// usually left null so Kotlin defaults to the month + /// containing . + /// + public Java.Lang.Long? InitialDisplayedMonthMillis { get; set; } + + /// + /// Inclusive year range for the year-grid view. Read once by the + /// Phase 4b RememberDatePickerState bridge on first + /// composition. + /// + public IntRange? InitialYearRange { get; set; } + + /// + /// Initial display mode (DatePicker or Input). Maps + /// to Kotlin's DisplayMode packed-int enum. null uses + /// Kotlin's default (calendar mode). + /// + public int? InitialDisplayMode { get; set; } + + /// + /// Per-day / per-year enable/disable adapter. Read once by the + /// Phase 4b RememberDatePickerState bridge on first + /// composition. The adapter itself can mutate state — Kotlin + /// re-invokes isSelectableDate / isSelectableYear on + /// every grid render — but the adapter instance reference + /// participates in remember's key, so callers must hold a + /// stable reference (e.g. allocate one adapter per host instance + /// as a readonly field, mirroring the Phase 10 + /// ConfirmStateChange pattern). + /// + public ISelectableDates? InitialSelectableDates { get; set; } + + /// + /// The currently selected date as Unix epoch milliseconds (UTC), or + /// null if no date is selected. Mirrors Kotlin's + /// DatePickerState.selectedDateMillis: Long?. Returns + /// null until the first render binds + /// this state to the JVM picker. + /// + public long? SelectedDateMillis + { + get => Jvm?.SelectedDateMillis?.LongValue(); + set + { + if (Jvm is not null) + Jvm.SelectedDateMillis = value is long ms ? Java.Lang.Long.ValueOf(ms) : null; + } + } + + /// + /// First-of-month milliseconds for the month currently shown by the + /// picker. Mirrors Kotlin's DatePickerState.displayedMonthMillis. + /// Returns 0 until the state is bound. + /// + public long DisplayedMonthMillis + { + get => Jvm?.DisplayedMonthMillis ?? 0L; + set { if (Jvm is not null) Jvm.DisplayedMonthMillis = value; } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePicker.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePicker.cs new file mode 100644 index 000000000..b9778344c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePicker.cs @@ -0,0 +1,20 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 DateRangePicker. Place inside +/// 's body. Pass an explicit +/// to read the picked +/// range from a button callback; if none is supplied a fresh state is +/// created internally and the selection is unobservable. Same shape as +/// — see that facade for the broader pattern. +/// +/// +/// The generated Render() calls +/// ComposeBridges.RememberDateRangePickerState to obtain the JVM +/// state handle, populates the wrapper's Jvm field on first +/// render (so subsequent property reads on the wrapper hit the live +/// state), and forwards the handle into the +/// androidx.compose.material3.DateRangePickerKt.DateRangePicker +/// composable. +/// +public sealed partial class DateRangePicker; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePickerDialog.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePickerDialog.cs new file mode 100644 index 000000000..d0cceeb6f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePickerDialog.cs @@ -0,0 +1,11 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 dialog host for a . The +/// underlying Kotlin composable is the same +/// DatePickerDialog_androidKt.DatePickerDialog used by the +/// single-date ; only the body changes. +/// Place a (or any composable) inside +/// Body. +/// +public sealed partial class DateRangePickerDialog; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePickerState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePickerState.cs new file mode 100644 index 000000000..eac5c71b2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangePickerState.cs @@ -0,0 +1,89 @@ +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for . The +/// underlying JVM androidx.compose.material3.DateRangePickerState +/// is created lazily the first time a +/// bound to this state is rendered; before that point reads from +/// / +/// return null and calls to +/// are no-ops. +/// +/// +/// Typical usage — Remember a state instance, pass it to a +/// , and read the picked range from the +/// dialog's ConfirmButton.OnClick: +/// +/// var rangeState = Remember(() => new DateRangePickerState()); +/// +/// new DatePickerDialog(onDismissRequest: ...) +/// { +/// ConfirmButton = new Button(onClick: () => +/// { +/// var start = rangeState.SelectedStartDateMillis; +/// var end = rangeState.SelectedEndDateMillis; +/// // start/end are Unix epoch milliseconds (UTC), nullable. +/// }) +/// { new Text("OK") }, +/// Body = new DateRangePicker(rangeState), +/// } +/// +/// +public sealed class DateRangePickerState +{ + internal IDateRangePickerState? Jvm; + + /// + /// The start of the currently selected range as Unix epoch + /// milliseconds (UTC), or null if no start date is selected. + /// Mirrors Kotlin's DateRangePickerState.selectedStartDateMillis: Long?. + /// Returns null until the first + /// render binds this state to the JVM picker. Setting either end of + /// the range goes through the JVM setSelection helper to keep + /// the start/end pair consistent. + /// + public long? SelectedStartDateMillis + { + get => Jvm?.SelectedStartDateMillis?.LongValue(); + set => SetSelection(value, SelectedEndDateMillis); + } + + /// + /// The end of the currently selected range as Unix epoch + /// milliseconds (UTC), or null if no end date is selected. + /// Mirrors Kotlin's DateRangePickerState.selectedEndDateMillis: Long?. + /// Returns null until the state is bound. + /// + public long? SelectedEndDateMillis + { + get => Jvm?.SelectedEndDateMillis?.LongValue(); + set => SetSelection(SelectedStartDateMillis, value); + } + + /// + /// First-of-month milliseconds for the month currently shown by the + /// picker. Mirrors Kotlin's DateRangePickerState.displayedMonthMillis. + /// Returns 0 until the state is bound. + /// + public long DisplayedMonthMillis + { + get => Jvm?.DisplayedMonthMillis ?? 0L; + set { if (Jvm is not null) Jvm.DisplayedMonthMillis = value; } + } + + /// + /// Sets both ends of the selected range in a single JVM round-trip. + /// Use this when both endpoints change at once to avoid the + /// intermediate "start without end" / "end without start" state the + /// individual property setters would otherwise produce. + /// + public void SetSelection(long? startDateMillis, long? endDateMillis) + { + if (Jvm is null) return; + var start = startDateMillis is long s ? Java.Lang.Long.ValueOf(s) : null; + var end = endDateMillis is long e ? Java.Lang.Long.ValueOf(e) : null; + Jvm.SetSelection(start, end); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangeSelectableDates.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangeSelectableDates.cs new file mode 100644 index 000000000..f2adcf552 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DateRangeSelectableDates.cs @@ -0,0 +1,72 @@ +using Android.Runtime; +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +/// +/// SelectableDates adapter that gates calendar dates and years +/// against an inclusive / +/// window plus an inclusive +/// / window. Designed to be +/// allocated once per host instance as a +/// readonly field — the instance reference is part of the +/// RememberDatePickerState cache key, so re-allocating per +/// composition would invalidate the picker's cached state. Mutating +/// the bounds at any time is cheap; Kotlin re-invokes +/// / +/// on every grid render. +/// +/// +/// All four bounds are nullable. null means "no bound on that +/// side" — e.g. only setting blocks past +/// dates while leaving the future open. This mirrors the pattern used +/// by Phase 10's DrawerValueConfirmStateChange: a per-instance +/// JCW that keeps stable JNI identity across recompositions while the +/// managed-side data behind it is free to change. +/// +[Register("net/compose/DateRangeSelectableDates")] +public sealed class DateRangeSelectableDates : Java.Lang.Object, ISelectableDates +{ + /// + /// Inclusive lower bound on the selectable date in Unix epoch + /// milliseconds (UTC). null = no lower bound. + /// + public long? MinUtcMillis { get; set; } + + /// + /// Inclusive upper bound on the selectable date in Unix epoch + /// milliseconds (UTC). null = no upper bound. + /// + public long? MaxUtcMillis { get; set; } + + /// + /// Inclusive lower bound for selectable years in the year-grid + /// view. null = no lower bound. + /// + public int? MinYear { get; set; } + + /// + /// Inclusive upper bound for selectable years in the year-grid + /// view. null = no upper bound. + /// + public int? MaxYear { get; set; } + + /// + /// Returns true when falls + /// within .. + /// inclusive. Either bound being null disables that side of + /// the comparison. + /// + public bool IsSelectableDate(long utcTimeMillis) => + (MinUtcMillis is not long mn || utcTimeMillis >= mn) && + (MaxUtcMillis is not long mx || utcTimeMillis <= mx); + + /// + /// Returns true when falls within + /// .. inclusive. Either + /// bound being null disables that side of the comparison. + /// + public bool IsSelectableYear(int year) => + (MinYear is not int mn || year >= mn) && + (MaxYear is not int mx || year <= mx); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DerivedState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DerivedState.cs new file mode 100644 index 000000000..94ae62856 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DerivedState.cs @@ -0,0 +1,51 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Read-only state computed from other state, the C# parity for +/// Kotlin's derivedStateOf { … }. Wraps Compose's +/// IState (returned by +/// SnapshotStateKt.DerivedStateOf(IFunction0)) so the value +/// can be read from any composition that holds the instance — +/// reading subscribes the calling composition +/// scope, and any change to the underlying state read inside the +/// calculation triggers a recomposition of those scopes. +/// +/// +/// var name = Remember(() => new MutableState<string>("Ada")); +/// var greet = Remember(() => ComposeExtensions.DerivedStateOf(() => $"Hi, {name.Value}!")); +/// // In a Render body: +/// new Text(greet.Value); // recomposes when name.Value changes +/// +/// +/// The calculation runs lazily and is memoised by Compose between +/// reads — only re-runs when one of the state values it read changes. +/// +/// +/// Use to +/// build instances; the constructor is internal because the wrapped +/// IState must come from a Kotlin derivedStateOf call +/// to participate correctly in Compose's snapshot system. +/// +public sealed class DerivedState : IState +{ + readonly IState _state; + + internal DerivedState(IState state) => _state = state; + + /// + /// The current derived value, recomputed lazily by Compose when + /// any of the state values read inside the calculation change. + /// Reading from a composition scope subscribes that scope so it + /// recomposes when this value changes. + /// + public T Value => MutableState.FromJava(_state.Value); + + /// + /// Returns the underlying value's string representation so + /// $"...{state}..." interpolation reads as Kotlin would + /// (a null value renders as "null"). + /// + public override string ToString() => Value?.ToString() ?? "null"; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisableSelection.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisableSelection.cs new file mode 100644 index 000000000..d2643184e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisableSelection.cs @@ -0,0 +1,18 @@ +namespace AndroidX.Compose; + +/// +/// Foundation DisableSelection — opts a subtree out of an +/// enclosing . Use this for content +/// inside a selectable region that should remain non-selectable +/// (e.g. action labels or chrome embedded in a body of selectable +/// text). +/// +/// new SelectionContainer +/// { +/// new Text("Selectable body."), +/// new DisableSelection { new Text("Not selectable.") }, +/// } +/// +/// Has no effect when used outside a . +/// +public sealed partial class DisableSelection; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DismissibleDrawerSheet.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DismissibleDrawerSheet.cs new file mode 100644 index 000000000..61a53d280 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DismissibleDrawerSheet.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 DismissibleDrawerSheet — the panel shown by a +/// dismissible-style navigation drawer. ContainerColor +/// defaults to 0L, which falls back to the active theme's +/// surface — matches the upstream Kotlin default +/// (DrawerDefaults.standardContainerColor). +/// +public sealed partial class DismissibleDrawerSheet; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DismissibleNavigationDrawer.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DismissibleNavigationDrawer.cs new file mode 100644 index 000000000..cc67a25e3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DismissibleNavigationDrawer.cs @@ -0,0 +1,31 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 DismissibleNavigationDrawer — a drawer that +/// pushes the main content aside when open (no scrim, no overlay). +/// The Drawer slot typically holds a +/// . Drag horizontally to toggle. +/// Behavior mirrors for the state +/// holder and ConfirmStateChange veto. +/// +public sealed partial class DismissibleNavigationDrawer +{ + /// + /// Convenience: starting + /// on first composition. Mirrors Kotlin + /// rememberDrawerState(initialValue = DrawerValue.Open). + /// + /// + /// Setting this constructs a default + /// with the requested initial value. If the caller also passes an + /// explicit holder to the constructor, the init setter overwrites + /// it — pass the holder directly (with the desired + /// InitialValue) when you need to share state across + /// recompositions. + /// + public AndroidX.Compose.Material3.DrawerValue? InitialValue + { + get => _drawerState?.InitialValue; + init => _drawerState = new DrawerStateHolder(value); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisposableEffect.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisposableEffect.cs new file mode 100644 index 000000000..04c384582 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisposableEffect.cs @@ -0,0 +1,67 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Tree-syntax wrapper around +/// . +/// Re-runs Effect on first composition and any time +/// Key1 / Key2 / Key3 changes; calls the cleanup +/// on key change or when this node leaves +/// the composition. +/// +/// +/// +/// new DisposableEffect(_sensorId, scope => +/// { +/// var registration = SensorRegistry.Subscribe(_sensorId, OnSensorTick); +/// return () => registration.Dispose(); +/// }) +/// +/// +public sealed class DisposableEffect : ComposableNode +{ + readonly object? _key1, _key2, _key3; + readonly int _keyCount; + readonly Func _effect; + + /// Single-key form. + public DisposableEffect( + object? key1, + Func effect) + { + ArgumentNullException.ThrowIfNull(effect); + _key1 = key1; _keyCount = 1; _effect = effect; + } + + /// Two-key form. + public DisposableEffect( + object? key1, + object? key2, + Func effect) + { + ArgumentNullException.ThrowIfNull(effect); + _key1 = key1; _key2 = key2; _keyCount = 2; _effect = effect; + } + + /// Three-key form. + public DisposableEffect( + object? key1, + object? key2, + object? key3, + Func effect) + { + ArgumentNullException.ThrowIfNull(effect); + _key1 = key1; _key2 = key2; _key3 = key3; _keyCount = 3; _effect = effect; + } + + public override void Render(IComposer composer) + { + switch (_keyCount) + { + case 1: composer.DisposableEffect(_key1, _effect); break; + case 2: composer.DisposableEffect(_key1, _key2, _effect); break; + default: composer.DisposableEffect(_key1, _key2, _key3, _effect); break; + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisposableEffectBody.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisposableEffectBody.cs new file mode 100644 index 000000000..6b6a3aad8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DisposableEffectBody.cs @@ -0,0 +1,64 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW for Compose's DisposableEffect body — +/// Function1<DisposableEffectScope, DisposableEffectResult>. +/// Kotlin invokes the body once per (key change | enter composition) +/// and stores the returned ; when +/// keys change or the call site leaves the composition Compose calls +/// on the stored result. +/// +/// +/// The C# body returns a "onDispose" +/// callback. We wrap that callback in a fresh +/// and hand it to +/// , which +/// is the only way to construct an +/// without subclassing the interface in Java. +/// +[Register("net/compose/DisposableEffectBody")] +internal sealed class DisposableEffectBody : Java.Lang.Object, IFunction1 +{ + readonly Func _body; + + public DisposableEffectBody(Func body) + { + _body = body; + } + + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + // Compose always passes a non-null DisposableEffectScope (the + // singleton `InternalDisposableEffectScope`). Use JavaCast + // rather than `p0 as DisposableEffectScope` because Mono.Android's + // peer cache doesn't know Kotlin's `internal object` subclasses + // implement the bound interface — a plain `as` returns null even + // when the underlying Java object does implement the interface. + if (p0 is null) + throw new InvalidOperationException( + "DisposableEffect body received a null DisposableEffectScope"); + + DisposableEffectScope scope; + try + { + scope = p0.JavaCast(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + "DisposableEffect body could not project arg (" + + (p0.Class?.Name ?? "") + + ") as DisposableEffectScope", ex); + } + + var onDispose = _body(scope) + ?? throw new InvalidOperationException( + "DisposableEffect body returned a null onDispose callback. " + + "Return `() => { }` if there's nothing to clean up."); + return (Java.Lang.Object)scope.OnDispose(new ComposableLambda0(onDispose)); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DockedSearchBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DockedSearchBar.cs new file mode 100644 index 000000000..288185b47 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DockedSearchBar.cs @@ -0,0 +1,166 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 DockedSearchBar. Two deprecated overloads share this +/// class: +/// +/// +/// — the +/// boolean-state variant (M3 1.2+ form: caller supplies an +/// slot and toggles expanded). +/// +/// +/// +/// — the even older query-based variant (M3 1.0 form: the input +/// field is built into the composable; caller supplies +/// , , and +/// slots). +/// +/// +/// +/// +/// The Material 3 1.4 design replaces both variants with the state-based +/// + pair +/// (sharing a ); use that pair for new code. +/// Both overloads here remain because compose-samples ports (e.g. +/// the Reply sample) still bind against the deprecated APIs. +/// +/// Collection-initialized children render as the docked-popup result list +/// inside the bar's content lambda for both overloads (the +/// underlying Kotlin lambda receives a ColumnScope). +/// +[Obsolete("Use the state-based SearchBar + ExpandedDockedSearchBar pair instead.")] +public sealed class DockedSearchBar : ComposableContainer +{ + readonly bool _isQueryBased; + + // Boolean-state (M3 1.2+) ctor. + readonly bool _expanded; + readonly Action? _onExpandedChange; + + // Query-based (M3 1.0) ctor. + readonly string? _query; + readonly Action? _onQueryChange; + readonly Action? _onSearch; + readonly bool _active; + readonly Action? _onActiveChange; + + /// + /// Constructs the boolean-state variant. The caller toggles + /// in response to + /// and supplies an + /// slot. + /// + public DockedSearchBar(bool expanded, Action onExpandedChange) + { + _expanded = expanded; + _onExpandedChange = onExpandedChange; + } + + /// + /// Constructs the query-based variant. The bar renders its own input + /// field driven by / + /// ; tapping it toggles + /// via , + /// and pressing IME-Search fires . + /// Decorate the field with , + /// , and . + /// + public DockedSearchBar( + string query, + Action onQueryChange, + Action onSearch, + bool active, + Action onActiveChange) + { + _isQueryBased = true; + _query = query; + _onQueryChange = onQueryChange; + _onSearch = onSearch; + _active = active; + _onActiveChange = onActiveChange; + } + + /// + /// Boolean-state variant: required composable that renders the + /// search input field at the top of the bar. + /// + public ComposableNode? InputField { get; set; } + + /// + /// Query-based variant: optional leading-icon slot (e.g. a search + /// glyph) shown at the start of the built-in input field. + /// + public ComposableNode? LeadingIcon { get; set; } + + /// + /// Query-based variant: optional trailing-icon slot (e.g. a clear + /// or close button) shown at the end of the built-in input field. + /// + public ComposableNode? TrailingIcon { get; set; } + + /// + /// Query-based variant: optional placeholder shown inside the + /// built-in input field while query is empty. + /// + public ComposableNode? Placeholder { get; set; } + + /// + public override void Render(IComposer composer) + { + if (_isQueryBased) + RenderQueryBased(composer); + else + RenderBooleanState(composer); + } + + void RenderBooleanState(IComposer composer) + { + if (InputField is null) + throw new InvalidOperationException( + "DockedSearchBar.InputField is required (the Kotlin parameter has no default)."); + + var inputField = ComposableLambdas.Wrap2(composer, c => InputField.Render(c)); + var onExpandedChange = new ComposableLambda1(o => + _onExpandedChange!(((Java.Lang.Boolean)o!).BooleanValue())); + var content = ComposableLambdas.Wrap3(composer, c => RenderChildren(c)); + + ComposeBridges.DockedSearchBar( + inputField: inputField, + expanded: _expanded, + onExpandedChange: onExpandedChange, + modifier: BuildModifier(), + content: content, + composer: composer); + } + + void RenderQueryBased(IComposer composer) + { + var onQueryChange = new ComposableLambda1(o => + _onQueryChange!(o?.ToString() ?? string.Empty)); + var onSearch = new ComposableLambda1(o => + _onSearch!(o?.ToString() ?? string.Empty)); + var onActiveChange = new ComposableLambda1(o => + _onActiveChange!(((Java.Lang.Boolean)o!).BooleanValue())); + + var placeholder = Placeholder is null ? null : ComposableLambdas.Wrap2(composer, c => Placeholder.Render(c)); + var leadingIcon = LeadingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => LeadingIcon.Render(c)); + var trailingIcon = TrailingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => TrailingIcon.Render(c)); + var content = ComposableLambdas.Wrap3(composer, c => RenderChildren(c)); + + ComposeBridges.DockedSearchBarWithQuery( + query: _query!, + onQueryChange: onQueryChange, + onSearch: onSearch, + active: _active, + onActiveChange: onActiveChange, + modifier: BuildModifier(), + placeholder: placeholder, + leadingIcon: leadingIcon, + trailingIcon: trailingIcon, + content: content, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Dp.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Dp.cs new file mode 100644 index 000000000..327a9bf2e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Dp.cs @@ -0,0 +1,105 @@ +namespace AndroidX.Compose; + +/// +/// Density-independent pixel — C# mirror of Kotlin's +/// androidx.compose.ui.unit.Dp (@JvmInline value class Dp(val value: Float)). +/// +/// +/// +/// Compose's Dp is an inline value class around a Float; the +/// wire representation crossing JNI is just the underlying . +/// This struct preserves that wire shape while giving the C# facade a typed +/// surface to accept density-independent values in. +/// +/// +/// Implicit conversions from and mean +/// existing call sites like Modifier.Padding(16) continue to +/// compile unchanged. To explicitly construct a Dp, use the +/// constructor or one of the conversion operators. +/// +/// +public readonly struct Dp : IEquatable, IComparable +{ + /// The underlying density-independent pixel count. + public float Value { get; } + + /// Construct a from the underlying float value. + public Dp(float value) + { + Value = value; + } + + /// The zero-dp constant (equivalent to 0.dp in Kotlin). + public static Dp Zero => new(AndroidX.Compose.UI.Unit.DpKt.GetDp(0)); + + /// Implicit conversion from for ergonomic call sites. + public static implicit operator Dp(int value) => new Dp(value); + + /// Implicit conversion from for ergonomic call sites. + public static implicit operator Dp(float value) => new Dp(value); + + /// + public bool Equals(Dp other) => Value.Equals(other.Value); + + /// + public override bool Equals(object? obj) => obj is Dp dp && Equals(dp); + + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + public int CompareTo(Dp other) => Value.CompareTo(other.Value); + + /// Equality operator. + public static bool operator ==(Dp left, Dp right) => left.Equals(right); + + /// Inequality operator. + public static bool operator !=(Dp left, Dp right) => !left.Equals(right); + + /// Add two values. + public static Dp operator +(Dp a, Dp b) => new(a.Value + b.Value); + + /// Subtract one value from another. + public static Dp operator -(Dp a, Dp b) => new(a.Value - b.Value); + + /// Scale a value by a scalar factor. + public static Dp operator *(Dp a, float scalar) => new(a.Value * scalar); + + /// Scale a value by a scalar factor. + public static Dp operator *(float scalar, Dp a) => new(a.Value * scalar); + + /// Divide a value by a scalar factor. + public static Dp operator /(Dp a, float scalar) => new(a.Value / scalar); + + /// + /// Divide one by another and get the dimensionless ratio + /// (mirrors Kotlin's Dp.div(Dp): Float). + /// + public static float operator /(Dp a, Dp b) => a.Value / b.Value; + + /// Negate a value. + public static Dp operator -(Dp a) => new(-a.Value); + + /// Less-than comparison. + public static bool operator <(Dp a, Dp b) => a.Value < b.Value; + + /// Greater-than comparison. + public static bool operator >(Dp a, Dp b) => a.Value > b.Value; + + /// Less-than-or-equal comparison. + public static bool operator <=(Dp a, Dp b) => a.Value <= b.Value; + + /// Greater-than-or-equal comparison. + public static bool operator >=(Dp a, Dp b) => a.Value >= b.Value; + + /// + public override string ToString() => $"{Value}.dp"; + + /// + /// Pack a nullable into the raw float the + /// JNI slot expects. null -> 0f, which the auto-mask + /// in the bridge generator pairs with leaving the matching + /// $default bit set so Kotlin substitutes its real default. + /// + public static float Pack(Dp? value) => value?.Value ?? 0f; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DpExtensions.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DpExtensions.cs new file mode 100644 index 000000000..5d97960ee --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DpExtensions.cs @@ -0,0 +1,32 @@ +namespace AndroidX.Compose; + +/// +/// Extension methods that let you write 8.Dp() / 16f.Dp() as +/// the C# counterpart of Kotlin's 8.dp / 16f.dp property +/// syntax, producing a typed from any numeric literal or +/// variable without going through the implicit conversion. +/// +/// +/// The implicit / conversions on +/// already cover passing a literal directly into a typed +/// parameter. These extensions are the right choice when +/// you need an *explicit* on the right-hand side — e.g. +/// hoisting a layout constant into a typed local (var pad = 8.Dp();), +/// participating in arithmetic (start + 8.Dp()), or disambiguating +/// the new typed Arrangement.SpacedBy(Dp) overload from the +/// pre-existing SpacedBy(int). +/// +public static class DpExtensions +{ + /// + /// Convert an count of density-independent pixels into + /// a typed . Equivalent to Kotlin's Int.dp. + /// + public static Dp Dp(this int value) => new(value); + + /// + /// Convert a count of density-independent pixels into + /// a typed . Equivalent to Kotlin's Float.dp. + /// + public static Dp Dp(this float value) => new(value); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragAndDropEvent.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragAndDropEvent.cs new file mode 100644 index 000000000..cac3965f8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragAndDropEvent.cs @@ -0,0 +1,58 @@ +using Android.Views; + +namespace AndroidX.Compose; + +/// +/// Wrapper around Compose's androidx.compose.ui.draganddrop.DragAndDropEvent +/// — the payload passed to and to the +/// shouldStartDragAndDrop predicate on +/// . +/// +/// exposes the underlying +/// so callers can read ClipData, ClipDescription, and the drop +/// position via the regular Android API. is a quick +/// accessor for the MIME types advertised by the source app — useful for the +/// gating predicate (e.g. event.MimeTypes.Any(m => m.StartsWith("image/"))). +/// +public sealed class DragAndDropEvent +{ + internal AndroidX.Compose.UI.Draganddrop.DragAndDropEvent Jvm { get; } + + internal DragAndDropEvent(AndroidX.Compose.UI.Draganddrop.DragAndDropEvent jvm) + { + ArgumentNullException.ThrowIfNull(jvm); + Jvm = jvm; + } + + /// + /// The Android backing this Compose drag event. + /// Use it to read , + /// , the action code, and the + /// drop position the same way you would in a classic + /// View.IOnDragListener. + /// + public DragEvent AndroidDragEvent => + AndroidX.Compose.UI.Draganddrop.DragAndDrop_androidKt.ToAndroidDragEvent(Jvm); + + /// + /// MIME types advertised by the drag source via its + /// . Returns an empty list + /// when the source carries no MIME metadata. + /// + public IReadOnlyList MimeTypes + { + get + { + var desc = AndroidDragEvent.ClipDescription; + if (desc is null) + return []; + int count = desc.MimeTypeCount; + if (count <= 0) + return []; + var arr = new string[count]; + for (int i = 0; i < count; i++) + arr[i] = desc.GetMimeType(i) ?? ""; + return arr; + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragAndDropTarget.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragAndDropTarget.cs new file mode 100644 index 000000000..572b80a12 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragAndDropTarget.cs @@ -0,0 +1,115 @@ +using Android.Runtime; +using AndroidX.Compose.UI.Draganddrop; + +namespace AndroidX.Compose; + +/// +/// androidx.compose.ui.draganddrop.DragAndDropTarget adapter — the +/// receiver of drag-and-drop callbacks installed by +/// . +/// Mirrors Kotlin's DragAndDropTarget interface as a delegate-bag: +/// processes the dropped payload (return true +/// to consume); the other six callbacks (, +/// , , , +/// , ) are typically used for +/// hover-state visuals — leave them null to inherit Kotlin's +/// default no-op behaviour. +/// +/// The Java peer is created once per managed instance, so +/// the target +/// to keep the modifier-element identity stable across recompositions — +/// otherwise Compose rebuilds the underlying DragAndDropTargetElement +/// every frame and any internal hover/started/ended bookkeeping resets. +/// Delegates may be reassigned at any time; the new delegate is observed +/// on the next event. +/// +[Register("net/compose/DragAndDropTarget")] +public sealed class DragAndDropTarget : Java.Lang.Object, IDragAndDropTarget +{ + /// + /// Delegate invoked when a drag is dropped onto the modified composable. + /// Return true to mark the drop as consumed (matches Kotlin + /// onDrop's contract). When null the target reports the + /// drop as unhandled and the system propagates it to the next eligible + /// listener. + /// + public Func? OnDrop { get; set; } + + /// + /// Invoked once when a drag-and-drop session that this target opted + /// into via shouldStartDragAndDrop begins. A common use is to + /// flip a "drag in progress" state-holder so siblings can reveal + /// affordances. Mirrors Kotlin onStarted. + /// + public Action? OnStarted { get; set; } + + /// + /// Invoked when the dragged item enters this target's bounds — the + /// canonical place to highlight the drop zone. Mirrors Kotlin + /// onEntered. + /// + public Action? OnEntered { get; set; } + + /// + /// Invoked while the dragged item moves over this target. Mirrors + /// Kotlin onMoved. + /// + public Action? OnMoved { get; set; } + + /// + /// Invoked when the source app modifies the drag's ClipData or + /// ClipDescription mid-session (e.g. switches MIME types). + /// Mirrors Kotlin onChanged. + /// + public Action? OnChanged { get; set; } + + /// + /// Invoked when the dragged item leaves this target's bounds — the + /// counterpart to ; clear any hover highlight + /// here. Mirrors Kotlin onExited. + /// + public Action? OnExited { get; set; } + + /// + /// Invoked once when the drag-and-drop session ends — fired regardless + /// of whether the drop happened on this target. Use it to reset any + /// session-scoped state set in . Mirrors Kotlin + /// onEnded. + /// + public Action? OnEnded { get; set; } + + /// Construct an unwired target. Assign at least before installing it on a modifier chain. + public DragAndDropTarget() { } + + /// Convenience ctor that wires in one expression. + public DragAndDropTarget(Func onDrop) + { + OnDrop = onDrop; + } + + bool IDragAndDropTarget.OnDrop(AndroidX.Compose.UI.Draganddrop.DragAndDropEvent p0) + { + var cb = OnDrop; + if (cb is null) + return false; + return cb(new DragAndDropEvent(p0)); + } + + void IDragAndDropTarget.OnStarted(AndroidX.Compose.UI.Draganddrop.DragAndDropEvent p0) => + OnStarted?.Invoke(new DragAndDropEvent(p0)); + + void IDragAndDropTarget.OnEntered(AndroidX.Compose.UI.Draganddrop.DragAndDropEvent p0) => + OnEntered?.Invoke(new DragAndDropEvent(p0)); + + void IDragAndDropTarget.OnMoved(AndroidX.Compose.UI.Draganddrop.DragAndDropEvent p0) => + OnMoved?.Invoke(new DragAndDropEvent(p0)); + + void IDragAndDropTarget.OnChanged(AndroidX.Compose.UI.Draganddrop.DragAndDropEvent p0) => + OnChanged?.Invoke(new DragAndDropEvent(p0)); + + void IDragAndDropTarget.OnExited(AndroidX.Compose.UI.Draganddrop.DragAndDropEvent p0) => + OnExited?.Invoke(new DragAndDropEvent(p0)); + + void IDragAndDropTarget.OnEnded(AndroidX.Compose.UI.Draganddrop.DragAndDropEvent p0) => + OnEnded?.Invoke(new DragAndDropEvent(p0)); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragCallback.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragCallback.cs new file mode 100644 index 000000000..5a4b45fb8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragCallback.cs @@ -0,0 +1,56 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW adapter implementing Function2<PointerInputChange, Offset, +/// Unit> for the onDrag callback of +/// detectDragGestures. The Kotlin signature passes the live +/// PointerInputChange as p0 (which carries pointer id, +/// position, pressure, etc.) and the per-frame drag delta as a +/// @JvmInline value class Offset wrapper in p1. +/// +/// +/// +/// v1 only surfaces the drag delta — PointerInputChange is +/// passed through opaquely (the C# binding doesn't model it) and +/// the user's body receives just the +/// per-frame delta in local layout pixels. To accumulate totals the +/// caller maintains its own running sum across onDragStart +/// / onDrag / onDragEnd. +/// +/// +/// The boxed Offset argument (p1) is unboxed via the +/// shared helper's +/// unbox-impl()J lookup pattern. +/// +/// +[Register("net/compose/DragCallback")] +internal sealed class DragCallback : Java.Lang.Object, IFunction2 +{ + static IntPtr s_unbox; + + readonly Action _body; + + public DragCallback(Action body) => _body = body; + + public Java.Lang.Object Invoke(Java.Lang.Object? p0, Java.Lang.Object? p1) + { + long packed = 0L; + if (p1 is not null) + { + EnsureUnboxMethod(); + packed = JNIEnv.CallLongMethod(p1.Handle, s_unbox); + } + _body(Offset.FromPacked(packed)); + return Kotlin.Unit.Instance!; + } + + static void EnsureUnboxMethod() + { + if (s_unbox != IntPtr.Zero) return; + var cls = JNIEnv.FindClass("androidx/compose/ui/geometry/Offset"); + s_unbox = JNIEnv.GetMethodID(cls, "unbox-impl", "()J"); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragGestureBlock.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragGestureBlock.cs new file mode 100644 index 000000000..806c78061 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DragGestureBlock.cs @@ -0,0 +1,135 @@ +using Android.Runtime; +using Kotlin.Coroutines; +using Kotlin.Coroutines.Intrinsics; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW exposing a that Compose's +/// pointerInput(key, handler) invokes inside the gesture-detector +/// coroutine, then forwards to detectDragGestures. Mirrors the +/// shape of but routes to the +/// drag-gesture suspend bridge. +/// +/// +/// +/// Tail-call semantics. Inside we +/// call with the OUTER +/// continuation Kotlin handed us. The raw result — +/// COROUTINE_SUSPENDED, kotlin.Unit, or a +/// kotlin.Result.Failure — is returned verbatim. Kotlin +/// cancels the outer coroutine when the modifier is removed or the +/// pointer-input key changes; detectDragGestures' next suspend +/// point throws CancellationException, which propagates +/// through the continuation we forwarded. +/// +/// +/// Callback freshness. The four +/// onDragStart / onDragEnd / onDragCancel / +/// onDrag JCW instances are captured at construction time; +/// updating the C# delegates and rebuilding the modifier won't +/// restart this coroutine unless the caller varies the wrapping +/// pointerInput key. See for +/// the same caveat with detectTapGestures. +/// +/// +[Register("net/compose/DragGestureBlock")] +internal sealed class DragGestureBlock : Java.Lang.Object, IFunction2 +{ + static IntPtr s_suspendedHandle; + + readonly OffsetCallback? _onDragStart; + readonly UnitCallback? _onDragEnd; + readonly UnitCallback? _onDragCancel; + readonly DragCallback _onDrag; + // When true, forward to detectDragGesturesAfterLongPress (drag is only + // recognised after the long-press timeout) instead of detectDragGestures. + readonly bool _afterLongPress; + + public DragGestureBlock( + OffsetCallback? onDragStart, + UnitCallback? onDragEnd, + UnitCallback? onDragCancel, + DragCallback onDrag, + bool afterLongPress = false) + { + _onDragStart = onDragStart; + _onDragEnd = onDragEnd; + _onDragCancel = onDragCancel; + _onDrag = onDrag; + _afterLongPress = afterLongPress; + } + + public Java.Lang.Object? Invoke(Java.Lang.Object? scope, Java.Lang.Object? cont) + { + if (scope is null) + throw new InvalidOperationException( + "DragGestureBlock.Invoke received a null PointerInputScope in slot 0"); + if (cont is null) + throw new InvalidOperationException( + "DragGestureBlock.Invoke received a null Continuation in slot 1"); + + var scopeHandle = scope.Handle; + + IContinuation continuation; + try + { + continuation = cont.JavaCast(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + "DragGestureBlock.Invoke could not project slot 1 (" + + (cont.Class?.Name ?? "") + + ") as kotlin.coroutines.Continuation", ex); + } + + try + { + var startHandle = _onDragStart is null ? (IntPtr?)null : ((Java.Lang.Object)_onDragStart).Handle; + var endHandle = _onDragEnd is null ? (IntPtr?)null : ((Java.Lang.Object)_onDragEnd).Handle; + var cancelHandle = _onDragCancel is null ? (IntPtr?)null : ((Java.Lang.Object)_onDragCancel).Handle; + var dragHandle = ((Java.Lang.Object)_onDrag).Handle; + + var resultHandle = _afterLongPress + ? ComposeBridges.DetectDragGesturesAfterLongPress( + scopeHandle, startHandle, endHandle, cancelHandle, dragHandle, continuation) + : ComposeBridges.DetectDragGestures( + scopeHandle, startHandle, endHandle, cancelHandle, dragHandle, continuation); + + if (resultHandle == IntPtr.Zero) + return null; + + EnsureSuspendedHandle(); + if (JNIEnv.IsSameObject(resultHandle, s_suspendedHandle)) + { + JNIEnv.DeleteLocalRef(resultHandle); + return IntrinsicsKt.COROUTINE_SUSPENDED; + } + + return Java.Lang.Object.GetObject( + resultHandle, JniHandleOwnership.TransferLocalRef); + } + finally + { + GC.KeepAlive(scope); + GC.KeepAlive(cont); + GC.KeepAlive(_onDragStart); + GC.KeepAlive(_onDragEnd); + GC.KeepAlive(_onDragCancel); + GC.KeepAlive(_onDrag); + } + } + + static void EnsureSuspendedHandle() + { + if (s_suspendedHandle != IntPtr.Zero) return; + var inst = IntrinsicsKt.COROUTINE_SUSPENDED; + var gref = JNIEnv.NewGlobalRef(inst.Handle); + if (Interlocked.CompareExchange( + ref s_suspendedHandle, gref, IntPtr.Zero) != IntPtr.Zero) + JNIEnv.DeleteGlobalRef(gref); + GC.KeepAlive(inst); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DraggableState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DraggableState.cs new file mode 100644 index 000000000..0e47bf723 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DraggableState.cs @@ -0,0 +1,76 @@ +using AndroidX.Compose.Foundation.Gestures; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for . +/// Wraps the bound androidx.compose.foundation.gestures.DraggableState +/// interface. The constructor calls the bound +/// DraggableKt.DraggableState(onDelta) factory under the hood. +/// +/// +/// +/// Construct one inside a callback so +/// the same state survives recompositions: +/// +/// var offset = RememberSaveable(() => new MutableNumberState<float>(0f)); +/// var drag = Remember(() => new DraggableState(delta => offset.Value += delta)); +/// +/// new Box +/// { +/// Modifier.Draggable(drag, Orientation.Horizontal), +/// // ... draggable content ... +/// } +/// +/// +/// +/// For per-recomposition delegate identity, prefer +/// +/// — Kotlin's rememberDraggableState wraps your callback in a +/// rememberUpdatedState cell so the underlying Java +/// DraggableState stays stable while the lambda can change. +/// +/// +public sealed class DraggableState +{ + internal IDraggableState Jvm { get; } + + /// + /// Build a new backed by a fresh Kotlin + /// DraggableState. The callback + /// receives the raw drag delta in pixels along the chosen + /// ; mutate whatever offset state your + /// composables read so the dragged content moves on the next + /// recomposition. + /// + public DraggableState(Action onDelta) + { + ArgumentNullException.ThrowIfNull(onDelta); + var jcw = new ComposableLambda1(boxed => + { + var f = boxed as Java.Lang.Float + ?? throw new InvalidCastException( + $"Expected java.lang.Float in DraggableState.onDelta; got '{boxed?.Class?.Name ?? "null"}'."); + onDelta(f.FloatValue()); + }); + Jvm = DraggableKt.DraggableState(jcw) + ?? throw new InvalidOperationException( + "DraggableKt.DraggableState returned null."); + } + + // Constructor used by ComposeExtensions.RememberDraggableState to wrap the + // remember-cached IDraggableState handle that Kotlin built. + internal DraggableState(IDraggableState jvm) + { + Jvm = jvm; + } + + /// + /// Dispatch a raw drag delta to this state without going through the + /// gesture pipeline. Mirrors Kotlin's + /// DraggableState.dispatchRawDelta(delta); useful for + /// programmatically nudging a draggable from outside a touch + /// gesture (e.g. animation, keyboard handler). + /// + public void DispatchRawDelta(float delta) => Jvm.DispatchRawDelta(delta); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DrawerStateHolder.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DrawerStateHolder.cs new file mode 100644 index 000000000..4f3b228ad --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DrawerStateHolder.cs @@ -0,0 +1,109 @@ +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for +/// and . Wraps Kotlin's +/// DrawerState (created via rememberDrawerState) so a +/// facade can carry an initial across +/// recompositions and expose the live drawer position back to C#. +/// +/// +/// This type is named with a Holder suffix to avoid +/// colliding with the binding's +/// class — both +/// would otherwise resolve to DrawerState when a user imports +/// using AndroidX.Compose.Material3; and +/// using AndroidX.Compose; at the same time. +/// Construct one inside Remember so the same instance +/// survives recomposition: +/// +/// var drawer = Remember(() => new DrawerStateHolder(DrawerValue.Closed)); +/// +/// new ModalNavigationDrawer(drawerState: drawer) +/// { +/// Drawer = new ModalDrawerSheet { … }, +/// Content = new Column { … }, +/// ConfirmStateChange = v => !formIsDirty || v != DrawerValue.Closed, +/// }; +/// +/// +public sealed class DrawerStateHolder +{ + internal DrawerState? Jvm; + + /// + /// Initial the drawer remembers on first + /// composition. Defaults to . + /// + public DrawerValue InitialValue { get; } + + /// + /// Construct a holder with the given + /// (or when omitted). + /// + public DrawerStateHolder(DrawerValue? initialValue = null) + { + InitialValue = initialValue ?? DrawerValue.Closed!; + } + + /// + /// The drawer's current visual state. Falls back to + /// until the holder is bound to a live + /// peer (i.e. the first render has happened). + /// + public DrawerValue CurrentValue => Jvm?.CurrentValue ?? InitialValue; + + /// + /// The drawer's target state during animation, or the resting + /// state otherwise. Falls back to until + /// bound. + /// + public DrawerValue TargetValue => Jvm?.TargetValue ?? InitialValue; + + /// + /// true when the drawer is fully open. Equivalent to + /// CurrentValue == DrawerValue.Open. + /// + public bool IsOpen => Jvm?.IsOpen ?? (InitialValue == DrawerValue.Open); + + /// + /// true when the drawer is fully closed. Equivalent to + /// CurrentValue == DrawerValue.Closed. + /// + public bool IsClosed => Jvm?.IsClosed ?? (InitialValue == DrawerValue.Closed); + + /// + /// Slide the drawer open with the default animation. Mirrors + /// Kotlin's DrawerState.open(). Safe to call from a button + /// onClick — the returned completes when + /// the animation lands. Throws + /// if invoked before + /// the holder is bound to a live peer (i.e. before the first + /// composition pass that renders the + /// facade). + /// + public Task OpenAsync() + { + var jvm = Jvm + ?? throw new InvalidOperationException( + "DrawerStateHolder.OpenAsync requires the holder to be bound to a live drawer; call it after the first render."); + return SuspendBridge.Invoke(cont => + ComposeBridges.DrawerStateOpen(((Java.Lang.Object)jvm).Handle, cont)); + } + + /// + /// Slide the drawer closed with the default animation. Mirrors + /// Kotlin's DrawerState.close(). See + /// for the binding caveat. + /// + public Task CloseAsync() + { + var jvm = Jvm + ?? throw new InvalidOperationException( + "DrawerStateHolder.CloseAsync requires the holder to be bound to a live drawer; call it after the first render."); + return SuspendBridge.Invoke(cont => + ComposeBridges.DrawerStateClose(((Java.Lang.Object)jvm).Handle, cont)); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DrawerValueConfirmStateChange.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DrawerValueConfirmStateChange.cs new file mode 100644 index 000000000..1782cbb8a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DrawerValueConfirmStateChange.cs @@ -0,0 +1,55 @@ +using Android.Runtime; +using AndroidX.Compose.Material3; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function1<DrawerValue, Boolean> adapter used as the +/// confirmStateChange callback when constructing a +/// DrawerState via rememberDrawerState. The callback is +/// part of the remember cache key in Kotlin, so this type is +/// allocated once per instance +/// and reused across recompositions — a fresh adapter each pass would +/// invalidate the cached state holder and the drawer would forget +/// whether it's open. is read on every JNI +/// invocation, so callers may mutate it freely without re-allocating. +/// When is null the adapter behaves as +/// the Kotlin default { true } — every transition is allowed. +/// +/// +/// The name follows the source generator's convention +/// <TName>ConfirmStateChange so a +/// [ConfirmStateChange(typeof(DrawerValue))] attribute on a +/// Remember bridge parameter resolves to this adapter automatically. +/// Public so generated facade classes (which live alongside this type +/// in AndroidX.Compose) can declare it as a readonly field; +/// it is not part of the developer-facing API and should not be +/// constructed directly. +/// +[Register("net/compose/DrawerValueConfirmStateChange")] +public sealed class DrawerValueConfirmStateChange : Java.Lang.Object, IFunction1 +{ + /// + /// Developer-supplied veto delegate, assigned by the generator- + /// emitted facade in its Render preamble from the facade's + /// ConfirmStateChange property. Treated as + /// { true } when null. + /// + public Func? Callback { get; set; } + + /// + /// Kotlin Function1.invoke entry point. Marshals the JNI + /// argument to , invokes + /// , and returns a boxed + /// . + /// + public Java.Lang.Object? Invoke(Java.Lang.Object? p0) + { + var cb = Callback; + if (cb is null) + return Java.Lang.Boolean.True; + var value = Android.Runtime.Extensions.JavaCast(p0!); + return cb(value) ? Java.Lang.Boolean.True : Java.Lang.Boolean.False; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DropdownMenu.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DropdownMenu.cs new file mode 100644 index 000000000..3d7c45193 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DropdownMenu.cs @@ -0,0 +1,24 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 DropdownMenu — the popup that anchors +/// children. Pair with a trigger +/// (commonly an ) and a +/// to control visibility: +/// +/// var open = Remember(() => new MutableState<bool>(false)); +/// +/// new Box +/// { +/// new IconButton(onClick: () => open.Value = true) +/// { new Text("⋮") }, +/// new DropdownMenu(expanded: open.Value, onDismissRequest: () => open.Value = false) +/// { +/// new DropdownMenuItem( +/// text: new Text("Refresh"), +/// onClick: () => { open.Value = false; ... }), +/// }, +/// } +/// +/// +public sealed partial class DropdownMenu; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DropdownMenuItem.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DropdownMenuItem.cs new file mode 100644 index 000000000..3fcc646f7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/DropdownMenuItem.cs @@ -0,0 +1,71 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 DropdownMenuItem — a single selectable row inside a +/// . The text composable and +/// onClick callback are required (passed as constructor arguments). +/// +/// +/// +/// new DropdownMenu(expanded: open.Value, onDismissRequest: () => open.Value = false) +/// { +/// new DropdownMenuItem(text: new Text("Refresh"), onClick: () => { open.Value = false; ... }), +/// new DropdownMenuItem(text: new Text("Settings"), onClick: () => { open.Value = false; ... }), +/// } +/// +/// +public sealed class DropdownMenuItem : ComposableNode +{ + readonly ComposableNode _text; + readonly Action _onClick; + + public DropdownMenuItem(ComposableNode text, Action onClick) + { + _text = text; + _onClick = onClick; + } + + /// Optional: leading icon (composable, typically ). + public ComposableNode? LeadingIcon { get; set; } + + /// Optional: trailing icon (composable, typically ). + public ComposableNode? TrailingIcon { get; set; } + + /// Whether the item responds to clicks. Defaults to true. + public bool Enabled { get; set; } = true; + + public override void Render(IComposer composer) + { + var text = ComposableLambdas.Wrap2(composer, c => _text.Render(c)); + var onClick = new ComposableLambda0(_onClick); + var modifier = BuildModifier(); + + Kotlin.Jvm.Functions.IFunction2? leading = LeadingIcon is null ? null + : ComposableLambdas.Wrap2(composer, c => LeadingIcon.Render(c)); + Kotlin.Jvm.Functions.IFunction2? trailing = TrailingIcon is null ? null + : ComposableLambdas.Wrap2(composer, c => TrailingIcon.Render(c)); + + int defaults = (int)DropdownMenuItemDefault.All; + if (modifier is not null) defaults &= ~(int)DropdownMenuItemDefault.Modifier; + if (leading is not null) defaults &= ~(int)DropdownMenuItemDefault.LeadingIcon; + if (trailing is not null) defaults &= ~(int)DropdownMenuItemDefault.TrailingIcon; + if (!Enabled) defaults &= ~(int)DropdownMenuItemDefault.Enabled; + + AndroidMenu_androidKt.DropdownMenuItem( + text: text, + onClick: onClick, + modifier: modifier, + leadingIcon: leading, + trailingIcon: trailing, + enabled: Enabled, + colors: null, + contentPadding: null, + interactionSource: null, + _composer: composer, + p10: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/EffectsBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/EffectsBridges.cs new file mode 100644 index 000000000..af8627837 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/EffectsBridges.cs @@ -0,0 +1,72 @@ +namespace AndroidX.Compose; + +// Raw-JNI helpers for the Compose effect APIs in +// `androidx.compose.runtime.EffectsKt`. The entry points themselves +// (`SideEffect`, `DisposableEffect`, `LaunchedEffect`, +// `RememberCoroutineScope`) are bound directly by the +// `Xamarin.AndroidX.Compose.Runtime.Android` NuGet, so we don't need +// `[ComposeBridge]` shims for them. What we *do* need is: +// +// - `BoxKey(object?)` — non-generic equivalent of +// `MutableState.ToJava`, so the public C# effect APIs can take +// `object?` keys and box primitives once per render. +// +// `kotlin.Result` plumbing (constructing/reading `Result.Failure`) +// lives in , shared with `SuspendBridge`. +internal static partial class ComposeBridges +{ + /// + /// Non-generic mirror of for + /// boxing an opaque key into the + /// the EffectsKt entry points expect. + /// Supports null, any peer, + /// , and every common .NET primitive. + /// + internal static Java.Lang.Object? BoxKey(object? key) => key switch + { + null => null, + Java.Lang.Object o => o, + string s => new Java.Lang.String(s), + bool b => Java.Lang.Boolean.ValueOf(b), + char c => Java.Lang.Character.ValueOf(c), + sbyte sb => Java.Lang.Byte.ValueOf(sb), + byte by => Java.Lang.Short.ValueOf((short)by), + short sh => Java.Lang.Short.ValueOf(sh), + ushort us => Java.Lang.Integer.ValueOf(us), + int i => Java.Lang.Integer.ValueOf(i), + uint ui => Java.Lang.Long.ValueOf(ui), + long l => Java.Lang.Long.ValueOf(l), + ulong ul => Java.Lang.Long.ValueOf(unchecked((long)ul)), + float f => Java.Lang.Float.ValueOf(f), + double d => Java.Lang.Double.ValueOf(d), + // Anything else has no obvious mapping to Java's `Object.equals` + // semantics that Compose uses for key comparison. Boxed value + // types (Guid, DateTime, enums, records) can produce a fresh + // identity hash every render — silently triggering an effect + // restart on every recomposition. Refuse and let the caller + // pick a stable representation (a string, a primitive, or a + // pre-allocated Java.Lang.Object peer). + _ => throw new NotSupportedException( + $"Compose effect key type '{key.GetType().FullName}' is not supported. " + + "Use a Java.Lang.Object, string, bool, char, sbyte/byte/short/ushort/int/uint/long/ulong/float/double, " + + "or convert your key to a stable form (e.g. ToString() or a stable hash).") + }; + + /// + /// Box an array of ? keys into a Java + /// Object[] for the vararg keys overload of + /// + /// / + /// . + /// Currently unused — the 1/2/3-key overloads cover the common + /// case and avoid the per-render array allocation. Kept here for + /// the future variadic public overload. + /// + internal static Java.Lang.Object?[] BoxKeyArray(object?[] keys) + { + var boxed = new Java.Lang.Object?[keys.Length]; + for (int i = 0; i < keys.Length; i++) + boxed[i] = BoxKey(keys[i]); + return boxed; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedAssistChip.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedAssistChip.cs new file mode 100644 index 000000000..eb493d7a6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedAssistChip.cs @@ -0,0 +1,7 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ElevatedAssistChip — same API surface as +/// but uses shadow elevation for emphasis. +/// +public sealed partial class ElevatedAssistChip; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedButton.cs new file mode 100644 index 000000000..0004ed81d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ElevatedButton. Same shape as . +/// +public sealed partial class ElevatedButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedCard.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedCard.cs new file mode 100644 index 000000000..c5e073a5c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedCard.cs @@ -0,0 +1,8 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ElevatedCard — like but uses +/// shadow rather than tonal elevation for separation. Children are +/// added via collection-initializer syntax. +/// +public sealed partial class ElevatedCard; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedFilterChip.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedFilterChip.cs new file mode 100644 index 000000000..b93301cb3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedFilterChip.cs @@ -0,0 +1,7 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ElevatedFilterChip — same API surface as +/// but uses shadow elevation for emphasis. +/// +public sealed partial class ElevatedFilterChip; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedSuggestionChip.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedSuggestionChip.cs new file mode 100644 index 000000000..d349c954b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ElevatedSuggestionChip.cs @@ -0,0 +1,7 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ElevatedSuggestionChip — like +/// but uses shadow elevation for emphasis. +/// +public sealed partial class ElevatedSuggestionChip; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExpandedDockedSearchBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExpandedDockedSearchBar.cs new file mode 100644 index 000000000..c0504ae3c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExpandedDockedSearchBar.cs @@ -0,0 +1,42 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 ExpandedDockedSearchBar — the popup half of a +/// docked pair. Renders the search-results +/// content (collection-initialized children) inside a docked popup +/// when the shared is expanded. +/// +/// +/// Render BOTH this and a (or +/// ) inside the same parent, sharing the +/// same — Compose toggles this popup's +/// visibility internally. +/// +/// is required and is rendered at the top of +/// the expanded popup; the collection-initialized children render as +/// the result list below it (the underlying Kotlin content +/// lambda receives a ColumnScope). +/// +public sealed class ExpandedDockedSearchBar : ComposableContainer +{ + readonly SearchBarState _state; + + public ExpandedDockedSearchBar(SearchBarState state) => _state = state; + + /// Required: composable that renders the search input field inside the popup. + public ComposableNode? InputField { get; set; } + + public override void Render(IComposer composer) + { + if (InputField is null) + throw new InvalidOperationException( + "ExpandedDockedSearchBar.InputField is required (the Kotlin parameter has no default)."); + + var stateHandle = SearchBar.ResolveStateHandle(_state, composer); + var inputField = ComposableLambdas.Wrap2(composer, c => InputField.Render(c)); + var content = ComposableLambdas.Wrap3(composer, c => RenderChildren(c)); + ComposeBridges.ExpandedDockedSearchBar(stateHandle, inputField, BuildModifier(), content, composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExpandedFullScreenSearchBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExpandedFullScreenSearchBar.cs new file mode 100644 index 000000000..c9dbe663f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExpandedFullScreenSearchBar.cs @@ -0,0 +1,43 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 ExpandedFullScreenSearchBar — the full-screen +/// (Dialog-backed) popup half of a pair. +/// Renders the search-results content (collection-initialized children) +/// inside a fullscreen dialog when the shared +/// is expanded. +/// +/// +/// Render BOTH this and a (or +/// ) inside the same parent, sharing the +/// same — Compose toggles this popup's +/// visibility internally. +/// +/// is required and is rendered at the top of +/// the expanded popup; the collection-initialized children render as +/// the result list below it (the underlying Kotlin content +/// lambda receives a ColumnScope). +/// +public sealed class ExpandedFullScreenSearchBar : ComposableContainer +{ + readonly SearchBarState _state; + + public ExpandedFullScreenSearchBar(SearchBarState state) => _state = state; + + /// Required: composable that renders the search input field inside the popup. + public ComposableNode? InputField { get; set; } + + public override void Render(IComposer composer) + { + if (InputField is null) + throw new InvalidOperationException( + "ExpandedFullScreenSearchBar.InputField is required (the Kotlin parameter has no default)."); + + var stateHandle = SearchBar.ResolveStateHandle(_state, composer); + var inputField = ComposableLambdas.Wrap2(composer, c => InputField.Render(c)); + var content = ComposableLambdas.Wrap3(composer, c => RenderChildren(c)); + ComposeBridges.ExpandedFullScreenSearchBar(stateHandle, inputField, BuildModifier(), content, composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExposedDropdownMenu.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExposedDropdownMenu.cs new file mode 100644 index 000000000..841ab58cb --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExposedDropdownMenu.cs @@ -0,0 +1,12 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ExposedDropdownMenu — the popup half of an +/// . Hosts +/// children and reads the enclosing +/// 's scope handle from +/// ; rendering this outside an +/// throws +/// . +/// +public sealed partial class ExposedDropdownMenu; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExposedDropdownMenuBox.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExposedDropdownMenuBox.cs new file mode 100644 index 000000000..928024bf9 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExposedDropdownMenuBox.cs @@ -0,0 +1,35 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ExposedDropdownMenuBox — the layout container that +/// anchors an popup to a +/// (or ). Pair +/// with a to drive the expanded/collapsed +/// state: +/// +/// var open = Remember(() => new MutableState<bool>(false)); +/// var selected = Remember(() => new MutableState<string>("Apple")); +/// +/// new ExposedDropdownMenuBox(expanded: open.Value, onExpandedChange: v => open.Value = v) +/// { +/// new TextField(value: selected.Value, onValueChange: _ => { }) +/// { +/// Label = new Text("Fruit"), +/// }, +/// new ExposedDropdownMenu(expanded: open.Value, onDismissRequest: () => open.Value = false) +/// { +/// new DropdownMenuItem(text: new Text("Apple"), onClick: () => { selected.Value = "Apple"; open.Value = false; }), +/// new DropdownMenuItem(text: new Text("Banana"), onClick: () => { selected.Value = "Banana"; open.Value = false; }), +/// }, +/// } +/// +/// +/// +/// In Material 3 1.4.0.3 the menuAnchor() modifier (which wires +/// the textfield's tap/keyboard events to the box's expansion) is +/// available only on the Kotlin +/// receiver scope, and binding it from C# is tracked separately. For +/// now, drive the expanded state from onExpandedChange directly +/// or from a button callback. +/// +public sealed partial class ExposedDropdownMenuBox; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExtendedFloatingActionButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExtendedFloatingActionButton.cs new file mode 100644 index 000000000..c6e69bd35 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ExtendedFloatingActionButton.cs @@ -0,0 +1,56 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 ExtendedFloatingActionButton — the animated +/// extended FAB with separate Icon and Text slots and an +/// Expanded flag that animates between the icon-only and +/// icon + text states. +/// +/// +/// new ExtendedFloatingActionButton(onClick: () => count.Value++, expanded: true) +/// { +/// Icon = new Text("+"), +/// Text = new Text("Add"), +/// } +/// +/// +/// Both and are required — the +/// underlying Kotlin parameters have no default. Setting either to +/// null throws at +/// render time. +/// +public sealed partial class ExtendedFloatingActionButton +{ + /// + /// Calls the real ExtendedFloatingActionButton Kotlin bridge directly, + /// bypassing the generated slot-property indirection. Use from the Comet platform + /// layer where the generated slot-property Render path is unavailable (different + /// assembly) but the public static call surface is. + /// The and nodes are wrapped in + /// ComposableLambdas via so Compose + /// tracks their identity across recompositions. + /// + public static void RenderDirect( + ComposableNode icon, + ComposableNode text, + System.Action onClick, + bool expanded, + Color? containerColor, + Color? contentColor, + Modifier? modifier, + IComposer composer) + { + ComposeBridges.ExtendedFloatingActionButton( + text: ComposableLambdas.Wrap2(composer, c => text.Render(c)), + icon: ComposableLambdas.Wrap2(composer, c => icon.Render(c)), + onClick: new UnitCallback(onClick), + modifier: modifier?.Build(), + expanded: expanded, + shape: null, + containerColor: containerColor, + contentColor: contentColor, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledIconButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledIconButton.cs new file mode 100644 index 000000000..8717372d3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledIconButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 FilledIconButton. Same shape as . +/// +public sealed partial class FilledIconButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledIconToggleButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledIconToggleButton.cs new file mode 100644 index 000000000..86be2cb88 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledIconToggleButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 FilledIconToggleButton. Same shape as . +/// +public sealed partial class FilledIconToggleButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalButton.cs new file mode 100644 index 000000000..731a71ab9 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 FilledTonalButton. Same shape as . +/// +public sealed partial class FilledTonalButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalIconButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalIconButton.cs new file mode 100644 index 000000000..98baf6f7e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalIconButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 FilledTonalIconButton. Same shape as . +/// +public sealed partial class FilledTonalIconButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalIconToggleButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalIconToggleButton.cs new file mode 100644 index 000000000..983620048 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilledTonalIconToggleButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 FilledTonalIconToggleButton. Same shape as . +/// +public sealed partial class FilledTonalIconToggleButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilterChip.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilterChip.cs new file mode 100644 index 000000000..d52ffc7d9 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FilterChip.cs @@ -0,0 +1,7 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 FilterChip. Renders as either selected or unselected; +/// the onClick handler typically toggles the bound boolean state. +/// +public sealed partial class FilterChip; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlexibleBottomAppBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlexibleBottomAppBar.cs new file mode 100644 index 000000000..feea2f512 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlexibleBottomAppBar.cs @@ -0,0 +1,16 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 FlexibleBottomAppBar. Like +/// but with a fully customizable expanded height and horizontal +/// arrangement. The content slot is laid out in a RowScope and +/// filled from this bar's children: +/// +/// new FlexibleBottomAppBar +/// { +/// new IconButton(onClick: ...) { new Icon(painter, "Search") }, +/// new IconButton(onClick: ...) { new Icon(painter, "Settings") }, +/// } +/// +/// +public sealed partial class FlexibleBottomAppBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FloatingActionButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FloatingActionButton.cs new file mode 100644 index 000000000..ceaacacb7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FloatingActionButton.cs @@ -0,0 +1,4 @@ +namespace AndroidX.Compose; + +/// Material 3 FloatingActionButton. +public sealed partial class FloatingActionButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlowColumn.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlowColumn.cs new file mode 100644 index 000000000..ba44e3ce2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlowColumn.cs @@ -0,0 +1,27 @@ +namespace AndroidX.Compose; + +/// +/// Foundation FlowColumn — vertical mirror of . +/// Children are stacked top-to-bottom in the first column; once a +/// column runs out of vertical space, subsequent children flow into a +/// new column to the right. +/// +/// +/// new FlowColumn +/// { +/// new Text("Item A"), +/// new Text("Item B"), +/// // …more items, will start a new column when out of vertical space. +/// } +/// +/// +/// As with , the v1 facade uses the simpler +/// 7-param overload — no FlowColumnOverflow slot and no +/// scope-receiver helper for fillMaxColumnWidth. Because +/// FlowColumnScope extends ColumnScope, scope-aware +/// modifiers like and +/// work on +/// children here exactly as they do inside a plain +/// . +/// +public sealed partial class FlowColumn; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlowRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlowRow.cs new file mode 100644 index 000000000..fd706b26e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FlowRow.cs @@ -0,0 +1,27 @@ +namespace AndroidX.Compose; + +/// +/// Foundation FlowRow — like , but children +/// that overflow the row's width wrap onto a new line below. Use for +/// dynamic chip groups, tag clouds, or any horizontal layout where the +/// number of items is unknown at compile time. +/// +/// +/// new FlowRow +/// { +/// new AssistChip(onClick: ...) { Text = new Text("Music") }, +/// new AssistChip(onClick: ...) { Text = new Text("Movies") }, +/// // …more chips, will wrap when out of horizontal space. +/// } +/// +/// +/// v1 wires up the simplest 7-param Kotlin overload (no +/// FlowRowOverflow handle and no scope-receiver helpers for +/// fillMaxRowHeight). Because FlowRowScope extends +/// RowScope, scope-aware modifiers like +/// and +/// work on children +/// here exactly as they do inside a plain . The +/// overflow indicator slot remains a follow-up. +/// +public sealed partial class FlowRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FocusRequester.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FocusRequester.cs new file mode 100644 index 000000000..7f5cdde09 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FocusRequester.cs @@ -0,0 +1,53 @@ +using JFocusRequester = AndroidX.Compose.UI.Focus.FocusRequester; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around androidx.compose.ui.focus.FocusRequester. +/// Pair it with +/// to install the requester on a focusable node, then call +/// (e.g. from a button click) to programmatically +/// move focus there. +/// +/// +/// Holds a Compose FocusRequester instance. The instance must +/// outlive the composition that wired it — store one +/// per logical focus target on the activity +/// (or on a state holder) and reuse the same instance across renders. +/// Constructing a new instance per recomposition would silently drop +/// any pending request. +/// +/// +public sealed class FocusRequester +{ + internal JFocusRequester Java { get; } + + /// + /// Construct a new focus requester. Allocates a fresh Compose + /// FocusRequester instance. + /// + public FocusRequester() => Java = new JFocusRequester(); + + /// + /// Equivalent to Kotlin's no-arg focusRequester.requestFocus() — + /// asks Compose to move focus to the node currently wearing this + /// requester. Safe to call from event handlers (button clicks, + /// LaunchedEffect bodies, etc.). The .NET binding does not surface + /// the no-arg overload, so we call it directly via JNI. + /// + public void RequestFocus() => + ComposeBridges.FocusRequesterRequestFocus(((Java.Lang.Object)Java).Handle); + + /// + /// Captures focus on the node — input is pinned here until + /// is called. Returns true if the + /// capture succeeded. + /// + public bool CaptureFocus() => Java.CaptureFocus(); + + /// + /// Releases a previously-captured + /// focus. Returns true if focus was released. + /// + public bool FreeFocus() => Java.FreeFocus(); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FocusState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FocusState.cs new file mode 100644 index 000000000..7e5259bef --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FocusState.cs @@ -0,0 +1,47 @@ +using AndroidX.Compose.UI.Focus; + +namespace AndroidX.Compose; + +/// +/// Immutable snapshot of a Compose FocusState. Produced inside +/// the callback passed to +/// +/// — Compose reuses one FocusState JNI instance per node and +/// mutates it across recompositions, so we read all three booleans +/// once and hand back a value type the caller can keep without worrying +/// about peer lifetime. +/// +public readonly struct FocusState +{ + /// + /// true if the node owning the modifier currently has focus. + /// + public bool IsFocused { get; } + + /// + /// true if focus is held by this node or any of its + /// descendants (mirrors Kotlin's FocusState.hasFocus). + /// + public bool HasFocus { get; } + + /// + /// true if focus was captured via + /// FocusRequester.captureFocus() — keystrokes are pinned + /// to this node until freeFocus() is called. + /// + public bool IsCaptured { get; } + + /// + /// Construct a snapshot directly. Public so test code can build + /// fixtures without going through JNI. + /// + public FocusState(bool isFocused, bool hasFocus, bool isCaptured) + { + IsFocused = isFocused; + HasFocus = hasFocus; + IsCaptured = isCaptured; + } + + internal static FocusState From(IFocusState state) => + new(state.IsFocused, state.HasFocus, state.IsCaptured); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontFamily.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontFamily.cs new file mode 100644 index 000000000..75f38103c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontFamily.cs @@ -0,0 +1,62 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// 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 +/// (boilerplate emitted by ComposeCompanionGenerator); the bridge +/// generator's reference-type code path +/// (x is null ? IntPtr.Zero : x.Handle) passes the handle through +/// to the JNI L slot. +/// +/// Note the per-getter ReturnDescriptor overrides — the Kotlin +/// companion getters return concrete subtypes (SystemFontFamily / +/// GenericFontFamily), not the base interface, so the JNI +/// signatures we look up have to match. +/// +/// Will swap to bound AndroidX.Compose.UI.Text.Font.FontFamily +/// once +/// ships and we adopt the next Xamarin.AndroidX.Compose.UI.Text.Android +/// release. +/// +[ComposeCompanion("androidx/compose/ui/text/font/FontFamily")] +public sealed partial class FontFamily : Java.Lang.Object +{ + FontFamily(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + /// Wraps a platform (e.g. loaded from an + /// asset) into a Compose FontFamily via FontFamilyKt.FontFamily(Typeface). + public static FontFamily FromTypeface(Android.Graphics.Typeface typeface) + { + IntPtr handle = ComposeBridges.FontFamilyFromTypeface(typeface.Handle); + return new FontFamily(handle, JniHandleOwnership.TransferLocalRef); + } + + /// + /// FontFamily.Default — the platform's system font family. + /// + [ComposeCompanionGetter("getDefault", ReturnDescriptor = "Landroidx/compose/ui/text/font/SystemFontFamily;")] + public static partial FontFamily Default { get; } + + /// Generic sans-serif family (FontFamily.SansSerif). + [ComposeCompanionGetter("getSansSerif", ReturnDescriptor = "Landroidx/compose/ui/text/font/GenericFontFamily;")] + public static partial FontFamily SansSerif { get; } + + /// Generic serif family (FontFamily.Serif). + [ComposeCompanionGetter("getSerif", ReturnDescriptor = "Landroidx/compose/ui/text/font/GenericFontFamily;")] + public static partial FontFamily Serif { get; } + + /// Generic monospace family (FontFamily.Monospace). + [ComposeCompanionGetter("getMonospace", ReturnDescriptor = "Landroidx/compose/ui/text/font/GenericFontFamily;")] + public static partial FontFamily Monospace { get; } + + /// Generic cursive family (FontFamily.Cursive). + [ComposeCompanionGetter("getCursive", ReturnDescriptor = "Landroidx/compose/ui/text/font/GenericFontFamily;")] + public static partial FontFamily Cursive { get; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontStyle.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontStyle.cs new file mode 100644 index 000000000..4ca4ee15c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontStyle.cs @@ -0,0 +1,38 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// 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;. Both +/// halves are emitted by ComposeCompanionGenerator from the +/// InlineClass = true on the attribute below. +/// +/// Will swap to bound AndroidX.Compose.UI.Text.Font.FontStyle +/// once +/// ships. +/// +[ComposeCompanion("androidx/compose/ui/text/font/FontStyle", InlineClass = true)] +public sealed partial class FontStyle : Java.Lang.Object +{ + FontStyle(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + /// Upright glyphs (the default). + [ComposeCompanionGetter("getNormal-_-LCdwA")] + public static partial FontStyle Normal { get; } + + /// Italic / slanted glyphs. + [ComposeCompanionGetter("getItalic-_-LCdwA")] + public static partial FontStyle Italic { get; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontWeight.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontWeight.cs new file mode 100644 index 000000000..6b8c86c3c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/FontWeight.cs @@ -0,0 +1,65 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around androidx.compose.ui.text.font.FontWeight. +/// Compose's FontWeight is a real Kotlin class (not a value +/// class), but Compose 1.11.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 boilerplate is emitted by +/// ComposeCompanionGenerator from the +/// below); the bridge +/// generator's reference-type code path +/// (x is null ? IntPtr.Zero : x.Handle) then passes the handle +/// through to the JNI L slot. +/// +/// Will swap to bound AndroidX.Compose.UI.Text.Font.FontWeight +/// once +/// ships and we adopt Xamarin.AndroidX.Compose.UI.Text.Android +/// 1.11.2.1+. +/// +[ComposeCompanion("androidx/compose/ui/text/font/FontWeight")] +public sealed partial class FontWeight : Java.Lang.Object +{ + FontWeight(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + /// FontWeight.Thin (W100). + [ComposeCompanionGetter("getThin")] + public static partial FontWeight Thin { get; } + + /// FontWeight.ExtraLight (W200). + [ComposeCompanionGetter("getExtraLight")] + public static partial FontWeight ExtraLight { get; } + + /// FontWeight.Light (W300). + [ComposeCompanionGetter("getLight")] + public static partial FontWeight Light { get; } + + /// FontWeight.Normal (W400). The default. + [ComposeCompanionGetter("getNormal")] + public static partial FontWeight Normal { get; } + + /// FontWeight.Medium (W500). + [ComposeCompanionGetter("getMedium")] + public static partial FontWeight Medium { get; } + + /// FontWeight.SemiBold (W600). + [ComposeCompanionGetter("getSemiBold")] + public static partial FontWeight SemiBold { get; } + + /// FontWeight.Bold (W700). + [ComposeCompanionGetter("getBold")] + public static partial FontWeight Bold { get; } + + /// FontWeight.ExtraBold (W800). + [ComposeCompanionGetter("getExtraBold")] + public static partial FontWeight ExtraBold { get; } + + /// FontWeight.Black (W900). + [ComposeCompanionGetter("getBlack")] + public static partial FontWeight Black { get; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/GridCells.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/GridCells.cs new file mode 100644 index 000000000..65ab13383 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/GridCells.cs @@ -0,0 +1,32 @@ +using AndroidX.Compose.Foundation.Lazy.Grid; + +namespace AndroidX.Compose; + +/// +/// Factory helpers for , the column/row-count +/// strategy for and +/// . Mirrors the Kotlin sealed class +/// GridCells with its Fixed and Adaptive subclasses: +/// +/// new LazyVerticalGrid<int>(GridCells.Fixed(3), items, i => new Text($"{i}")) +/// new LazyVerticalGrid<int>(GridCells.Adaptive(80f), items, i => new Text($"{i}")) +/// +/// +public static class GridCells +{ + /// + /// Lay out a fixed number of equally-sized cells across the cross + /// axis (column count for , row + /// count for ). + /// + public static IGridCells Fixed(int count) => new GridCellsFixed(count); + + /// + /// Lay out as many equally-sized cells as fit, given a minimum cell + /// size (in dp) along the cross axis. The Kotlin + /// GridCells.Adaptive(Dp) constructor is stripped from the + /// binding (inline-class Dp), so this goes through a + /// hand-written JNI bridge in ComposeBridges. + /// + public static IGridCells Adaptive(float minSizeDp) => ComposeBridges.GridCellsAdaptive(minSizeDp); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalCenteredHeroCarousel.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalCenteredHeroCarousel.cs new file mode 100644 index 000000000..7165aeb9c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalCenteredHeroCarousel.cs @@ -0,0 +1,110 @@ +using AndroidX.Compose.Material3.Carousel; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Material 3 HorizontalCenteredHeroCarousel — a horizontal +/// carousel that centers a single "hero" item and trims the +/// surrounding items to small previews on either side. Use for +/// editorial or featured content where one item should dominate at +/// any given scroll position. +/// +/// +/// new HorizontalCenteredHeroCarousel<Story>( +/// items: stories, +/// itemContent: s => new Card { new Text(s.Headline) }) +/// +/// +/// Material 3 1.4 only ships horizontal carousels — there is no +/// vertical hero variant in the bound bindings. +/// +public sealed class HorizontalCenteredHeroCarousel : ComposableNode +{ + readonly IReadOnlyList _items; + readonly Func _itemContent; + IFunction0? _itemCountFn; + + public HorizontalCenteredHeroCarousel(IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional carousel state. Leave null to let the facade allocate + /// one via Compose's rememberCarouselState. + /// + public CarouselState? State { get; set; } + + /// + /// Max width of the centered hero item in dp. + /// uses Compose's default + /// (Dp.Unspecified) which lets the hero expand to fill the + /// viewport minus the small-item keylines. + /// + public float MaxItemWidth { get; set; } = float.NaN; + + /// Item spacing in dp. uses Compose's default (0.dp). + public float ItemSpacing { get; set; } = float.NaN; + + /// User scroll gesture toggle. null uses Compose's default (true). + public bool? UserScrollEnabled { get; set; } + + public override void Render(IComposer composer) + { + _itemCountFn ??= new ComposableLambda0Int(() => _items.Count); + + var rememberedState = CarouselStateKt.RememberCarouselState( + p0: 0, + itemCount: _itemCountFn, + _composer: composer, + initialItem: 0, + _changed: 1); + var state = State ?? rememberedState; + + var content = ComposableLambdas.Wrap4(composer, + (_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + }); + + var modifier = BuildModifier(); + + // $default bit positions for HorizontalCenteredHeroCarousel-p2lB3Bg: + // 0 = state (required) + // 1 = modifier + // 2 = maxItemWidth + // 3 = itemSpacing + // 4 = flingBehavior (always defaulted) + // 5 = userScrollEnabled + // 6 = minSmallItemWidth (always defaulted) + // 7 = maxSmallItemWidth (always defaulted) + // 8 = contentPadding (always defaulted) + // 9 = content (required) + int defaults = 1 << 4 | 1 << 6 | 1 << 7 | 1 << 8; + if (modifier is null) defaults |= 1 << 1; + if (float.IsNaN(MaxItemWidth)) defaults |= 1 << 2; + if (float.IsNaN(ItemSpacing)) defaults |= 1 << 3; + if (UserScrollEnabled is null) defaults |= 1 << 5; + + CarouselKt.HorizontalCenteredHeroCarousel( + state: state, + modifier: modifier, + maxItemWidth: float.IsNaN(MaxItemWidth) ? 0f : MaxItemWidth, + itemSpacing: float.IsNaN(ItemSpacing) ? 0f : ItemSpacing, + flingBehavior: null, + userScrollEnabled: UserScrollEnabled ?? true, + minSmallItemWidth: 0f, + maxSmallItemWidth: 0f, + contentPadding: null, + content: content, + _composer: composer, + p11: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalDivider.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalDivider.cs new file mode 100644 index 000000000..04bbd06a3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalDivider.cs @@ -0,0 +1,36 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 HorizontalDivider — a thin horizontal line used to +/// separate sibling content blocks (typically inside a +/// ). +/// +public sealed class HorizontalDivider : ComposableNode +{ + /// Optional explicit thickness in Dp. Leave null to use the Material default. + public float? ThicknessDp { get; set; } + + /// Optional explicit color. Leave null to use the Material default. + public Color? Color { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + + int defaults = (int)HorizontalDividerDefault.All; + if (modifier is not null) defaults &= ~(int)HorizontalDividerDefault.Modifier; + if (ThicknessDp.HasValue) defaults &= ~(int)HorizontalDividerDefault.Thickness; + if (Color.HasValue) defaults &= ~(int)HorizontalDividerDefault.Color; + + DividerKt.HorizontalDivider( + modifier: modifier, + thickness: ThicknessDp ?? 0f, + color: Color is { } c ? c : 0L, + _composer: composer, + p4: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalMultiBrowseCarousel.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalMultiBrowseCarousel.cs new file mode 100644 index 000000000..ea7fa9f46 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalMultiBrowseCarousel.cs @@ -0,0 +1,104 @@ +using AndroidX.Compose.Material3.Carousel; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Material 3 HorizontalMultiBrowseCarousel — a horizontal +/// carousel that lays out a large "preferred" item plus one or more +/// adjacent smaller items in the keylines, so the user can preview +/// the next/previous entries while browsing. The carousel snaps so a +/// preferred-width item is always centered. +/// +/// +/// new HorizontalMultiBrowseCarousel<Photo>( +/// items: photos, +/// preferredItemWidth: 240f, +/// itemContent: p => new Card { new Text(p.Title) }) +/// +/// +/// Material 3 1.4 only ships horizontal carousels — there is no +/// vertical multi-browse variant in the bound bindings. +/// +public sealed class HorizontalMultiBrowseCarousel : ComposableNode +{ + readonly IReadOnlyList _items; + readonly float _preferredItemWidth; + readonly Func _itemContent; + IFunction0? _itemCountFn; + + public HorizontalMultiBrowseCarousel(IReadOnlyList items, float preferredItemWidth, Func itemContent) + { + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + _preferredItemWidth = preferredItemWidth; + } + + /// + /// Optional carousel state. Leave null to let the facade allocate + /// one via Compose's rememberCarouselState. + /// + public CarouselState? State { get; set; } + + /// Item spacing in dp. uses Compose's default (0.dp). + public float ItemSpacing { get; set; } = float.NaN; + + /// User scroll gesture toggle. null uses Compose's default (true). + public bool? UserScrollEnabled { get; set; } + + public override void Render(IComposer composer) + { + _itemCountFn ??= new ComposableLambda0Int(() => _items.Count); + + var rememberedState = CarouselStateKt.RememberCarouselState( + p0: 0, + itemCount: _itemCountFn, + _composer: composer, + initialItem: 0, + _changed: 1); + var state = State ?? rememberedState; + + var content = ComposableLambdas.Wrap4(composer, + (_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + }); + + var modifier = BuildModifier(); + + // $default bit positions for HorizontalMultiBrowseCarousel-3tcCNu0: + // 0 = state (required) + // 1 = preferredItemWidth (required) + // 2 = modifier + // 3 = itemSpacing + // 4 = flingBehavior (always defaulted) + // 5 = userScrollEnabled + // 6 = minSmallItemWidth (always defaulted) + // 7 = maxSmallItemWidth (always defaulted) + // 8 = contentPadding (always defaulted) + // 9 = content (required) + int defaults = 1 << 4 | 1 << 6 | 1 << 7 | 1 << 8; + if (modifier is null) defaults |= 1 << 2; + if (float.IsNaN(ItemSpacing)) defaults |= 1 << 3; + if (UserScrollEnabled is null) defaults |= 1 << 5; + + CarouselKt.HorizontalMultiBrowseCarousel( + state: state, + preferredItemWidth: _preferredItemWidth, + modifier: modifier, + itemSpacing: float.IsNaN(ItemSpacing) ? 0f : ItemSpacing, + flingBehavior: null, + userScrollEnabled: UserScrollEnabled ?? true, + minSmallItemWidth: 0f, + maxSmallItemWidth: 0f, + contentPadding: null, + content: content, + _composer: composer, + p11: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalPager.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalPager.cs new file mode 100644 index 000000000..f104bd31a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalPager.cs @@ -0,0 +1,126 @@ +using AndroidX.Compose.Foundation.Pager; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Foundation HorizontalPager — swipes through a fixed list of +/// items one page at a time along the horizontal axis. Like +/// , only the visible page (plus a small +/// off-screen buffer) is composed; unlike LazyRow, the pager snaps to +/// page boundaries and exposes a for reading +/// the current page or driving programmatic scroll. +/// +/// +/// new HorizontalPager<Story>( +/// items: stories, +/// itemContent: s => new Card { new Text(s.Headline) }) +/// +/// +/// Set to a remembered if +/// other UI needs to react to page changes (e.g. a tab indicator under +/// the pager). When left null the pager allocates and remembers its +/// own state internally. +/// +public sealed class HorizontalPager : ComposableNode +{ + readonly IReadOnlyList _items; + readonly Func _itemContent; + IFunction0? _pageCountFn; + + /// + /// Construct a pager that snaps through , + /// rendering each item via . + /// + public HorizontalPager(IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional caller-supplied state holder. Leave null to let the + /// facade allocate one via rememberPagerState internally. + /// + public PagerState? State { get; set; } + + /// + /// Optional fixed content padding applied inside the pager. + /// Useful for letting pages peek (i.e. visible side gutters that + /// reveal slivers of the previous and next pages) without + /// shrinking the page bounds via . + /// + public PaddingValues? ContentPadding { get; set; } + + public override void Render(IComposer composer) + { + // When the caller supplies a PagerState wrapper its Jvm is + // built eagerly in the wrapper's ctor (via the non-@Composable + // PagerStateKt.PagerState factory), so we just pass it through + // and reads from C# build code subscribe to snapshot changes + // regardless of render order — see issue #119. + // + // When State == null we fall back to rememberPagerState so the + // pager owns an internal scroll-position-preserving state + // across recompositions. + AndroidX.Compose.Foundation.Pager.PagerState jvmState; + if (State is not null) + { + jvmState = State.Jvm; + } + else + { + // Compose's rememberPagerState reads the pageCount lambda + // on every measure pass, so the lambda has to close over + // the live _items reference (count can change between + // recompositions when callers swap in a new list). + _pageCountFn ??= new ComposableLambda0Int(() => _items.Count); + jvmState = PagerStateKt.RememberPagerState( + p0: 0, + initialPageOffsetFraction: 0f, + pageCount: _pageCountFn, + _composer: composer, + initialPage: 0, + _changed: 0); + } + + var modifier = BuildModifier(); + var content = ComposableLambdas.Wrap4(composer, (_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + }); + + // 14 user-controllable params for HorizontalPager (state and + // pageContent are always provided). All bits start set so Kotlin + // substitutes its real defaults; we clear bits only for the + // params this v1 facade exposes. + int defaults = (int)HorizontalPagerDefault.All; + if (modifier is not null) defaults &= ~(int)HorizontalPagerDefault.Modifier; + if (ContentPadding is not null) defaults &= ~(int)HorizontalPagerDefault.ContentPadding; + + PagerKt.HorizontalPager( + state: jvmState, + modifier: modifier, + contentPadding: ContentPadding?.Jvm, + pageSize: null, + p4: 0, // beyondViewportPageCount + pageSpacing: 0f, + verticalAlignment: null, + flingBehavior: null, + userScrollEnabled: true, + reverseLayout: false, + key: null, + pageNestedScrollConnection: null, + snapPosition: null, + overscrollEffect: null, + pageContent: content, + _composer: composer, + beyondViewportPageCount: 0, // $changed slot 1 + _changed: 0, // $changed slot 2 + _changed1: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalUncontainedCarousel.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalUncontainedCarousel.cs new file mode 100644 index 000000000..3bea42fa8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/HorizontalUncontainedCarousel.cs @@ -0,0 +1,128 @@ +using AndroidX.Compose.Material3.Carousel; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Material 3 HorizontalUncontainedCarousel — a single-row +/// carousel where every item has the same fixed width and the carousel +/// scrolls horizontally without snapping a "small" item into the +/// keylines. Use when the items are uniform and you want the simplest +/// keyline strategy. +/// +/// The facade is data-driven (same shape as ): +/// you pass the items list, the item width in dp, and a per-item +/// content callback at construction time. The underlying +/// is auto-allocated via Compose's +/// rememberCarouselState unless you supply one in +/// . +/// +/// +/// new HorizontalUncontainedCarousel<Photo>( +/// items: photos, +/// itemWidth: 200f, +/// itemContent: p => new Card { new Text(p.Title) }) +/// +/// +/// Material 3 1.4 only ships horizontal carousels — there is no +/// VerticalUncontainedCarousel in the bound bindings. +/// +public sealed class HorizontalUncontainedCarousel : ComposableNode +{ + readonly IReadOnlyList _items; + readonly float _itemWidth; + readonly Func _itemContent; + IFunction0? _itemCountFn; + + public HorizontalUncontainedCarousel(IReadOnlyList items, float itemWidth, Func itemContent) + { + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + _itemWidth = itemWidth; + } + + /// + /// Optional carousel state. Leave null to let the facade allocate + /// one via Compose's rememberCarouselState. Set this when + /// you want to drive scroll position programmatically or observe + /// . + /// + public CarouselState? State { get; set; } + + /// Item spacing in dp. uses Compose's default (0.dp). + public float ItemSpacing { get; set; } = float.NaN; + + /// User scroll gesture toggle. null uses Compose's default (true). + public bool? UserScrollEnabled { get; set; } + + public override void Render(IComposer composer) + { + // Stable Function0 reference: the underlying CarouselState + // captures this lambda once and queries it on each measure + // pass, so re-allocating per render is wasteful — and + // unhelpful, since the closure already reads _items.Count + // lazily. + _itemCountFn ??= new ComposableLambda0Int(() => _items.Count); + + // Always call RememberCarouselState — both for slot-table + // stability (so the carousel call below sees a consistent + // sequence of remember slots across recompositions) and so + // the M3 carousel internally picks up the latest itemCount + // lambda. Binding parameter names are confusingly mapped: + // p0 = initialItem (the actual int) + // itemCount = the Function0 + // _composer = composer + // initialItem = $changed (pass 0) + // _changed = $default (bit 0 = use Kotlin default for initialItem) + var rememberedState = CarouselStateKt.RememberCarouselState( + p0: 0, + itemCount: _itemCountFn, + _composer: composer, + initialItem: 0, + _changed: 1); + var state = State ?? rememberedState; + + // Wrap4 (not Instantiate4): the content lambda is a direct + // @Composable parameter on the carousel call — its identity + // belongs to the OUTER composition's slot table even though + // the body runs inside the carousel's pager subcomposition. + var content = ComposableLambdas.Wrap4(composer, + (_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + }); + + var modifier = BuildModifier(); + + // $default bit positions for HorizontalUncontainedCarousel-VUP9l70: + // 0 = state (required, never defaulted) + // 1 = itemWidth (required, never defaulted) + // 2 = modifier + // 3 = itemSpacing + // 4 = flingBehavior (always defaulted) + // 5 = userScrollEnabled + // 6 = contentPadding (always defaulted) + // 7 = content (required, never defaulted) + int defaults = 1 << 4 | 1 << 6; + if (modifier is null) defaults |= 1 << 2; + if (float.IsNaN(ItemSpacing)) defaults |= 1 << 3; + if (UserScrollEnabled is null) defaults |= 1 << 5; + + CarouselKt.HorizontalUncontainedCarousel( + state: state, + itemWidth: _itemWidth, + modifier: modifier, + itemSpacing: float.IsNaN(ItemSpacing) ? 0f : ItemSpacing, + flingBehavior: null, + userScrollEnabled: UserScrollEnabled ?? true, + contentPadding: null, + content: content, + _composer: composer, + p9: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IMutableStateWrapper.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IMutableStateWrapper.cs new file mode 100644 index 000000000..1dc92b958 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IMutableStateWrapper.cs @@ -0,0 +1,18 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Non-generic view onto a / +/// wrapper. Exists so +/// +/// can detect "T is a state-holder wrapper" with a cheap +/// is-check (no reflection, trim-safe), and so the dispatcher +/// can swap the wrapper's underlying +/// after Compose's rememberSaveable hands back a (possibly +/// restored) state on first composition. +/// +internal interface IMutableStateWrapper +{ + IMutableState State { get; set; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IState.cs new file mode 100644 index 000000000..d5439b87b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IState.cs @@ -0,0 +1,22 @@ +namespace AndroidX.Compose; + +/// +/// Read-only view onto an observable value. The C# parity of +/// Kotlin's androidx.compose.runtime.State<T>: a single +/// getter whose read participates in the +/// composition's snapshot dependency tracking, so any composable that +/// reads it re-runs when the value changes. +/// +/// The observed value type. +/// +/// Implementations in AndroidX.Compose: , +/// , , +/// and . Pass IState<T> +/// when an API needs a read-only handle that a caller can subscribe +/// to from a composition body without exposing a setter. +/// +public interface IState +{ + /// The current value. Reads inside a composition subscribe the surrounding scope to changes. + T Value { get; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IStateFlow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IStateFlow.cs new file mode 100644 index 000000000..7b9759a99 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IStateFlow.cs @@ -0,0 +1,29 @@ +namespace AndroidX.Compose; + +/// +/// Read-only observable value that always has a current value — +/// AndroidX.Compose's C# parity for Kotlin's +/// kotlinx.coroutines.flow.StateFlow<T>. Exposes +/// (always populated) and participates +/// in Compose's snapshot dependency tracking when read inside a +/// composition body. +/// +/// The observed value type. +/// +/// +/// Implementation surface is closed. The only +/// supported implementation is the snapshot-backed +/// . AndroidX.Compose does not bridge to +/// real Kotlin StateFlow instances yet — a custom managed +/// implementation cannot participate in Compose's snapshot tracking +/// without going through / +/// , which +/// already does. Treat this +/// interface as a documentation marker that "this read-only state +/// originated from a view-model flow"; collect it via +/// . +/// +/// +public interface IStateFlow : IState +{ +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Icon.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Icon.cs new file mode 100644 index 000000000..3b6ab719e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Icon.cs @@ -0,0 +1,31 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 Icon — renders a small image used for affordance, +/// typically inside a , , +/// or a . +/// +/// Three source types are supported: +/// +/// An +/// AndroidX.Compose.UI.Graphics.Vector.ImageVector obtained +/// from a Compose icon library — routes through the +/// directly-bound IconKt.Icon(ImageVector, …) overload via the +/// generator's Phase 11 secondary-ctor dispatch. +/// An Android drawable resource id — resolved +/// inside Render via painterResource(id) and forwarded +/// through the IconKt.Icon-ww6aTOc(Painter, …) JNI +/// bridge. +/// A pre-resolved +/// AndroidX.Compose.UI.Graphics.Painter.Painter — the +/// caller-owned handle is forwarded directly through the same +/// JNI bridge. +/// +/// +/// +/// Set the Tint property to a packed +/// (implicit-converted to long) to override the icon color. +/// Leave it unset (null) to inherit the surrounding Material +/// content color (Compose's LocalContentColor). +/// +public sealed partial class Icon; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IconButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IconButton.cs new file mode 100644 index 000000000..435e445e8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IconButton.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 IconButton. Children render into a +/// Function2 content slot (no RowScope). Typical use: +/// +/// new IconButton(...) { new Text("☆") } +/// +/// +public sealed partial class IconButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IconToggleButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IconToggleButton.cs new file mode 100644 index 000000000..0feeb14ae --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IconToggleButton.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 IconToggleButton: +/// +/// new IconToggleButton(@checked: state.Value, onCheckedChange: v => state.Value = v) { new Text("★") } +/// +/// +public sealed partial class IconToggleButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Icons.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Icons.cs new file mode 100644 index 000000000..f97bc629c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Icons.cs @@ -0,0 +1,680 @@ +using AndroidX.Compose.UI.Graphics.Vector; + +namespace AndroidX.Compose; + +/// +/// Material Design icons — C# mirror of Kotlin's +/// androidx.compose.material.icons.Icons companion object. +/// +/// Each style class exposes the ~50 most commonly used Material icons +/// from the material-icons-core artifact as static +/// properties — e.g. Icons.Filled.Search, Icons.Outlined.Menu, +/// Icons.AutoMirrored.Filled.ArrowBack. +/// +/// is an alias for , matching +/// Kotlin's Icons.Default = Filled. Pass the result to the +/// constructor. +/// +/// Sourced from Xamarin.AndroidX.Compose.Material.Icons.Core; +/// for the larger catalog see Compose's material-icons-extended +/// artifact (not yet bound). +/// +public static class Icons +{ + /// Material Filled icon style. + public static class Filled + { + /// Material AccountBox icon. + public static ImageVector AccountBox => AndroidX.Compose.Material.Icons.Filled.AccountBoxKt.GetAccountBox(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material AccountCircle icon. + public static ImageVector AccountCircle => AndroidX.Compose.Material.Icons.Filled.AccountCircleKt.GetAccountCircle(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Add icon. + public static ImageVector Add => AndroidX.Compose.Material.Icons.Filled.AddKt.GetAdd(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material AddCircle icon. + public static ImageVector AddCircle => AndroidX.Compose.Material.Icons.Filled.AddCircleKt.GetAddCircle(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material ArrowDropDown icon. + public static ImageVector ArrowDropDown => AndroidX.Compose.Material.Icons.Filled.ArrowDropDownKt.GetArrowDropDown(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Build icon. + public static ImageVector Build => AndroidX.Compose.Material.Icons.Filled.BuildKt.GetBuild(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Call icon. + public static ImageVector Call => AndroidX.Compose.Material.Icons.Filled.CallKt.GetCall(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Check icon. + public static ImageVector Check => AndroidX.Compose.Material.Icons.Filled.CheckKt.GetCheck(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material CheckCircle icon. + public static ImageVector CheckCircle => AndroidX.Compose.Material.Icons.Filled.CheckCircleKt.GetCheckCircle(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Clear icon. + public static ImageVector Clear => AndroidX.Compose.Material.Icons.Filled.ClearKt.GetClear(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Close icon. + public static ImageVector Close => AndroidX.Compose.Material.Icons.Filled.CloseKt.GetClose(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Create icon. + public static ImageVector Create => AndroidX.Compose.Material.Icons.Filled.CreateKt.GetCreate(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material DateRange icon. + public static ImageVector DateRange => AndroidX.Compose.Material.Icons.Filled.DateRangeKt.GetDateRange(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Delete icon. + public static ImageVector Delete => AndroidX.Compose.Material.Icons.Filled.DeleteKt.GetDelete(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Done icon. + public static ImageVector Done => AndroidX.Compose.Material.Icons.Filled.DoneKt.GetDone(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Edit icon. + public static ImageVector Edit => AndroidX.Compose.Material.Icons.Filled.EditKt.GetEdit(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Email icon. + public static ImageVector Email => AndroidX.Compose.Material.Icons.Filled.EmailKt.GetEmail(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Face icon. + public static ImageVector Face => AndroidX.Compose.Material.Icons.Filled.FaceKt.GetFace(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Favorite icon. + public static ImageVector Favorite => AndroidX.Compose.Material.Icons.Filled.FavoriteKt.GetFavorite(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material FavoriteBorder icon. + public static ImageVector FavoriteBorder => AndroidX.Compose.Material.Icons.Filled.FavoriteBorderKt.GetFavoriteBorder(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Home icon. + public static ImageVector Home => AndroidX.Compose.Material.Icons.Filled.HomeKt.GetHome(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Info icon. + public static ImageVector Info => AndroidX.Compose.Material.Icons.Filled.InfoKt.GetInfo(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material KeyboardArrowDown icon. + public static ImageVector KeyboardArrowDown => AndroidX.Compose.Material.Icons.Filled.KeyboardArrowDownKt.GetKeyboardArrowDown(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material KeyboardArrowUp icon. + public static ImageVector KeyboardArrowUp => AndroidX.Compose.Material.Icons.Filled.KeyboardArrowUpKt.GetKeyboardArrowUp(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material LocationOn icon. + public static ImageVector LocationOn => AndroidX.Compose.Material.Icons.Filled.LocationOnKt.GetLocationOn(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Lock icon. + public static ImageVector Lock => AndroidX.Compose.Material.Icons.Filled.LockKt.GetLock(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material MailOutline icon. + public static ImageVector MailOutline => AndroidX.Compose.Material.Icons.Filled.MailOutlineKt.GetMailOutline(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Menu icon. + public static ImageVector Menu => AndroidX.Compose.Material.Icons.Filled.MenuKt.GetMenu(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material MoreVert icon. + public static ImageVector MoreVert => AndroidX.Compose.Material.Icons.Filled.MoreVertKt.GetMoreVert(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Notifications icon. + public static ImageVector Notifications => AndroidX.Compose.Material.Icons.Filled.NotificationsKt.GetNotifications(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Person icon. + public static ImageVector Person => AndroidX.Compose.Material.Icons.Filled.PersonKt.GetPerson(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Phone icon. + public static ImageVector Phone => AndroidX.Compose.Material.Icons.Filled.PhoneKt.GetPhone(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Place icon. + public static ImageVector Place => AndroidX.Compose.Material.Icons.Filled.PlaceKt.GetPlace(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material PlayArrow icon. + public static ImageVector PlayArrow => AndroidX.Compose.Material.Icons.Filled.PlayArrowKt.GetPlayArrow(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Refresh icon. + public static ImageVector Refresh => AndroidX.Compose.Material.Icons.Filled.RefreshKt.GetRefresh(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Search icon. + public static ImageVector Search => AndroidX.Compose.Material.Icons.Filled.SearchKt.GetSearch(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Settings icon. + public static ImageVector Settings => AndroidX.Compose.Material.Icons.Filled.SettingsKt.GetSettings(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Share icon. + public static ImageVector Share => AndroidX.Compose.Material.Icons.Filled.ShareKt.GetShare(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material ShoppingCart icon. + public static ImageVector ShoppingCart => AndroidX.Compose.Material.Icons.Filled.ShoppingCartKt.GetShoppingCart(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Star icon. + public static ImageVector Star => AndroidX.Compose.Material.Icons.Filled.StarKt.GetStar(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material ThumbUp icon. + public static ImageVector ThumbUp => AndroidX.Compose.Material.Icons.Filled.ThumbUpKt.GetThumbUp(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + /// Material Warning icon. + public static ImageVector Warning => AndroidX.Compose.Material.Icons.Filled.WarningKt.GetWarning(AndroidX.Compose.Material.Icons.Icons.Filled.Instance); + } + + /// Material Outlined icon style. + public static class Outlined + { + /// Material AccountBox icon. + public static ImageVector AccountBox => AndroidX.Compose.Material.Icons.Outlined.AccountBoxKt.GetAccountBox(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material AccountCircle icon. + public static ImageVector AccountCircle => AndroidX.Compose.Material.Icons.Outlined.AccountCircleKt.GetAccountCircle(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Add icon. + public static ImageVector Add => AndroidX.Compose.Material.Icons.Outlined.AddKt.GetAdd(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material AddCircle icon. + public static ImageVector AddCircle => AndroidX.Compose.Material.Icons.Outlined.AddCircleKt.GetAddCircle(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material ArrowDropDown icon. + public static ImageVector ArrowDropDown => AndroidX.Compose.Material.Icons.Outlined.ArrowDropDownKt.GetArrowDropDown(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Build icon. + public static ImageVector Build => AndroidX.Compose.Material.Icons.Outlined.BuildKt.GetBuild(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Call icon. + public static ImageVector Call => AndroidX.Compose.Material.Icons.Outlined.CallKt.GetCall(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Check icon. + public static ImageVector Check => AndroidX.Compose.Material.Icons.Outlined.CheckKt.GetCheck(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material CheckCircle icon. + public static ImageVector CheckCircle => AndroidX.Compose.Material.Icons.Outlined.CheckCircleKt.GetCheckCircle(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Clear icon. + public static ImageVector Clear => AndroidX.Compose.Material.Icons.Outlined.ClearKt.GetClear(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Close icon. + public static ImageVector Close => AndroidX.Compose.Material.Icons.Outlined.CloseKt.GetClose(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Create icon. + public static ImageVector Create => AndroidX.Compose.Material.Icons.Outlined.CreateKt.GetCreate(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material DateRange icon. + public static ImageVector DateRange => AndroidX.Compose.Material.Icons.Outlined.DateRangeKt.GetDateRange(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Delete icon. + public static ImageVector Delete => AndroidX.Compose.Material.Icons.Outlined.DeleteKt.GetDelete(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Done icon. + public static ImageVector Done => AndroidX.Compose.Material.Icons.Outlined.DoneKt.GetDone(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Edit icon. + public static ImageVector Edit => AndroidX.Compose.Material.Icons.Outlined.EditKt.GetEdit(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Email icon. + public static ImageVector Email => AndroidX.Compose.Material.Icons.Outlined.EmailKt.GetEmail(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Face icon. + public static ImageVector Face => AndroidX.Compose.Material.Icons.Outlined.FaceKt.GetFace(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Favorite icon. + public static ImageVector Favorite => AndroidX.Compose.Material.Icons.Outlined.FavoriteKt.GetFavorite(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material FavoriteBorder icon. + public static ImageVector FavoriteBorder => AndroidX.Compose.Material.Icons.Outlined.FavoriteBorderKt.GetFavoriteBorder(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Home icon. + public static ImageVector Home => AndroidX.Compose.Material.Icons.Outlined.HomeKt.GetHome(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Info icon. + public static ImageVector Info => AndroidX.Compose.Material.Icons.Outlined.InfoKt.GetInfo(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material KeyboardArrowDown icon. + public static ImageVector KeyboardArrowDown => AndroidX.Compose.Material.Icons.Outlined.KeyboardArrowDownKt.GetKeyboardArrowDown(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material KeyboardArrowUp icon. + public static ImageVector KeyboardArrowUp => AndroidX.Compose.Material.Icons.Outlined.KeyboardArrowUpKt.GetKeyboardArrowUp(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material LocationOn icon. + public static ImageVector LocationOn => AndroidX.Compose.Material.Icons.Outlined.LocationOnKt.GetLocationOn(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Lock icon. + public static ImageVector Lock => AndroidX.Compose.Material.Icons.Outlined.LockKt.GetLock(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material MailOutline icon. + public static ImageVector MailOutline => AndroidX.Compose.Material.Icons.Outlined.MailOutlineKt.GetMailOutline(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Menu icon. + public static ImageVector Menu => AndroidX.Compose.Material.Icons.Outlined.MenuKt.GetMenu(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material MoreVert icon. + public static ImageVector MoreVert => AndroidX.Compose.Material.Icons.Outlined.MoreVertKt.GetMoreVert(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Notifications icon. + public static ImageVector Notifications => AndroidX.Compose.Material.Icons.Outlined.NotificationsKt.GetNotifications(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Person icon. + public static ImageVector Person => AndroidX.Compose.Material.Icons.Outlined.PersonKt.GetPerson(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Phone icon. + public static ImageVector Phone => AndroidX.Compose.Material.Icons.Outlined.PhoneKt.GetPhone(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Place icon. + public static ImageVector Place => AndroidX.Compose.Material.Icons.Outlined.PlaceKt.GetPlace(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material PlayArrow icon. + public static ImageVector PlayArrow => AndroidX.Compose.Material.Icons.Outlined.PlayArrowKt.GetPlayArrow(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Refresh icon. + public static ImageVector Refresh => AndroidX.Compose.Material.Icons.Outlined.RefreshKt.GetRefresh(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Search icon. + public static ImageVector Search => AndroidX.Compose.Material.Icons.Outlined.SearchKt.GetSearch(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Settings icon. + public static ImageVector Settings => AndroidX.Compose.Material.Icons.Outlined.SettingsKt.GetSettings(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Share icon. + public static ImageVector Share => AndroidX.Compose.Material.Icons.Outlined.ShareKt.GetShare(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material ShoppingCart icon. + public static ImageVector ShoppingCart => AndroidX.Compose.Material.Icons.Outlined.ShoppingCartKt.GetShoppingCart(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Star icon. + public static ImageVector Star => AndroidX.Compose.Material.Icons.Outlined.StarKt.GetStar(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material ThumbUp icon. + public static ImageVector ThumbUp => AndroidX.Compose.Material.Icons.Outlined.ThumbUpKt.GetThumbUp(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + /// Material Warning icon. + public static ImageVector Warning => AndroidX.Compose.Material.Icons.Outlined.WarningKt.GetWarning(AndroidX.Compose.Material.Icons.Icons.Outlined.Instance); + } + + /// Material Rounded icon style. + public static class Rounded + { + /// Material AccountBox icon. + public static ImageVector AccountBox => AndroidX.Compose.Material.Icons.Rounded.AccountBoxKt.GetAccountBox(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material AccountCircle icon. + public static ImageVector AccountCircle => AndroidX.Compose.Material.Icons.Rounded.AccountCircleKt.GetAccountCircle(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Add icon. + public static ImageVector Add => AndroidX.Compose.Material.Icons.Rounded.AddKt.GetAdd(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material AddCircle icon. + public static ImageVector AddCircle => AndroidX.Compose.Material.Icons.Rounded.AddCircleKt.GetAddCircle(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material ArrowDropDown icon. + public static ImageVector ArrowDropDown => AndroidX.Compose.Material.Icons.Rounded.ArrowDropDownKt.GetArrowDropDown(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Build icon. + public static ImageVector Build => AndroidX.Compose.Material.Icons.Rounded.BuildKt.GetBuild(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Call icon. + public static ImageVector Call => AndroidX.Compose.Material.Icons.Rounded.CallKt.GetCall(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Check icon. + public static ImageVector Check => AndroidX.Compose.Material.Icons.Rounded.CheckKt.GetCheck(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material CheckCircle icon. + public static ImageVector CheckCircle => AndroidX.Compose.Material.Icons.Rounded.CheckCircleKt.GetCheckCircle(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Clear icon. + public static ImageVector Clear => AndroidX.Compose.Material.Icons.Rounded.ClearKt.GetClear(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Close icon. + public static ImageVector Close => AndroidX.Compose.Material.Icons.Rounded.CloseKt.GetClose(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Create icon. + public static ImageVector Create => AndroidX.Compose.Material.Icons.Rounded.CreateKt.GetCreate(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material DateRange icon. + public static ImageVector DateRange => AndroidX.Compose.Material.Icons.Rounded.DateRangeKt.GetDateRange(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Delete icon. + public static ImageVector Delete => AndroidX.Compose.Material.Icons.Rounded.DeleteKt.GetDelete(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Done icon. + public static ImageVector Done => AndroidX.Compose.Material.Icons.Rounded.DoneKt.GetDone(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Edit icon. + public static ImageVector Edit => AndroidX.Compose.Material.Icons.Rounded.EditKt.GetEdit(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Email icon. + public static ImageVector Email => AndroidX.Compose.Material.Icons.Rounded.EmailKt.GetEmail(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Face icon. + public static ImageVector Face => AndroidX.Compose.Material.Icons.Rounded.FaceKt.GetFace(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Favorite icon. + public static ImageVector Favorite => AndroidX.Compose.Material.Icons.Rounded.FavoriteKt.GetFavorite(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material FavoriteBorder icon. + public static ImageVector FavoriteBorder => AndroidX.Compose.Material.Icons.Rounded.FavoriteBorderKt.GetFavoriteBorder(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Home icon. + public static ImageVector Home => AndroidX.Compose.Material.Icons.Rounded.HomeKt.GetHome(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Info icon. + public static ImageVector Info => AndroidX.Compose.Material.Icons.Rounded.InfoKt.GetInfo(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material KeyboardArrowDown icon. + public static ImageVector KeyboardArrowDown => AndroidX.Compose.Material.Icons.Rounded.KeyboardArrowDownKt.GetKeyboardArrowDown(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material KeyboardArrowUp icon. + public static ImageVector KeyboardArrowUp => AndroidX.Compose.Material.Icons.Rounded.KeyboardArrowUpKt.GetKeyboardArrowUp(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material LocationOn icon. + public static ImageVector LocationOn => AndroidX.Compose.Material.Icons.Rounded.LocationOnKt.GetLocationOn(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Lock icon. + public static ImageVector Lock => AndroidX.Compose.Material.Icons.Rounded.LockKt.GetLock(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material MailOutline icon. + public static ImageVector MailOutline => AndroidX.Compose.Material.Icons.Rounded.MailOutlineKt.GetMailOutline(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Menu icon. + public static ImageVector Menu => AndroidX.Compose.Material.Icons.Rounded.MenuKt.GetMenu(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material MoreVert icon. + public static ImageVector MoreVert => AndroidX.Compose.Material.Icons.Rounded.MoreVertKt.GetMoreVert(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Notifications icon. + public static ImageVector Notifications => AndroidX.Compose.Material.Icons.Rounded.NotificationsKt.GetNotifications(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Person icon. + public static ImageVector Person => AndroidX.Compose.Material.Icons.Rounded.PersonKt.GetPerson(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Phone icon. + public static ImageVector Phone => AndroidX.Compose.Material.Icons.Rounded.PhoneKt.GetPhone(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Place icon. + public static ImageVector Place => AndroidX.Compose.Material.Icons.Rounded.PlaceKt.GetPlace(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material PlayArrow icon. + public static ImageVector PlayArrow => AndroidX.Compose.Material.Icons.Rounded.PlayArrowKt.GetPlayArrow(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Refresh icon. + public static ImageVector Refresh => AndroidX.Compose.Material.Icons.Rounded.RefreshKt.GetRefresh(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Search icon. + public static ImageVector Search => AndroidX.Compose.Material.Icons.Rounded.SearchKt.GetSearch(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Settings icon. + public static ImageVector Settings => AndroidX.Compose.Material.Icons.Rounded.SettingsKt.GetSettings(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Share icon. + public static ImageVector Share => AndroidX.Compose.Material.Icons.Rounded.ShareKt.GetShare(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material ShoppingCart icon. + public static ImageVector ShoppingCart => AndroidX.Compose.Material.Icons.Rounded.ShoppingCartKt.GetShoppingCart(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Star icon. + public static ImageVector Star => AndroidX.Compose.Material.Icons.Rounded.StarKt.GetStar(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material ThumbUp icon. + public static ImageVector ThumbUp => AndroidX.Compose.Material.Icons.Rounded.ThumbUpKt.GetThumbUp(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + /// Material Warning icon. + public static ImageVector Warning => AndroidX.Compose.Material.Icons.Rounded.WarningKt.GetWarning(AndroidX.Compose.Material.Icons.Icons.Rounded.Instance); + } + + /// Material Sharp icon style. + public static class Sharp + { + /// Material AccountBox icon. + public static ImageVector AccountBox => AndroidX.Compose.Material.Icons.Sharp.AccountBoxKt.GetAccountBox(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material AccountCircle icon. + public static ImageVector AccountCircle => AndroidX.Compose.Material.Icons.Sharp.AccountCircleKt.GetAccountCircle(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Add icon. + public static ImageVector Add => AndroidX.Compose.Material.Icons.Sharp.AddKt.GetAdd(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material AddCircle icon. + public static ImageVector AddCircle => AndroidX.Compose.Material.Icons.Sharp.AddCircleKt.GetAddCircle(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material ArrowDropDown icon. + public static ImageVector ArrowDropDown => AndroidX.Compose.Material.Icons.Sharp.ArrowDropDownKt.GetArrowDropDown(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Build icon. + public static ImageVector Build => AndroidX.Compose.Material.Icons.Sharp.BuildKt.GetBuild(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Call icon. + public static ImageVector Call => AndroidX.Compose.Material.Icons.Sharp.CallKt.GetCall(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Check icon. + public static ImageVector Check => AndroidX.Compose.Material.Icons.Sharp.CheckKt.GetCheck(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material CheckCircle icon. + public static ImageVector CheckCircle => AndroidX.Compose.Material.Icons.Sharp.CheckCircleKt.GetCheckCircle(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Clear icon. + public static ImageVector Clear => AndroidX.Compose.Material.Icons.Sharp.ClearKt.GetClear(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Close icon. + public static ImageVector Close => AndroidX.Compose.Material.Icons.Sharp.CloseKt.GetClose(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Create icon. + public static ImageVector Create => AndroidX.Compose.Material.Icons.Sharp.CreateKt.GetCreate(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material DateRange icon. + public static ImageVector DateRange => AndroidX.Compose.Material.Icons.Sharp.DateRangeKt.GetDateRange(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Delete icon. + public static ImageVector Delete => AndroidX.Compose.Material.Icons.Sharp.DeleteKt.GetDelete(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Done icon. + public static ImageVector Done => AndroidX.Compose.Material.Icons.Sharp.DoneKt.GetDone(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Edit icon. + public static ImageVector Edit => AndroidX.Compose.Material.Icons.Sharp.EditKt.GetEdit(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Email icon. + public static ImageVector Email => AndroidX.Compose.Material.Icons.Sharp.EmailKt.GetEmail(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Face icon. + public static ImageVector Face => AndroidX.Compose.Material.Icons.Sharp.FaceKt.GetFace(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Favorite icon. + public static ImageVector Favorite => AndroidX.Compose.Material.Icons.Sharp.FavoriteKt.GetFavorite(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material FavoriteBorder icon. + public static ImageVector FavoriteBorder => AndroidX.Compose.Material.Icons.Sharp.FavoriteBorderKt.GetFavoriteBorder(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Home icon. + public static ImageVector Home => AndroidX.Compose.Material.Icons.Sharp.HomeKt.GetHome(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Info icon. + public static ImageVector Info => AndroidX.Compose.Material.Icons.Sharp.InfoKt.GetInfo(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material KeyboardArrowDown icon. + public static ImageVector KeyboardArrowDown => AndroidX.Compose.Material.Icons.Sharp.KeyboardArrowDownKt.GetKeyboardArrowDown(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material KeyboardArrowUp icon. + public static ImageVector KeyboardArrowUp => AndroidX.Compose.Material.Icons.Sharp.KeyboardArrowUpKt.GetKeyboardArrowUp(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material LocationOn icon. + public static ImageVector LocationOn => AndroidX.Compose.Material.Icons.Sharp.LocationOnKt.GetLocationOn(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Lock icon. + public static ImageVector Lock => AndroidX.Compose.Material.Icons.Sharp.LockKt.GetLock(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material MailOutline icon. + public static ImageVector MailOutline => AndroidX.Compose.Material.Icons.Sharp.MailOutlineKt.GetMailOutline(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Menu icon. + public static ImageVector Menu => AndroidX.Compose.Material.Icons.Sharp.MenuKt.GetMenu(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material MoreVert icon. + public static ImageVector MoreVert => AndroidX.Compose.Material.Icons.Sharp.MoreVertKt.GetMoreVert(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Notifications icon. + public static ImageVector Notifications => AndroidX.Compose.Material.Icons.Sharp.NotificationsKt.GetNotifications(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Person icon. + public static ImageVector Person => AndroidX.Compose.Material.Icons.Sharp.PersonKt.GetPerson(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Phone icon. + public static ImageVector Phone => AndroidX.Compose.Material.Icons.Sharp.PhoneKt.GetPhone(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Place icon. + public static ImageVector Place => AndroidX.Compose.Material.Icons.Sharp.PlaceKt.GetPlace(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material PlayArrow icon. + public static ImageVector PlayArrow => AndroidX.Compose.Material.Icons.Sharp.PlayArrowKt.GetPlayArrow(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Refresh icon. + public static ImageVector Refresh => AndroidX.Compose.Material.Icons.Sharp.RefreshKt.GetRefresh(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Search icon. + public static ImageVector Search => AndroidX.Compose.Material.Icons.Sharp.SearchKt.GetSearch(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Settings icon. + public static ImageVector Settings => AndroidX.Compose.Material.Icons.Sharp.SettingsKt.GetSettings(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Share icon. + public static ImageVector Share => AndroidX.Compose.Material.Icons.Sharp.ShareKt.GetShare(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material ShoppingCart icon. + public static ImageVector ShoppingCart => AndroidX.Compose.Material.Icons.Sharp.ShoppingCartKt.GetShoppingCart(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Star icon. + public static ImageVector Star => AndroidX.Compose.Material.Icons.Sharp.StarKt.GetStar(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material ThumbUp icon. + public static ImageVector ThumbUp => AndroidX.Compose.Material.Icons.Sharp.ThumbUpKt.GetThumbUp(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + /// Material Warning icon. + public static ImageVector Warning => AndroidX.Compose.Material.Icons.Sharp.WarningKt.GetWarning(AndroidX.Compose.Material.Icons.Icons.Sharp.Instance); + } + + /// Material TwoTone icon style. + public static class TwoTone + { + /// Material AccountBox icon. + public static ImageVector AccountBox => AndroidX.Compose.Material.Icons.TwoTone.AccountBoxKt.GetAccountBox(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material AccountCircle icon. + public static ImageVector AccountCircle => AndroidX.Compose.Material.Icons.TwoTone.AccountCircleKt.GetAccountCircle(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Add icon. + public static ImageVector Add => AndroidX.Compose.Material.Icons.TwoTone.AddKt.GetAdd(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material AddCircle icon. + public static ImageVector AddCircle => AndroidX.Compose.Material.Icons.TwoTone.AddCircleKt.GetAddCircle(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material ArrowDropDown icon. + public static ImageVector ArrowDropDown => AndroidX.Compose.Material.Icons.TwoTone.ArrowDropDownKt.GetArrowDropDown(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Build icon. + public static ImageVector Build => AndroidX.Compose.Material.Icons.TwoTone.BuildKt.GetBuild(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Call icon. + public static ImageVector Call => AndroidX.Compose.Material.Icons.TwoTone.CallKt.GetCall(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Check icon. + public static ImageVector Check => AndroidX.Compose.Material.Icons.TwoTone.CheckKt.GetCheck(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material CheckCircle icon. + public static ImageVector CheckCircle => AndroidX.Compose.Material.Icons.TwoTone.CheckCircleKt.GetCheckCircle(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Clear icon. + public static ImageVector Clear => AndroidX.Compose.Material.Icons.TwoTone.ClearKt.GetClear(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Close icon. + public static ImageVector Close => AndroidX.Compose.Material.Icons.TwoTone.CloseKt.GetClose(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Create icon. + public static ImageVector Create => AndroidX.Compose.Material.Icons.TwoTone.CreateKt.GetCreate(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material DateRange icon. + public static ImageVector DateRange => AndroidX.Compose.Material.Icons.TwoTone.DateRangeKt.GetDateRange(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Delete icon. + public static ImageVector Delete => AndroidX.Compose.Material.Icons.TwoTone.DeleteKt.GetDelete(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Done icon. + public static ImageVector Done => AndroidX.Compose.Material.Icons.TwoTone.DoneKt.GetDone(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Edit icon. + public static ImageVector Edit => AndroidX.Compose.Material.Icons.TwoTone.EditKt.GetEdit(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Email icon. + public static ImageVector Email => AndroidX.Compose.Material.Icons.TwoTone.EmailKt.GetEmail(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Face icon. + public static ImageVector Face => AndroidX.Compose.Material.Icons.TwoTone.FaceKt.GetFace(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Favorite icon. + public static ImageVector Favorite => AndroidX.Compose.Material.Icons.TwoTone.FavoriteKt.GetFavorite(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material FavoriteBorder icon. + public static ImageVector FavoriteBorder => AndroidX.Compose.Material.Icons.TwoTone.FavoriteBorderKt.GetFavoriteBorder(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Home icon. + public static ImageVector Home => AndroidX.Compose.Material.Icons.TwoTone.HomeKt.GetHome(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Info icon. + public static ImageVector Info => AndroidX.Compose.Material.Icons.TwoTone.InfoKt.GetInfo(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material KeyboardArrowDown icon. + public static ImageVector KeyboardArrowDown => AndroidX.Compose.Material.Icons.TwoTone.KeyboardArrowDownKt.GetKeyboardArrowDown(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material KeyboardArrowUp icon. + public static ImageVector KeyboardArrowUp => AndroidX.Compose.Material.Icons.TwoTone.KeyboardArrowUpKt.GetKeyboardArrowUp(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material LocationOn icon. + public static ImageVector LocationOn => AndroidX.Compose.Material.Icons.TwoTone.LocationOnKt.GetLocationOn(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Lock icon. + public static ImageVector Lock => AndroidX.Compose.Material.Icons.TwoTone.LockKt.GetLock(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material MailOutline icon. + public static ImageVector MailOutline => AndroidX.Compose.Material.Icons.TwoTone.MailOutlineKt.GetMailOutline(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Menu icon. + public static ImageVector Menu => AndroidX.Compose.Material.Icons.TwoTone.MenuKt.GetMenu(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material MoreVert icon. + public static ImageVector MoreVert => AndroidX.Compose.Material.Icons.TwoTone.MoreVertKt.GetMoreVert(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Notifications icon. + public static ImageVector Notifications => AndroidX.Compose.Material.Icons.TwoTone.NotificationsKt.GetNotifications(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Person icon. + public static ImageVector Person => AndroidX.Compose.Material.Icons.TwoTone.PersonKt.GetPerson(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Phone icon. + public static ImageVector Phone => AndroidX.Compose.Material.Icons.TwoTone.PhoneKt.GetPhone(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Place icon. + public static ImageVector Place => AndroidX.Compose.Material.Icons.TwoTone.PlaceKt.GetPlace(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material PlayArrow icon. + public static ImageVector PlayArrow => AndroidX.Compose.Material.Icons.TwoTone.PlayArrowKt.GetPlayArrow(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Refresh icon. + public static ImageVector Refresh => AndroidX.Compose.Material.Icons.TwoTone.RefreshKt.GetRefresh(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Search icon. + public static ImageVector Search => AndroidX.Compose.Material.Icons.TwoTone.SearchKt.GetSearch(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Settings icon. + public static ImageVector Settings => AndroidX.Compose.Material.Icons.TwoTone.SettingsKt.GetSettings(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Share icon. + public static ImageVector Share => AndroidX.Compose.Material.Icons.TwoTone.ShareKt.GetShare(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material ShoppingCart icon. + public static ImageVector ShoppingCart => AndroidX.Compose.Material.Icons.TwoTone.ShoppingCartKt.GetShoppingCart(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Star icon. + public static ImageVector Star => AndroidX.Compose.Material.Icons.TwoTone.StarKt.GetStar(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material ThumbUp icon. + public static ImageVector ThumbUp => AndroidX.Compose.Material.Icons.TwoTone.ThumbUpKt.GetThumbUp(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + /// Material Warning icon. + public static ImageVector Warning => AndroidX.Compose.Material.Icons.TwoTone.WarningKt.GetWarning(AndroidX.Compose.Material.Icons.Icons.TwoTone.Instance); + } + + /// Alias for — matches Kotlin's Icons.Default. + public static class Default + { + /// + public static ImageVector AccountBox => Filled.AccountBox; + /// + public static ImageVector AccountCircle => Filled.AccountCircle; + /// + public static ImageVector Add => Filled.Add; + /// + public static ImageVector AddCircle => Filled.AddCircle; + /// + public static ImageVector ArrowDropDown => Filled.ArrowDropDown; + /// + public static ImageVector Build => Filled.Build; + /// + public static ImageVector Call => Filled.Call; + /// + public static ImageVector Check => Filled.Check; + /// + public static ImageVector CheckCircle => Filled.CheckCircle; + /// + public static ImageVector Clear => Filled.Clear; + /// + public static ImageVector Close => Filled.Close; + /// + public static ImageVector Create => Filled.Create; + /// + public static ImageVector DateRange => Filled.DateRange; + /// + public static ImageVector Delete => Filled.Delete; + /// + public static ImageVector Done => Filled.Done; + /// + public static ImageVector Edit => Filled.Edit; + /// + public static ImageVector Email => Filled.Email; + /// + public static ImageVector Face => Filled.Face; + /// + public static ImageVector Favorite => Filled.Favorite; + /// + public static ImageVector FavoriteBorder => Filled.FavoriteBorder; + /// + public static ImageVector Home => Filled.Home; + /// + public static ImageVector Info => Filled.Info; + /// + public static ImageVector KeyboardArrowDown => Filled.KeyboardArrowDown; + /// + public static ImageVector KeyboardArrowUp => Filled.KeyboardArrowUp; + /// + public static ImageVector LocationOn => Filled.LocationOn; + /// + public static ImageVector Lock => Filled.Lock; + /// + public static ImageVector MailOutline => Filled.MailOutline; + /// + public static ImageVector Menu => Filled.Menu; + /// + public static ImageVector MoreVert => Filled.MoreVert; + /// + public static ImageVector Notifications => Filled.Notifications; + /// + public static ImageVector Person => Filled.Person; + /// + public static ImageVector Phone => Filled.Phone; + /// + public static ImageVector Place => Filled.Place; + /// + public static ImageVector PlayArrow => Filled.PlayArrow; + /// + public static ImageVector Refresh => Filled.Refresh; + /// + public static ImageVector Search => Filled.Search; + /// + public static ImageVector Settings => Filled.Settings; + /// + public static ImageVector Share => Filled.Share; + /// + public static ImageVector ShoppingCart => Filled.ShoppingCart; + /// + public static ImageVector Star => Filled.Star; + /// + public static ImageVector ThumbUp => Filled.ThumbUp; + /// + public static ImageVector Warning => Filled.Warning; + } + + /// + /// Auto-mirrored icons — variants that flip horizontally when the + /// active LayoutDirection is RTL. Prefer these over the + /// non-mirrored counterparts for directional affordances like back + /// arrows and list bullets. + /// + public static class AutoMirrored + { + /// Auto-mirrored Filled icon style. + public static class Filled + { + /// Auto-mirrored ArrowBack icon. + public static ImageVector ArrowBack => AndroidX.Compose.Material.Icons.AutoMirrored.Filled.ArrowBackKt.GetArrowBack(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Filled.Instance); + /// Auto-mirrored ArrowForward icon. + public static ImageVector ArrowForward => AndroidX.Compose.Material.Icons.AutoMirrored.Filled.ArrowForwardKt.GetArrowForward(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Filled.Instance); + /// Auto-mirrored ExitToApp icon. + public static ImageVector ExitToApp => AndroidX.Compose.Material.Icons.AutoMirrored.Filled.ExitToAppKt.GetExitToApp(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Filled.Instance); + /// Auto-mirrored KeyboardArrowLeft icon. + public static ImageVector KeyboardArrowLeft => AndroidX.Compose.Material.Icons.AutoMirrored.Filled.KeyboardArrowLeftKt.GetKeyboardArrowLeft(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Filled.Instance); + /// Auto-mirrored KeyboardArrowRight icon. + public static ImageVector KeyboardArrowRight => AndroidX.Compose.Material.Icons.AutoMirrored.Filled.KeyboardArrowRightKt.GetKeyboardArrowRight(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Filled.Instance); + /// Auto-mirrored List icon. + public static ImageVector List => AndroidX.Compose.Material.Icons.AutoMirrored.Filled.ListKt.GetList(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Filled.Instance); + /// Auto-mirrored Send icon. + public static ImageVector Send => AndroidX.Compose.Material.Icons.AutoMirrored.Filled.SendKt.GetSend(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Filled.Instance); + } + + /// Auto-mirrored Outlined icon style. + public static class Outlined + { + /// Auto-mirrored ArrowBack icon. + public static ImageVector ArrowBack => AndroidX.Compose.Material.Icons.AutoMirrored.Outlined.ArrowBackKt.GetArrowBack(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Outlined.Instance); + /// Auto-mirrored ArrowForward icon. + public static ImageVector ArrowForward => AndroidX.Compose.Material.Icons.AutoMirrored.Outlined.ArrowForwardKt.GetArrowForward(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Outlined.Instance); + /// Auto-mirrored ExitToApp icon. + public static ImageVector ExitToApp => AndroidX.Compose.Material.Icons.AutoMirrored.Outlined.ExitToAppKt.GetExitToApp(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Outlined.Instance); + /// Auto-mirrored KeyboardArrowLeft icon. + public static ImageVector KeyboardArrowLeft => AndroidX.Compose.Material.Icons.AutoMirrored.Outlined.KeyboardArrowLeftKt.GetKeyboardArrowLeft(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Outlined.Instance); + /// Auto-mirrored KeyboardArrowRight icon. + public static ImageVector KeyboardArrowRight => AndroidX.Compose.Material.Icons.AutoMirrored.Outlined.KeyboardArrowRightKt.GetKeyboardArrowRight(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Outlined.Instance); + /// Auto-mirrored List icon. + public static ImageVector List => AndroidX.Compose.Material.Icons.AutoMirrored.Outlined.ListKt.GetList(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Outlined.Instance); + /// Auto-mirrored Send icon. + public static ImageVector Send => AndroidX.Compose.Material.Icons.AutoMirrored.Outlined.SendKt.GetSend(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Outlined.Instance); + } + + /// Auto-mirrored Rounded icon style. + public static class Rounded + { + /// Auto-mirrored ArrowBack icon. + public static ImageVector ArrowBack => AndroidX.Compose.Material.Icons.AutoMirrored.Rounded.ArrowBackKt.GetArrowBack(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Rounded.Instance); + /// Auto-mirrored ArrowForward icon. + public static ImageVector ArrowForward => AndroidX.Compose.Material.Icons.AutoMirrored.Rounded.ArrowForwardKt.GetArrowForward(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Rounded.Instance); + /// Auto-mirrored ExitToApp icon. + public static ImageVector ExitToApp => AndroidX.Compose.Material.Icons.AutoMirrored.Rounded.ExitToAppKt.GetExitToApp(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Rounded.Instance); + /// Auto-mirrored KeyboardArrowLeft icon. + public static ImageVector KeyboardArrowLeft => AndroidX.Compose.Material.Icons.AutoMirrored.Rounded.KeyboardArrowLeftKt.GetKeyboardArrowLeft(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Rounded.Instance); + /// Auto-mirrored KeyboardArrowRight icon. + public static ImageVector KeyboardArrowRight => AndroidX.Compose.Material.Icons.AutoMirrored.Rounded.KeyboardArrowRightKt.GetKeyboardArrowRight(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Rounded.Instance); + /// Auto-mirrored List icon. + public static ImageVector List => AndroidX.Compose.Material.Icons.AutoMirrored.Rounded.ListKt.GetList(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Rounded.Instance); + /// Auto-mirrored Send icon. + public static ImageVector Send => AndroidX.Compose.Material.Icons.AutoMirrored.Rounded.SendKt.GetSend(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Rounded.Instance); + } + + /// Auto-mirrored Sharp icon style. + public static class Sharp + { + /// Auto-mirrored ArrowBack icon. + public static ImageVector ArrowBack => AndroidX.Compose.Material.Icons.AutoMirrored.Sharp.ArrowBackKt.GetArrowBack(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Sharp.Instance); + /// Auto-mirrored ArrowForward icon. + public static ImageVector ArrowForward => AndroidX.Compose.Material.Icons.AutoMirrored.Sharp.ArrowForwardKt.GetArrowForward(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Sharp.Instance); + /// Auto-mirrored ExitToApp icon. + public static ImageVector ExitToApp => AndroidX.Compose.Material.Icons.AutoMirrored.Sharp.ExitToAppKt.GetExitToApp(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Sharp.Instance); + /// Auto-mirrored KeyboardArrowLeft icon. + public static ImageVector KeyboardArrowLeft => AndroidX.Compose.Material.Icons.AutoMirrored.Sharp.KeyboardArrowLeftKt.GetKeyboardArrowLeft(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Sharp.Instance); + /// Auto-mirrored KeyboardArrowRight icon. + public static ImageVector KeyboardArrowRight => AndroidX.Compose.Material.Icons.AutoMirrored.Sharp.KeyboardArrowRightKt.GetKeyboardArrowRight(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Sharp.Instance); + /// Auto-mirrored List icon. + public static ImageVector List => AndroidX.Compose.Material.Icons.AutoMirrored.Sharp.ListKt.GetList(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Sharp.Instance); + /// Auto-mirrored Send icon. + public static ImageVector Send => AndroidX.Compose.Material.Icons.AutoMirrored.Sharp.SendKt.GetSend(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.Sharp.Instance); + } + + /// Auto-mirrored TwoTone icon style. + public static class TwoTone + { + /// Auto-mirrored ArrowBack icon. + public static ImageVector ArrowBack => AndroidX.Compose.Material.Icons.AutoMirrored.TwoTone.ArrowBackKt.GetArrowBack(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.TwoTone.Instance); + /// Auto-mirrored ArrowForward icon. + public static ImageVector ArrowForward => AndroidX.Compose.Material.Icons.AutoMirrored.TwoTone.ArrowForwardKt.GetArrowForward(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.TwoTone.Instance); + /// Auto-mirrored ExitToApp icon. + public static ImageVector ExitToApp => AndroidX.Compose.Material.Icons.AutoMirrored.TwoTone.ExitToAppKt.GetExitToApp(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.TwoTone.Instance); + /// Auto-mirrored KeyboardArrowLeft icon. + public static ImageVector KeyboardArrowLeft => AndroidX.Compose.Material.Icons.AutoMirrored.TwoTone.KeyboardArrowLeftKt.GetKeyboardArrowLeft(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.TwoTone.Instance); + /// Auto-mirrored KeyboardArrowRight icon. + public static ImageVector KeyboardArrowRight => AndroidX.Compose.Material.Icons.AutoMirrored.TwoTone.KeyboardArrowRightKt.GetKeyboardArrowRight(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.TwoTone.Instance); + /// Auto-mirrored List icon. + public static ImageVector List => AndroidX.Compose.Material.Icons.AutoMirrored.TwoTone.ListKt.GetList(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.TwoTone.Instance); + /// Auto-mirrored Send icon. + public static ImageVector Send => AndroidX.Compose.Material.Icons.AutoMirrored.TwoTone.SendKt.GetSend(AndroidX.Compose.Material.Icons.Icons.AutoMirrored.TwoTone.Instance); + } + + /// Alias for . + public static class Default + { + /// + public static ImageVector ArrowBack => Filled.ArrowBack; + /// + public static ImageVector ArrowForward => Filled.ArrowForward; + /// + public static ImageVector ExitToApp => Filled.ExitToApp; + /// + public static ImageVector KeyboardArrowLeft => Filled.KeyboardArrowLeft; + /// + public static ImageVector KeyboardArrowRight => Filled.KeyboardArrowRight; + /// + public static ImageVector List => Filled.List; + /// + public static ImageVector Send => Filled.Send; + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Image.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Image.cs new file mode 100644 index 000000000..188225797 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Image.cs @@ -0,0 +1,39 @@ +namespace AndroidX.Compose; + +/// +/// Foundation Image composable. Two ctor shapes are exposed: +/// +/// +/// +/// — pass an Android drawable +/// resource id; the bridge resolves it via +/// painterResource(id, composer) inside the composition +/// and forwards the Painter to +/// ImageKt.Image(Painter, ...). This is the common case. +/// +/// +/// +/// +/// +/// — pass a pre-resolved Compose Painter. Useful when the +/// image isn't packaged as a drawable resource: e.g. a +/// BitmapPainter wrapping an arbitrary +/// android.graphics.Bitmap, or a Painter materialized +/// from an asynchronous load. +/// +/// +/// +/// The Painter type itself is bound by +/// Xamarin.AndroidX.Compose.UI.Graphics; only the +/// ImageKt.Image-... overloads (mangled by an inline-class +/// param) need the JNI bridge for direct invocation. +/// +public sealed partial class Image +{ + /// Render an Android drawable resource with no content description (decorative). + public Image(int drawableResourceId) : this(drawableResourceId, null) { } + + /// Render a pre-resolved Compose Painter with no content description (decorative). + public Image(global::AndroidX.Compose.UI.Graphics.Painter.Painter painter) + : this(painter, null) { } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ImeAction.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ImeAction.cs new file mode 100644 index 000000000..8989389c1 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ImeAction.cs @@ -0,0 +1,90 @@ +using Android.Runtime; +using BoundCompanion = AndroidX.Compose.UI.Text.Input.ImeAction.Companion; + +namespace AndroidX.Compose; + +/// +/// C# accessor for Compose's soft-keyboard action enumeration — +/// androidx.compose.ui.text.input.ImeAction. Surfaces every +/// constant on the Kotlin ImeAction.Companion +/// (Done, Search, Send, Go, Next, +/// Previous, etc.) as a named int property so call sites +/// can write +/// field.KeyboardOptions = d.Copy(imeAction: ImeAction.Search, …) +/// instead of hand-coded magic numbers. +/// +/// In Kotlin source ImeAction is a @JvmInline value +/// class wrapping an Int. Every bound API that consumes one +/// (e.g. KeyboardOptions.Copy(int keyboardType, int imeAction, …)) +/// takes a raw int at the JNI boundary, so this type +/// intentionally exposes the raw int directly — no box-impl +/// indirection required. +/// +/// Internals: +/// and its nested are fully bound +/// (every Search-eUduSuo-style getter surfaces as an int +/// property on the Companion peer). The only thing missing is a +/// static Companion Companion { get; } accessor on the outer +/// class — Mono's binder skips it for Kotlin object companions +/// on @JvmInline value class outers +/// (). +/// So we bootstrap the singleton handle exactly once via JNI +/// (GetStaticObjectField), wrap it as the bound Companion peer, +/// and every constant accessor below routes through that peer's +/// generated InvokeNonvirtualInt32Method. Same pattern as +/// . +/// +public static class ImeAction +{ + static BoundCompanion? s_companion; + + static BoundCompanion Companion() + { + if (s_companion is not null) return s_companion; + IntPtr local = IntPtr.Zero; + try + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/input/ImeAction"); + IntPtr fid = JNIEnv.GetStaticFieldID(cls, "Companion", + "Landroidx/compose/ui/text/input/ImeAction$Companion;"); + local = JNIEnv.GetStaticObjectField(cls, fid); + return s_companion = Java.Lang.Object.GetObject( + local, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + // GetObject(.., TransferLocalRef) consumes `local` on success; + // the explicit DeleteLocalRef only runs if the wrapper threw + // before taking ownership. + if (local != IntPtr.Zero && s_companion is null) + JNIEnv.DeleteLocalRef(local); + } + } + + /// Unspecified IME action — equivalent to . + public static int Unspecified { get; } = Companion().Unspecified_eUduSuo; + + /// Platform-default IME action (typically a newline / next-line). + public static int Default { get; } = Companion().Default_eUduSuo; + + /// Suppresses the IME action button entirely. + public static int None { get; } = Companion().None_eUduSuo; + + /// Confirm-and-go action — surfaces a "Go" key on the IME. + public static int Go { get; } = Companion().Go_eUduSuo; + + /// Search action — surfaces a magnifier "Search" key on the IME. + public static int Search { get; } = Companion().Search_eUduSuo; + + /// Send action — surfaces a paper-plane "Send" key on the IME. + public static int Send { get; } = Companion().Send_eUduSuo; + + /// Move-to-previous-field action. + public static int Previous { get; } = Companion().Previous_eUduSuo; + + /// Move-to-next-field action. + public static int Next { get; } = Companion().Next_eUduSuo; + + /// Done / dismiss-keyboard action. + public static int Done { get; } = Companion().Done_eUduSuo; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/InputChip.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/InputChip.cs new file mode 100644 index 000000000..6f7b1fe17 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/InputChip.cs @@ -0,0 +1,7 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 InputChip. Adds an Avatar slot in +/// addition to the leading/trailing slots common to the chip family. +/// +public sealed partial class InputChip; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IntCallback.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IntCallback.cs new file mode 100644 index 000000000..075308575 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/IntCallback.cs @@ -0,0 +1,26 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW adapter implementing Function1<Integer, Integer>, used +/// by the slideIn* / slideOut* transition factories whose +/// offset parameter is a Kotlin (Int) -> Int lambda mapping the +/// container's measured width/height to the slide's initial/target +/// offset in pixels. Boxed via java.lang.Integer at the JNI +/// boundary. +/// +[Register("net/compose/IntCallback")] +internal sealed class IntCallback : Java.Lang.Object, IFunction1 +{ + readonly Func _body; + + public IntCallback(Func body) => _body = body; + + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + int size = p0 is Java.Lang.Integer i ? i.IntValue() : 0; + return Java.Lang.Integer.ValueOf(_body(size))!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Java/PointerInputEventHandlerImpl.java b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Java/PointerInputEventHandlerImpl.java new file mode 100644 index 000000000..19880eec3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Java/PointerInputEventHandlerImpl.java @@ -0,0 +1,35 @@ +package net.compose; + +import androidx.compose.ui.input.pointer.PointerInputEventHandler; +import androidx.compose.ui.input.pointer.PointerInputScope; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import kotlin.jvm.functions.Function2; + +/** + * Java-side bridge that implements the empty + * {@code androidx.compose.ui.input.pointer.PointerInputEventHandler} + * interface (whose only abstract method is the suspend + * {@code invoke(PointerInputScope)}, exposed in bytecode as the + * {@code Object invoke(PointerInputScope, Continuation)} pair). + * + *

The .NET-for-Android binder strips + * {@code PointerInputEventHandler.invoke} because its receiver is a + * value-class-mangled type, so we can't implement it directly from C# + * with the standard Android Callable Wrapper pipeline. Instead we + * forward to a plain {@code Function2}, which IS bound and easy to + * implement from C# (see {@code AndroidX.Compose.PointerInputBlock}).

+ */ +final class PointerInputEventHandlerImpl implements PointerInputEventHandler { + private final Function2, Object> block; + + public PointerInputEventHandlerImpl( + Function2, Object> block) { + this.block = block; + } + + @Override + public Object invoke(PointerInputScope scope, Continuation continuation) { + return block.invoke(scope, continuation); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/JobCompletionHandler.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/JobCompletionHandler.cs new file mode 100644 index 000000000..9b5d770eb --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/JobCompletionHandler.cs @@ -0,0 +1,43 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW for Function1<Throwable?, Unit> registered via +/// . +/// Compose invokes this handler when the launched coroutine's job +/// transitions into the cancelling state, so we can propagate +/// cancellation into the C# +/// that the user's Func<CancellationToken, Task> body +/// observes. +/// +[Register("net/compose/JobCompletionHandler")] +internal sealed class JobCompletionHandler : Java.Lang.Object, IFunction1 +{ + readonly CancellationTokenSource _cts; + + public JobCompletionHandler(CancellationTokenSource cts) + { + _cts = cts; + } + + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + // p0 is the Throwable cause (or null for normal completion). + // Either way we cancel the CTS — the user's Task body sees + // ct.IsCancellationRequested and exits cooperatively. + try + { + _cts.Cancel(); + } + catch + { + // Cancel is best-effort. A racing Dispose on the CTS or + // an exception raised by a registered callback should + // never propagate out into the JVM (Kotlin would crash + // the process with an UnhandledException). Swallow. + } + return Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardActionsHelper.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardActionsHelper.cs new file mode 100644 index 000000000..164a8a326 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardActionsHelper.cs @@ -0,0 +1,51 @@ +using AndroidXKeyboardActions = AndroidX.Compose.Foundation.Text.KeyboardActions; + +namespace AndroidX.Compose; + +/// +/// C#-friendly factory for Compose's +/// — +/// the per-IME-action callback bag passed to +/// / +/// 's keyboardActions slot. +/// +/// +/// The bound +/// +/// ctor takes IFunction1? parameters whose only consumer is +/// the internal ComposableLambda1 JCW. ComposableLambda1 +/// is intentionally internal (it's a generated net.compose.* +/// JCW; making it public would freeze the JCW class name into the +/// public API). This helper wraps each non-null +/// in that internal JCW and forwards to +/// the bound ctor — call sites elsewhere stay free of JNI plumbing. +/// +/// Each callback receives no payload because Kotlin's +/// KeyboardActionScope only exposes a +/// defaultKeyboardAction() trampoline back to Compose's +/// platform-default behaviour, which we never want to invoke from +/// managed code (the action key is exactly what we're overriding). +/// +public static class KeyboardActionsHelper +{ + /// + /// Build a wrapping the + /// supplied callbacks. Any null slot keeps Compose's + /// default (which on the soft-keyboard side dismisses the IME for + /// every action except None/Default). + /// + public static AndroidXKeyboardActions Create( + Action? onDone = null, + Action? onGo = null, + Action? onNext = null, + Action? onPrevious = null, + Action? onSearch = null, + Action? onSend = null) => + new AndroidXKeyboardActions( + onDone: onDone is null ? null : new ComposableLambda1(_ => onDone()), + onGo: onGo is null ? null : new ComposableLambda1(_ => onGo()), + onNext: onNext is null ? null : new ComposableLambda1(_ => onNext()), + onPrevious: onPrevious is null ? null : new ComposableLambda1(_ => onPrevious()), + onSearch: onSearch is null ? null : new ComposableLambda1(_ => onSearch()), + onSend: onSend is null ? null : new ComposableLambda1(_ => onSend())); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardOptionsCompanion.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardOptionsCompanion.cs new file mode 100644 index 000000000..4d0438132 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardOptionsCompanion.cs @@ -0,0 +1,71 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Cached accessor for androidx.compose.foundation.text.KeyboardOptions.Companion — +/// the singleton that exposes Default (the immutable +/// KeyboardOptions instance shared by every Compose text field +/// when callers haven't overridden the IME slot). +/// +/// The Mono binding for KeyboardOptions generates the +/// Companion nested class but does NOT surface a static field +/// accessor on the outer type (Kotlin object companions usually +/// land as a Companion getter — the binder skips it here). The +/// outer class also has no public ctor (the Kotlin one takes +/// @JvmInline value class params and is stripped) and no +/// KeyboardOptionsKt static helper — JNI lookup of the +/// Companion field is the only path to bootstrap an instance. +/// After that, everything flows through bound APIs +/// (Companion.Default, Default.Copy(int keyboardType, …), +/// etc.). +/// +/// Mirrors ; intentionally exposed +/// public (vs. 's +/// internal) so callers building their own +/// +/// (e.g. MAUI EntryHandler, gallery numeric-keyboard demo) can +/// reach without re-implementing the JNI dance +/// or pulling in an InternalsVisibleTo hook. +/// +public static class KeyboardOptionsCompanion +{ + static AndroidX.Compose.Foundation.Text.KeyboardOptions.Companion? s_companion; + static AndroidX.Compose.Foundation.Text.KeyboardOptions? s_default; + + /// + /// Resolve and cache the KeyboardOptions.Companion singleton. + /// First call performs the JNI field lookup; subsequent calls + /// return the cached wrapper. + /// + public static AndroidX.Compose.Foundation.Text.KeyboardOptions.Companion Get() + { + if (s_companion is not null) return s_companion; + IntPtr local = IntPtr.Zero; + try + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/foundation/text/KeyboardOptions"); + IntPtr fid = JNIEnv.GetStaticFieldID(cls, "Companion", + "Landroidx/compose/foundation/text/KeyboardOptions$Companion;"); + local = JNIEnv.GetStaticObjectField(cls, fid); + return s_companion = Java.Lang.Object.GetObject( + local, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + // GetObject(.., TransferLocalRef) consumes `local` on success; + // the explicit DeleteLocalRef only runs if the wrapper threw + // before taking ownership. + if (local != IntPtr.Zero && s_companion is null) + JNIEnv.DeleteLocalRef(local); + } + } + + /// + /// The default KeyboardOptions instance — equivalent to + /// Kotlin's KeyboardOptions.Default. Use as the starting + /// point for .Copy(...) overrides. + /// + public static AndroidX.Compose.Foundation.Text.KeyboardOptions Default => + s_default ??= Get().Default; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardType.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardType.cs new file mode 100644 index 000000000..b3c638d0d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KeyboardType.cs @@ -0,0 +1,91 @@ +using Android.Runtime; +using BoundCompanion = AndroidX.Compose.UI.Text.Input.KeyboardType.Companion; + +namespace AndroidX.Compose; + +/// +/// C# accessor for Compose's IME-type enumeration — +/// androidx.compose.ui.text.input.KeyboardType. Surfaces every +/// constant on the Kotlin KeyboardType.Companion +/// (Text, Number, Phone, Uri, Email, +/// Password, etc.) as a named int property so call sites +/// can write +/// field.KeyboardOptions = d.Copy(..., KeyboardType.Number, ...) +/// instead of hand-coded magic numbers. +/// +/// In Kotlin source KeyboardType is a +/// @JvmInline value class wrapping an Int. Every bound +/// API that consumes one (e.g. KeyboardOptions.Copy(int +/// keyboardType, …)) takes a raw int at the JNI boundary, +/// so this type intentionally exposes the raw int directly — no +/// box-impl indirection required. +/// +/// Internals: +/// and its nested are fully bound +/// (every Number_PjHm6EE-style getter surfaces as an int +/// property on the Companion peer). The only thing missing is a +/// static Companion Companion { get; } accessor on the outer +/// class — Mono's binder skips it for Kotlin object companions +/// (). +/// So we bootstrap the singleton handle exactly once via JNI +/// (GetStaticObjectField), wrap it as the bound Companion peer, +/// and every constant accessor below routes through that peer's +/// generated InvokeNonvirtualInt32Method. +/// +public static class KeyboardType +{ + static BoundCompanion? s_companion; + + static BoundCompanion Companion() + { + if (s_companion is not null) return s_companion; + IntPtr local = IntPtr.Zero; + try + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/input/KeyboardType"); + IntPtr fid = JNIEnv.GetStaticFieldID(cls, "Companion", + "Landroidx/compose/ui/text/input/KeyboardType$Companion;"); + local = JNIEnv.GetStaticObjectField(cls, fid); + return s_companion = Java.Lang.Object.GetObject( + local, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + // GetObject(.., TransferLocalRef) consumes `local` on success; + // the explicit DeleteLocalRef only runs if the wrapper threw + // before taking ownership. + if (local != IntPtr.Zero && s_companion is null) + JNIEnv.DeleteLocalRef(local); + } + } + + /// The unspecified-keyboard-type sentinel (Kotlin's default). + public static int Unspecified { get; } = Companion().Unspecified_PjHm6EE; + + /// Plain text keyboard — the default IME on most fields. + public static int Text { get; } = Companion().Text_PjHm6EE; + + /// ASCII-only keyboard. + public static int Ascii { get; } = Companion().Ascii_PjHm6EE; + + /// Numeric-only keyboard (digits, minus, decimal). + public static int Number { get; } = Companion().Number_PjHm6EE; + + /// Telephone-number keyboard (digits + + * #). + public static int Phone { get; } = Companion().Phone_PjHm6EE; + + /// URI-entry keyboard (/ . :). + public static int Uri { get; } = Companion().Uri_PjHm6EE; + + /// Email-address keyboard (@ .). + public static int Email { get; } = Companion().Email_PjHm6EE; + + /// Password keyboard — masks input, suppresses suggestions. + public static int Password { get; } = Companion().Password_PjHm6EE; + + /// Numeric password keyboard — digit-only password (e.g. PINs). + public static int NumberPassword { get; } = Companion().NumberPassword_PjHm6EE; + + /// Decimal-number keyboard (digits + decimal separator). + public static int Decimal { get; } = Companion().Decimal_PjHm6EE; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KotlinResult.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KotlinResult.cs new file mode 100644 index 000000000..b7a50278e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/KotlinResult.cs @@ -0,0 +1,171 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Raw-JNI helpers for Kotlin's kotlin.Result type — the boxed +/// success/failure wrapper Kotlin coroutines pass through +/// Continuation.resumeWith. +/// +/// +/// +/// Both ends of the bridge live here: +/// +/// +/// +/// — call kotlin.ResultKt.createFailure(Throwable) to wrap a C# +/// exception in a Result.Failure for +/// Continuation.resumeWith (used by +/// and any other C#→Kotlin suspend +/// resume site). +/// + +/// — peek at a +/// resumed Result handle to detect a failure box and extract +/// the underlying Throwable for the awaiting C# task (used by +/// ). +/// +/// +/// Why raw JNI: kotlin.Result is a @JvmInline value class +/// wrapping Object, so every accessor on it (and every helper +/// on ResultKt) emits a mangled JVM name (Result-impl, +/// createFailure-impl, isFailure-impl, +/// exceptionOrNull-impl) that the binder strips — same root +/// cause as +/// dotnet/java-interop#1440. +/// The nested Result$Failure class is intentionally +/// internal in Kotlin source and isn't surfaced by the binder +/// regardless. When #1440 lands and the binder restores +/// ResultKt.createFailure(Throwable) / throwOnFailure(Object) +/// / etc., the bodies of these helpers can be replaced with calls to +/// the bound members and the cached field/method ids deleted. +/// +/// +/// Caching strategy: JNIEnv.FindClass / +/// JNIEnv.GetStaticMethodID / JNIEnv.GetFieldID +/// are pure functions of their string args, and Mono.Android's +/// FindClass returns a stable, globally registered class ref +/// — no NewGlobalRef/DeleteLocalRef dance. Multi-thread +/// racers all observe the same ids; losing the race just re-writes +/// identical values, so plain stores are fine. +/// +/// +internal static class KotlinResult +{ + // kotlin/ResultKt — host of the static `createFailure(Throwable): Object` helper. + static IntPtr s_resultKtClass; + static IntPtr s_resultKtCreateFailureMethod; + + // kotlin/Result$Failure — the boxed-failure type Kotlin uses to + // distinguish failure resumes from success resumes. Its `exception` + // field carries the underlying Throwable. + static IntPtr s_resultFailureClass; + static IntPtr s_resultFailureExceptionField; + + /// + /// Construct a kotlin.Result.Failure(throwable) instance — + /// the boxed-failure form a + /// expects when something goes wrong inside its suspend body. + /// + /// + /// The Java throwable to wrap; non-null. C# exceptions can be + /// converted via + /// (or any helper that produces a ). + /// + /// + /// A managed peer wrapping the Kotlin failure object. The caller + /// passes this directly to continuation.ResumeWith(result). + /// + internal static unsafe Java.Lang.Object CreateFailure(Java.Lang.Throwable throwable) + { + ArgumentNullException.ThrowIfNull(throwable); + + EnsureResultKt(); + + try + { + JValue* args = stackalloc JValue[1]; + args[0] = new JValue(throwable); + var handle = JNIEnv.CallStaticObjectMethod( + s_resultKtClass, s_resultKtCreateFailureMethod, args); + // Transfer the local ref into a managed peer; the caller + // hands the peer to Continuation.ResumeWith which keeps it + // alive until the coroutine actually resumes. + return Java.Lang.Object.GetObject( + handle, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + GC.KeepAlive(throwable); + } + } + + /// + /// Returns if + /// refers to a kotlin.Result.Failure instance — i.e. the + /// resumed value represents a failure rather than a success. + /// + /// + /// Raw JNI handle to test. is treated as + /// "not a failure" (a null resume value is a successful null). + /// + internal static bool IsFailure(IntPtr handle) + { + if (handle == IntPtr.Zero) return false; + EnsureResultFailure(); + return JNIEnv.IsInstanceOf(handle, s_resultFailureClass); + } + + /// + /// Extract the underlying from a + /// kotlin.Result.Failure peer. + /// + /// + /// A peer wrapping a Result.Failure handle. Must have been + /// vetted with first. + /// + /// + /// The wrapped throwable as a + /// ( derives from + /// on Mono.Android, so callers can + /// catch (Exception) or pattern-match on the Java type). + /// + internal static Exception ExtractException(Java.Lang.Object failure) + { + ArgumentNullException.ThrowIfNull(failure); + // s_resultFailureExceptionField is guaranteed populated: + // IsFailure is the only sanctioned predicate, and it calls + // EnsureResultFailure before this method ever runs. + IntPtr exHandle = JNIEnv.GetObjectField(failure.Handle, s_resultFailureExceptionField); + try + { + var th = Java.Lang.Object.GetObject( + exHandle, JniHandleOwnership.TransferLocalRef); + if (th is not null) + return th; + return new InvalidOperationException( + "Kotlin suspend call failed with a null Throwable in Result.Failure"); + } + finally + { + GC.KeepAlive(failure); + } + } + + static void EnsureResultKt() + { + if (s_resultKtCreateFailureMethod != IntPtr.Zero) return; + s_resultKtClass = JNIEnv.FindClass("kotlin/ResultKt"); + s_resultKtCreateFailureMethod = JNIEnv.GetStaticMethodID( + s_resultKtClass, + "createFailure", + "(Ljava/lang/Throwable;)Ljava/lang/Object;"); + } + + static void EnsureResultFailure() + { + if (s_resultFailureClass != IntPtr.Zero) return; + var cls = JNIEnv.FindClass("kotlin/Result$Failure"); + s_resultFailureExceptionField = JNIEnv.GetFieldID(cls, "exception", "Ljava/lang/Throwable;"); + s_resultFailureClass = cls; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LambdaViewModelFactory.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LambdaViewModelFactory.cs new file mode 100644 index 000000000..90a256e55 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LambdaViewModelFactory.cs @@ -0,0 +1,46 @@ +using Android.Runtime; +using AndroidX.Lifecycle; + +namespace AndroidX.Compose; + +/// +/// Internal JCW adapter that exposes a as an +/// so +/// can construct +/// the view model the first time the host's +/// doesn't already +/// contain the requested key. +/// +/// +/// +/// One factory instance is allocated per +/// +/// call. The factory is short-lived — Kotlin only retains a +/// reference to it for the duration of get(key, modelClass), +/// after which the constructed VM is cached in the store and the +/// factory can be released. +/// +/// +/// is the only callback Kotlin +/// invokes when no CreationExtras-aware overload is required. +/// Both shapes route to the wrapped ; the +/// supplied is intentionally ignored +/// — the caller already committed to the C# generic T when +/// they called . +/// +/// +[Register("net/compose/LambdaViewModelFactory")] +sealed class LambdaViewModelFactory : Java.Lang.Object, ViewModelProvider.IFactory +{ + readonly Func _factory; + + public LambdaViewModelFactory(Func factory) + => _factory = factory; + + /// + /// Kotlin fun <T : ViewModel> create(modelClass: Class<T>): T + /// — invokes the wrapped delegate and returns the new instance. + /// + public Java.Lang.Object Create(Java.Lang.Class modelClass) + => _factory(); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeFlexibleTopAppBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeFlexibleTopAppBar.cs new file mode 100644 index 000000000..8553a980b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeFlexibleTopAppBar.cs @@ -0,0 +1,12 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 LargeFlexibleTopAppBar — a two-row top app bar +/// similar to but with a configurable +/// expanded height and an optional second-line Subtitle slot. +/// Supports the same ScrollBehavior slot as +/// — see for the +/// scroll-behavior + +/// pairing. +/// +public sealed partial class LargeFlexibleTopAppBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeFloatingActionButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeFloatingActionButton.cs new file mode 100644 index 000000000..92431b3b2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeFloatingActionButton.cs @@ -0,0 +1,16 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 LargeFloatingActionButton. Identical shape to +/// — just the larger (96 dp) +/// container. Use the collection-initializer form for a single icon +/// child: +/// +/// +/// new LargeFloatingActionButton(onClick: () => count.Value++) +/// { +/// new Text("+"), +/// } +/// +/// +public sealed partial class LargeFloatingActionButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeTopAppBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeTopAppBar.cs new file mode 100644 index 000000000..bebcce8c1 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LargeTopAppBar.cs @@ -0,0 +1,11 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 LargeTopAppBar — a taller variant of +/// that gives the title a full-width row +/// when expanded. Same slots as . Typically +/// paired with +/// +/// on its ScrollBehavior slot. +/// +public sealed partial class LargeTopAppBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LaunchedEffect.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LaunchedEffect.cs new file mode 100644 index 000000000..6b8ca5e47 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LaunchedEffect.cs @@ -0,0 +1,70 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Tree-syntax wrapper around +/// . +/// Launches Body as a C# +/// on first composition and any time a key changes; cancels the +/// supplied on key +/// change or when this node leaves the composition. +/// +/// +/// +/// new LaunchedEffect(_sessionId, async ct => +/// { +/// while (!ct.IsCancellationRequested) +/// { +/// await Task.Delay(1000, ct); +/// _ticks.Value++; +/// } +/// }) +/// +/// +public sealed class LaunchedEffect : ComposableNode +{ + readonly object? _key1, _key2, _key3; + readonly int _keyCount; + readonly Func _body; + + /// Single-key form. + public LaunchedEffect( + object? key1, + Func body) + { + ArgumentNullException.ThrowIfNull(body); + _key1 = key1; _keyCount = 1; _body = body; + } + + /// Two-key form. + public LaunchedEffect( + object? key1, + object? key2, + Func body) + { + ArgumentNullException.ThrowIfNull(body); + _key1 = key1; _key2 = key2; _keyCount = 2; _body = body; + } + + /// Three-key form. + public LaunchedEffect( + object? key1, + object? key2, + object? key3, + Func body) + { + ArgumentNullException.ThrowIfNull(body); + _key1 = key1; _key2 = key2; _key3 = key3; _keyCount = 3; _body = body; + } + + public override void Render(IComposer composer) + { + switch (_keyCount) + { + case 1: composer.LaunchedEffect(_key1, _body); break; + case 2: composer.LaunchedEffect(_key1, _key2, _body); break; + default: composer.LaunchedEffect(_key1, _key2, _key3, _body); break; + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LaunchedEffectBody.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LaunchedEffectBody.cs new file mode 100644 index 000000000..1b90f53a3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LaunchedEffectBody.cs @@ -0,0 +1,249 @@ +using System.Diagnostics; +using Android.Runtime; +using Kotlin.Coroutines; +using Kotlin.Coroutines.Intrinsics; +using Kotlin.Jvm.Functions; +using Xamarin.KotlinX.Coroutines; + +namespace AndroidX.Compose; + +/// +/// JCW for the suspend lambda Kotlin's LaunchedEffect expects: +/// Function2<CoroutineScope, Continuation<in Unit>, Any?>. +/// Bridges the Kotlin coroutine to a C# async Task body that +/// accepts a . +/// +/// +/// +/// The coroutine protocol contract: Invoke must either return +/// the final boxed result (success / failure) synchronously, +/// or return the +/// sentinel and resume +/// the supplied later. This implementation +/// always returns the sentinel and resumes from a +/// +/// once the C# completes, +/// even when the body completes synchronously — that keeps the +/// resume protocol uniform and lets Kotlin's dispatcher own thread +/// affinity for the resume side. +/// +/// +/// Cancellation flow: +/// +/// +/// Allocate a CTS for this invocation. +/// Register a 3-arg InvokeOnCompletion(onCancelling: true, invokeImmediately: true, handler) +/// hook on the job — the 1-arg overload only fires after completion +/// and is too late to cancel a still-suspended Task. +/// Invoke the user's +/// Func<CancellationToken, Task> with cts.Token. +/// Once the resulting Task completes (sync or async), +/// resume the continuation with Unit on success / +/// Result.Failure(throwable) on fault. An +/// +/// once-gate keeps the resume from firing twice if the Job is +/// cancelled while the ContinueWith is still racing. +/// +/// +[Register("net/compose/LaunchedEffectBody")] +internal sealed class LaunchedEffectBody : Java.Lang.Object, IFunction2 +{ + readonly Func _body; + + public LaunchedEffectBody( + Func body) + { + _body = body; + } + + public Java.Lang.Object? Invoke(Java.Lang.Object? p0, Java.Lang.Object? p1) + { + // p0 is CoroutineScope (unused — we have a managed Task). + // p1 is the Continuation we resume when the Task ends. + // Kotlin hands us an anonymous synthetic class (e.g. + // `IntrinsicsKt__IntrinsicsJvmKt$createCoroutineUnintercepted$$inlined$...$4`) + // that does implement `kotlin.coroutines.Continuation` but isn't + // in Mono.Android's peer registry, so a plain `as IContinuation` + // cast returns null. JavaCast bypasses the managed-type + // cache and synthesizes an interface peer from the raw handle, + // which is exactly what we need. + if (p1 is null) + throw new InvalidOperationException( + "LaunchedEffect Invoke received a null Continuation in slot 1"); + + IContinuation continuation; + try + { + continuation = p1.JavaCast(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + "LaunchedEffect Invoke could not project slot 1 (" + + (p1.Class?.Name ?? "") + + ") as kotlin.coroutines.Continuation", ex); + } + + var cts = new CancellationTokenSource(); + var resumed = new ResumeOnceGate(); + + // Bind the Job's cancellation → our CTS so the user's Task can + // observe ct.IsCancellationRequested and bail cooperatively. + // The IDisposableHandle is held strongly through `state` below + // so it doesn't get yanked by GC before completion. + var handler = new JobCompletionHandler(cts); + IDisposableHandle? completionRegistration = null; + try + { + var job = JobKt.GetJob(continuation.Context); + completionRegistration = job.InvokeOnCompletion( + onCancelling: true, invokeImmediately: true, handler); + } + catch (Exception ex) + { + // Without a Job in the context we can't wire cancellation + // — but the user's Task can still run. Log and continue. + Debug.WriteLine( + "AndroidX.Compose.LaunchedEffect: failed to register Job completion handler: " + ex); + } + + Task task; + try + { + task = _body(cts.Token) ?? Task.CompletedTask; + } + catch (Exception ex) + { + // Synchronous fault from the body (rare — Func bodies + // typically wrap their work in async). Throw a Java + // RuntimeException; Kotlin's coroutine machinery converts it + // into a Result.Failure for any waiting awaiters of the job + // and the InvokeOnCompletion hook still fires. Include + // ex.ToString() so the original stack trace + type stay + // visible in logcat / coroutine debug output. + completionRegistration?.Dispose(); + cts.Dispose(); + handler.Dispose(); + throw new Java.Lang.RuntimeException( + "LaunchedEffect body threw synchronously: " + ex); + } + + // Always go through ContinueWith so the resume runs on the + // default TaskScheduler rather than inlining on whatever thread + // happened to complete the Task. This keeps lifetime tracking + // simple and matches what Kotlin's own `kotlinx-coroutines` + // does when handed an external future. + var ctx = new ResumeContext( + continuation, cts, handler, completionRegistration, resumed); + task.ContinueWith( + static (t, state) => + { + var c = (ResumeContext)state!; + c.Resume(t); + }, + ctx, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + + return IntrinsicsKt.COROUTINE_SUSPENDED; + } + + // Holds everything we need to safely resume the continuation + // exactly once and release the JCWs / handler registration after. + sealed class ResumeContext + { + readonly IContinuation _continuation; + readonly CancellationTokenSource _cts; + readonly JobCompletionHandler _handler; + readonly IDisposableHandle? _completionRegistration; + readonly ResumeOnceGate _gate; + + public ResumeContext( + IContinuation continuation, + CancellationTokenSource cts, + JobCompletionHandler handler, + IDisposableHandle? completionRegistration, + ResumeOnceGate gate) + { + _continuation = continuation; + _cts = cts; + _handler = handler; + _completionRegistration = completionRegistration; + _gate = gate; + } + + public void Resume(Task t) + { + if (!_gate.TryEnter()) + return; + + try + { + Java.Lang.Object? result; + if (t.IsFaulted) + { + var inner = t.Exception?.GetBaseException() + ?? new Exception("LaunchedEffect Task faulted with no exception"); + var throwable = ToThrowable(inner); + result = KotlinResult.CreateFailure(throwable); + } + else if (t.IsCanceled) + { + // Surface cancellation as Result.Failure(CancellationException) + // so Kotlin's coroutine machinery records this as a + // cancellation rather than treating it as success. + // kotlinx.coroutines.CancellationException is a + // typealias for java.util.concurrent.CancellationException + // on JVM, so the simpler Java type is fine. + var ce = new Java.Util.Concurrent.CancellationException( + "LaunchedEffect Task was cancelled"); + result = KotlinResult.CreateFailure(ce); + } + else + { + result = Kotlin.Unit.Instance!; + } + + try + { + _continuation.ResumeWith(result); + } + catch (Exception ex) + { + // ResumeWith on an already-cancelled continuation + // may throw IllegalStateException from Kotlin's + // dispatched continuation impl. Log and swallow — + // there's no caller to surface this to. + Debug.WriteLine( + "AndroidX.Compose.LaunchedEffect: continuation resume failed: " + ex); + } + } + finally + { + try { _completionRegistration?.Dispose(); } catch { } + try { _cts.Dispose(); } catch { } + // Don't dispose _handler — Kotlin's job machinery may + // still hold a reference to it briefly. Let GC reclaim + // the JCW once Kotlin drops it. + GC.KeepAlive(_handler); + } + } + + static Java.Lang.Throwable ToThrowable(Exception ex) => + ex switch + { + Java.Lang.Throwable th => th, + OperationCanceledException => + new Java.Util.Concurrent.CancellationException(ex.Message ?? "cancelled"), + _ => new Java.Lang.RuntimeException(ex.GetType().Name + ": " + ex.Message), + }; + } + + sealed class ResumeOnceGate + { + int _state; // 0 = pending, 1 = resumed + public bool TryEnter() => + Interlocked.CompareExchange(ref _state, 1, 0) == 0; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyColumn.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyColumn.cs new file mode 100644 index 000000000..96cda98c9 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyColumn.cs @@ -0,0 +1,173 @@ +using Android.Runtime; +using AndroidX.Compose.Foundation.Lazy; +using AndroidX.Compose.Foundation.Layout; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation LazyColumn — the lazy equivalent of . +/// Only the items currently visible (plus a small lookahead) are +/// composed and laid out, so it's the right choice any time the child +/// count exceeds what fits on screen. +/// +/// The facade is data-driven instead of collection-init: you pass the +/// items list and a per-item content callback at construction time. +/// Compose handles scrolling automatically — no scroll-state plumbing +/// required for the common case. +/// +/// +/// new LazyColumn<int>( +/// items: Enumerable.Range(0, 1000).ToList(), +/// itemContent: i => new Text($"Row {i}")) +/// +/// +/// Set (typically the result of a remembered +/// ) to read scroll position or drive +/// programmatic scrolling. When left null Compose creates its own +/// internal state via the Kotlin default rememberLazyListState(). +/// +/// When placed as the body of a , the +/// scaffold-supplied PaddingValues is forwarded into Kotlin's +/// own contentPadding parameter (NOT applied as +/// Modifier.padding) so the list itself fills the scaffold body +/// — items scroll under top app bars, bottom app bars, and system +/// chrome (gesture nav) like upstream M3 templates, and the first / +/// last items remain reachable above and below them. +/// +public sealed class LazyColumn : ComposableNode +{ + readonly IReadOnlyList _items; + readonly Func _itemContent; + IPaddingValues? _runtimeContentPadding; + + public LazyColumn(IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional scroll state. Leave null to let Compose substitute its + /// own remembered state. + /// + public LazyListState? State { get; set; } + + /// + /// When , items are stacked from the bottom up + /// — the first item rendered sits at the bottom of the viewport and + /// scroll offset 0 corresponds to the end of the list. Mirrors + /// Kotlin's reverseLayout parameter. Defaults to + /// (normal top-down layout). + /// + /// + /// Reverse layout is the canonical pattern for chat / message + /// timelines where newly appended items should appear at the bottom + /// and the scroll position should pin to the latest message. Pair + /// with items.Reverse() at the data layer if your underlying + /// collection is in chronological order — Compose lays items out + /// bottom-first when this flag is set. + /// + public bool ReverseLayout { get; set; } + + /// + /// Optional fixed content padding applied inside the list (not as a + /// modifier on the list frame). Items can scroll behind this area + /// but the first/last items stay fully reachable. Takes precedence + /// over a -supplied PaddingValues when + /// both are present. + /// + public PaddingValues? ContentPadding { get; set; } + + /// + /// When a parent layout (typically ) hands us + /// a runtime PaddingValues, route it into LazyColumn's own + /// contentPadding: parameter instead of letting the base + /// implementation prepend a Modifier.padding op. This gives + /// the "items scroll under the top/bottom bars" behavior Material's + /// templates rely on while keeping the first and last item + /// reachable above and below the chrome. + /// + internal override void Render(IComposer composer, IntPtr paddingHandle) + { + if (paddingHandle == IntPtr.Zero) + { + Render(composer); + return; + } + + // DoNotTransfer because the Scaffold content lambda owns the + // local ref for the duration of this call. We wrap, JavaCast to + // the interface, then dispose immediately — the cast holds its + // own peer reference, so the temporary Java.Lang.Object wrapper + // doesn't need to outlive this method. + IPaddingValues pv; + using (var pvObj = new Java.Lang.Object(paddingHandle, JniHandleOwnership.DoNotTransfer)) + pv = pvObj.JavaCast(); + + var prev = _runtimeContentPadding; + _runtimeContentPadding = pv; + try + { + Render(composer); + } + finally + { + _runtimeContentPadding = prev; + } + } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var content = new ComposableLambda1(scopeObj => + { + var scope = Android.Runtime.Extensions.JavaCast(scopeObj!); + // contentType is non-nullable in Kotlin (defaults to { null }), + // so we always provide a stub; key is nullable and we pass null + // to keep the Compose-default positional keying. + // itemContent IS @Composable but runs at measure time inside the + // lazy list's SubcomposeLayout, so route through Instantiate4 + // (composer-less ComposableLambdaInstance) — Wrap4 would crash + // because there's no active composer once the DSL builder runs. + scope.Items( + _items.Count, + key: null, + contentType: new ComposableLambda1(_ => { }), + itemContent: ComposableLambdas.Instantiate4((_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + })); + }); + + var contentPadding = ContentPadding?.Jvm ?? _runtimeContentPadding; + + int defaults = (int)LazyColumnDefault.All; + if (modifier is not null) defaults &= ~(int)LazyColumnDefault.Modifier; + if (State is not null) defaults &= ~(int)LazyColumnDefault.State; + if (contentPadding is not null) defaults &= ~(int)LazyColumnDefault.ContentPadding; + // Bool params lower to a single $default bit. Kotlin's default + // is false, so only clear the bit when the caller asked for + // true — otherwise let Kotlin substitute its own false and + // save the explicit-pass round trip. + if (ReverseLayout) defaults &= ~(int)LazyColumnDefault.ReverseLayout; + + LazyDslKt.LazyColumn( + modifier: modifier, + state: State?.Jvm, + contentPadding: contentPadding, + reverseLayout: ReverseLayout, + verticalArrangement: null, + horizontalAlignment: null, + flingBehavior: null, + userScrollEnabled: true, + overscrollEffect: null, + content: content, + _composer: composer, + p11: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyHorizontalGrid.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyHorizontalGrid.cs new file mode 100644 index 000000000..43bc7d1ce --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyHorizontalGrid.cs @@ -0,0 +1,87 @@ +using AndroidX.Compose.Foundation.Lazy.Grid; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation LazyHorizontalGrid — lazy 2D grid that scrolls +/// horizontally. Cells are arranged into +/// rows (count) or rows (minimum cell +/// height in dp); items flow top-to-bottom, left-to-right. +/// +/// +/// new LazyHorizontalGrid<int>( +/// rows: GridCells.Fixed(2), +/// items: items, +/// itemContent: i => new Text($"{i}")) +/// +/// +public sealed class LazyHorizontalGrid : ComposableNode +{ + readonly IGridCells _rows; + readonly IReadOnlyList _items; + readonly Func _itemContent; + + public LazyHorizontalGrid(IGridCells rows, IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(rows); + _rows = rows; + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional scroll state. Leave null to let Compose substitute its + /// own remembered state. + /// + public LazyGridState? State { get; set; } + + /// + /// Optional fixed content padding applied inside the grid (not as a + /// modifier on the grid frame). + /// + public PaddingValues? ContentPadding { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var content = new ComposableLambda1(scopeObj => + { + var scope = Android.Runtime.Extensions.JavaCast(scopeObj!); + scope.Items( + _items.Count, + key: null, + span: null, + contentType: new ComposableLambda1(_ => { }), + p4: ComposableLambdas.Instantiate4((_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + })); + }); + + int defaults = (int)LazyHorizontalGridDefault.All & ~(int)LazyHorizontalGridDefault.Rows; + if (modifier is not null) defaults &= ~(int)LazyHorizontalGridDefault.Modifier; + if (State is not null) defaults &= ~(int)LazyHorizontalGridDefault.State; + if (ContentPadding is not null) defaults &= ~(int)LazyHorizontalGridDefault.ContentPadding; + + LazyGridDslKt.LazyHorizontalGrid( + rows: _rows, + modifier: modifier, + state: State, + contentPadding: ContentPadding?.Jvm, + reverseLayout: false, + horizontalArrangement: null, + verticalArrangement: null, + flingBehavior: null, + userScrollEnabled: true, + overscrollEffect: null, + content: content, + _composer: composer, + p12: 0, + _changed: 0, + _changed1: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyHorizontalStaggeredGrid.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyHorizontalStaggeredGrid.cs new file mode 100644 index 000000000..bc9468d95 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyHorizontalStaggeredGrid.cs @@ -0,0 +1,105 @@ +using AndroidX.Compose.Foundation.Lazy.Staggeredgrid; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation LazyHorizontalStaggeredGrid — horizontal mirror of +/// . Items flow top-to-bottom +/// in each row, and rows scroll horizontally with row-level +/// independent positions producing the staggered look. Cell strategies +/// live on . +/// +/// +/// new LazyHorizontalStaggeredGrid<Photo>( +/// rows: StaggeredGridCells.Fixed(2), +/// items: photos, +/// itemContent: p => new Image(p.Url)) +/// +/// +public sealed class LazyHorizontalStaggeredGrid : ComposableNode +{ + readonly IStaggeredGridCells _rows; + readonly IReadOnlyList _items; + readonly Func _itemContent; + + /// + /// Construct a horizontal staggered grid that scrolls a row count + /// strategy () over , + /// rendering each via . + /// + public LazyHorizontalStaggeredGrid(IStaggeredGridCells rows, IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(rows); + _rows = rows; + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional scroll state. Leave null to let Compose substitute its + /// own remembered state. + /// + public LazyStaggeredGridState? State { get; set; } + + /// + /// Optional fixed content padding applied inside the grid (not as a + /// modifier on the grid frame). + /// + public PaddingValues? ContentPadding { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var content = new ComposableLambda1(scopeObj => + { + var scope = Android.Runtime.Extensions.JavaCast(scopeObj!); + scope.Items( + _items.Count, + key: null, + contentType: new ComposableLambda1(_ => { }), + span: null, + p4: ComposableLambdas.Instantiate4((_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + })); + }); + + // Bit positions for LazyHorizontalStaggeredGrid: + // 0 = rows (always provided) + // 1 = modifier + // 2 = state + // 3 = contentPadding + // 4 = reverseLayout + // 5 = verticalArrangement + // 6 = horizontalItemSpacing + // 7 = flingBehavior + // 8 = userScrollEnabled + // 9 = overscrollEffect + // 10 = content (always provided) + int defaults = (int)LazyHorizontalStaggeredGridDefault.All & ~(int)LazyHorizontalStaggeredGridDefault.Rows; + if (modifier is not null) defaults &= ~(int)LazyHorizontalStaggeredGridDefault.Modifier; + if (State is not null) defaults &= ~(int)LazyHorizontalStaggeredGridDefault.State; + if (ContentPadding is not null) defaults &= ~(int)LazyHorizontalStaggeredGridDefault.ContentPadding; + + LazyStaggeredGridDslKt.LazyHorizontalStaggeredGrid( + rows: _rows, + modifier: modifier, + state: State, + contentPadding: ContentPadding?.Jvm, + reverseLayout: false, + verticalArrangement: null, + horizontalItemSpacing: 0f, + flingBehavior: null, + userScrollEnabled: true, + overscrollEffect: null, + content: content, + _composer: composer, + p12: 0, + _changed: 0, + _changed1: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyListState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyListState.cs new file mode 100644 index 000000000..84d505aa4 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyListState.cs @@ -0,0 +1,182 @@ + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for and +/// . Wraps the bound +/// androidx.compose.foundation.lazy.LazyListState — Kotlin's +/// class is plain enough for the binder to expose its parameterless +/// and two-int constructors directly, so no JNI bridge is needed to +/// construct one. The wrapper exists so the facade can grow async +/// scroll APIs ( / +/// ) without polluting the +/// binding surface, and so the Remember helper +/// () +/// can hand back a typed managed object. +/// +/// +/// +/// Obtain one via +/// +/// so the scroll position survives recompositions: +/// +/// var listState = composer.RememberLazyListState(); +/// +/// new LazyColumn<int>(items, i => new Text($"{i}")) +/// { +/// State = listState, +/// }; +/// +/// +/// +/// Unlike Kotlin's rememberLazyListState() (which uses +/// rememberSaveable), this state survives recompositions but +/// not process death / configuration changes. For most apps that's +/// fine; if you need saved scroll state, track it yourself. +/// +/// +/// Programmatic scrolling is exposed via the Async methods — +/// snaps instantly, +/// runs the default spring +/// animation. Both bridge the underlying Kotlin suspend +/// function through so they integrate with C# +/// async/await. +/// +/// +public sealed class LazyListState +{ + internal AndroidX.Compose.Foundation.Lazy.LazyListState Jvm { get; } + + /// + /// Create a new seeded with the given + /// initial first-visible-item index and scroll offset. Both + /// default to 0 (scrolled to the very start). + /// + /// + /// The item index that should be the first visible item when the + /// list first composes. Mirrors Kotlin's + /// initialFirstVisibleItemIndex. + /// + /// + /// Initial scroll offset of the first visible item, in pixels. + /// Mirrors Kotlin's initialFirstVisibleItemScrollOffset. + /// + public LazyListState( + int initialFirstVisibleItemIndex = 0, + int initialFirstVisibleItemScrollOffset = 0) + { + Jvm = new AndroidX.Compose.Foundation.Lazy.LazyListState( + initialFirstVisibleItemIndex, + initialFirstVisibleItemScrollOffset); + } + + // Internal wrap-around-existing constructor used by + // ComposeExtensions.RememberLazyListState to surface the Kotlin + // remember-cached binding instance. + internal LazyListState(AndroidX.Compose.Foundation.Lazy.LazyListState jvm) + { + Jvm = jvm; + } + + /// + /// Index of the first item that's currently visible. Mirrors + /// Kotlin's LazyListState.firstVisibleItemIndex. + /// + public int FirstVisibleItemIndex => Jvm.FirstVisibleItemIndex; + + /// + /// Scroll offset of the first visible item, in pixels. Mirrors + /// Kotlin's LazyListState.firstVisibleItemScrollOffset. + /// + public int FirstVisibleItemScrollOffset => Jvm.FirstVisibleItemScrollOffset; + + /// + /// true while a fling or programmatic scroll is in flight. + /// Mirrors Kotlin's LazyListState.isScrollInProgress. + /// + public bool IsScrollInProgress => Jvm.IsScrollInProgress; + + /// + /// true when the content can scroll further backward + /// (toward index 0 in normal layout, away from index 0 in + /// reverseLayout). Mirrors Kotlin's + /// LazyListState.canScrollBackward. + /// + public bool CanScrollBackward => Jvm.CanScrollBackward; + + /// + /// true when the content can scroll further forward (away + /// from index 0 in normal layout, toward index 0 in + /// reverseLayout). Mirrors Kotlin's + /// LazyListState.canScrollForward. + /// + public bool CanScrollForward => Jvm.CanScrollForward; + + /// + /// true when the most recent scroll motion was backward + /// (toward lower offsets). Mirrors Kotlin's + /// LazyListState.lastScrolledBackward. + /// + public bool LastScrolledBackward => Jvm.LastScrolledBackward; + + /// + /// true when the most recent scroll motion was forward + /// (toward higher offsets). Mirrors Kotlin's + /// LazyListState.lastScrolledForward. + /// + public bool LastScrolledForward => Jvm.LastScrolledForward; + + /// + /// Snap instantly to the given item + /// (no animation), positioning it at the start of the viewport. + /// Mirrors Kotlin's + /// LazyListState.scrollToItem(index, scrollOffset). + /// + /// Target item index. + /// + /// Additional pixel offset added to the item's position once it's + /// the first visible item. Defaults to 0. + /// + /// + /// Cancels the returned task with + /// . See + /// remarks for the current (C#-only) + /// cancellation semantics. + /// + public Task ScrollToItemAsync( + int index, + int scrollOffset = 0, + CancellationToken cancellationToken = default) => + SuspendBridge.Invoke(cont => + ComposeBridges.LazyListStateScrollToItem( + ((Java.Lang.Object)Jvm).Handle, index, scrollOffset, cont), + cancellationToken); + + /// + /// Animate to the given item using the + /// default Compose spring animation, positioning it at the start + /// of the viewport. Mirrors Kotlin's + /// LazyListState.animateScrollToItem(index, scrollOffset); + /// the returned task completes when the animation lands. + /// + /// Target item index. + /// + /// Additional pixel offset added to the item's position once it's + /// the first visible item. Defaults to 0. + /// + /// + /// Cancels the returned task with + /// . The underlying + /// Kotlin animation keeps running to its natural completion — see + /// remarks for the current (C#-only) + /// cancellation semantics. + /// + public Task AnimateScrollToItemAsync( + int index, + int scrollOffset = 0, + CancellationToken cancellationToken = default) => + SuspendBridge.Invoke(cont => + ComposeBridges.LazyListStateAnimateScrollToItem( + ((Java.Lang.Object)Jvm).Handle, index, scrollOffset, cont), + cancellationToken); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyRow.cs new file mode 100644 index 000000000..4618c076d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyRow.cs @@ -0,0 +1,101 @@ +using AndroidX.Compose.Foundation.Lazy; +using AndroidX.Compose.Runtime; +using BindingArrangement = AndroidX.Compose.Foundation.Layout.Arrangement; + +namespace AndroidX.Compose; + +/// +/// Foundation LazyRow — horizontal mirror of . +/// Use when the item count along the horizontal axis exceeds the screen +/// width (chip rows, image carousels, etc.). +/// +/// +/// new LazyRow<Photo>(items: photos, itemContent: p => new Image(p.Url)) +/// +/// +public sealed class LazyRow : ComposableNode +{ + readonly IReadOnlyList _items; + readonly Func _itemContent; + + public LazyRow(IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional scroll state. Leave null to let Compose substitute its + /// own remembered state. + /// + public LazyListState? State { get; set; } + + /// + /// Optional horizontal arrangement (e.g. ) + /// applied between items. Leave to use Compose's + /// default (Arrangement.Start). Must wrap a horizontal-capable + /// Compose ; throws + /// at render time for a + /// vertical-only value (e.g. ). + /// + public Arrangement? HorizontalArrangement { get; set; } + + /// + /// Optional fixed content padding applied inside the row (not as a + /// modifier on the row frame). Items can scroll behind this area but + /// the first/last items stay fully reachable. + /// + public PaddingValues? ContentPadding { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var content = new ComposableLambda1(scopeObj => + { + var scope = Android.Runtime.Extensions.JavaCast(scopeObj!); + scope.Items( + _items.Count, + key: null, + contentType: new ComposableLambda1(_ => { }), + itemContent: ComposableLambdas.Instantiate4((_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + })); + }); + + BindingArrangement.IHorizontal? horizontal = null; + if (HorizontalArrangement is not null) + { + horizontal = HorizontalArrangement.Horizontal + ?? throw new ArgumentException( + $"{nameof(HorizontalArrangement)} must wrap a horizontal " + + $"or horizontal-or-vertical Compose Arrangement; got a " + + $"vertical-only value (e.g. Arrangement.Top / Arrangement.Bottom).", + nameof(HorizontalArrangement)); + } + + int defaults = (int)LazyRowDefault.All; + if (modifier is not null) defaults &= ~(int)LazyRowDefault.Modifier; + if (State is not null) defaults &= ~(int)LazyRowDefault.State; + if (ContentPadding is not null) defaults &= ~(int)LazyRowDefault.ContentPadding; + if (horizontal is not null) defaults &= ~(int)LazyRowDefault.HorizontalArrangement; + + LazyDslKt.LazyRow( + modifier: modifier, + state: State?.Jvm, + contentPadding: ContentPadding?.Jvm, + reverseLayout: false, + horizontalArrangement: horizontal, + verticalAlignment: null, + flingBehavior: null, + userScrollEnabled: true, + overscrollEffect: null, + content: content, + _composer: composer, + p11: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyVerticalGrid.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyVerticalGrid.cs new file mode 100644 index 000000000..efbaa365f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyVerticalGrid.cs @@ -0,0 +1,93 @@ +using AndroidX.Compose.Foundation.Lazy.Grid; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation LazyVerticalGrid — lazy 2D grid that scrolls +/// vertically. Cells are arranged into +/// columns (count) or columns (minimum +/// cell width in dp); items flow left-to-right, top-to-bottom. +/// +/// +/// new LazyVerticalGrid<int>( +/// columns: GridCells.Fixed(3), +/// items: Enumerable.Range(0, 50).ToList(), +/// itemContent: i => new Card { new Text($"Cell {i}") }) +/// +/// +public sealed class LazyVerticalGrid : ComposableNode +{ + readonly IGridCells _columns; + readonly IReadOnlyList _items; + readonly Func _itemContent; + + public LazyVerticalGrid(IGridCells columns, IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(columns); + _columns = columns; + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional scroll state. Leave null to let Compose substitute its + /// own remembered state. + /// + public LazyGridState? State { get; set; } + + /// + /// Optional fixed content padding applied inside the grid (not as a + /// modifier on the grid frame). Items can scroll behind this area + /// but the first/last items stay fully reachable. + /// + public PaddingValues? ContentPadding { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var content = new ComposableLambda1(scopeObj => + { + var scope = Android.Runtime.Extensions.JavaCast(scopeObj!); + // span: null = 1 cell per item (the Compose default). + scope.Items( + _items.Count, + key: null, + span: null, + contentType: new ComposableLambda1(_ => { }), + p4: ComposableLambdas.Instantiate4((_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + })); + }); + + // Start from "use all defaults", then clear the columns bit + // (always supplied) and any other params the caller customized. + int defaults = (int)LazyVerticalGridDefault.All & ~(int)LazyVerticalGridDefault.Columns; + if (modifier is not null) defaults &= ~(int)LazyVerticalGridDefault.Modifier; + if (State is not null) defaults &= ~(int)LazyVerticalGridDefault.State; + if (ContentPadding is not null) defaults &= ~(int)LazyVerticalGridDefault.ContentPadding; + + // 11 user params → Compose splits $changed into two ints + // (p12, _changed) and $default lives in the trailing _changed1. + LazyGridDslKt.LazyVerticalGrid( + columns: _columns, + modifier: modifier, + state: State, + contentPadding: ContentPadding?.Jvm, + reverseLayout: false, + verticalArrangement: null, + horizontalArrangement: null, + flingBehavior: null, + userScrollEnabled: true, + overscrollEffect: null, + content: content, + _composer: composer, + p12: 0, + _changed: 0, + _changed1: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyVerticalStaggeredGrid.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyVerticalStaggeredGrid.cs new file mode 100644 index 000000000..9ccc7be82 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LazyVerticalStaggeredGrid.cs @@ -0,0 +1,113 @@ +using AndroidX.Compose.Foundation.Lazy.Staggeredgrid; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation LazyVerticalStaggeredGrid — a 2D grid that scrolls +/// vertically, where each cell is sized to its own content (so columns +/// drift independently and Pinterest-style staggered layouts fall out +/// for free). Cell strategies live on +/// (Fixed(count), +/// Adaptive(minDp), FixedSize(dp)). +/// +/// +/// new LazyVerticalStaggeredGrid<Photo>( +/// columns: StaggeredGridCells.Adaptive(120f), +/// items: photos, +/// itemContent: p => new Image(p.Url)) +/// +/// +public sealed class LazyVerticalStaggeredGrid : ComposableNode +{ + readonly IStaggeredGridCells _columns; + readonly IReadOnlyList _items; + readonly Func _itemContent; + + /// + /// Construct a vertical staggered grid that scrolls a column count + /// strategy () over , + /// rendering each via . + /// + public LazyVerticalStaggeredGrid(IStaggeredGridCells columns, IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(columns); + _columns = columns; + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional scroll state. Leave null to let Compose substitute its + /// own remembered state. + /// + public LazyStaggeredGridState? State { get; set; } + + /// + /// Optional fixed content padding applied inside the grid (not as a + /// modifier on the grid frame). + /// + public PaddingValues? ContentPadding { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var content = new ComposableLambda1(scopeObj => + { + var scope = Android.Runtime.Extensions.JavaCast(scopeObj!); + // span: null = 1 cell per item (the Compose default); itemContent + // runs at measure time inside the staggered grid's + // SubcomposeLayout, so route through Instantiate4 (the + // composer-less ComposableLambdaInstance) — Wrap4 would crash + // because there's no active composer once the DSL builder runs. + scope.Items( + _items.Count, + key: null, + contentType: new ComposableLambda1(_ => { }), + span: null, + p4: ComposableLambdas.Instantiate4((_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + })); + }); + + // Bit positions for LazyVerticalStaggeredGrid (Kotlin source order): + // 0 = columns (always provided) + // 1 = modifier + // 2 = state + // 3 = contentPadding + // 4 = reverseLayout + // 5 = verticalItemSpacing + // 6 = horizontalArrangement + // 7 = flingBehavior + // 8 = userScrollEnabled + // 9 = overscrollEffect + // 10 = content (always provided) + int defaults = (int)LazyVerticalStaggeredGridDefault.All & ~(int)LazyVerticalStaggeredGridDefault.Columns; + if (modifier is not null) defaults &= ~(int)LazyVerticalStaggeredGridDefault.Modifier; + if (State is not null) defaults &= ~(int)LazyVerticalStaggeredGridDefault.State; + if (ContentPadding is not null) defaults &= ~(int)LazyVerticalStaggeredGridDefault.ContentPadding; + + // 11 user params → Compose splits $changed into two ints + // (p12, _changed) and $default lives in the trailing _changed1. + LazyStaggeredGridDslKt.LazyVerticalStaggeredGrid( + columns: _columns, + modifier: modifier, + state: State, + contentPadding: ContentPadding?.Jvm, + reverseLayout: false, + verticalItemSpacing: 0f, + horizontalArrangement: null, + flingBehavior: null, + userScrollEnabled: true, + overscrollEffect: null, + content: content, + _composer: composer, + p12: 0, + _changed: 0, + _changed1: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LeadingIconTab.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LeadingIconTab.cs new file mode 100644 index 000000000..0f4abe10e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LeadingIconTab.cs @@ -0,0 +1,8 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 LeadingIconTab. Like but the +/// icon and text are required and laid out side-by-side (icon first) +/// instead of stacked vertically. +/// +public sealed partial class LeadingIconTab; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LineBreakValues.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LineBreakValues.cs new file mode 100644 index 000000000..3f836f183 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LineBreakValues.cs @@ -0,0 +1,47 @@ +namespace AndroidX.Compose; + +/// +/// Packed androidx.compose.ui.text.style.LineBreak constants — +/// the wrap-strategy presets Compose text styles carry (Simple = +/// greedy, Heading = balanced + phrase word break, Paragraph = +/// high-quality). LineBreak is a Kotlin value class, so its companion +/// getters carry a mangled JVM suffix (getHeading-rAG3T2k) that varies +/// by compiler version; the constants are therefore resolved once via Java +/// reflection by getter-name prefix instead of a hard-coded signature. +/// +public static class LineBreakValues +{ + static int? s_simple, s_heading, s_paragraph; + + /// Greedy wrapping — the Compose default for unstyled text. + public static int Simple => s_simple ??= Fetch("getSimple"); + + /// Balanced wrapping with phrase-based word breaks — Compose's preset for titles. + public static int Heading => s_heading ??= Fetch("getHeading"); + + /// High-quality (non-greedy) wrapping — Compose's preset for body copy. + public static int Paragraph => s_paragraph ??= Fetch("getParagraph"); + + static int Fetch(string getterPrefix) + { + var cls = Java.Lang.Class.ForName("androidx.compose.ui.text.style.LineBreak$Companion"); + var lineBreak = Java.Lang.Class.ForName("androidx.compose.ui.text.style.LineBreak"); + var fid = lineBreak.GetDeclaredField("Companion"); + fid.Accessible = true; + var companion = fid.Get(null); + foreach (var m in cls.GetDeclaredMethods()) + { + // Match on prefix + int return: Kotlin also emits a synthetic void + // `get*-XXXX$annotations()` holder that the prefix alone would catch. + if (m.Name.StartsWith(getterPrefix, System.StringComparison.Ordinal) + && m.ReturnType?.Name == "int") + { + m.Accessible = true; + var boxed = m.Invoke(companion) + ?? throw new System.InvalidOperationException($"LineBreak.Companion.{m.Name} returned null"); + return Java.Lang.Integer.ParseInt(boxed.ToString()); + } + } + throw new System.MissingMethodException($"LineBreak.Companion.{getterPrefix}* not found"); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinearProgressIndicator.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinearProgressIndicator.cs new file mode 100644 index 000000000..a521907c8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinearProgressIndicator.cs @@ -0,0 +1,81 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 LinearProgressIndicator. Defaults to the +/// indeterminate animation; set to a non-null +/// 0..1 value to render the determinate (progress-driven) overload: +/// +/// new LinearProgressIndicator { Modifier = Modifier.FillMaxWidth() } +/// new LinearProgressIndicator { Progress = 0.45f } +/// +/// The full state-reading () -> Float overload (lambda-driven +/// progress) isn't wrapped — set from a +/// MutableState in your render and Compose will recompose this +/// node at the next frame. +/// +public sealed class LinearProgressIndicator : ComposableNode +{ + /// + /// Optional progress in [0, 1]. When non-null, renders the + /// determinate overload (filled bar at the given fraction); + /// when null, renders the indeterminate animation. + /// + public float? Progress { get; set; } + + /// Optional explicit color. Leave null to use the Material default. + public Color? Color { get; set; } + + /// Optional explicit track color. Leave null to use the Material default. + public Color? TrackColor { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + + if (Progress is { } progress) + { + // Determinate overload: (Float, Modifier?, J color, J trackColor, + // I strokeCap; Composer; I $changed, I $default). + // Generated `LinearProgressIndicatorDeterminateDefault` enum + // covers slots 1..4 (progress on bit 0 is always provided + // and skipped via the `!` prefix in `ComposeDefaults.cs`). + var defaults = LinearProgressIndicatorDeterminateDefault.All; + if (modifier is not null) defaults &= ~LinearProgressIndicatorDeterminateDefault.Modifier; + if (Color.HasValue) defaults &= ~LinearProgressIndicatorDeterminateDefault.Color; + if (TrackColor.HasValue) defaults &= ~LinearProgressIndicatorDeterminateDefault.TrackColor; + +#pragma warning disable CS0618 // Float-progress overload is deprecated in Compose + // 1.7+ in favour of the lambda overload, but the + // lambda variant is not yet wrapped here. + ProgressIndicatorKt.LinearProgressIndicator( + progress: progress, + modifier: modifier, + color: Color is { } pc ? pc : 0L, + trackColor: TrackColor is { } pt ? pt : 0L, + p4: 0, + _composer: composer, + strokeCap: 0, + _changed: (int)defaults); +#pragma warning restore CS0618 + return; + } + + var indeterminateDefaults = LinearProgressIndicatorDefault.All; + if (modifier is not null) indeterminateDefaults &= ~LinearProgressIndicatorDefault.Modifier; + if (Color.HasValue) indeterminateDefaults &= ~LinearProgressIndicatorDefault.Color; + if (TrackColor.HasValue) indeterminateDefaults &= ~LinearProgressIndicatorDefault.TrackColor; + + ProgressIndicatorKt.LinearProgressIndicator( + modifier: modifier, + color: Color is { } c ? c : 0L, + trackColor: TrackColor is { } t ? t : 0L, + p3: 0, + gapSize: 0f, + _composer: composer, + strokeCap: 0, + _changed: (int)indeterminateDefaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinkAnnotation.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinkAnnotation.cs new file mode 100644 index 000000000..c35224fae --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinkAnnotation.cs @@ -0,0 +1,58 @@ +using BindingLink = AndroidX.Compose.UI.Text.LinkAnnotation; +using BindingTextLinkStyles = AndroidX.Compose.UI.Text.TextLinkStyles; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around androidx.compose.ui.text.LinkAnnotation — +/// attaches a tappable target to a range inside an +/// . The two concrete subtypes are +/// produced via the static factories and +/// ; PushLink/AddLink on +/// accept either. +/// +/// +/// Url annotations are handled by Compose itself — tapping opens the +/// URL via the platform's UriHandler. Clickable annotations +/// invoke the C# the caller supplied +/// (e.g. to show a profile dialog for an @mention). +/// +public sealed class LinkAnnotation +{ + internal BindingLink Binding { get; } + + LinkAnnotation(BindingLink binding) => Binding = binding; + + /// + /// Create a URL link. Compose will launch the platform URI handler + /// when the rendered text is tapped — no extra wiring required. + /// + /// Absolute URL to open. + /// Optional default to + /// apply to the linked range; pass to + /// inherit from the enclosing text styling. + public static LinkAnnotation Url(string url, SpanStyle? style = null) + { + var styles = style is null ? null : new BindingTextLinkStyles(style.Build(), null, null, null); + return new LinkAnnotation(new BindingLink.Url(url, styles, null)); + } + + /// + /// Create a clickable annotation whose tap fires a C# callback. + /// Useful for in-app navigation — mentions, hashtags, etc. — where + /// the linked text isn't a URL. + /// + /// Identifier passed back to + /// when invoked; appears as the link's tag in Compose's annotation + /// model. + /// Callback fired on tap. Receives . + /// Optional default for + /// the linked range. + public static LinkAnnotation Clickable(string tag, Action onClick, SpanStyle? style = null) + { + ArgumentNullException.ThrowIfNull(onClick); + var styles = style is null ? null : new BindingTextLinkStyles(style.Build(), null, null, null); + var listener = new LinkClickListener(tag, onClick); + return new LinkAnnotation(new BindingLink.Clickable(tag, styles, listener)); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinkClickListener.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinkClickListener.cs new file mode 100644 index 000000000..c7d018517 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LinkClickListener.cs @@ -0,0 +1,26 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// androidx.compose.ui.text.LinkInteractionListener adapter +/// that forwards Compose's link-tap callbacks to a C# +/// . Allocated once per +/// call and held by the +/// resulting so the JNI peer stays alive +/// as long as the annotation does. +/// +[Register("net/compose/LinkClickListener")] +internal sealed class LinkClickListener : Java.Lang.Object, AndroidX.Compose.UI.Text.ILinkInteractionListener +{ + readonly string _tag; + readonly Action _onClick; + + public LinkClickListener(string tag, Action onClick) + { + _tag = tag; + _onClick = onClick; + } + + public void OnClick(AndroidX.Compose.UI.Text.LinkAnnotation? link) => _onClick(_tag); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ListItem.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ListItem.cs new file mode 100644 index 000000000..229855c93 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ListItem.cs @@ -0,0 +1,17 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ListItem. A row in a list with a required +/// Headline and four optional content slots (Overline, +/// Supporting, Leading, Trailing): +/// +/// new ListItem +/// { +/// Headline = new Text("Inbox"), +/// Supporting = new Text("3 unread"), +/// Leading = new Icon(Resource.Drawable.ic_inbox, "Inbox"), +/// Trailing = new Text("→"), +/// } +/// +/// +public sealed partial class ListItem; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalColorScheme.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalColorScheme.cs new file mode 100644 index 000000000..bee1567e2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalColorScheme.cs @@ -0,0 +1,35 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// The Material 3 installed by the nearest +/// . Equivalent to Kotlin's +/// androidx.compose.material3.LocalColorScheme. +/// +/// Reading this directly is rarely necessary — most consumers go +/// through 's own accessors — but it's +/// exposed for parity with the Kotlin API and for callers that want to +/// override the scheme inside a subtree without redefining the whole +/// theme. +/// +public static class LocalColorScheme +{ + static readonly CompositionLocal s_instance = + new(ColorSchemeKt.LocalColorScheme); + + /// + /// Read the current value, equivalent to Kotlin's + /// LocalColorScheme.current. + /// + public static ColorScheme Current(IComposer composer) => + s_instance.Current(composer); + + /// + /// Pair this local with for installation + /// by a . + /// + public static ProvidedValue Provides(ColorScheme value) => + s_instance.Provides(value); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalConfiguration.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalConfiguration.cs new file mode 100644 index 000000000..f07d2c6b9 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalConfiguration.cs @@ -0,0 +1,30 @@ +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Platform; + +namespace AndroidX.Compose; + +/// +/// The current , updated +/// when the system delivers a configuration change (locale, orientation, +/// dark mode, etc.). Equivalent to Kotlin's +/// androidx.compose.ui.platform.LocalConfiguration. +/// +public static class LocalConfiguration +{ + static readonly CompositionLocal s_instance = + new(AndroidCompositionLocals_androidKt.LocalConfiguration); + + /// + /// Read the current value, equivalent to Kotlin's + /// LocalConfiguration.current. + /// + public static Android.Content.Res.Configuration Current(IComposer composer) => + s_instance.Current(composer); + + /// + /// Pair this local with for installation + /// by a . + /// + public static ProvidedValue Provides(Android.Content.Res.Configuration value) => + s_instance.Provides(value); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalContext.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalContext.cs new file mode 100644 index 000000000..ea19c1a47 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalContext.cs @@ -0,0 +1,36 @@ +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Platform; + +namespace AndroidX.Compose; + +/// +/// The Android attached to the host +/// ComposeView. Equivalent to Kotlin's +/// androidx.compose.ui.platform.LocalContext. +/// +/// Read inside a Render method via +/// ; install a different value for a +/// subtree by passing +/// to a . +/// +public static class LocalContext +{ + static readonly CompositionLocal s_instance = + new(AndroidCompositionLocals_androidKt.LocalContext); + + /// + /// Read the current value, equivalent to Kotlin's + /// LocalContext.current. Pass the + /// handed to the enclosing Render method. + /// + public static Android.Content.Context Current(IComposer composer) => + s_instance.Current(composer); + + /// + /// Pair this local with for installation + /// by a . Equivalent to + /// Kotlin's infix LocalContext provides value syntax. + /// + public static ProvidedValue Provides(Android.Content.Context value) => + s_instance.Provides(value); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalLifecycleOwner.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalLifecycleOwner.cs new file mode 100644 index 000000000..bf3bfa238 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalLifecycleOwner.cs @@ -0,0 +1,42 @@ +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Platform; + +namespace AndroidX.Compose; + +/// +/// The that hosts the +/// current composition (typically the host activity or fragment). Use +/// this to scope coroutines / effects to the same lifecycle as the UI +/// tree. Equivalent to Kotlin's +/// androidx.compose.ui.platform.LocalLifecycleOwner. +/// +/// +/// We bind the legacy +/// androidx.compose.ui.platform.LocalLifecycleOwner here instead +/// of the newer androidx.lifecycle.compose.LocalLifecycleOwner +/// because the lifecycle-runtime-compose binding currently strips that +/// type. The legacy accessor is marked @Deprecated in Kotlin but +/// still delegates to the same composition local at runtime — swap once +/// the upstream binding exposes it. +/// +public static class LocalLifecycleOwner +{ +#pragma warning disable CS0618 // legacy LocalLifecycleOwner, see + static readonly CompositionLocal s_instance = + new(AndroidCompositionLocals_androidKt.LocalLifecycleOwner); +#pragma warning restore CS0618 + + /// + /// Read the current value, equivalent to Kotlin's + /// LocalLifecycleOwner.current. + /// + public static AndroidX.Lifecycle.ILifecycleOwner Current(IComposer composer) => + s_instance.Current(composer); + + /// + /// Pair this local with for installation + /// by a . + /// + public static ProvidedValue Provides(AndroidX.Lifecycle.ILifecycleOwner value) => + s_instance.Provides(value); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalResources.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalResources.cs new file mode 100644 index 000000000..30473f250 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalResources.cs @@ -0,0 +1,29 @@ +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Platform; + +namespace AndroidX.Compose; + +/// +/// The current , already +/// configuration-aware (re-read on configuration change). Equivalent +/// to Kotlin's androidx.compose.ui.platform.LocalResources. +/// +public static class LocalResources +{ + static readonly CompositionLocal s_instance = + new(AndroidCompositionLocals_androidKt.LocalResources); + + /// + /// Read the current value, equivalent to Kotlin's + /// LocalResources.current. + /// + public static Android.Content.Res.Resources Current(IComposer composer) => + s_instance.Current(composer); + + /// + /// Pair this local with for installation + /// by a . + /// + public static ProvidedValue Provides(Android.Content.Res.Resources value) => + s_instance.Provides(value); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalView.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalView.cs new file mode 100644 index 000000000..cdf470f3a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/LocalView.cs @@ -0,0 +1,29 @@ +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Platform; + +namespace AndroidX.Compose; + +/// +/// The hosting — usually the +/// AbstractComposeView embedding this composition. Equivalent +/// to Kotlin's androidx.compose.ui.platform.LocalView. +/// +public static class LocalView +{ + static readonly CompositionLocal s_instance = + new(AndroidCompositionLocals_androidKt.LocalView); + + /// + /// Read the current value, equivalent to Kotlin's + /// LocalView.current. + /// + public static Android.Views.View Current(IComposer composer) => + s_instance.Current(composer); + + /// + /// Pair this local with for installation + /// by a . + /// + public static ProvidedValue Provides(Android.Views.View value) => + s_instance.Provides(value); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ManagedBox.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ManagedBox.cs new file mode 100644 index 000000000..d50b68a5d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ManagedBox.cs @@ -0,0 +1,49 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Wraps an arbitrary managed value as a +/// so it can cross the JNI boundary. +/// Used by to hand pure-C# POCO +/// values (themes, settings, custom contexts) to Kotlin's +/// compositionLocalOf / staticCompositionLocalOf +/// machinery without forcing users to make those types derive +/// from . +/// +/// Non-generic on purpose: a generic JCW would require a +/// per-closed-type Java Callable Wrapper generated at facade +/// compile time, which is impossible for sample-side or any other +/// consumer-defined T. The single net/compose/ManagedBox +/// peer covers every payload; the typed unbox happens in C# inside +/// . +/// +/// Equality is forwarded to the boxed value's own +/// / +/// so structural-equality CompositionLocalOf readers +/// recompose only when the underlying value actually changes — two +/// distinct instances wrapping equal +/// values compare equal. +/// +/// Held by the Java-side provider machinery while a +/// is on the composition's +/// stack; collected once Compose drops its reference. The JCW base +/// class keeps the managed peer alive for the JVM peer's lifetime. +/// +[Register("net/compose/ManagedBox")] +internal sealed class ManagedBox : Java.Lang.Object +{ + public object? Value { get; } + + public ManagedBox(object? value) => Value = value; + + public override bool Equals(Java.Lang.Object? obj) => + obj is ManagedBox other && + EqualityComparer.Default.Equals(Value, other.Value); + + public override int GetHashCode() => + Value is null ? 0 : Value.GetHashCode(); + + public override string ToString() => + Value?.ToString() ?? "null"; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MaterialTheme.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MaterialTheme.cs new file mode 100644 index 000000000..d97adeafb --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MaterialTheme.cs @@ -0,0 +1,427 @@ +using Android.OS; +using AndroidX.Compose.Foundation; +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 MaterialTheme root. Supplies the active +/// , , and +/// to every child composable. +/// +/// +/// +/// With no properties set, MaterialTheme picks an Android 12+ +/// dynamic (Material You) light or dark color scheme depending on +/// , falling back to the +/// static M3 baseline palette on API levels < 31. +/// +/// +/// To override a slice of the theme, set , +/// , or directly — the +/// matching static factory methods build the scheme: +/// +/// +/// new MaterialTheme { +/// ColorScheme = MaterialTheme.DarkColorScheme(), +/// UseDynamicColor = false, +/// new Text("Hello"), +/// } +/// +/// +/// Child composables can read the active theme via +/// / +/// / +/// . +/// +/// +public sealed class MaterialTheme : ComposableContainer +{ + /// + /// Optional explicit color scheme. When (the + /// default), the theme picks one based on + /// and . + /// + public ColorScheme? ColorScheme { get; set; } + + /// + /// Optional explicit typography. When (the + /// default), the Compose-provided M3 baseline typography is used. + /// + public Typography? Typography { get; set; } + + /// + /// Optional explicit shape set. When (the + /// default), the Compose-provided M3 baseline shapes are used. + /// + public Shapes? Shapes { get; set; } + + /// + /// Force the theme into dark or light mode. When + /// (the default), the theme follows + /// . + /// + public bool? Dark { get; set; } + + /// + /// When (the default) and running on Android + /// 12 or later, use Material You dynamic color (derived from the + /// system wallpaper). Has no effect when + /// is set, or when running on API levels < 31. + /// + public bool UseDynamicColor { get; set; } = true; + + public override void Render(IComposer composer) + { + bool dark = Dark ?? DarkThemeKt.IsSystemInDarkTheme(composer, 0); + + ColorScheme scheme = ColorScheme ?? ResolveDefaultScheme(composer, dark); + + // MaterialThemeDefault is generated from `[ComposeDefaults]` + // and contains a bit per optional Kotlin parameter (ColorScheme, + // Shapes, Typography). Content is always provided. Start with + // every bit set, then clear the bits for slots we actually + // supply. + var defaults = MaterialThemeDefault.All & ~MaterialThemeDefault.ColorScheme; + if (Shapes is not null) defaults &= ~MaterialThemeDefault.Shapes; + if (Typography is not null) defaults &= ~MaterialThemeDefault.Typography; + + var content = ComposableLambdas.Wrap2(composer, c => RenderChildren(c)); + MaterialThemeKt.MaterialTheme( + colorScheme: scheme, + shapes: Shapes, + typography: Typography, + content: content, + _composer: composer, + p5: 0, + _changed: (int)defaults); + } + + ColorScheme ResolveDefaultScheme(IComposer composer, bool dark) + { + if (UseDynamicColor && Build.VERSION.SdkInt >= BuildVersionCodes.S) + { + // Read the composition-scoped Android context rather than + // Android.App.Application.Context, so the theme reflects any + // override installed by an enclosing CompositionLocalProvider + // (e.g. a contextual content-wrapper) and updates correctly on + // activity recreations. + var ctx = LocalContext.Current(composer); + return dark + ? DynamicTonalPaletteKt.DynamicDarkColorScheme(ctx) + : DynamicTonalPaletteKt.DynamicLightColorScheme(ctx); + } + return dark + ? ComposeBridges.DefaultDarkColorScheme() + : ComposeBridges.DefaultLightColorScheme(); + } + + /// + /// Build the Material 3 baseline light — + /// the same palette Kotlin's lightColorScheme() returns when + /// every parameter is left at its default. + /// + public static ColorScheme LightColorScheme() => ComposeBridges.DefaultLightColorScheme(); + + /// + /// Build the Material 3 baseline dark — + /// the same palette Kotlin's darkColorScheme() returns when + /// every parameter is left at its default. + /// + public static ColorScheme DarkColorScheme() => ComposeBridges.DefaultDarkColorScheme(); + + /// + /// Build a custom Material 3 light with + /// the supplied per-slot color overrides. Any parameter left at + /// falls back to the corresponding M3 tonal + /// palette token default. + /// + /// + /// Mirror of Kotlin's + /// lightColorScheme(primary = …, onPrimary = …, …) builder. + /// + public static ColorScheme LightColorScheme( + Color? primary = null, Color? onPrimary = null, + Color? primaryContainer = null, Color? onPrimaryContainer = null, + Color? inversePrimary = null, + Color? secondary = null, Color? onSecondary = null, + Color? secondaryContainer = null, Color? onSecondaryContainer = null, + Color? tertiary = null, Color? onTertiary = null, + Color? tertiaryContainer = null, Color? onTertiaryContainer = null, + Color? background = null, Color? onBackground = null, + Color? surface = null, Color? onSurface = null, + Color? surfaceVariant = null, Color? onSurfaceVariant = null, + Color? surfaceTint = null, + Color? inverseSurface = null, Color? inverseOnSurface = null, + Color? error = null, Color? onError = null, + Color? errorContainer = null, Color? onErrorContainer = null, + Color? outline = null, Color? outlineVariant = null, + Color? scrim = null, + Color? surfaceBright = null, + Color? surfaceContainer = null, Color? surfaceContainerHigh = null, + Color? surfaceContainerHighest = null, Color? surfaceContainerLow = null, + Color? surfaceContainerLowest = null, + Color? surfaceDim = null, + Color? primaryFixed = null, Color? primaryFixedDim = null, + Color? onPrimaryFixed = null, Color? onPrimaryFixedVariant = null, + Color? secondaryFixed = null, Color? secondaryFixedDim = null, + Color? onSecondaryFixed = null, Color? onSecondaryFixedVariant = null, + Color? tertiaryFixed = null, Color? tertiaryFixedDim = null, + Color? onTertiaryFixed = null, Color? onTertiaryFixedVariant = null) => + BuildColorScheme(light: true, + primary, onPrimary, primaryContainer, onPrimaryContainer, inversePrimary, + secondary, onSecondary, secondaryContainer, onSecondaryContainer, + tertiary, onTertiary, tertiaryContainer, onTertiaryContainer, + background, onBackground, surface, onSurface, surfaceVariant, onSurfaceVariant, + surfaceTint, inverseSurface, inverseOnSurface, + error, onError, errorContainer, onErrorContainer, + outline, outlineVariant, scrim, + surfaceBright, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, + surfaceContainerLow, surfaceContainerLowest, surfaceDim, + primaryFixed, primaryFixedDim, onPrimaryFixed, onPrimaryFixedVariant, + secondaryFixed, secondaryFixedDim, onSecondaryFixed, onSecondaryFixedVariant, + tertiaryFixed, tertiaryFixedDim, onTertiaryFixed, onTertiaryFixedVariant); + + /// + /// Build a custom Material 3 dark with + /// the supplied per-slot color overrides. Any parameter left at + /// falls back to the corresponding M3 tonal + /// palette token default. + /// + /// + /// Mirror of Kotlin's + /// darkColorScheme(primary = …, onPrimary = …, …) builder. + /// + public static ColorScheme DarkColorScheme( + Color? primary = null, Color? onPrimary = null, + Color? primaryContainer = null, Color? onPrimaryContainer = null, + Color? inversePrimary = null, + Color? secondary = null, Color? onSecondary = null, + Color? secondaryContainer = null, Color? onSecondaryContainer = null, + Color? tertiary = null, Color? onTertiary = null, + Color? tertiaryContainer = null, Color? onTertiaryContainer = null, + Color? background = null, Color? onBackground = null, + Color? surface = null, Color? onSurface = null, + Color? surfaceVariant = null, Color? onSurfaceVariant = null, + Color? surfaceTint = null, + Color? inverseSurface = null, Color? inverseOnSurface = null, + Color? error = null, Color? onError = null, + Color? errorContainer = null, Color? onErrorContainer = null, + Color? outline = null, Color? outlineVariant = null, + Color? scrim = null, + Color? surfaceBright = null, + Color? surfaceContainer = null, Color? surfaceContainerHigh = null, + Color? surfaceContainerHighest = null, Color? surfaceContainerLow = null, + Color? surfaceContainerLowest = null, + Color? surfaceDim = null, + Color? primaryFixed = null, Color? primaryFixedDim = null, + Color? onPrimaryFixed = null, Color? onPrimaryFixedVariant = null, + Color? secondaryFixed = null, Color? secondaryFixedDim = null, + Color? onSecondaryFixed = null, Color? onSecondaryFixedVariant = null, + Color? tertiaryFixed = null, Color? tertiaryFixedDim = null, + Color? onTertiaryFixed = null, Color? onTertiaryFixedVariant = null) => + BuildColorScheme(light: false, + primary, onPrimary, primaryContainer, onPrimaryContainer, inversePrimary, + secondary, onSecondary, secondaryContainer, onSecondaryContainer, + tertiary, onTertiary, tertiaryContainer, onTertiaryContainer, + background, onBackground, surface, onSurface, surfaceVariant, onSurfaceVariant, + surfaceTint, inverseSurface, inverseOnSurface, + error, onError, errorContainer, onErrorContainer, + outline, outlineVariant, scrim, + surfaceBright, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, + surfaceContainerLow, surfaceContainerLowest, surfaceDim, + primaryFixed, primaryFixedDim, onPrimaryFixed, onPrimaryFixedVariant, + secondaryFixed, secondaryFixedDim, onSecondaryFixed, onSecondaryFixedVariant, + tertiaryFixed, tertiaryFixedDim, onTertiaryFixed, onTertiaryFixedVariant); + + static ColorScheme BuildColorScheme(bool light, + Color? primary, Color? onPrimary, Color? primaryContainer, Color? onPrimaryContainer, Color? inversePrimary, + Color? secondary, Color? onSecondary, Color? secondaryContainer, Color? onSecondaryContainer, + Color? tertiary, Color? onTertiary, Color? tertiaryContainer, Color? onTertiaryContainer, + Color? background, Color? onBackground, Color? surface, Color? onSurface, Color? surfaceVariant, Color? onSurfaceVariant, + Color? surfaceTint, Color? inverseSurface, Color? inverseOnSurface, + Color? error, Color? onError, Color? errorContainer, Color? onErrorContainer, + Color? outline, Color? outlineVariant, Color? scrim, + Color? surfaceBright, Color? surfaceContainer, Color? surfaceContainerHigh, Color? surfaceContainerHighest, + Color? surfaceContainerLow, Color? surfaceContainerLowest, Color? surfaceDim, + Color? primaryFixed, Color? primaryFixedDim, Color? onPrimaryFixed, Color? onPrimaryFixedVariant, + Color? secondaryFixed, Color? secondaryFixedDim, Color? onSecondaryFixed, Color? onSecondaryFixedVariant, + Color? tertiaryFixed, Color? tertiaryFixedDim, Color? onTertiaryFixed, Color? onTertiaryFixedVariant) + { + // Slot order matches Kotlin's lightColorScheme/darkColorScheme + // parameter list and the ColorSchemeDefault enum declared in + // ComposeDefaults.cs. + Color?[] slots = new Color?[] + { + primary, onPrimary, primaryContainer, onPrimaryContainer, inversePrimary, + secondary, onSecondary, secondaryContainer, onSecondaryContainer, + tertiary, onTertiary, tertiaryContainer, onTertiaryContainer, + background, onBackground, surface, onSurface, surfaceVariant, onSurfaceVariant, + surfaceTint, inverseSurface, inverseOnSurface, + error, onError, errorContainer, onErrorContainer, + outline, outlineVariant, scrim, + surfaceBright, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, + surfaceContainerLow, surfaceContainerLowest, surfaceDim, + primaryFixed, primaryFixedDim, onPrimaryFixed, onPrimaryFixedVariant, + secondaryFixed, secondaryFixedDim, onSecondaryFixed, onSecondaryFixedVariant, + tertiaryFixed, tertiaryFixedDim, onTertiaryFixed, onTertiaryFixedVariant, + }; + + // ColorSchemeDefault is a long-backed [Flags] enum (48 slots + // exceeds the 31-bit int boundary), generated by + // ComposeDefaultsGenerator from the declarative attribute in + // ComposeDefaults.cs. Split() turns the typed mask into the + // (mask0, mask1) pair Kotlin's pair-of-int $default trampoline + // expects. + long[] colors = new long[slots.Length]; + var defaults = ColorSchemeDefault.None; + for (int i = 0; i < slots.Length; i++) + { + if (slots[i] is Color c) + colors[i] = c; + else + defaults |= (ColorSchemeDefault)(1L << i); + } + + var (mask0, mask1) = defaults.Split(); + return light + ? ComposeBridges.CustomLightColorScheme(colors, mask0, mask1) + : ComposeBridges.CustomDarkColorScheme(colors, mask0, mask1); + } + + /// + /// Build a dynamic (Material You) light color scheme from the + /// supplied context, or + /// when + /// is . Requires Android 12 (API 31) or later. + /// + public static ColorScheme DynamicLightColorScheme(Android.Content.Context? context = null) => + DynamicTonalPaletteKt.DynamicLightColorScheme(context ?? Android.App.Application.Context); + + /// + /// Build a dynamic (Material You) dark color scheme from the + /// supplied context, or + /// when + /// is . Requires Android 12 (API 31) or later. + /// + public static ColorScheme DynamicDarkColorScheme(Android.Content.Context? context = null) => + DynamicTonalPaletteKt.DynamicDarkColorScheme(context ?? Android.App.Application.Context); + + /// + /// Build a Material 3 with per-slot + /// overrides. Any parameter left at falls + /// back to the corresponding M3 baseline shape default. + /// + /// + /// Mirror of Kotlin's + /// Shapes(extraSmall = …, small = …, medium = …, large = …, extraLarge = …) + /// constructor. Use / + /// / + /// to build the per-slot + /// values. + /// + public static Shapes BuildShapes( + Shape? extraSmall = null, + Shape? small = null, + Shape? medium = null, + Shape? large = null, + Shape? extraLarge = null) + { + // ShapesDefault is generated from a declarative + // `[assembly: ComposeDefaults("ShapesDefault", ...)]`. Bit set => + // Kotlin substitutes the per-slot M3 baseline. + var defaults = ShapesDefault.None; + if (extraSmall is null) defaults |= ShapesDefault.ExtraSmall; + if (small is null) defaults |= ShapesDefault.Small; + if (medium is null) defaults |= ShapesDefault.Medium; + if (large is null) defaults |= ShapesDefault.Large; + if (extraLarge is null) defaults |= ShapesDefault.ExtraLarge; + + return ComposeBridges.BuildShapes( + extraSmall is null ? IntPtr.Zero : ((Java.Lang.Object)extraSmall).Handle, + small is null ? IntPtr.Zero : ((Java.Lang.Object)small).Handle, + medium is null ? IntPtr.Zero : ((Java.Lang.Object)medium).Handle, + large is null ? IntPtr.Zero : ((Java.Lang.Object)large).Handle, + extraLarge is null ? IntPtr.Zero : ((Java.Lang.Object)extraLarge).Handle, + (int)defaults); + } + + /// + /// Compose's isSystemInDarkTheme(): + /// when the device is currently in dark mode. Must be called from + /// inside a composable's Render body. + /// + public static bool IsSystemInDarkTheme(IComposer composer) => + DarkThemeKt.IsSystemInDarkTheme(composer, 0); + + /// + /// Build a Material 3 with per-slot text + /// style overrides. Any slot falls back to + /// the M3 baseline token for that role. + /// + /// + /// Mirror of Kotlin's + /// Typography(displayLarge = …, …) constructor with all 15 + /// slots optional. Build each with the + /// builder properties (Color, FontSize, + /// FontWeight, …); unset properties on the + /// inherit from the M3 default. + /// + public static Typography BuildTypography( + TextStyle? displayLarge = null, + TextStyle? displayMedium = null, + TextStyle? displaySmall = null, + TextStyle? headlineLarge = null, + TextStyle? headlineMedium = null, + TextStyle? headlineSmall = null, + TextStyle? titleLarge = null, + TextStyle? titleMedium = null, + TextStyle? titleSmall = null, + TextStyle? bodyLarge = null, + TextStyle? bodyMedium = null, + TextStyle? bodySmall = null, + TextStyle? labelLarge = null, + TextStyle? labelMedium = null, + TextStyle? labelSmall = null) + { + // TypographyDefault is generated from a declarative + // `[assembly: ComposeDefaults("TypographyDefault", ...)]`. + // Bit set => Kotlin substitutes the per-slot M3 baseline token. + var defaults = TypographyDefault.None; + if (displayLarge is null) defaults |= TypographyDefault.DisplayLarge; + if (displayMedium is null) defaults |= TypographyDefault.DisplayMedium; + if (displaySmall is null) defaults |= TypographyDefault.DisplaySmall; + if (headlineLarge is null) defaults |= TypographyDefault.HeadlineLarge; + if (headlineMedium is null) defaults |= TypographyDefault.HeadlineMedium; + if (headlineSmall is null) defaults |= TypographyDefault.HeadlineSmall; + if (titleLarge is null) defaults |= TypographyDefault.TitleLarge; + if (titleMedium is null) defaults |= TypographyDefault.TitleMedium; + if (titleSmall is null) defaults |= TypographyDefault.TitleSmall; + if (bodyLarge is null) defaults |= TypographyDefault.BodyLarge; + if (bodyMedium is null) defaults |= TypographyDefault.BodyMedium; + if (bodySmall is null) defaults |= TypographyDefault.BodySmall; + if (labelLarge is null) defaults |= TypographyDefault.LabelLarge; + if (labelMedium is null) defaults |= TypographyDefault.LabelMedium; + if (labelSmall is null) defaults |= TypographyDefault.LabelSmall; + + return ComposeBridges.BuildTypography( + displayLarge?.Build(), + displayMedium?.Build(), + displaySmall?.Build(), + headlineLarge?.Build(), + headlineMedium?.Build(), + headlineSmall?.Build(), + titleLarge?.Build(), + titleMedium?.Build(), + titleSmall?.Build(), + bodyLarge?.Build(), + bodyMedium?.Build(), + bodySmall?.Build(), + labelLarge?.Build(), + labelMedium?.Build(), + labelSmall?.Build(), + (int)defaults); + } + +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MediumFlexibleTopAppBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MediumFlexibleTopAppBar.cs new file mode 100644 index 000000000..9b46bd0f3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MediumFlexibleTopAppBar.cs @@ -0,0 +1,12 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 MediumFlexibleTopAppBar — a two-row top app bar +/// similar to but with a configurable +/// expanded height and an optional second-line Subtitle slot. +/// Supports the same ScrollBehavior slot as +/// — see for the +/// scroll-behavior + +/// pairing. +/// +public sealed partial class MediumFlexibleTopAppBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MediumTopAppBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MediumTopAppBar.cs new file mode 100644 index 000000000..7f36f9753 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MediumTopAppBar.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 MediumTopAppBar — a two-row top app bar that +/// collapses to a single row as the underlying scroll behavior +/// progresses. Same slots as . Typically paired +/// with +/// on its ScrollBehavior slot. +/// +public sealed partial class MediumTopAppBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Microsoft.AndroidX.Compose.csproj b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Microsoft.AndroidX.Compose.csproj new file mode 100644 index 000000000..7c4e28729 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Microsoft.AndroidX.Compose.csproj @@ -0,0 +1,86 @@ + + + + net11.0-android + 24 + enable + enable + true + true + AndroidX.Compose + + $(NoWarn);RS0026;RS0027 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + + + diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalBottomSheet.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalBottomSheet.cs new file mode 100644 index 000000000..77dd43967 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalBottomSheet.cs @@ -0,0 +1,32 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ModalBottomSheet — a modal sheet anchored to the +/// bottom of the screen. The sheet's lifecycle is gated by the caller: +/// keep visibility in a and clear it in +/// onDismissRequest. +/// +/// +/// +/// var show = Remember(() => new MutableState<bool>(false)); +/// show.Value +/// ? new ModalBottomSheet(onDismissRequest: () => show.Value = false) +/// { +/// new Column { new Text("Sheet contents") }, +/// } +/// : null +/// +/// +/// +/// To opt out of the half-expanded resting state, pass an explicit +/// : +/// +/// var sheet = Remember(() => new SheetStateHolder(skipPartiallyExpanded: true)); +/// new ModalBottomSheet(onDismissRequest: ..., sheetState: sheet) +/// { +/// ConfirmValueChange = v => !formIsDirty || v != SheetValue.Hidden, +/// new Column { ... }, +/// } +/// +/// +public sealed partial class ModalBottomSheet; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalDrawerSheet.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalDrawerSheet.cs new file mode 100644 index 000000000..a4f6fe7e7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalDrawerSheet.cs @@ -0,0 +1,16 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ModalDrawerSheet — the panel shown by a +/// modal-style navigation drawer. Lays out children as a Column. +/// Typically holds nav items; in this facade any +/// works. +/// +/// ContainerColor defaults to 0L, which the facade +/// resolves to the active +/// MaterialTheme.colorScheme.surfaceContainerLow — matches +/// the upstream Kotlin default +/// (DrawerDefaults.modalContainerColor). Pass any other +/// value to override. +/// +public sealed partial class ModalDrawerSheet; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalNavigationDrawer.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalNavigationDrawer.cs new file mode 100644 index 000000000..0c24de79f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalNavigationDrawer.cs @@ -0,0 +1,33 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ModalNavigationDrawer — a phone-sized drawer that +/// slides over the content with a scrim. The Drawer slot +/// typically holds a ; the Content +/// slot holds the screen the drawer overlays. Drag from the left edge +/// or tap the scrim to toggle; pass a +/// to programmatically inspect the drawer position. Set +/// ConfirmStateChange to veto a transition (return +/// false) — e.g. block close while a form is dirty. +/// +public sealed partial class ModalNavigationDrawer +{ + /// + /// Convenience: starting + /// on first composition. Mirrors Kotlin + /// rememberDrawerState(initialValue = DrawerValue.Open). + /// + /// + /// Setting this constructs a default + /// with the requested initial value. If the caller also passes an + /// explicit holder to the constructor, the init setter overwrites + /// it — pass the holder directly (with the desired + /// InitialValue) when you need to share state across + /// recompositions. + /// + public AndroidX.Compose.Material3.DrawerValue? InitialValue + { + get => _drawerState?.InitialValue; + init => _drawerState = new DrawerStateHolder(value); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalWideNavigationRail.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalWideNavigationRail.cs new file mode 100644 index 000000000..5c54f8be1 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModalWideNavigationRail.cs @@ -0,0 +1,42 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ModalWideNavigationRail — the modal-overlay +/// variant of . Drawn on top of the +/// host screen with a scrim, suitable for narrow-width adaptive +/// layouts. Children flow into the rail body; Header is an +/// optional slot drawn above them. +/// +/// +/// The Kotlin signature has no onDismissRequest callback — +/// dismissal is driven internally by the rail's +/// . Because the live +/// expand/collapse/snapTo operations are Kotlin +/// suspend functions and the facade does not yet have a coroutine-scope +/// story, the rail is wired with hideOnCollapse = true (its +/// content visually unmounts on collapse) and the C# facade has no +/// way to observe the collapse. Drive open/close from a separate +/// -of- gate by mirroring +/// the pattern. +/// +/// var show = Remember(() => new MutableState<bool>(false)); +/// var tab = Remember(() => new MutableNumberState<int>(0)); +/// new Row +/// { +/// new Button(onClick: () => show.Value = true) { new Text("Menu") }, +/// show.Value +/// ? new ModalWideNavigationRail +/// { +/// Header = new Text("Sections"), +/// new WideNavigationRailItem(selected: tab.Value == 0, +/// onClick: () => { tab.Value = 0; show.Value = false; }) +/// { +/// Icon = new Text("🏠"), +/// Label = new Text("Home"), +/// }, +/// } +/// : null, +/// }; +/// +/// +public sealed partial class ModalWideNavigationRail; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Modifier.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Modifier.cs new file mode 100644 index 000000000..86f4e0c20 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Modifier.cs @@ -0,0 +1,411 @@ +using Android.Runtime; +using AndroidX.Compose.UI; + +namespace AndroidX.Compose; + +/// +/// C# mirror of Kotlin's androidx.compose.ui.Modifier chain. +/// Build a chain by calling a static factory on this type — each call +/// returns a NEW immutable with the op appended. +/// Chain further ops via the matching extension methods in +/// : +/// +/// +/// new Column +/// { +/// Modifier.Padding(16).FillMaxWidth(), +/// new Text("Hello"), +/// } +/// +/// +/// On a leaf composable use object-initializer syntax: +/// +/// +/// new Text("Hello") { Modifier = Modifier.Padding(8) } +/// +/// +/// is the empty Modifier — the identity element +/// for composition. The static factories +/// here delegate to it. +/// +/// At Render time the chain is materialized into an +/// IModifier by replaying each op against +/// androidx.compose.ui.Modifier.Companion (resolved via the +/// $$INSTANCE static field Kotlin emits for object +/// declarations) via JNI — see +/// . Building +/// cheap modifier chains every recomposition is the per-composition +/// cost the Tier 1.5 facade pays — Tier 2 codegen would skip it. +/// +public sealed class Modifier +{ + static readonly Func[] EmptyOps = Array.Empty>(); + static readonly Modifier _companion = new Modifier(EmptyOps); + + /// + /// The empty Modifier — entry point for the fluent chain. + /// Mirrors Kotlin's Modifier.Companion object, which is + /// also the identity element of Modifier.then(...). + /// + public static Modifier Companion => _companion; + + readonly Func[] _ops; + + Modifier(Func[] ops) + { + _ops = ops; + } + + internal Modifier Append(Func op) + { + var arr = new Func[_ops.Length + 1]; + Array.Copy(_ops, arr, _ops.Length); + arr[_ops.Length] = op; + return new Modifier(arr); + } + + /// + /// Concatenate onto this chain, returning + /// a new . Mirrors Kotlin's + /// Modifier.then(other) — the receiver's ops apply first, + /// then 's ops. + /// + public Modifier Then(Modifier other) + { + ArgumentNullException.ThrowIfNull(other); + if (other._ops.Length == 0) return this; + if (_ops.Length == 0) return other; + var arr = new Func[_ops.Length + other._ops.Length]; + Array.Copy(_ops, arr, _ops.Length); + Array.Copy(other._ops, 0, arr, _ops.Length, other._ops.Length); + return new Modifier(arr); + } + + // ---- Static chain entry points (factories) ---- + // + // Each factory delegates to the matching extension method on + // ModifierExtensions via _companion. Together they let callers + // write `Modifier.X(...)` to start a chain and `.Y(...)` to + // continue it, matching Kotlin's `Modifier.x(...).y(...)` 1:1. + + /// + public static Modifier Padding(Dp all) => _companion.Padding(all); + + /// + public static Modifier Padding(Dp horizontal = default, Dp vertical = default) => _companion.Padding(horizontal, vertical); + + /// + public static Modifier Padding(Dp start = default, Dp top = default, Dp end = default, Dp bottom = default) => _companion.Padding(start, top, end, bottom); + + /// + public static Modifier Padding(PaddingValues paddingValues) => _companion.Padding(paddingValues); + + /// + public static Modifier FillMaxWidth(float fraction = 1f) => _companion.FillMaxWidth(fraction); + + /// + public static Modifier FillMaxHeight(float fraction = 1f) => _companion.FillMaxHeight(fraction); + + /// + public static Modifier FillMaxSize(float fraction = 1f) => _companion.FillMaxSize(fraction); + + /// + public static Modifier Height(Dp height) => _companion.Height(height); + + /// + public static Modifier Width(Dp width) => _companion.Width(width); + + /// + public static Modifier Size(Dp size) => _companion.Size(size); + + /// + public static Modifier Size(Dp width, Dp height) => _companion.Size(width, height); + + /// + public static Modifier SafeDrawingPadding() => _companion.SafeDrawingPadding(); + + /// + public static Modifier SystemBarsPadding() => _companion.SystemBarsPadding(); + + /// + public static Modifier MinimumInteractiveComponentSize() => _companion.MinimumInteractiveComponentSize(); + + /// + public static Modifier Background(Color color) => _companion.Background(color); + + /// + public static Modifier Background(Color color, Shape? shape) => _companion.Background(color, shape); + + /// + public static Modifier Border(Dp width, Color color) => _companion.Border(width, color); + + /// + public static Modifier Border(Dp width, Color color, Dp cornerRadius) => _companion.Border(width, color, cornerRadius); + + /// + public static Modifier Border(Dp width, Color color, Shape? shape) => _companion.Border(width, color, shape); + + /// + public static Modifier Clip(Dp cornerRadius) => _companion.Clip(cornerRadius); + + /// + public static Modifier Clip(Shape shape) => _companion.Clip(shape); + + /// + public static Modifier Clickable(Action onClick) => _companion.Clickable(onClick); + + /// + public static Modifier DragAndDropTarget(Func shouldStartDragAndDrop, DragAndDropTarget target) => _companion.DragAndDropTarget(shouldStartDragAndDrop, target); + + /// + public static Modifier VerticalScroll(ScrollState state, bool enabled = true, bool reverseScrolling = false) => _companion.VerticalScroll(state, enabled, reverseScrolling); + + /// + public static Modifier HorizontalScroll(ScrollState state, bool enabled = true, bool reverseScrolling = false) => _companion.HorizontalScroll(state, enabled, reverseScrolling); + + /// + public static Modifier Draggable(DraggableState state, Orientation orientation, bool enabled = true) => _companion.Draggable(state, orientation, enabled); + + /// + public static Modifier NestedScroll(AndroidX.Compose.UI.Input.NestedScroll.INestedScrollConnection connection) => _companion.NestedScroll(connection); + + /// + public static Modifier Weight(float weight, bool fill = true) => _companion.Weight(weight, fill); + + /// + public static Modifier WidthIn(Dp? min = null, Dp? max = null) => _companion.WidthIn(min, max); + + /// + public static Modifier HeightIn(Dp? min = null, Dp? max = null) => _companion.HeightIn(min, max); + + /// + public static Modifier SizeIn(Dp? minWidth = null, Dp? minHeight = null, Dp? maxWidth = null, Dp? maxHeight = null) => _companion.SizeIn(minWidth, minHeight, maxWidth, maxHeight); + + /// + public static Modifier RequiredSize(Dp size) => _companion.RequiredSize(size); + + /// + public static Modifier RequiredSize(Dp width, Dp height) => _companion.RequiredSize(width, height); + + /// + public static Modifier RequiredWidth(Dp width) => _companion.RequiredWidth(width); + + /// + public static Modifier RequiredHeight(Dp height) => _companion.RequiredHeight(height); + + /// + public static Modifier DefaultMinSize(Dp? minWidth = null, Dp? minHeight = null) => _companion.DefaultMinSize(minWidth, minHeight); + + /// + public static Modifier WrapContentSize(bool unbounded = false) => _companion.WrapContentSize(unbounded); + + /// + public static Modifier WrapContentWidth(bool unbounded = false) => _companion.WrapContentWidth(unbounded); + + /// + public static Modifier WrapContentHeight(bool unbounded = false) => _companion.WrapContentHeight(unbounded); + + /// + public static Modifier AspectRatio(float ratio, bool matchHeightConstraintsFirst = false) => _companion.AspectRatio(ratio, matchHeightConstraintsFirst); + + /// + public static Modifier Offset(Dp? x = null, Dp? y = null) => _companion.Offset(x, y); + + /// + public static Modifier AbsoluteOffset(Dp? x = null, Dp? y = null) => _companion.AbsoluteOffset(x, y); + + /// + public static Modifier ZIndex(float z) => _companion.ZIndex(z); + + /// + public static Modifier Alpha(float alpha) => _companion.Alpha(alpha); + + /// + public static Modifier Rotate(float degrees) => _companion.Rotate(degrees); + + /// + public static Modifier Scale(float scale) => _companion.Scale(scale); + + /// + public static Modifier Scale(float scaleX, float scaleY) => _companion.Scale(scaleX, scaleY); + + /// + public static Modifier Shadow(Dp elevation, Shape? shape = null) => _companion.Shadow(elevation, shape); + + /// + public static Modifier GraphicsLayer(float? scaleX = null, float? scaleY = null, float? alpha = null, float? translationX = null, float? translationY = null, float? shadowElevation = null, float? rotationX = null, float? rotationY = null, float? rotationZ = null, float? cameraDistance = null, long? transformOrigin = null, Shape? shape = null, bool? clip = null) => _companion.GraphicsLayer(scaleX, scaleY, alpha, translationX, translationY, shadowElevation, rotationX, rotationY, rotationZ, cameraDistance, transformOrigin, shape, clip); + + /// + public static Modifier ImePadding() => _companion.ImePadding(); + + /// + public static Modifier NavigationBarsPadding() => _companion.NavigationBarsPadding(); + + /// + public static Modifier StatusBarsPadding() => _companion.StatusBarsPadding(); + + /// + public static Modifier DisplayCutoutPadding() => _companion.DisplayCutoutPadding(); + + /// + public static Modifier CaptionBarPadding() => _companion.CaptionBarPadding(); + + /// + public static Modifier MandatorySystemGesturesPadding() => _companion.MandatorySystemGesturesPadding(); + + /// + public static Modifier SafeContentPadding() => _companion.SafeContentPadding(); + + /// + public static Modifier SafeGesturesPadding() => _companion.SafeGesturesPadding(); + + /// + public static Modifier SystemGesturesPadding() => _companion.SystemGesturesPadding(); + + /// + public static Modifier WaterfallPadding() => _companion.WaterfallPadding(); + + /// + public static Modifier TestTag(string tag) => _companion.TestTag(tag); + + /// + public static Modifier Align(Alignment alignment) => _companion.Align(alignment); + + /// + public static Modifier Align(Alignment.Vertical alignment) => _companion.Align(alignment); + + /// + public static Modifier Align(Alignment.Horizontal alignment) => _companion.Align(alignment); + + /// + public static Modifier MatchParentSize() => _companion.MatchParentSize(); + + /// + public static Modifier Focusable(bool enabled = true) => _companion.Focusable(enabled); + + /// + public static Modifier FocusGroup() => _companion.FocusGroup(); + + /// + public static Modifier OnFocusChanged(Action onFocusChanged) => _companion.OnFocusChanged(onFocusChanged); + + /// + public static Modifier FocusRequester(FocusRequester requester) => _companion.FocusRequester(requester); + + /// + public static Modifier CombinedClickable(Action onClick, Action? onLongClick = null, Action? onDoubleClick = null) => _companion.CombinedClickable(onClick, onLongClick, onDoubleClick); + + /// + public static Modifier Selectable(bool selected, Action onClick) => _companion.Selectable(selected, onClick); + + /// + public static Modifier Toggleable(bool value, Action onValueChange) => _companion.Toggleable(value, onValueChange); + + /// + public static Modifier Semantics(string contentDescription) => _companion.Semantics(contentDescription); + + /// + public static Modifier Semantics(bool mergeDescendants, string contentDescription) => _companion.Semantics(mergeDescendants, contentDescription); + + /// + public static Modifier Semantics(SemanticsRole role) => _companion.Semantics(role); + + /// + public static Modifier Semantics(string? contentDescription, SemanticsRole? role) => _companion.Semantics(contentDescription, role); + + /// + public static Modifier Semantics(bool mergeDescendants, string? contentDescription, SemanticsRole? role) => _companion.Semantics(mergeDescendants, contentDescription, role); + + /// + public static Modifier ClearAndSetSemantics(string contentDescription) => _companion.ClearAndSetSemantics(contentDescription); + + /// + public static Modifier ClearAndSetSemantics(string? contentDescription, SemanticsRole? role) => _companion.ClearAndSetSemantics(contentDescription, role); + + /// + public static Modifier DetectTapGestures(Action? onTap = null, Action? onPress = null, Action? onLongPress = null, Action? onDoubleTap = null, object? key = null) => _companion.DetectTapGestures(onTap, onPress, onLongPress, onDoubleTap, key); + + /// + public static Modifier DetectDragGestures(Action onDrag, Action? onDragStart = null, Action? onDragEnd = null, Action? onDragCancel = null, object? key = null) => _companion.DetectDragGestures(onDrag, onDragStart, onDragEnd, onDragCancel, key); + + /// + public static Modifier DetectTransformGestures(Action onGesture, bool panZoomLock = false, object? key = null) => _companion.DetectTransformGestures(onGesture, panZoomLock, key); + + /// + public static Modifier Semantics(Action properties) => _companion.Semantics(properties); + + /// + public static Modifier Semantics(bool mergeDescendants, Action properties) => _companion.Semantics(mergeDescendants, properties); + + /// + public static Modifier ClearAndSetSemantics(Action properties) => _companion.ClearAndSetSemantics(properties); + + /// + /// Modifier.padding(paddingValues) — pads using the + /// PaddingValues handle a layout (e.g. ) + /// passes to its content lambda. Internal: only Scaffold-shaped + /// composables that receive a runtime PaddingValues need it. + /// + internal Modifier Padding(IntPtr paddingValues) => + Append(curr => ComposeBridges.ModifierPaddingValues(curr, paddingValues)); + + /// + /// Materialize the chain into a managed IModifier wrapper. + /// Returns null when the chain is empty (no ops appended) so + /// callers can keep the Kotlin $default bit set and let + /// Compose substitute the real default. + /// + internal IModifier? Build() => Build(IntPtr.Zero); + + /// + /// Materialize the chain into a managed IModifier wrapper, + /// optionally prepending a Modifier.padding(contentPadding) + /// op against the supplied handle. The padding + /// op runs FIRST, before any user ops — semantically equivalent to + /// Modifier.padding(contentPadding).then(this) but without + /// allocating a managed wrapper. Used by + /// to apply the + /// -supplied PaddingValues to the body + /// node on every measure pass without per-pass allocations + /// (issue #46). + /// + internal IModifier? Build(IntPtr contentPadding) + { + bool hasContentPadding = contentPadding != IntPtr.Zero; + if (_ops.Length == 0 && !hasContentPadding) + return null; + + // `current` always holds the latest live local ref. On the + // happy path we zero it out after handing ownership to + // GetObject; the finally then no-ops. On any exception path + // — JNI op throwing, GetObject throwing, anything in between + // — the finally deletes whichever local ref is still live so + // we never leak. + IntPtr current = IntPtr.Zero; + try + { + current = ComposeBridges.ModifierCompanionInstance(); + if (hasContentPadding) + { + IntPtr next = ComposeBridges.ModifierPaddingValues(current, contentPadding); + JNIEnv.DeleteLocalRef(current); + current = next; + } + for (int i = 0; i < _ops.Length; i++) + { + IntPtr next = _ops[i](current); + JNIEnv.DeleteLocalRef(current); + current = next; + } + + var result = Java.Lang.Object.GetObject(current, JniHandleOwnership.TransferLocalRef)!; + current = IntPtr.Zero; + return result; + } + finally + { + if (current != IntPtr.Zero) + JNIEnv.DeleteLocalRef(current); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModifierExtensions.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModifierExtensions.cs new file mode 100644 index 000000000..f8f791490 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ModifierExtensions.cs @@ -0,0 +1,1453 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Fluent chain helpers for . Each method is an +/// extension on so callers can write both +/// Modifier.Padding(16).FillMaxWidth() (the static factory on +/// kicks off the chain, then these extensions +/// continue it) and someModifier.Padding(16) (extension on an +/// existing instance). +/// +public static class ModifierExtensions +{ + /// + /// Modifier.padding(all: Dp) — applies + /// of padding to every edge. + /// + public static Modifier Padding(this Modifier modifier, Dp all) + { + var dp = all.Value; + return modifier.Append(h => ComposeBridges.ModifierPaddingAll(h, dp)); + } + + /// + /// Modifier.padding(horizontal: Dp, vertical: Dp). Each edge + /// defaults to 0.dp, so callers can name just the axis they + /// care about (e.g. someModifier.Padding(horizontal: 16)) and + /// mirror Kotlin's modifier.padding(horizontal = 16.dp). + /// + public static Modifier Padding(this Modifier modifier, Dp horizontal = default, Dp vertical = default) + { + var h = horizontal.Value; + var v = vertical.Value; + return modifier.Append(curr => ComposeBridges.ModifierPaddingHV(curr, h, v)); + } + + /// + /// Modifier.padding(start: Dp, top: Dp, end: Dp, bottom: Dp). + /// Each edge defaults to 0.dp, so callers can name just the + /// edges they want (e.g. someModifier.Padding(top: 8)) and mirror + /// Kotlin's modifier.padding(top = 8.dp). + /// + public static Modifier Padding(this Modifier modifier, Dp start = default, Dp top = default, Dp end = default, Dp bottom = default) + { + var s = start.Value; + var t = top.Value; + var e = end.Value; + var b = bottom.Value; + return modifier.Append(curr => ComposeBridges.ModifierPaddingLTRB(curr, s, t, e, b)); + } + + /// + /// Modifier.padding(paddingValues: PaddingValues) — apply a + /// pre-built as a single + /// Modifier.padding op. Useful for forwarding the + /// PaddingValues a parent hands its + /// content lambda into a non-list child that should pad rather + /// than route into a native contentPadding slot. + /// + public static Modifier Padding(this Modifier modifier, PaddingValues paddingValues) + { + ArgumentNullException.ThrowIfNull(paddingValues); + var pv = paddingValues; + return modifier.Append(curr => ComposeBridges.ModifierPaddingValues(curr, ((Java.Lang.Object)pv).Handle)); + } + + /// + /// Modifier.fillMaxWidth(fraction). Defaults to filling + /// the entire available width ( = 1). + /// + public static Modifier FillMaxWidth(this Modifier modifier, float fraction = 1f) => + modifier.Append(h => ComposeBridges.ModifierFillMaxWidth(h, fraction)); + + /// + /// Modifier.fillMaxHeight(fraction). + /// + public static Modifier FillMaxHeight(this Modifier modifier, float fraction = 1f) => + modifier.Append(h => ComposeBridges.ModifierFillMaxHeight(h, fraction)); + + /// + /// Modifier.fillMaxSize(fraction) — fills both width and height. + /// + public static Modifier FillMaxSize(this Modifier modifier, float fraction = 1f) => + modifier.Append(h => ComposeBridges.ModifierFillMaxSize(h, fraction)); + + /// + /// Modifier.height(dp) — sets a fixed height in dp. + /// Required to give vertically-scrolling content (e.g. + /// ) a bounded viewport when it lives + /// inside an unbounded parent like a regular . + /// + public static Modifier Height(this Modifier modifier, Dp height) + { + var f = height.Value; + return modifier.Append(h => ComposeBridges.ModifierHeight(h, f)); + } + + /// + /// Modifier.width(dp) — sets a fixed width in dp. + /// + public static Modifier Width(this Modifier modifier, Dp width) + { + var f = width.Value; + return modifier.Append(h => ComposeBridges.ModifierWidth(h, f)); + } + + /// + /// Modifier.size(dp) — sets both width and height to the + /// same value in dp. + /// + public static Modifier Size(this Modifier modifier, Dp size) + { + var f = size.Value; + return modifier.Append(h => ComposeBridges.ModifierSizeAll(h, f)); + } + + /// + /// Modifier.size(width, height) in dp. + /// + public static Modifier Size(this Modifier modifier, Dp width, Dp height) + { + var w = width.Value; + var h = height.Value; + return modifier.Append(curr => ComposeBridges.ModifierSizeWH(curr, w, h)); + } + + /// + /// Modifier.safeDrawingPadding() — pads for the union of + /// system bars, IME, and display cutouts. Use as the outer modifier + /// on a root composable (or inside a 's body + /// when no is supplied) to keep + /// content out of inset regions under edge-to-edge. + /// + public static Modifier SafeDrawingPadding(this Modifier modifier) => + modifier.Append(h => ComposeBridges.ModifierSafeDrawingPadding(h)); + + /// + /// Modifier.systemBarsPadding() — pads for status + nav bars + /// only (ignoring IME and cutouts). Prefer + /// in most apps. + /// + public static Modifier SystemBarsPadding(this Modifier modifier) => + modifier.Append(h => ComposeBridges.ModifierSystemBarsPadding(h)); + + /// + /// Modifier.minimumInteractiveComponentSize() — reserves at + /// least 48dp × 48dp around the composable to keep touch targets + /// accessible. Add to icon-only buttons, dense list rows, or any + /// other small clickable surface so its hit-box meets the Material + /// 3 accessibility minimum. Must appear in the chain BEFORE any + /// / / + /// modifier that would limit the + /// composable's constraints, otherwise the reserved space is + /// clamped away. + /// + /// + /// This modifier only affects layout — it doesn't enable touch + /// expansion (the platform already does that via + /// ViewConfiguration). Its job is to make sure neighboring + /// composables don't crowd the touch region. + /// + public static Modifier MinimumInteractiveComponentSize(this Modifier modifier) => + modifier.Append(h => ComposeBridges.ModifierMinimumInteractiveComponentSize(h)); + + /// + /// Modifier.background(color) — paints a flat fill behind the + /// composable using a RectangleShape. Takes a packed Compose + /// androidx.compose.ui.graphics.Color value (a ULong + /// surfaced as a long in the binding because Color is a Kotlin + /// @JvmInline value class). Build one via + /// , + /// , or one of the + /// named constants on (e.g. + /// ). + /// + public static Modifier Background(this Modifier modifier, Color color) => + modifier.Append(curr => ComposeBridges.ModifierBackground(curr, color, null)); + + /// + /// Modifier.background(color, shape) — paints a flat fill + /// behind the composable, clipped to . Pass + /// null for the default RectangleShape; otherwise build + /// a shape via , + /// , + /// , etc. The shape is + /// captured by the closure so its Java peer stays alive across + /// recompositions. + /// + public static Modifier Background(this Modifier modifier, Color color, Shape? shape) => + modifier.Append(curr => ComposeBridges.ModifierBackground(curr, color, shape?.Handle)); + + /// + /// Modifier.border(width, color) — draws a rectangular stroke + /// around the composable. is the stroke + /// width in density-independent pixels. For rounded corners use + /// ; for arbitrary shapes use + /// . + /// + public static Modifier Border(this Modifier modifier, Dp width, Color color) + { + var w = width.Value; + long c = color; + return modifier.Append(curr => ComposeBridges.ModifierBorder(curr, w, c, null)); + } + + /// + /// Modifier.border(width, color, RoundedCornerShape(cornerRadius)) — + /// draws a stroke with rounded corners. Match + /// to a + /// earlier in the chain so the corners align (otherwise the + /// rectangular stroke gets sliced by a rounded clip and you see + /// jagged corner stubs). + /// + public static Modifier Border(this Modifier modifier, Dp width, Color color, Dp cornerRadius) + { + var w = width.Value; + var r = cornerRadius.Value; + long c = color; + if (r <= 0f) + return modifier.Append(curr => ComposeBridges.ModifierBorder(curr, w, c, null)); + + return modifier.Append(curr => + { + IntPtr shape = ComposeBridges.RoundedCornerShape(r); + try + { + return ComposeBridges.ModifierBorder(curr, w, c, shape); + } + finally + { + if (shape != IntPtr.Zero) + JNIEnv.DeleteLocalRef(shape); + } + }); + } + + /// + /// Modifier.border(width, color, shape) — overload taking an + /// explicit for non-rounded geometry + /// (cut corners, custom shape factories, etc.). Pass null for + /// RectangleShape. The shape is captured by the closure so its + /// Java peer stays alive across recompositions. + /// + public static Modifier Border(this Modifier modifier, Dp width, Color color, Shape? shape) + { + var w = width.Value; + long c = color; + return modifier.Append(curr => ComposeBridges.ModifierBorder(curr, w, c, shape?.Handle)); + } + + /// + /// Modifier.background(brush, shape, alpha) — paints a + /// (gradient, + /// solid color, or any other Brush subclass) behind the + /// composable, clipped to . Pass + /// null for the default ; + /// supply to fade the brush + /// (1f = fully opaque, 0f = invisible). + /// + /// + /// The brush is captured by the closure so its Java peer stays + /// alive across recompositions. For static brushes prefer + /// constructing once outside the composition (or wrapping the + /// factory call in composer.Remember(() => ...)) so the + /// Java instance is reused frame-to-frame. + /// + public static Modifier Background( + this Modifier modifier, AndroidX.Compose.UI.Graphics.Brush brush, Shape? shape = null, float alpha = 1f) + { + ArgumentNullException.ThrowIfNull(brush); + return modifier.Append(curr => ComposeBridges.ModifierBackgroundBrush(curr, brush, shape, alpha)); + } + + /// + /// Modifier.border(width, brush, shape) — draws a stroke + /// painted by the supplied + /// around the composable, clipped to . Pass + /// null for the default . + /// + /// + /// The brush is captured by the closure so its Java peer stays + /// alive across recompositions — see + /// + /// for the same notes around per-recomposition allocation. + /// + public static Modifier Border( + this Modifier modifier, Dp width, AndroidX.Compose.UI.Graphics.Brush brush, Shape? shape = null) + { + ArgumentNullException.ThrowIfNull(brush); + var w = width.Value; + // The Brush overload of BorderKt.border declares no default for + // shape, so the AAR has no $default synthetic — substitute + // RectangleShape here instead. + var s = shape ?? Shape.Rectangle; + return modifier.Append(curr => ComposeBridges.ModifierBorderBrush(curr, w, brush, s)); + } + + /// + /// Modifier.clip(RoundedCornerShape()) — + /// rounds the four corners by the same radius and clips drawing to the + /// resulting shape. Pass 0 for no rounding (rectangle clip). + /// + public static Modifier Clip(this Modifier modifier, Dp cornerRadius) + { + var dp = cornerRadius.Value; + return modifier.Append(curr => ComposeBridges.ModifierClipRoundedCorners(curr, dp)); + } + + /// + /// Modifier.clip(shape) — clips drawing (and pointer hits) + /// to the supplied . Use with + /// for circular clips, + /// for chamfered + /// corners, or any other shape factory. The shape is captured by + /// the closure so its Java peer stays alive across recompositions. + /// + public static Modifier Clip(this Modifier modifier, Shape shape) + { + ArgumentNullException.ThrowIfNull(shape); + return modifier.Append(curr => ComposeBridges.ModifierClip(curr, shape.Handle)); + } + + /// + /// Modifier.clickable { onClick() } — handles taps with the + /// default Material indication / ripple. The click handler runs on + /// the UI thread. + /// + public static Modifier Clickable(this Modifier modifier, Action onClick) + { + ArgumentNullException.ThrowIfNull(onClick); + + var lambda = new ComposableLambda0(onClick); + return modifier.Append(curr => ComposeBridges.ModifierClickable(curr, lambda)); + } + + /// + /// Modifier.dragAndDropTarget(shouldStartDragAndDrop, target) — + /// marks the composable as a drop zone for drags originating from other + /// apps or other composables. The + /// predicate runs once per + /// drag-start; return true to opt this target in for that drag + /// (a common gate is e => e.MimeTypes.Any(m => m.StartsWith("image/"))). + /// While the drag is in progress Compose forwards events to + /// ; assign its + /// to handle the dropped payload. + /// + /// Both arguments should be hoisted into + /// composer.Remember so the + /// underlying DragAndDropTargetElement keeps a stable identity + /// across recompositions: + /// + /// var target = composer.Remember(() => new DragAndDropTarget { OnDrop = e => { /* ... */ return true; } }); + /// new Column + /// { + /// Modifier.FillMaxSize() + /// .DragAndDropTarget(e => e.MimeTypes.Any(m => m.StartsWith("image/")), target), + /// /* children */ + /// }; + /// + /// + /// Predicate invoked when a drag + /// begins. Return true to register interest in the drag (and + /// receive subsequent events on ), false + /// to ignore it. + /// Receiver of drag events for this composable. At + /// minimum set ; the optional + /// / + /// / + /// / + /// hooks drive + /// hover/session visuals. + public static Modifier DragAndDropTarget(this Modifier modifier, + Func shouldStartDragAndDrop, + DragAndDropTarget target) + { + ArgumentNullException.ThrowIfNull(shouldStartDragAndDrop); + ArgumentNullException.ThrowIfNull(target); + var predicate = new ShouldStartDragAndDropCallback(shouldStartDragAndDrop); + return modifier.Append(curr => ComposeBridges.ModifierDragAndDropTarget(curr, predicate, target)); + } + + /// + /// Modifier.verticalScroll(state) — makes a non-Lazy parent + /// (e.g. a regular or ) + /// vertically scrollable when its content overflows. Hold the + /// across recompositions with + /// composer.Remember: + /// + /// var scroll = Remember(() => new ScrollState()); + /// new Column { Modifier.VerticalScroll(scroll), /* children */ }; + /// + /// Prefer for long lists of like-shaped + /// items — it only composes the children currently on screen. Use + /// for a small, known set of children + /// that simply might overflow on smaller screens. + /// + /// Scroll position state to drive (and read + /// the current offset from). + /// When false, the user can no longer + /// scroll the content via touch gestures. + /// still updates as the layout changes, but this binding does not + /// yet expose programmatic scrolling (Kotlin's + /// scrollTo / animateScrollTo are suspend + /// functions, not yet wired up). + /// When true, flip the + /// scroll direction so the start of the content sits at the + /// bottom of the viewport (and dragging up reveals earlier + /// content). Defaults to false. + public static Modifier VerticalScroll(this Modifier modifier, ScrollState state, bool enabled = true, bool reverseScrolling = false) + { + ArgumentNullException.ThrowIfNull(state); + var jvm = state.Jvm; + return modifier.Append(curr => + ComposeBridges.ModifierVerticalScroll( + curr, ((Java.Lang.Object)jvm).Handle, enabled, reverseScrolling)); + } + + /// + /// Modifier.horizontalScroll(state) — makes a non-Lazy + /// parent (e.g. a regular or ) + /// horizontally scrollable when its content overflows. See + /// for the typical usage shape; prefer + /// for long horizontally-scrollable lists. + /// + /// Scroll position state to drive (and read + /// the current offset from). + /// When false, the user can no longer + /// scroll the content via touch gestures. + /// still updates as the layout changes, but this binding does not + /// yet expose programmatic scrolling (Kotlin's + /// scrollTo / animateScrollTo are suspend + /// functions, not yet wired up). + /// When true, flip the + /// scroll direction so the start of the content sits at the end + /// of the viewport. Defaults to false. + public static Modifier HorizontalScroll(this Modifier modifier, ScrollState state, bool enabled = true, bool reverseScrolling = false) + { + ArgumentNullException.ThrowIfNull(state); + var jvm = state.Jvm; + return modifier.Append(curr => + ComposeBridges.ModifierHorizontalScroll( + curr, ((Java.Lang.Object)jvm).Handle, enabled, reverseScrolling)); + } + + /// + /// Modifier.draggable(state, orientation, enabled) — drag + /// gesture handler that reports raw drag deltas (in pixels along + /// the chosen ) to the supplied + /// . Unlike scroll, this modifier does + /// not consume the deltas itself — your onDelta callback + /// inside decides what to do with the + /// movement (typically: update an offset state that another + /// modifier reads). + /// + /// State holder that receives drag deltas. + /// Build via new DraggableState(delta => ...) inside a + /// composer.Remember call, or via + /// + /// for stable Java identity across recompositions when the + /// callback closure changes. + /// Axis the gesture operates on — + /// for up-down drags, + /// for left-right drags. + /// When false, the modifier ignores + /// touch input. Defaults to true. + public static Modifier Draggable(this Modifier modifier, DraggableState state, Orientation orientation, bool enabled = true) + { + ArgumentNullException.ThrowIfNull(state); + var jvm = state.Jvm; + var jvmOrientation = orientation == Orientation.Horizontal + ? AndroidX.Compose.Foundation.Gestures.Orientation.Horizontal! + : AndroidX.Compose.Foundation.Gestures.Orientation.Vertical!; + return modifier.Append(curr => + ComposeBridges.ModifierDraggable( + curr, + ((Java.Lang.Object)jvm).Handle, + ((Java.Lang.Object)jvmOrientation).Handle, + enabled)); + } + + /// + /// Modifier.nestedScroll(connection) — bridge a scrollable + /// container to a nested-scrolling parent (most commonly a + /// Material 3 / + /// / that collapses on scroll). + /// Apply this modifier to the scrolling container — usually a + /// LazyColumn, LazyRow, or a + /// with VerticalScroll(ScrollState) — and Compose will + /// route scroll deltas through so + /// the parent can consume some of them before the container does. + /// + /// + /// The connection to forward scroll deltas to. Pair with an + /// + /// by passing + /// ; + /// the scroll behavior is also set as the bar's + /// ScrollBehavior property so both sides agree on the + /// shared . + /// + public static Modifier NestedScroll(this Modifier modifier, AndroidX.Compose.UI.Input.NestedScroll.INestedScrollConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + return modifier.Append(curr => ComposeBridges.ModifierNestedScroll(curr, connection)); + } + + /// + /// Modifier.weight(weight, fill = true) — only valid inside a + /// or (or any container that + /// publishes / ). + /// Distributes the leftover space along the parent's main axis in + /// proportion to other weighted children. Set + /// to false to let the child be smaller than its allotted slot. + /// + public static Modifier Weight(this Modifier modifier, float weight, bool fill = true) + { + return modifier.Append(curr => + { + IntPtr scope = RenderContext.CurrentScope; + ScopeKind kind = RenderContext.CurrentScopeKind; + return kind switch + { + ScopeKind.Row => ComposeBridges.RowScopeModifierWeight(scope, curr, weight, fill), + ScopeKind.Column => ComposeBridges.ColumnScopeModifierWeight(scope, curr, weight, fill), + _ => throw new InvalidOperationException( + "Modifier.Weight() can only be used inside a Row, Column, or " + + "Row/Column-shaped scope (BottomAppBar, NavigationBar, …). " + + $"Current scope kind: {kind}.") + }; + }); + } + + /// + /// Modifier.widthIn(min, max) — adds a min and/or max width + /// constraint. Pass null for either bound to leave it + /// unconstrained (Kotlin's Dp.Unspecified default), so + /// WidthIn(min: 100) caps only the lower bound. + /// + public static Modifier WidthIn(this Modifier modifier, Dp? min = null, Dp? max = null) => + modifier.Append(curr => ComposeBridges.ModifierWidthIn(curr, min, max)); + + /// + /// Modifier.heightIn(min, max) — adds a min and/or max height + /// constraint. Pass null for either bound to leave it + /// unconstrained. + /// + public static Modifier HeightIn(this Modifier modifier, Dp? min = null, Dp? max = null) => + modifier.Append(curr => ComposeBridges.ModifierHeightIn(curr, min, max)); + + /// + /// Modifier.sizeIn(minWidth, minHeight, maxWidth, maxHeight) — + /// constrains both axes. Pass null for any bound to leave it + /// unconstrained. + /// + public static Modifier SizeIn(this Modifier modifier, Dp? minWidth = null, Dp? minHeight = null, Dp? maxWidth = null, Dp? maxHeight = null) => + modifier.Append(curr => ComposeBridges.ModifierSizeIn(curr, minWidth, minHeight, maxWidth, maxHeight)); + + /// + /// Modifier.requiredSize(size) — declares an exact size that + /// bypasses the parent's constraints (the composable is allowed to + /// be drawn outside the parent if needed). Use sparingly; prefer + /// for normal layout. + /// + public static Modifier RequiredSize(this Modifier modifier, Dp size) + { + var dp = size.Value; + return modifier.Append(curr => ComposeBridges.ModifierRequiredSizeAll(curr, dp)); + } + + /// + /// Modifier.requiredSize(width, height) — overload taking + /// independent width and height (each bypassing parent constraints). + /// + public static Modifier RequiredSize(this Modifier modifier, Dp width, Dp height) + { + var w = width.Value; + var h = height.Value; + return modifier.Append(curr => ComposeBridges.ModifierRequiredSizeWH(curr, w, h)); + } + + /// + /// Modifier.requiredWidth(width) — declares an exact width + /// that bypasses the parent's constraints. + /// + public static Modifier RequiredWidth(this Modifier modifier, Dp width) + { + var w = width.Value; + return modifier.Append(curr => ComposeBridges.ModifierRequiredWidth(curr, w)); + } + + /// + /// Modifier.requiredHeight(height) — declares an exact height + /// that bypasses the parent's constraints. + /// + public static Modifier RequiredHeight(this Modifier modifier, Dp height) + { + var h = height.Value; + return modifier.Append(curr => ComposeBridges.ModifierRequiredHeight(curr, h)); + } + + /// + /// Modifier.defaultMinSize(minWidth, minHeight) — supplies a + /// minimum size that's only used when the parent doesn't already + /// constrain that dimension. Useful for default-sized buttons / + /// chips that should grow with content but never shrink below a + /// hit-target floor. Pass null for either bound to leave it + /// unspecified. + /// + public static Modifier DefaultMinSize(this Modifier modifier, Dp? minWidth = null, Dp? minHeight = null) => + modifier.Append(curr => ComposeBridges.ModifierDefaultMinSize(curr, minWidth, minHeight)); + + /// + /// Modifier.wrapContentSize(unbounded) — measures content + /// without imposing the parent's constraints, then centers the + /// result inside the parent's available space. Set + /// to true to let content + /// overflow the parent. + /// + public static Modifier WrapContentSize(this Modifier modifier, bool unbounded = false) => + modifier.Append(curr => ComposeBridges.ModifierWrapContentSize(curr, unbounded)); + + /// + /// Modifier.wrapContentWidth(unbounded) — same as + /// but only relaxes the width + /// axis. + /// + public static Modifier WrapContentWidth(this Modifier modifier, bool unbounded = false) => + modifier.Append(curr => ComposeBridges.ModifierWrapContentWidth(curr, unbounded)); + + /// + /// Modifier.wrapContentHeight(unbounded) — same as + /// but only relaxes the height + /// axis. + /// + public static Modifier WrapContentHeight(this Modifier modifier, bool unbounded = false) => + modifier.Append(curr => ComposeBridges.ModifierWrapContentHeight(curr, unbounded)); + + /// + /// Modifier.aspectRatio(ratio, matchHeightConstraintsFirst) — + /// forces the composable's width-to-height ratio. + /// is width / height: 16f / 9f + /// for a wide video frame, 1f for a square. When + /// is true, the + /// height constraint is preferred when both width and height are + /// bounded; otherwise width wins (Kotlin's default). + /// + public static Modifier AspectRatio(this Modifier modifier, float ratio, bool matchHeightConstraintsFirst = false) => + modifier.Append(curr => ComposeBridges.ModifierAspectRatio(curr, ratio, matchHeightConstraintsFirst)); + + /// + /// Modifier.offset(x, y) — shifts the composable's draw + /// position by (x, y) without affecting the layout slot the parent + /// allocates for it. Layout-direction-aware (start/end on RTL). + /// Pass null for either axis to leave it at 0.dp. + /// + public static Modifier Offset(this Modifier modifier, Dp? x = null, Dp? y = null) => + modifier.Append(curr => ComposeBridges.ModifierOffset(curr, x, y)); + + /// + /// Modifier.absoluteOffset(x, y) — like + /// but always uses absolute (left/right) axes, ignoring layout + /// direction. + /// + public static Modifier AbsoluteOffset(this Modifier modifier, Dp? x = null, Dp? y = null) => + modifier.Append(curr => ComposeBridges.ModifierAbsoluteOffset(curr, x, y)); + + /// + /// Modifier.zIndex(z) — sets the draw order within a parent + /// layout. Children with higher draw on top of + /// siblings with lower values. Defaults to 0f. + /// + public static Modifier ZIndex(this Modifier modifier, float z) => + modifier.Append(curr => ComposeBridges.ModifierZIndex(curr, z)); + + /// + /// Modifier.alpha(alpha) — applies an alpha multiplier to + /// the composable's draw output. 0f is fully transparent; + /// 1f is fully opaque. Cheap to animate (forces a graphics + /// layer). + /// + public static Modifier Alpha(this Modifier modifier, float alpha) => + modifier.Append(curr => ComposeBridges.ModifierAlpha(curr, alpha)); + + /// + /// Modifier.rotate(degrees) — rotates the composable around + /// its center by . Negative values rotate + /// counter-clockwise. + /// + public static Modifier Rotate(this Modifier modifier, float degrees) => + modifier.Append(curr => ComposeBridges.ModifierRotate(curr, degrees)); + + /// + /// Modifier.scale(scale) — uniform scale around the + /// composable's center. + /// + public static Modifier Scale(this Modifier modifier, float scale) => + modifier.Append(curr => ComposeBridges.ModifierScaleUniform(curr, scale)); + + /// + /// Modifier.scale(scaleX, scaleY) — independent X / Y scale + /// around the composable's center. + /// + public static Modifier Scale(this Modifier modifier, float scaleX, float scaleY) => + modifier.Append(curr => ComposeBridges.ModifierScaleXY(curr, scaleX, scaleY)); + + /// + /// Modifier.shadow(elevation, shape) — draws a soft drop + /// shadow under the composable. is the + /// shadow's apparent depth in dp. Pass a non-null + /// to clip the shadow to a custom outline; + /// otherwise it follows Kotlin's default RectangleShape. The + /// shape is captured by the closure so its Java peer stays alive + /// across recompositions. Kotlin's default for the underlying + /// clip parameter (elevation > 0.dp) is honored. + /// + public static Modifier Shadow(this Modifier modifier, Dp elevation, Shape? shape = null) + { + var e = elevation.Value; + return modifier.Append(curr => ComposeBridges.ModifierShadow(curr, e, shape?.Handle)); + } + + /// + /// Modifier.graphicsLayer(...) — applies a draw-time + /// graphics layer to the composable. All parameters default to + /// null, which lets Compose substitute its own + /// per-property defaults (identity for transforms, full opacity + /// for , etc.). Supply only the + /// properties you want to override. + /// + /// Horizontal scale (1.0 = no scaling). + /// Vertical scale (1.0 = no scaling). + /// Opacity in [0, 1]. + /// Horizontal translation in pixels. + /// Vertical translation in pixels. + /// Elevation in pixels (use to specify in Dp). + /// Rotation around the X axis in degrees. + /// Rotation around the Y axis in degrees. + /// Rotation around the Z axis in degrees (clockwise). + /// Distance of the camera from the rotation pivot, in pixels. + /// Packed TransformOrigin value (use ); the default is the center (0.5, 0.5). + /// Clip / shadow outline shape (default = rectangle). + /// Whether to clip content to . + public static Modifier GraphicsLayer(this Modifier modifier, + float? scaleX = null, + float? scaleY = null, + float? alpha = null, + float? translationX = null, + float? translationY = null, + float? shadowElevation = null, + float? rotationX = null, + float? rotationY = null, + float? rotationZ = null, + float? cameraDistance = null, + long? transformOrigin = null, + Shape? shape = null, + bool? clip = null) + { + return modifier.Append(curr => ComposeBridges.ModifierGraphicsLayer( + curr, + scaleX, + scaleY, + alpha, + translationX, + translationY, + shadowElevation, + rotationX, + rotationY, + rotationZ, + cameraDistance, + transformOrigin, + shape?.Handle, + clip)); + } + + /// + /// Modifier.imePadding() — pads the composable so it sits + /// above the soft keyboard (IME) when it's visible. Use under + /// edge-to-edge to keep input controls visible. + /// + public static Modifier ImePadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierImePadding(curr)); + + /// + /// Modifier.navigationBarsPadding() — pads for the system + /// navigation bar inset only. + /// + public static Modifier NavigationBarsPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierNavigationBarsPadding(curr)); + + /// + /// Modifier.statusBarsPadding() — pads for the status bar + /// inset only. + /// + public static Modifier StatusBarsPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierStatusBarsPadding(curr)); + + /// + /// Modifier.displayCutoutPadding() — pads for display cutouts + /// (notches, hole-punch cameras) so content doesn't get clipped by + /// hardware features. + /// + public static Modifier DisplayCutoutPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierDisplayCutoutPadding(curr)); + + /// + /// Modifier.captionBarPadding() — pads for the caption bar + /// inset (window decorations on freeform / desktop windowing modes). + /// On phones without a caption bar this is a no-op. + /// + public static Modifier CaptionBarPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierCaptionBarPadding(curr)); + + /// + /// Modifier.mandatorySystemGesturesPadding() — pads for the + /// subset of gesture insets the system always reserves for itself + /// (e.g. the bottom home-gesture strip), even when the user opts + /// out of edge gestures. + /// + public static Modifier MandatorySystemGesturesPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierMandatorySystemGesturesPadding(curr)); + + /// + /// Modifier.safeContentPadding() — union of + /// and + /// . Use for content that should + /// avoid both visual obstructions and gesture zones. + /// + public static Modifier SafeContentPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierSafeContentPadding(curr)); + + /// + /// Modifier.safeGesturesPadding() — union of + /// + + /// + the tappable-element + /// insets. Use to keep interactive UI out of the system's gesture + /// zones. + /// + public static Modifier SafeGesturesPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierSafeGesturesPadding(curr)); + + /// + /// Modifier.systemGesturesPadding() — pads for the system + /// gesture insets (the edge regions where the OS may interpret + /// swipes as system gestures such as back / home). + /// + public static Modifier SystemGesturesPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierSystemGesturesPadding(curr)); + + /// + /// Modifier.waterfallPadding() — pads for waterfall display + /// insets (the curved edges of waterfall-screen devices). No-op on + /// flat-screen phones. + /// + public static Modifier WaterfallPadding(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierWaterfallPadding(curr)); + + /// + /// Modifier.testTag(tag) — attaches a stable identifier for + /// UI testing frameworks (Compose UI Test, Espresso). Has no visual + /// effect; only affects the semantics tree. + /// + public static Modifier TestTag(this Modifier modifier, string tag) + { + ArgumentNullException.ThrowIfNull(tag); + return modifier.Append(curr => ComposeBridges.ModifierTestTag(curr, tag)); + } + + /// + /// Modifier.align(alignment) — positions the child within + /// a parent . Only valid inside a + /// (any container that publishes ). + /// + public static Modifier Align(this Modifier modifier, Alignment alignment) + { + ArgumentNullException.ThrowIfNull(alignment); + return modifier.Append(curr => + { + IntPtr scope = RenderContext.CurrentScope; + if (RenderContext.CurrentScopeKind != ScopeKind.Box) + throw new InvalidOperationException( + "Modifier.Align(Alignment) is only valid inside a Box. " + + $"Current scope kind: {RenderContext.CurrentScopeKind}."); + return ComposeBridges.BoxScopeAlign(scope, curr, ((Java.Lang.Object)alignment).Handle); + }); + } + + /// + /// Modifier.align(alignment) for a Row child — aligns the + /// child vertically within the row. Only valid inside a + /// . + /// + public static Modifier Align(this Modifier modifier, Alignment.Vertical alignment) + { + ArgumentNullException.ThrowIfNull(alignment); + return modifier.Append(curr => + { + IntPtr scope = RenderContext.CurrentScope; + if (RenderContext.CurrentScopeKind != ScopeKind.Row) + throw new InvalidOperationException( + "Modifier.Align(Alignment.Vertical) is only valid inside a Row. " + + $"Current scope kind: {RenderContext.CurrentScopeKind}."); + return ComposeBridges.RowScopeAlignVertical(scope, curr, ((Java.Lang.Object)alignment.Java).Handle); + }); + } + + /// + /// Modifier.align(alignment) for a Column child — aligns the + /// child horizontally within the column. Only valid inside a + /// . + /// + public static Modifier Align(this Modifier modifier, Alignment.Horizontal alignment) + { + ArgumentNullException.ThrowIfNull(alignment); + return modifier.Append(curr => + { + IntPtr scope = RenderContext.CurrentScope; + if (RenderContext.CurrentScopeKind != ScopeKind.Column) + throw new InvalidOperationException( + "Modifier.Align(Alignment.Horizontal) is only valid inside a Column. " + + $"Current scope kind: {RenderContext.CurrentScopeKind}."); + return ComposeBridges.ColumnScopeAlignHorizontal(scope, curr, ((Java.Lang.Object)alignment.Java).Handle); + }); + } + + /// + /// Modifier.matchParentSize() — sizes the child to match + /// the parent 's measured size without + /// participating in measurement. Only valid inside a + /// . + /// + public static Modifier MatchParentSize(this Modifier modifier) => + modifier.Append(curr => + { + IntPtr scope = RenderContext.CurrentScope; + if (RenderContext.CurrentScopeKind != ScopeKind.Box) + throw new InvalidOperationException( + "Modifier.MatchParentSize() is only valid inside a Box. " + + $"Current scope kind: {RenderContext.CurrentScopeKind}."); + return ComposeBridges.BoxScopeMatchParentSize(scope, curr); + }); + + /// + /// Modifier.focusable(enabled = true) — marks the node as a + /// focus target. Combine with + /// to programmatically move focus, or with + /// + /// to observe focus changes. + /// + public static Modifier Focusable(this Modifier modifier, bool enabled = true) => + modifier.Append(curr => ComposeBridges.ModifierFocusable(curr, enabled)); + + /// + /// Modifier.focusGroup() — groups focusable descendants so + /// two-dimensional focus search treats them as a single unit. + /// + public static Modifier FocusGroup(this Modifier modifier) => + modifier.Append(curr => ComposeBridges.ModifierFocusGroup(curr)); + + /// + /// Modifier.onFocusChanged { ... } — invokes + /// whenever the node gains, loses, or has its focus state mutated + /// (capture / release). The callback receives an immutable + /// snapshot. + /// + public static Modifier OnFocusChanged(this Modifier modifier, Action onFocusChanged) + { + ArgumentNullException.ThrowIfNull(onFocusChanged); + var f1 = new ComposableLambda1(arg => + { + if (arg is null) return; + var fs = Android.Runtime.Extensions.JavaCast(arg); + onFocusChanged(FocusState.From(fs)); + }); + return modifier.Append(curr => ComposeBridges.ModifierOnFocusChanged(curr, f1)); + } + + /// + /// Modifier.focusRequester(requester) — installs + /// on the node so the caller can + /// programmatically move focus by calling + /// . + /// + public static Modifier FocusRequester(this Modifier modifier, FocusRequester requester) + { + ArgumentNullException.ThrowIfNull(requester); + return modifier.Append(curr => + ComposeBridges.ModifierFocusRequester(curr, ((Java.Lang.Object)requester.Java).Handle)); + } + + /// + /// Modifier.combinedClickable(...) — clickable with optional + /// long-press and double-tap handlers. is + /// required; pass null for either of the other two to fall + /// back to Kotlin's "ignore that gesture" defaults. + /// + public static Modifier CombinedClickable(this Modifier modifier, + Action onClick, + Action? onLongClick = null, + Action? onDoubleClick = null) + { + ArgumentNullException.ThrowIfNull(onClick); + var click = new ComposableLambda0(onClick); + var longClick = onLongClick is null ? null : new ComposableLambda0(onLongClick); + var doubleClick = onDoubleClick is null ? null : new ComposableLambda0(onDoubleClick); + return modifier.Append(curr => + ComposeBridges.ModifierCombinedClickable(curr, longClick, doubleClick, click)); + } + + /// + /// Modifier.selectable(selected, onClick) — marks the node + /// as a selectable choice in a single-selection group (e.g. a + /// radio button group). Sets up the right accessibility semantics + /// and forwards taps to . + /// + public static Modifier Selectable(this Modifier modifier, bool selected, Action onClick) + { + ArgumentNullException.ThrowIfNull(onClick); + var click = new ComposableLambda0(onClick); + return modifier.Append(curr => + ComposeBridges.ModifierSelectable(curr, selected, click)); + } + + /// + /// Modifier.toggleable(value, onValueChange) — marks the + /// node as a binary toggle (e.g. a checkbox row). Sets up the + /// right accessibility semantics and forwards taps to + /// with the negated value. + /// + public static Modifier Toggleable(this Modifier modifier, bool value, Action onValueChange) + { + ArgumentNullException.ThrowIfNull(onValueChange); + var f1 = new ComposableLambda1(arg => + { + bool v = arg is Java.Lang.Boolean jb && jb.BooleanValue(); + onValueChange(v); + }); + return modifier.Append(curr => + ComposeBridges.ModifierToggleable(curr, value, f1)); + } + + /// + /// Modifier.semantics { contentDescription = ... } — adds + /// a content description for accessibility (TalkBack reads it + /// aloud when the node is focused). Doesn't merge descendant + /// semantics; call the overload taking mergeDescendants for + /// that. + /// + public static Modifier Semantics(this Modifier modifier, string contentDescription) => + modifier.Semantics(mergeDescendants: false, contentDescription, role: null); + + /// + /// Modifier.semantics(mergeDescendants) { contentDescription = ... } — + /// set to true for a + /// container that should announce itself instead of its children + /// (e.g. a card with a label and a value). + /// + public static Modifier Semantics(this Modifier modifier, bool mergeDescendants, string contentDescription) => + modifier.Semantics(mergeDescendants, contentDescription, role: null); + + /// + /// Modifier.semantics { role = ... } — tags the node with + /// an accessibility (e.g. + /// ). Useful when wrapping a + /// custom composable that's clickable but isn't a real + /// , so TalkBack announces "button" instead + /// of just the content description. + /// + public static Modifier Semantics(this Modifier modifier, SemanticsRole role) => + modifier.Semantics(mergeDescendants: false, contentDescription: null, role: role); + + /// + /// Modifier.semantics { contentDescription = ...; role = ... } — + /// combine a content description with a role in a single + /// semantics block. Either argument may be omitted by passing + /// null. + /// + public static Modifier Semantics(this Modifier modifier, string? contentDescription, SemanticsRole? role) => + modifier.Semantics(mergeDescendants: false, contentDescription, role); + + /// + /// Modifier.semantics(mergeDescendants) { contentDescription = ...; role = ... } — + /// full form. Pass null for either property to skip it. + /// At least one of or + /// must be supplied. + /// + public static Modifier Semantics(this Modifier modifier, bool mergeDescendants, string? contentDescription, SemanticsRole? role) + { + if (contentDescription is null && role is null) + throw new ArgumentException( + "At least one of contentDescription or role must be supplied.", + nameof(contentDescription)); + var properties = new ComposableLambda1(arg => + { + if (arg is Java.Lang.Object obj) + { + if (contentDescription is not null) + ComposeBridges.SemanticsSetContentDescription(obj.Handle, contentDescription); + if (role is not null) + ComposeBridges.SemanticsSetRole(obj.Handle, (int)role.Value); + } + }); + return modifier.Append(curr => + ComposeBridges.ModifierSemantics(curr, mergeDescendants, properties)); + } + + /// + /// Modifier.clearAndSetSemantics { contentDescription = ... } — + /// like , but discards the + /// descendant semantics first. Use when a custom composable + /// should appear as a single accessibility node with a curated + /// description, hiding implementation details. + /// + public static Modifier ClearAndSetSemantics(this Modifier modifier, string contentDescription) => + modifier.ClearAndSetSemantics(contentDescription, role: null); + + /// + /// Modifier.clearAndSetSemantics { contentDescription = ...; role = ... } — + /// combine a content description with a + /// in a single clear-and-set block. At least one of + /// or + /// must be supplied. + /// + public static Modifier ClearAndSetSemantics(this Modifier modifier, string? contentDescription, SemanticsRole? role) + { + if (contentDescription is null && role is null) + throw new ArgumentException( + "At least one of contentDescription or role must be supplied.", + nameof(contentDescription)); + var properties = new ComposableLambda1(arg => + { + if (arg is Java.Lang.Object obj) + { + if (contentDescription is not null) + ComposeBridges.SemanticsSetContentDescription(obj.Handle, contentDescription); + if (role is not null) + ComposeBridges.SemanticsSetRole(obj.Handle, (int)role.Value); + } + }); + return modifier.Append(curr => + ComposeBridges.ModifierClearAndSetSemantics(curr, properties)); + } + + /// + /// Modifier.pointerInput(Unit) { detectTapGestures(...) } — + /// detect the four basic single-pointer tap gestures (tap, press, + /// long-press, double-tap) and invoke the supplied C# callbacks on + /// the UI thread with the tap position as an + /// in local layout pixels. + /// + /// + /// + /// All four callback parameters are optional; pass only the ones + /// you care about. When all four are null this modifier is + /// a no-op (but still allocates the per-pointer-input state + /// holder, so prefer omitting it entirely in that case). + /// + /// + /// Unlike , this modifier + /// supplies no Material indication / ripple, no + /// accessibility semantics, and no role — it's the low-level + /// gesture primitive. Use + /// for ordinary clickable surfaces; reach for + /// when you need long-press, + /// double-tap, or precise tap positions. + /// + /// + /// Callback freshness gotcha. The Kotlin + /// pointerInput modifier only restarts its coroutine when + /// its key changes — not when the handler instance + /// changes. Because all C# callback adapters share the same Java + /// class, simply rebuilding this modifier with different lambdas + /// will NOT pick up the new callbacks. To refresh, either + /// (a) supply a varying derived from the + /// values your callbacks close over, or (b) have the callbacks + /// read mutable state via a remembered + /// MutableState<T> so they pick up new values on + /// each invocation. + /// + /// + /// Fired once the system confirms the gesture + /// is a simple tap (not the start of a long-press or double-tap). + /// Fired the moment the finger goes down, + /// before the system has decided which gesture this will become. + /// Useful for "pressed" visual feedback. + /// Fired after the system's + /// long-press timeout (typically ~500ms) while the finger is still + /// down. + /// Fired when a second tap arrives + /// within the system double-tap timeout (typically ~300ms) of the + /// first. + /// Identity key passed to Kotlin's + /// pointerInput. Defaults to a stable singleton + /// (Kotlin.Unit), so the gesture detector coroutine starts + /// once and never restarts. Pass any value whose Equals + /// changes when callbacks should reset. + public static Modifier DetectTapGestures(this Modifier modifier, + Action? onTap = null, + Action? onPress = null, + Action? onLongPress = null, + Action? onDoubleTap = null, + object? key = null) + { + var tapCb = onTap is null ? null : new OffsetCallback(onTap); + var pressCb = onPress is null ? null : new OffsetPressCallback(onPress); + var longPressCb = onLongPress is null ? null : new OffsetCallback(onLongPress); + var doubleTapCb = onDoubleTap is null ? null : new OffsetCallback(onDoubleTap); + + var block = new PointerInputBlock(tapCb, pressCb, longPressCb, doubleTapCb); + return AppendPointerInput(modifier, key, block); + } + + /// + /// Modifier.pointerInput(key) { detectDragGestures(...) } — + /// detect single-pointer drag gestures (touch-down, drag, lift). + /// Each callback receives positions / deltas in local layout + /// pixels. + /// + /// + /// + /// is required; the start / end / cancel + /// callbacks are optional and default to no-ops on the Kotlin + /// side. The drag delta in is the + /// per-frame movement, not the cumulative pan; callers tracking + /// totals across the gesture maintain a running sum keyed by + /// / . + /// + /// + /// The same callback-freshness gotcha as + /// + /// applies — vary to restart the gesture + /// coroutine with new lambdas, otherwise the captured callbacks + /// from the first composition stay live. + /// + /// + /// Fired once at the moment a drag is + /// recognised (after the system's touch-slop threshold). Position + /// is the pointer location at touch-down. + /// Fired when the gesture completes + /// (pointer lifted within the view). + /// Fired when the gesture is cancelled + /// (a parent intercepted, multi-touch confused the detector, + /// etc.). + /// Required. Fired once per pointer move with + /// the per-frame delta. Use / + /// as float pixel deltas. + /// Identity key — same semantics as on + /// . + public static Modifier DetectDragGestures(this Modifier modifier, + Action onDrag, + Action? onDragStart = null, + Action? onDragEnd = null, + Action? onDragCancel = null, + object? key = null) + { + ArgumentNullException.ThrowIfNull(onDrag); + + var startCb = onDragStart is null ? null : new OffsetCallback(onDragStart); + var endCb = onDragEnd is null ? null : new UnitCallback(onDragEnd); + var cancelCb = onDragCancel is null ? null : new UnitCallback(onDragCancel); + var dragCb = new DragCallback(onDrag); + + var block = new DragGestureBlock(startCb, endCb, cancelCb, dragCb); + return AppendPointerInput(modifier, key, block); + } + + /// + /// Modifier.pointerInput(key) { detectDragGesturesAfterLongPress(...) } + /// — like + /// but the drag is only recognised after the system long-press + /// timeout (~500ms) while the finger is still down. This is the gesture + /// the gold Jetchat RecordButton uses for press-and-hold-to-record + /// with swipe-to-cancel (RecordButton.kt:156). + /// + /// Same callback-freshness caveat as + /// . + /// Required. Per-frame drag delta once the long-press + /// has engaged. / are pixel deltas. + /// Fired once the long-press engages and the + /// first drag is recognised. Position is the pointer location. + /// Fired when the pointer lifts after dragging. + /// Fired when the gesture is cancelled. + /// Identity key — see . + public static Modifier DetectDragGesturesAfterLongPress(this Modifier modifier, + Action onDrag, + Action? onDragStart = null, + Action? onDragEnd = null, + Action? onDragCancel = null, + object? key = null) + { + ArgumentNullException.ThrowIfNull(onDrag); + + var startCb = onDragStart is null ? null : new OffsetCallback(onDragStart); + var endCb = onDragEnd is null ? null : new UnitCallback(onDragEnd); + var cancelCb = onDragCancel is null ? null : new UnitCallback(onDragCancel); + var dragCb = new DragCallback(onDrag); + + var block = new DragGestureBlock(startCb, endCb, cancelCb, dragCb, afterLongPress: true); + return AppendPointerInput(modifier, key, block); + } + + /// + /// Modifier.pointerInput(key) { detectTransformGestures(...) } + /// — detect multi-pointer transform gestures (pinch zoom, two- + /// finger pan, rotate). The single + /// callback receives the centroid, cumulative pan delta, zoom + /// multiplier, and rotation in degrees on every pointer move. + /// + /// + /// + /// Compose's detectTransformGestures applies the per-frame + /// deltas continuously — there are no separate start / end + /// callbacks. The caller maintains its own state holder if it + /// wants to detect "pinch starts" (e.g. by latching on the first + /// non-1.0 zoom value). + /// + /// + /// The same callback-freshness gotcha as the other + /// Modifier.detect*Gestures extensions applies; vary + /// to restart with new lambdas. + /// + /// + /// Required. Receives + /// (centroid, pan, zoom, rotation). centroid is the + /// average position of all active pointers; pan is the + /// per-frame pan delta; zoom is the per-frame multiplier + /// (1.0 means no zoom this frame); rotation is the + /// per-frame rotation in degrees. + /// When , locks + /// out rotation while the user is panning or zooming — useful + /// for image / map viewers that don't want stray rotations. + /// Defaults to matching Kotlin. + /// Identity key — see + /// . + public static Modifier DetectTransformGestures(this Modifier modifier, + Action onGesture, + bool panZoomLock = false, + object? key = null) + { + ArgumentNullException.ThrowIfNull(onGesture); + + var gestureCb = new TransformGestureCallback(onGesture); + var block = new TransformGestureBlock(panZoomLock, gestureCb); + return AppendPointerInput(modifier, key, block); + } + + // Shared plumbing for DetectTapGestures / DetectDragGestures / + // DetectTransformGestures: resolve the user-supplied `key` to a + // Java object (Kotlin uses reference equality for the default- + // overload form, so we want a stable JNI object for the "no key" + // path — Kotlin.Unit.Instance is the canonical singleton), then + // wrap the gesture-block JCW in a PointerInputEventHandler and + // apply Modifier.pointerInput(key, handler). Both keyObj and the + // block are kept alive across the JNI call via GC.KeepAlive. + static Modifier AppendPointerInput(Modifier modifier, object? key, Java.Lang.Object block) + { + var keyObj = key switch + { + null => (Java.Lang.Object)Kotlin.Unit.Instance!, + Java.Lang.Object jlo => jlo, + string s => new Java.Lang.String(s), + int i => Java.Lang.Integer.ValueOf(i), + long l => Java.Lang.Long.ValueOf(l), + bool b => Java.Lang.Boolean.ValueOf(b), + _ => new Java.Lang.String(key.ToString() ?? ""), + }; + + return modifier.Append(curr => + { + var handlerLocal = IntPtr.Zero; + try + { + handlerLocal = ComposeBridges.NewPointerInputEventHandler(block.Handle); + return ComposeBridges.ModifierPointerInput( + curr, keyObj.Handle, handlerLocal); + } + finally + { + if (handlerLocal != IntPtr.Zero) + Android.Runtime.JNIEnv.DeleteLocalRef(handlerLocal); + GC.KeepAlive(keyObj); + GC.KeepAlive(block); + } + }); + } + + /// + /// Modifier.semantics { ... } — fluent builder form that + /// exposes all supported semantic properties (selected, role, + /// content/state description, onClick label) on + /// . Doesn't merge descendant + /// semantics; use the overload taking + /// + mergeDescendants for that. + /// + /// Builder callback. Each chained call on + /// the supplied sets one property on + /// the underlying Kotlin SemanticsPropertyReceiver. + public static Modifier Semantics(this Modifier modifier, Action properties) => + modifier.Semantics(mergeDescendants: false, properties); + + /// + /// Modifier.semantics(mergeDescendants) { ... } — fluent + /// builder form. Set to + /// true for a container that should announce itself as a + /// single accessibility node instead of exposing its children + /// (e.g. a card with a label and a value). + /// + /// Whether to merge descendant + /// semantics into this node. + /// Builder callback. See + /// for available property setters. + public static Modifier Semantics(this Modifier modifier, bool mergeDescendants, Action properties) + { + ArgumentNullException.ThrowIfNull(properties); + var lambda = WrapSemanticsBuilder(properties); + return modifier.Append(curr => + ComposeBridges.ModifierSemantics(curr, mergeDescendants, lambda)); + } + + /// + /// Modifier.clearAndSetSemantics { ... } — fluent builder + /// form. Discards the descendant semantics first, then applies + /// the properties set in . Use when + /// a custom composable should appear as a single accessibility + /// node with a curated description, hiding implementation + /// details. + /// + /// Builder callback. See + /// for available property setters. + public static Modifier ClearAndSetSemantics(this Modifier modifier, Action properties) + { + ArgumentNullException.ThrowIfNull(properties); + var lambda = WrapSemanticsBuilder(properties); + return modifier.Append(curr => + ComposeBridges.ModifierClearAndSetSemantics(curr, lambda)); + } + + // Shared body for the two builder-form overloads above. Each + // invocation of the returned ComposableLambda1 gets a fresh + // SemanticsScope bound to the JNI handle Compose hands us, and + // the scope is Invalidate()d the moment the user callback returns + // so leaked references throw a clear error on reuse. + static ComposableLambda1 WrapSemanticsBuilder(Action properties) => + new(arg => + { + if (arg is Java.Lang.Object obj) + { + var scope = new SemanticsScope(obj.Handle); + try { properties(scope); } + finally { scope.Invalidate(); } + } + }); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MultiChoiceSegmentedButtonRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MultiChoiceSegmentedButtonRow.cs new file mode 100644 index 000000000..980008f3a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MultiChoiceSegmentedButtonRow.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 MultiChoiceSegmentedButtonRow. Container for +/// children with toggle (multi-select) +/// semantics — see for the +/// radio-button variant. +/// +public sealed partial class MultiChoiceSegmentedButtonRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableNumberState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableNumberState.cs new file mode 100644 index 000000000..92129fff2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableNumberState.cs @@ -0,0 +1,39 @@ +using System.Numerics; + +namespace AndroidX.Compose; + +/// +/// Thin subclass that adds operator ++/-- +/// for numeric so user code reads almost +/// identically to Kotlin: +/// +/// var count = c.MutableStateOf(0); // → MutableNumberState<int> via overload resolution +/// count++; // mutates the underlying value +/// Text($"Count: {count}"); // ToString() forwards to Value +/// +/// All primitive-fast-path machinery lives on the +/// base; this class exists purely so the +/// ++/-- operators (which require at +/// compile time) can be expressed on the C# type. Constructed via +/// new MutableNumberState<T>(initial) or — typically — via the +/// +/// family of numeric overloads. +/// +public class MutableNumberState : MutableState where T : INumber +{ + /// + /// Wrap a fresh numeric Compose state initialized with + /// . Delegates to , + /// which picks the + /// /Long/Float + /// fast path for the matching . + /// + public MutableNumberState(T initial) : base(initial) { } + + /// Pre-increment: equivalent to s.Value = s.Value + T.One. + public static MutableNumberState operator ++(MutableNumberState s) { s.Value++; return s; } + + /// Pre-decrement: equivalent to s.Value = s.Value - T.One. + public static MutableNumberState operator --(MutableNumberState s) { s.Value--; return s; } +} + diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableState.cs new file mode 100644 index 000000000..154e6faf8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableState.cs @@ -0,0 +1,248 @@ +using System.Runtime.CompilerServices; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Generic typed wrapper around . Reads/writes +/// values through the JVM state container so changes are observed by the +/// Compose runtime and trigger recomposition. +/// +/// +/// +/// For int, long, and float, the ctor picks a +/// primitive-specialized Compose state +/// (, , +/// ) so reads/writes avoid the +/// Java.Lang.Integer/Long/Float allocation that the +/// generic boxed path pays on every access. All other primitives that +/// can box (sbyte, byte, +/// short, ushort, uint, ulong, double, +/// bool, char, string, Nullable<T> of any +/// of those) fall through to the boxed path. Reference types deriving from +/// are stored as-is. +/// +/// +/// The primitive-specialized fast path requires the underlying +/// to actually be a primitive subtype. That's +/// true when constructed via new MutableState<int>(0) — the +/// ctor picks the right Kotlin factory. It's not guaranteed when +/// the wrapper is built around a state produced elsewhere, such as the +/// boxed mutableStateOf(restoredValue, policy) that +/// +/// returns after a process-death restore. The instance _kind field +/// is probed from the supplied state once at construction (and re-probed +/// on every swap) so the +/// getter/setter never attempts an invalid cast. +/// +/// +public class MutableState : IMutableStateWrapper, IState +{ + enum Kind { Other, Int, Long, Float } + + internal IMutableState _state; + Kind _kind; + + /// + /// Wrap a fresh Compose state initialized with . + /// Picks the primitive-specialized + /// // + /// when is int/long/float; + /// otherwise creates a boxed mutableStateOf with structural + /// equality. + /// + public MutableState(T initial) + { + _state = CreateState(initial); + _kind = ProbeKind(_state); + } + + // Subclass hook: lets a wrapper be built around a pre-existing + // IMutableState (e.g. one Compose handed back after RememberSaveable + // restoration). Probes _kind so the fast-path dispatch in Value stays + // correct regardless of who built the state. + internal MutableState(IMutableState state) + { + _state = state; + _kind = ProbeKind(state); + } + + /// + /// Subclass hook for 's setter. + /// Swaps the underlying and re-probes the + /// primitive-fast-path _kind field so subsequent + /// reads/writes target the right specialised getter. + /// Compose calls this through the + /// setter after rememberSaveable hands back a (potentially boxed) + /// restored state on first composition. + /// + internal virtual void SetUnderlyingState(IMutableState state) + { + _state = state; + _kind = ProbeKind(state); + } + + IMutableState IMutableStateWrapper.State + { + get => _state; + set => SetUnderlyingState(value); + } + + /// + /// The current value. Reading inside a composition subscribes the + /// surrounding scope to changes; writing triggers recomposition of any + /// scope that previously read the value. For + /// int/long/float-typed instances, reads/writes + /// route through the primitive Compose state without any boxing. + /// + public virtual T Value + { + get + { + switch (_kind) + { + case Kind.Int: + int i = ((IMutableIntState)_state).IntValue; + return Unsafe.As(ref i); + case Kind.Long: + long l = ((IMutableLongState)_state).LongValue; + return Unsafe.As(ref l); + case Kind.Float: + float f = ((IMutableFloatState)_state).FloatValue; + return Unsafe.As(ref f); + default: + return FromJava(_state.Value); + } + } + set + { + switch (_kind) + { + case Kind.Int: + ((IMutableIntState)_state).IntValue = Unsafe.As(ref value); + break; + case Kind.Long: + ((IMutableLongState)_state).LongValue = Unsafe.As(ref value); + break; + case Kind.Float: + ((IMutableFloatState)_state).FloatValue = Unsafe.As(ref value); + break; + default: + _state.Value = ToJava(value); + break; + } + } + } + + /// + /// Implicit conversion to the wrapped value so call sites read + /// idiomatically — e.g. if (count > 0), int total = count;, + /// (int)Math.Sqrt(count) — without sprinkling .Value + /// everywhere. Mirrors Kotlin's by remember { mutableStateOf(...) } + /// property-delegate ergonomics. + /// + /// + /// String interpolation ($"...{state}...") keeps calling + /// — interpolation boxes its arguments as + /// object, and the C# compiler does not apply user-defined + /// conversions when boxing — so this operator does not change + /// interpolation behavior. + /// + public static implicit operator T(MutableState state) + { + ArgumentNullException.ThrowIfNull(state); + return state.Value; + } + + /// + /// Returns the underlying value's string representation so + /// $"...{state}..." interpolation reads as Kotlin would + /// (a null value renders as "null"). + /// + public override string ToString() => Value?.ToString() ?? "null"; + + static IMutableState CreateState(T initial) + { + if (typeof(T) == typeof(int)) + return SnapshotIntStateKt.MutableIntStateOf(Unsafe.As(ref initial)); + if (typeof(T) == typeof(long)) + return SnapshotLongStateKt.MutableLongStateOf(Unsafe.As(ref initial)); + if (typeof(T) == typeof(float)) + return PrimitiveSnapshotStateKt.MutableFloatStateOf(Unsafe.As(ref initial)); + return SnapshotStateKt.MutableStateOf( + ToJava(initial), + SnapshotStateKt.StructuralEqualityPolicy()); + } + + static Kind ProbeKind(IMutableState state) => state switch + { + IMutableIntState => Kind.Int, + IMutableLongState => Kind.Long, + IMutableFloatState => Kind.Float, + _ => Kind.Other, + }; + + // Cached per-T: when T is Nullable (e.g. long?, int?, bool?), this + // is typeof(U) so the primitive dispatch in FromJava/ToJava treats + // `long?` the same as `long`. For all other T, it's just typeof(T). + static readonly Type s_effectiveType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + internal static Java.Lang.Object? ToJava(T value) + { + if (value is null) return null; + // Boxing a non-null Nullable to object yields a boxed U, not a + // boxed Nullable. That lets one switch handle both T == long and + // T == long? without a separate nullable branch. + object boxed = value!; + return boxed switch + { + Java.Lang.Object o => o, + string s => new Java.Lang.String(s), + bool b => Java.Lang.Boolean.ValueOf(b), + char c => Java.Lang.Character.ValueOf(c), + sbyte sb => Java.Lang.Byte.ValueOf(sb), + byte by => Java.Lang.Short.ValueOf((short)by), // widen 8u -> 16s + short sh => Java.Lang.Short.ValueOf(sh), + ushort us => Java.Lang.Integer.ValueOf(us), // widen 16u -> 32s + int i => Java.Lang.Integer.ValueOf(i), + uint ui => Java.Lang.Long.ValueOf(ui), // widen 32u -> 64s + long l => Java.Lang.Long.ValueOf(l), + ulong ul => Java.Lang.Long.ValueOf(unchecked((long)ul)), // bit-exact round-trip + float f => Java.Lang.Float.ValueOf(f), + double d => Java.Lang.Double.ValueOf(d), + _ => throw new NotSupportedException( + $"MutableState<{typeof(T).Name}>: T must be a Java.Lang.Object subclass, string, bool, char, or a built-in numeric primitive (sbyte/byte/short/ushort/int/uint/long/ulong/float/double), or a Nullable of any of those primitives. Types like decimal, Half, BigInteger, and IntPtr have no clean Java box and are not supported.") + }; + } + + internal static T FromJava(Java.Lang.Object? value) + { + // For T == Nullable this returns the empty Nullable (HasValue=false); + // for reference T it returns null; for non-nullable struct T it's + // unreachable in practice because Compose wouldn't store a Java null + // there, but `default!` is still the only sane fallback. + if (value is null) return default!; + if (value is T t) return t; + // Use the underlying type when T is Nullable, so e.g. `long?` + // dispatches into the same branch as `long`. The unbox cast + // `(T)(object)5L` works for T == long? because boxed-primitive + // unboxes directly into Nullable. + var type = s_effectiveType; + if (type == typeof(string)) return (T)(object)value.ToString()!; + if (type == typeof(bool)) return (T)(object)((Java.Lang.Boolean)value).BooleanValue(); + if (type == typeof(char)) return (T)(object)((Java.Lang.Character)value).CharValue(); + if (type == typeof(sbyte)) return (T)(object)((Java.Lang.Byte)value).ByteValue(); + if (type == typeof(byte)) return (T)(object)(byte)((Java.Lang.Short)value).ShortValue(); + if (type == typeof(short)) return (T)(object)((Java.Lang.Short)value).ShortValue(); + if (type == typeof(ushort)) return (T)(object)(ushort)((Java.Lang.Integer)value).IntValue(); + if (type == typeof(int)) return (T)(object)((Java.Lang.Integer)value).IntValue(); + if (type == typeof(uint)) return (T)(object)(uint)((Java.Lang.Long)value).LongValue(); + if (type == typeof(long)) return (T)(object)((Java.Lang.Long)value).LongValue(); + if (type == typeof(ulong)) return (T)(object)unchecked((ulong)((Java.Lang.Long)value).LongValue()); + if (type == typeof(float)) return (T)(object)((Java.Lang.Float)value).FloatValue(); + if (type == typeof(double)) return (T)(object)((Java.Lang.Double)value).DoubleValue(); + throw new NotSupportedException( + $"MutableState<{typeof(T).Name}>: don't know how to unwrap {value.GetType().Name}"); + } +} + diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateFlow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateFlow.cs new file mode 100644 index 000000000..b75016940 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateFlow.cs @@ -0,0 +1,138 @@ + +namespace AndroidX.Compose; + +/// +/// Mutable, snapshot-backed observable value — the C# parity of +/// Kotlin's kotlinx.coroutines.flow.MutableStateFlow<T>. +/// Designed to live on a and be +/// observed from composables via +/// . +/// +/// The observed value type. Arbitrary CLR +/// references (records, classes, value types) are supported — unlike +/// , the value is held in a plain managed +/// field and never round-tripped through a JVM box. +/// +/// +/// Snapshot-backed, not real Kotlin StateFlow: +/// internally this couples a managed field with a +/// -based version counter (the +/// same pattern uses). Reads inside +/// a composition body touch the counter so the surrounding scope +/// subscribes to changes; writes that flip the value increment the +/// counter and Compose triggers recomposition. Setting the value to +/// something equal to the current one (per +/// ) is a no-op, matching +/// MutableStateFlow.value's distinct-conflation contract. +/// +/// +/// What is intentionally missing: no +/// collect coroutine, no SharingStarted, no +/// replayCache, no subscriber count. The only consumption +/// path is from inside a composition; if you need a true +/// asynchronous flow, use over +/// the underlying state. +/// +/// +/// Threading: safe to read +/// from any thread (the backing snapshot state +/// supports it). Mutating / +/// from a background thread is supported in +/// the same sense Kotlin's MutableStateFlow supports it — +/// the change is visible to the next snapshot read. +/// is serialised under an instance lock to prevent lost updates +/// when concurrent transforms race; it is NOT a CAS loop and does +/// NOT guarantee the transform sees the same value the read above +/// it saw. +/// +/// +public sealed class MutableStateFlow : IStateFlow +{ + readonly object _lock = new(); + readonly MutableNumberState _tick = new(0); + T _value; + + /// Creates a new flow seeded with . + public MutableStateFlow(T initialValue) => _value = initialValue; + + /// + public T Value + { + get + { + // Subscribe the current composition (if any) to changes + // by reading the tick. Outside a composition, this is a + // plain field read and the subscription is a no-op. + _ = _tick.Value; + return _value; + } + set + { + // Distinct-conflate: writing the same value is a no-op, + // matching MutableStateFlow.value's contract. The lock + // makes the read-compare-write-bump atomic so a concurrent + // setter can't observe a half-updated state AND so the + // tick increment can't race past a slower writer (two + // concurrent setters must each produce a distinct tick + // value, otherwise Compose can miss an invalidation). + lock (_lock) + { + if (EqualityComparer.Default.Equals(_value, value)) + return; + _value = value; + _tick.Value++; + } + } + } + + /// + /// Sets to and + /// returns true if the assignment caused an emission + /// (i.e. the new value was distinct from the current one) or + /// false if it was conflated. Parity with Kotlin's + /// MutableStateFlow.tryEmit(value) — which always + /// succeeds for MutableStateFlow but conflates equal + /// values. + /// + public bool TryEmit(T value) + { + lock (_lock) + { + if (EqualityComparer.Default.Equals(_value, value)) + return false; + _value = value; + _tick.Value++; + return true; + } + } + + /// + /// Atomically applies to the + /// current value and emits the result, returning the new + /// value. Parity with Kotlin's + /// MutableStateFlow<T>.update { … }. + /// + /// + /// The transform runs under an instance-level lock, so two + /// concurrent calls are guaranteed to + /// observe each other's writes (no lost updates). The + /// transform must be side-effect free and short — long + /// computations under the lock will block readers that observe + /// concurrently. If the transform returns + /// a value equal to the current one, no emission occurs and + /// no recomposition is triggered. + /// + public T Update(Func transform) + { + ArgumentNullException.ThrowIfNull(transform); + lock (_lock) + { + var next = transform(_value); + if (EqualityComparer.Default.Equals(_value, next)) + return _value; + _value = next; + _tick.Value++; + return next; + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateList.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateList.cs new file mode 100644 index 000000000..efc2a93d6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateList.cs @@ -0,0 +1,108 @@ +using System.Collections; + +namespace AndroidX.Compose; + +/// +/// A managed wrapper that participates in +/// Compose's recomposition system without needing a Kotlin +/// SnapshotStateList binding. C# parity for Kotlin's +/// mutableStateListOf<T>(): every read (, +/// the indexer, ) touches an internal +/// version counter so the +/// composition scope subscribes; every mutation increments that +/// counter, triggering a recomposition. +/// +/// +/// var messages = Remember(() => new MutableStateList<Message>()); +/// // In a Render body: +/// foreach (var m in messages) +/// new Text(m.Content); +/// // Anywhere — triggers recomposition: +/// messages.Add(new Message(...)); +/// +/// +/// Reads MUST happen during composition for the subscription to take +/// effect. Mutations from a UI button handler (which run outside +/// composition) are fine — the next recomposition will see the new +/// version. Not thread-safe; intended for UI/composition-thread use. +/// +/// +/// This is a *managed* observable list, not a true Kotlin +/// SnapshotStateList. It tracks change-vs-no-change at the +/// list level (single tick counter), not per-index. Snapshot +/// isolation, custom SnapshotMutationPolicy, and per-read +/// fine-grained dependency tracking are not provided. For UI lists +/// where "any mutation triggers recomposition of any reader" is the +/// desired behaviour — which covers ~all typical Compose UI list +/// patterns — that's exactly the contract you want. +/// +public sealed class MutableStateList : IList, IReadOnlyList +{ + readonly List _items; + readonly MutableNumberState _tick = new(0); + + /// Creates an empty observable list. + public MutableStateList() => _items = new List(); + + /// Creates an observable list pre-populated with . + public MutableStateList(IEnumerable initial) => _items = new List(initial); + + /// Creates an observable list pre-populated with . + public MutableStateList(params T[] elements) => _items = elements is null ? new List() : new List(elements); + + // Subscribe the current composition scope to mutations. + void Track() { _ = _tick.Value; } + // Notify all subscribed scopes that the list has changed. + void Bump() => _tick.Value++; + + /// + public int Count + { + get { Track(); return _items.Count; } + } + + /// + public bool IsReadOnly => false; + + /// + public T this[int index] + { + get { Track(); return _items[index]; } + set { _items[index] = value; Bump(); } + } + + /// + public void Add(T item) { _items.Add(item); Bump(); } + + /// + public void Insert(int index, T item) { _items.Insert(index, item); Bump(); } + + /// + public bool Remove(T item) + { + if (_items.Remove(item)) { Bump(); return true; } + return false; + } + + /// + public void RemoveAt(int index) { _items.RemoveAt(index); Bump(); } + + /// + public void Clear() + { + if (_items.Count == 0) return; + _items.Clear(); + Bump(); + } + + /// + public bool Contains(T item) { Track(); return _items.Contains(item); } + /// + public int IndexOf(T item) { Track(); return _items.IndexOf(item); } + /// + public void CopyTo(T[] array, int arrayIndex) { Track(); _items.CopyTo(array, arrayIndex); } + + /// + public IEnumerator GetEnumerator() { Track(); return _items.GetEnumerator(); } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateMap.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateMap.cs new file mode 100644 index 000000000..a830696df --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/MutableStateMap.cs @@ -0,0 +1,146 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace AndroidX.Compose; + +/// +/// A managed wrapper that +/// participates in Compose's recomposition system without needing a +/// Kotlin SnapshotStateMap binding. C# parity for Kotlin's +/// mutableStateMapOf<K, V>(): every read touches an +/// internal version counter +/// so the composition scope subscribes; every mutation increments +/// that counter, triggering a recomposition. +/// +/// +/// var prefs = Remember(() => new MutableStateMap<string, bool>()); +/// // In a Render body: +/// new Text(prefs.TryGetValue("dark", out var v) && v ? "Dark" : "Light"); +/// // Anywhere — triggers recomposition: +/// prefs["dark"] = true; +/// +/// +/// Reads MUST happen during composition for the subscription to take +/// effect. Mutations from a UI button handler (which run outside +/// composition) are fine — the next recomposition will see the new +/// version. Not thread-safe; intended for UI/composition-thread use. +/// +/// +/// This is a *managed* observable map, not a true Kotlin +/// SnapshotStateMap. It tracks change-vs-no-change at the +/// map level (single tick counter), not per-key. Snapshot isolation, +/// custom SnapshotMutationPolicy, and per-read fine-grained +/// dependency tracking are not provided. For UI maps where "any +/// mutation triggers recomposition of any reader" is the desired +/// behaviour — which covers ~all typical Compose UI map patterns — +/// that's exactly the contract you want. +/// +public sealed class MutableStateMap + : IDictionary, IReadOnlyDictionary + where TKey : notnull +{ + readonly Dictionary _items; + readonly MutableNumberState _tick = new(0); + + /// Creates an empty observable map. + public MutableStateMap() => _items = new Dictionary(); + + /// Creates an observable map pre-populated with . + public MutableStateMap(IEnumerable> initial) + { + _items = new Dictionary(); + foreach (var kv in initial) _items[kv.Key] = kv.Value; + } + + void Track() { _ = _tick.Value; } + void Bump() => _tick.Value++; + + /// + public int Count { get { Track(); return _items.Count; } } + + /// + public bool IsReadOnly => false; + + /// + public TValue this[TKey key] + { + get { Track(); return _items[key]; } + set { _items[key] = value; Bump(); } + } + + /// + public ICollection Keys { get { Track(); return _items.Keys; } } + /// + public ICollection Values { get { Track(); return _items.Values; } } + + IEnumerable IReadOnlyDictionary.Keys { get { Track(); return _items.Keys; } } + IEnumerable IReadOnlyDictionary.Values { get { Track(); return _items.Values; } } + + /// + public void Add(TKey key, TValue value) { _items.Add(key, value); Bump(); } + + /// + public void Add(KeyValuePair item) { _items.Add(item.Key, item.Value); Bump(); } + + /// + public bool ContainsKey(TKey key) { Track(); return _items.ContainsKey(key); } + + /// + public bool Contains(KeyValuePair item) + { + Track(); + return _items.TryGetValue(item.Key, out var v) + && EqualityComparer.Default.Equals(v, item.Value); + } + + /// + public bool Remove(TKey key) + { + if (_items.Remove(key)) { Bump(); return true; } + return false; + } + + /// + public bool Remove(KeyValuePair item) + { + if (_items.TryGetValue(item.Key, out var v) + && EqualityComparer.Default.Equals(v, item.Value) + && _items.Remove(item.Key)) + { + Bump(); + return true; + } + return false; + } + + /// + public void Clear() + { + if (_items.Count == 0) return; + _items.Clear(); + Bump(); + } + + /// + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) + { + Track(); + return _items.TryGetValue(key, out value); + } + + /// + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + Track(); + ((ICollection>)_items).CopyTo(array, arrayIndex); + } + + /// + public IEnumerator> GetEnumerator() + { + Track(); + return _items.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavBackStackEntry.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavBackStackEntry.cs new file mode 100644 index 000000000..772582121 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavBackStackEntry.cs @@ -0,0 +1,53 @@ +namespace AndroidX.Compose; + +/// +/// C# wrapper around an +/// androidx.navigation.NavBackStackEntry — represents a single +/// destination on the navigation back stack. Each +/// composable("route") { ... } destination receives one when +/// composed: it carries the parsed route arguments (path +/// placeholders and query strings) plus the matched +/// NavDestination. +/// +/// +/// Usage with the dynamic-content overload of : +/// +/// +/// new NavDestination("user/{id}", entry => +/// { +/// var id = entry.Arguments?.GetString("id") ?? "?"; +/// return new Text($"User #{id}"); +/// }); +/// +/// +public sealed class NavBackStackEntry +{ + /// + /// The underlying Kotlin NavBackStackEntry. + /// + internal AndroidX.Navigation.NavBackStackEntry Jvm { get; } + + internal NavBackStackEntry(AndroidX.Navigation.NavBackStackEntry jvm) + { + ArgumentNullException.ThrowIfNull(jvm); + Jvm = jvm; + } + + /// + /// Arguments parsed for this entry — both path placeholders (e.g. + /// "user/{id}" populates "id") and any explicit + /// arguments configured on the destination. null until the + /// route has been resolved. Mirrors Kotlin's + /// NavBackStackEntry.arguments. + /// + public Bundle? Arguments => Jvm.Arguments; + + /// + /// Route registered for this destination — the same string the + /// caller passed to . + /// null if the destination was registered without a route + /// (e.g. by an integer id graph). Mirrors Kotlin's + /// NavBackStackEntry.destination.route. + /// + public string? Route => Jvm.Destination?.Route; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavController.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavController.cs new file mode 100644 index 000000000..e4fda9004 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavController.cs @@ -0,0 +1,135 @@ + +namespace AndroidX.Compose; + +/// +/// Caller-supplied wrapper around an +/// androidx.navigation.NavHostController. Pass an instance to +/// and it will be populated with the underlying +/// Kotlin controller on first composition (via Kotlin's +/// rememberNavController()); from then on the navigation methods +/// — , PopBackStack, +/// — forward straight to the bound +/// binding. +/// +/// +/// Hold a single instance per logical navigation graph (typically one +/// per ) inside a +/// composer.Remember callback so the controller +/// survives recompositions: +/// +/// +/// var nav = Remember(() => new NavController()); +/// +/// new NavHost(startDestination: "home", navController: nav) +/// { +/// new NavDestination("home") +/// { +/// new Button(onClick: () => nav.Navigate("detail")) +/// { +/// new Text("Go to detail"), +/// }, +/// }, +/// new NavDestination("detail") +/// { +/// new Text("Detail screen"), +/// }, +/// }; +/// +/// +public sealed class NavController +{ + /// + /// The underlying Kotlin NavHostController. Populated by + /// on first composition; null + /// before the host has rendered. Exposed as internal so the + /// facade can stamp it; user code reaches the controller through + /// the / / + /// methods. + /// + internal AndroidX.Navigation.NavHostController? Jvm { get; set; } + + AndroidX.Navigation.NavHostController EnsureJvm() => + Jvm ?? throw new InvalidOperationException( + "NavController has not been bound to a NavHost yet. Pass it to a NavHost and wait for the first composition before calling navigation methods."); + + /// + /// Navigate to . Equivalent to + /// Kotlin's navController.navigate(route). The route must + /// be registered via a child of the + /// owning . + /// + public void Navigate(string route) + { + ArgumentNullException.ThrowIfNull(route); + EnsureJvm().Navigate(route); + } + + /// + /// Navigate to with the back-stack + /// behaviour described by — the + /// C# equivalent of Kotlin's + /// navController.navigate(route, navOptions { ... }). + /// Used to implement the standard Material 3 bottom-navigation + /// pattern (popUpTo(startDestinationRoute) { saveState = true } + /// + launchSingleTop = true + restoreState = true): + /// + /// + /// nav.Navigate("search", new NavOptions + /// { + /// PopUpToRoute = "home", + /// PopUpToSaveState = true, + /// LaunchSingleTop = true, + /// RestoreState = true, + /// }); + /// + /// + public void Navigate(string route, NavOptions options) + { + ArgumentNullException.ThrowIfNull(route); + ArgumentNullException.ThrowIfNull(options); + EnsureJvm().Navigate(route, options.BuildJvm()); + } + + /// + /// Pop the most recent destination off the back stack. Returns + /// true when a destination was popped, false when + /// the stack was already empty. Mirrors Kotlin's + /// navController.popBackStack(). + /// + public bool PopBackStack() => EnsureJvm().PopBackStack(); + + /// + /// Pop until the given is on top of the + /// back stack. When is true, + /// the route itself is also popped. Mirrors Kotlin's + /// navController.popBackStack(route, inclusive). + /// + public bool PopBackStack(string route, bool inclusive) + { + ArgumentNullException.ThrowIfNull(route); + return EnsureJvm().PopBackStack(route, inclusive); + } + + /// + /// Navigate up to the previous destination — equivalent to + /// pressing the system Up button. Returns true when the up + /// navigation was handled, false otherwise. Mirrors + /// Kotlin's navController.navigateUp(). + /// + public bool NavigateUp() => EnsureJvm().NavigateUp(); + + /// + /// The current top-of-stack , or + /// null while the host is still resolving its start + /// destination. Mirrors Kotlin's + /// navController.currentBackStackEntry. + /// + public NavBackStackEntry? CurrentBackStackEntry + { + get + { + var jvm = EnsureJvm().CurrentBackStackEntry; + return jvm is null ? null : new NavBackStackEntry(jvm); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavDestination.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavDestination.cs new file mode 100644 index 000000000..857c24b5a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavDestination.cs @@ -0,0 +1,153 @@ +using System.Collections; +using Android.Runtime; +using AndroidX.Navigation; + +namespace AndroidX.Compose; + +/// +/// A single destination registered with a . The +/// route string is the unique key the host uses to match +/// calls; the body is +/// the @Composable content shown while this destination sits on top +/// of the back stack. +/// +/// +/// Two construction shapes — collection-init for static content, +/// or a Func<NavBackStackEntry, ComposableNode> for +/// dynamic content that needs to read route arguments: +/// +/// +/// // Static — same children every time the route is shown +/// new NavDestination("home") +/// { +/// new Text("Home"), +/// } +/// +/// // Dynamic — read the {id} placeholder from the back-stack entry +/// new NavDestination("user/{id}", entry => +/// { +/// var id = entry.Arguments?.GetString("id") ?? "?"; +/// return new Text($"User #{id}"); +/// }) +/// +/// +/// +/// Mirrors Kotlin's NavGraphBuilder.composable(route) { backStackEntry -> ... }. +/// Named NavDestination rather than Composable to avoid +/// confusion with the unrelated base class +/// and Kotlin's @Composable annotation — this type isn't a UI +/// node, it's a route registration. +/// Add instances to a via collection-initializer +/// — they're not normal s, since their +/// content is composed inside the NavHost's per-route subcomposition, +/// not the surrounding tree. +/// +/// +public sealed class NavDestination : IEnumerable +{ + readonly List _staticChildren = new(); + readonly Func? _factory; + + /// + /// Register a destination with a static child tree. Add children + /// via collection-initializer syntax. + /// + public NavDestination(string route) + { + ArgumentNullException.ThrowIfNull(route); + Route = route; + } + + /// + /// Register a destination whose content depends on the + /// — typically used to read + /// route placeholders (e.g. {id} in "user/{id}") + /// from the entry's . + /// + public NavDestination(string route, Func content) + { + ArgumentNullException.ThrowIfNull(route); + Route = route; + ArgumentNullException.ThrowIfNull(content); + _factory = content; + } + + /// The route string used to navigate to this destination. + public string Route { get; } + + /// + /// Add a child to the static content tree. Required by C#'s + /// collection-initializer syntax. Calling this on a destination + /// constructed with the dynamic-content factory throws. + /// + public void Add(ComposableNode? child) + { + if (_factory is not null) + throw new InvalidOperationException( + "NavDestination was constructed with a dynamic content factory; collection-init children are not supported."); + if (child is not null) + _staticChildren.Add(child); + } + + /// + /// Collection-initializer overload that exists only to give a clear + /// error message — Kotlin's composable(route) { ... } has no + /// per-destination Modifier slot, so silently accepting one + /// would be misleading. Wrap the destination's content in a + /// / / with + /// the modifier, or set the modifier on the surrounding + /// instead. + /// + public void Add(Modifier modifier) => + throw new InvalidOperationException( + "Compose Navigation's composable() route has no Modifier slot. " + + "Apply the Modifier to a wrapping Box/Column/Row inside the destination, " + + "or to the surrounding NavHost."); + + IEnumerator IEnumerable.GetEnumerator() => _staticChildren.GetEnumerator(); + + // Register this route into the surrounding NavHost's NavGraphBuilder. + // Called once per NavDestination per NavHost composition pass — Kotlin + // composable() runs synchronously inside the builder lambda, so the + // outer NavHost composer is still active here. The destination's + // content lambda, however, is invoked LATER inside the route's own + // subcomposition (every time the user navigates here), so the + // content must be wrapped via ComposableLambdaInstance, not + // ComposableLambda — see ComposableLambdas.InstantiateNavComposable. + internal void RegisterInto(NavGraphBuilder graphBuilder) + { + var content = ComposableLambdas.InstantiateNavComposable((entryHandle, destComposer) => + { + // Render either the dynamic factory's result or the static + // children inside the destination's own composer. + if (_factory is not null) + { + var entry = entryHandle == IntPtr.Zero + ? null + : Java.Lang.Object.GetObject( + entryHandle, JniHandleOwnership.DoNotTransfer); + var wrapper = entry is null ? null : new NavBackStackEntry(entry); + if (wrapper is null) + throw new InvalidOperationException( + "Compose Navigation invoked a destination with a null back-stack entry; this should never happen."); + _factory(wrapper).Render(destComposer); + } + else + { + for (int i = 0; i < _staticChildren.Count; i++) + { + destComposer.StartReplaceableGroup(i); + try { _staticChildren[i].Render(destComposer); } + finally { destComposer.EndReplaceableGroup(); } + } + } + }); + + ComposeBridges.NavGraphBuilderComposable( + navGraphBuilder: ((Java.Lang.Object)graphBuilder).Handle, + route: Route, + arguments: null, + deepLinks: null, + content: content); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavGraphBuilderLambda.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavGraphBuilderLambda.cs new file mode 100644 index 000000000..b615a3b29 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavGraphBuilderLambda.cs @@ -0,0 +1,38 @@ +using Android.Runtime; +using AndroidX.Navigation; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function1<NavGraphBuilder, Unit> — the builder argument +/// to NavHostKt.NavHost(navController, startDestination, modifier, +/// route, builder, ...). NOT a @Composable lambda: NavHost invokes +/// it synchronously during graph construction, before any destination +/// is composed, so it must NOT be wrapped via ComposableLambda +/// or ComposableLambdaInstance — those inject the +/// startRestartGroup/endRestartGroup machinery used by +/// real composables and would corrupt the surrounding composition. +/// +/// The body is a plain +/// that registers each route via Kotlin's composable() extension +/// (routed through ComposeBridges.NavGraphBuilderComposable). +/// +/// +[Register("net/compose/NavGraphBuilderLambda")] +internal sealed class NavGraphBuilderLambda : Java.Lang.Object, IFunction1 +{ + readonly Action _body; + + public NavGraphBuilderLambda(Action body) => _body = body; + + // Kotlin Function1 contractually returns + // Unit.INSTANCE — see ComposableLambda0/1 for the rationale. + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + ArgumentNullException.ThrowIfNull(p0); + var graphBuilder = Android.Runtime.Extensions.JavaCast(p0); + _body(graphBuilder); + return Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavHost.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavHost.cs new file mode 100644 index 000000000..53dca5f97 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavHost.cs @@ -0,0 +1,130 @@ +using System.Collections; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Compose Navigation host — the C# moral equivalent of Kotlin's +/// NavHost(navController, startDestination) { composable("a") { ... } }. +/// Holds a graph of destinations and switches +/// the visible one based on the bound +/// 's back stack. +/// +/// +/// Wire a remembered if you +/// want to drive navigation from outside the host (e.g. from button +/// onClick callbacks); leave it null to let the host create +/// its own internal controller via Kotlin's +/// rememberNavController(): +/// +/// +/// var nav = Remember(() => new NavController()); +/// +/// new NavHost(startDestination: "home", navController: nav) +/// { +/// new NavDestination("home") +/// { +/// new Button(onClick: () => nav.Navigate("detail")) +/// { +/// new Text("Go to detail"), +/// }, +/// }, +/// new NavDestination("detail") +/// { +/// new Button(onClick: () => nav.PopBackStack()) +/// { +/// new Text("Back"), +/// }, +/// }, +/// }; +/// +/// +/// +/// The host requires Xamarin.AndroidX.Navigation.Compose +/// — added transitively when you reference +/// AndroidX.Compose. See dotnet/android-libraries#1444 for +/// the upstream binder bug that requires the JNI bridges in +/// . +/// +/// +public sealed class NavHost : ComposableNode, IEnumerable +{ + readonly string _startDestination; + readonly NavController _navController; + readonly List _routes = new(); + + /// + /// Create a navigation host that starts at . + /// When is null, the host + /// allocates an internal one — fine for self-contained graphs that + /// don't need to navigate from outside (e.g. only button onClick + /// inside destinations). + /// + public NavHost(string startDestination, NavController? navController = null) + { + ArgumentNullException.ThrowIfNull(startDestination); + _startDestination = startDestination; + _navController = navController ?? new NavController(); + } + + /// The start destination route the host opens to. + public string StartDestination => _startDestination; + + /// The bound navigation controller — never null. + public NavController NavController => _navController; + + /// Add a destination via collection-initializer syntax. + public void Add(NavDestination? route) + { + if (route is not null) + _routes.Add(route); + } + + /// + /// Collection-initializer overload that lets callers set + /// inline alongside routes. + /// + public void Add(Modifier modifier) => Modifier = modifier; + + IEnumerator IEnumerable.GetEnumerator() => _routes.GetEnumerator(); + + public override void Render(IComposer composer) + { + // Allocate (or reuse) the underlying NavHostController via + // Kotlin's rememberNavController(). Because rememberNavController + // is itself remembered across recompositions, every render gets + // back the same controller handle — we just rebind the wrapper. + var controller = ComposeBridges.RememberNavController(composer); + _navController.Jvm = controller; + + var modifier = BuildModifier(); + + // The Kotlin NavHost does + // remember(route, startDestination, builder) { navController.createGraph(...) } + // so the builder's reference identity is part of the graph cache key. + // If we allocated a fresh NavGraphBuilderLambda on every recomposition, + // Compose would rebuild the entire graph and reset the back stack to + // the start destination after every state change. Cache the lambda + // in the slot table via ComposeExtensions.Remember so its identity is stable. + // + // Compose Navigation invokes the builder ONCE (inside createGraph), + // so the captured 'this' / '_routes' from the first render are the + // ones registered with the graph. Subsequent recompositions don't + // re-register routes — that matches Kotlin's behavior where the + // trailing graph-builder lambda is also captured once. + var self = this; + var builder = composer.Remember(() => new NavGraphBuilderLambda(graphBuilder => + { + for (int i = 0; i < self._routes.Count; i++) + self._routes[i].RegisterInto(graphBuilder); + })); + + ComposeBridges.NavHost( + navController: controller, + startDestination: _startDestination, + modifier: modifier, + route: null, + builder: builder, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavOptions.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavOptions.cs new file mode 100644 index 000000000..21ffb6681 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavOptions.cs @@ -0,0 +1,118 @@ + +namespace AndroidX.Compose; + +/// +/// Mutable property bag that mirrors the subset of Kotlin's +/// androidx.navigation.NavOptions.Builder needed by the +/// classic Material 3 bottom-navigation pattern. Pass to +/// to +/// pop-and-restore back-stack state when switching between top-level +/// tabs: +/// +/// +/// nav.Navigate("search", new NavOptions +/// { +/// PopUpToRoute = "home", // = startDestinationRoute +/// PopUpToSaveState = true, +/// LaunchSingleTop = true, +/// RestoreState = true, +/// }); +/// +/// +/// +/// Construct with object-initializer syntax — every property is +/// optional and defaults to "behave like the parameterless +/// overload". Reuse one +/// instance across navigate calls; the C# +/// peer is built on each call so subsequent mutations are observed. +/// +/// +/// +/// Not modelled (yet): Navigator.Extras, integer-id +/// popUpTo, custom enter/exit animations. File an issue if +/// you need them. +/// +/// +public sealed class NavOptions +{ + /// + /// Route at which to apply popUpTo before navigating; pops + /// every entry above this one off the back stack. Leave + /// (the default) to skip popUpTo + /// entirely. The route must already exist on the back stack — + /// typically the . + /// + public string? PopUpToRoute { get; set; } + + /// + /// When , the destination named by + /// is itself popped off the back + /// stack too. Ignored when is + /// . Mirrors Kotlin's + /// popUpTo(route) { inclusive = true }. + /// + public bool PopUpToInclusive { get; set; } + + /// + /// When , the popped destinations' + /// saved state — rememberSaveable, ViewModels — + /// is preserved and can be restored later by a navigate call + /// that also sets . Standard + /// bottom-nav pattern: PopUpToSaveState = true. Ignored + /// when is . + /// Mirrors Kotlin's popUpTo(route) { saveState = true }. + /// + public bool PopUpToSaveState { get; set; } + + /// + /// When , navigating to a destination + /// already on top of the back stack is a no-op — preventing a + /// duplicate entry from being pushed. Mirrors Kotlin's + /// launchSingleTop = true. + /// + public bool LaunchSingleTop { get; set; } + + /// + /// When , any state previously saved for + /// the target destination (via an earlier + /// pop) is restored when the + /// destination is re-entered. Mirrors Kotlin's + /// restoreState = true. + /// + public bool RestoreState { get; set; } + + /// + /// Build the Kotlin NavOptions peer by applying every + /// configured flag to a fresh + /// androidx.navigation.NavOptions.Builder. Called from + /// on + /// every navigate so mutations to the wrapper between calls are + /// observed by the next navigate. + /// + internal AndroidX.Navigation.NavOptions BuildJvm() + { + // Catch caller bugs early: the route-dependent flags are + // silently ignored by Kotlin when no popUpTo is set, which + // is exactly the surprise that bites people writing the + // bottom-nav pattern (they forget to set PopUpToRoute and + // wonder why state isn't being saved). + if (PopUpToRoute is null && (PopUpToInclusive || PopUpToSaveState)) + { + throw new InvalidOperationException( + "NavOptions.PopUpToInclusive / PopUpToSaveState require PopUpToRoute to be set; otherwise the popUpTo clause is dropped and the flags have no effect."); + } + + var builder = new AndroidX.Navigation.NavOptions.Builder(); + + if (PopUpToRoute is not null) + builder.SetPopUpTo(PopUpToRoute, PopUpToInclusive, PopUpToSaveState); + + if (LaunchSingleTop) + builder.SetLaunchSingleTop(true); + + if (RestoreState) + builder.SetRestoreState(true); + + return builder.Build(); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationBar.cs new file mode 100644 index 000000000..961e88c47 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationBar.cs @@ -0,0 +1,23 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 NavigationBar. Container for +/// children laid out horizontally: +/// +/// new NavigationBar +/// { +/// new NavigationBarItem(selected: tab == 0, onClick: () => tab.Value = 0) +/// { +/// Icon = new Text("🏠"), Label = new Text("Home"), +/// }, +/// new NavigationBarItem(selected: tab == 1, onClick: () => tab.Value = 1) +/// { +/// Icon = new Text("⚙"), Label = new Text("Settings"), +/// }, +/// } +/// +/// The RowScope receiver is published via +/// so child NavigationBarItems can +/// pass it to their RowScope-extension static. +/// +public sealed partial class NavigationBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationBarItem.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationBarItem.cs new file mode 100644 index 000000000..a3bb7c5f9 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationBarItem.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 NavigationBarItem. Must be a child of +/// — the Kotlin static method takes a +/// RowScope extension receiver, which the parent +/// publishes via RenderContext. +/// Icon is required; Label is optional. +/// +public sealed partial class NavigationBarItem; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationDrawerItem.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationDrawerItem.cs new file mode 100644 index 000000000..2cabe5ae8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationDrawerItem.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 NavigationDrawerItem — a single tap target inside a +/// , , +/// or . Label is required; +/// Icon and Badge are optional slots. The selected +/// state drives the active-indicator pill background. +/// +public sealed partial class NavigationDrawerItem; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationRail.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationRail.cs new file mode 100644 index 000000000..f279e47d1 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationRail.cs @@ -0,0 +1,17 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 NavigationRail. Vertical analog of +/// . Children are s: +/// +/// new NavigationRail +/// { +/// new NavigationRailItem(selected: tab == 0, onClick: ...) { Icon = ..., Label = ... }, +/// new NavigationRailItem(selected: tab == 1, onClick: ...) { Icon = ..., Label = ... }, +/// } +/// +/// NavigationRailItem (unlike ) is +/// a top-level static, not a ColumnScope extension, so children +/// render directly without a published scope receiver. +/// +public sealed partial class NavigationRail; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationRailItem.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationRailItem.cs new file mode 100644 index 000000000..be31be5d4 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NavigationRailItem.cs @@ -0,0 +1,8 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 NavigationRailItem. Used inside +/// . Icon is required; +/// Label is optional. +/// +public sealed partial class NavigationRailItem; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NoOpSearchCallback.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NoOpSearchCallback.cs new file mode 100644 index 000000000..1120e056f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/NoOpSearchCallback.cs @@ -0,0 +1,21 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function1<String, Unit> stub that ignores its argument +/// and returns null. Supplied to +/// SearchBarDefaults.InputField.onSearch when the C# caller did +/// not provide an onSearch callback — the Kotlin API does not +/// tolerate a null callback (it invokes the lambda when the user +/// presses the IME Search action). Used as a singleton to avoid +/// allocating one per render. +/// +[Register("net/compose/NoOpSearchCallback")] +internal sealed class NoOpSearchCallback : Java.Lang.Object, IFunction1 +{ + public static readonly NoOpSearchCallback Instance = new(); + + public Java.Lang.Object? Invoke(Java.Lang.Object? p0) => null; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ObjectFunction0.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ObjectFunction0.cs new file mode 100644 index 000000000..0c5d645e7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ObjectFunction0.cs @@ -0,0 +1,26 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW that implements Kotlin's Function0<Object> — a +/// zero-argument lambda returning a boxed managed object. Unlike +/// , which returns Unit.INSTANCE +/// (void in Kotlin terms), this one returns a typed value Compose / +/// the binding can consume. +/// +/// Used wherever a Kotlin API takes a () -> T producer of +/// a non-Unit value, e.g. +/// for the value the saveable registry should cache. +/// +[Register("net/compose/ObjectFunction0")] +internal sealed class ObjectFunction0 : Java.Lang.Object, IFunction0 +{ + readonly Func _factory; + + public ObjectFunction0(Func factory) => + _factory = factory; + + public Java.Lang.Object Invoke() => _factory()!; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Offset.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Offset.cs new file mode 100644 index 000000000..144c43fcb --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Offset.cs @@ -0,0 +1,94 @@ +namespace AndroidX.Compose; + +/// +/// C# wrapper around Kotlin's androidx.compose.ui.geometry.Offset. +/// +/// +/// +/// In Kotlin source, Offset is a @JvmInline value class +/// Offset(val packedValue: Long) that packs two +/// coordinates into a 64-bit value: in the high 32 bits +/// (raw IEEE-754 bits), in the low 32 bits. Compose uses +/// it as the position type for pointer events, layout offsets, gesture +/// detector callbacks, etc. +/// +/// +/// At the JVM boundary, the value-class lowering means a function +/// returning Offset actually returns a plain long, and +/// Function1<Offset, Unit> receives a boxed +/// androidx.compose.ui.geometry.Offset wrapper. The internal +/// / helpers handle both +/// representations; the typical C# caller works only with +/// / . +/// +/// +/// Will swap to bound AndroidX.Compose.UI.Geometry.Offset once +/// +/// ships and the binder stops stripping inline-class APIs. +/// +/// +public readonly struct Offset : IEquatable +{ + readonly long _packed; + + /// Construct from raw X/Y coordinates (typically pixels). + public Offset(float x, float y) + { + // Match Kotlin's `packFloats(x, y)`: X is the high 32 bits, Y the low. + // Use BitConverter so NaN / +0 / -0 round-trip exactly. + long hi = BitConverter.SingleToInt32Bits(x) & 0xFFFFFFFFL; + long lo = BitConverter.SingleToInt32Bits(y) & 0xFFFFFFFFL; + _packed = (hi << 32) | lo; + } + + Offset(long packed) => _packed = packed; + + internal long Packed => _packed; + + internal static Offset FromPacked(long packed) => new Offset(packed); + + /// The X coordinate (horizontal, typically pixels). + public float X => BitConverter.Int32BitsToSingle((int)((_packed >> 32) & 0xFFFFFFFFL)); + + /// The Y coordinate (vertical, typically pixels). + public float Y => BitConverter.Int32BitsToSingle((int)(_packed & 0xFFFFFFFFL)); + + /// Origin — (0, 0). + public static Offset Zero => new Offset(0f, 0f); + + /// + /// The Offset.Infinite sentinel — Compose uses + /// (+∞, +∞) as the "fill the whole drawing region" + /// endpoint (e.g. the default end of + /// Brush.linearGradient, which resolves to the + /// bottom-right corner at draw time). + /// + public static Offset Infinite => new Offset(float.PositiveInfinity, float.PositiveInfinity); + + /// + /// The Offset.Unspecified sentinel — Compose uses this to + /// mean "no value". Equal-by-value to Kotlin's + /// Offset.Unspecified (packed value 0x7FC000007FC00000, + /// i.e. for both components). + /// + public static Offset Unspecified => new Offset(unchecked((long)0x7FC000007FC00000UL)); + + /// + public bool Equals(Offset other) => _packed == other._packed; + + /// + public override bool Equals(object? obj) => obj is Offset o && Equals(o); + + /// + public override int GetHashCode() => _packed.GetHashCode(); + + /// Value equality (by packed representation). + public static bool operator ==(Offset a, Offset b) => a._packed == b._packed; + + /// Value inequality (by packed representation). + public static bool operator !=(Offset a, Offset b) => a._packed != b._packed; + + /// + public override string ToString() => + this == Unspecified ? "Offset.Unspecified" : $"Offset({X}, {Y})"; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OffsetCallback.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OffsetCallback.cs new file mode 100644 index 000000000..79119b66b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OffsetCallback.cs @@ -0,0 +1,42 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW adapter implementing Function1<Offset, Unit> for +/// gesture-detector callbacks like onTap / onLongPress / +/// onDoubleTap. Because Kotlin's Offset is a +/// @JvmInline value class, the boxed argument p0 arrives +/// as an androidx.compose.ui.geometry.Offset wrapper, not a +/// java.lang.Long. We unbox via the wrapper's +/// unbox-impl()J instance method. +/// +[Register("net/compose/OffsetCallback")] +internal sealed class OffsetCallback : Java.Lang.Object, IFunction1 +{ + static IntPtr s_unbox; + + readonly Action _body; + + public OffsetCallback(Action body) => _body = body; + + public Java.Lang.Object Invoke(Java.Lang.Object? p0) + { + long packed = 0L; + if (p0 is not null) + { + EnsureUnboxMethod(); + packed = JNIEnv.CallLongMethod(p0.Handle, s_unbox); + } + _body(Offset.FromPacked(packed)); + return Kotlin.Unit.Instance!; + } + + static void EnsureUnboxMethod() + { + if (s_unbox != IntPtr.Zero) return; + var cls = JNIEnv.FindClass("androidx/compose/ui/geometry/Offset"); + s_unbox = JNIEnv.GetMethodID(cls, "unbox-impl", "()J"); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OffsetPressCallback.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OffsetPressCallback.cs new file mode 100644 index 000000000..02cc493a2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OffsetPressCallback.cs @@ -0,0 +1,58 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW adapter implementing Function3<PressGestureScope, Offset, +/// Continuation, Object> for the onPress callback of +/// detectTapGestures. Kotlin lowers the original +/// suspend PressGestureScope.(Offset) -> Unit shape to a +/// Function3 with the receiver as p0, the offset as p1, +/// and the suspend continuation as p2. +/// +/// +/// +/// v1 invokes the user's body +/// synchronously and immediately returns Kotlin.Unit.Instance +/// (i.e. the suspend body completes synchronously without ever +/// suspending). The PressGestureScope receiver (which Kotlin +/// uses to wait for release / cancel via awaitRelease / +/// tryAwaitRelease) is intentionally NOT surfaced — that +/// requires bridging suspend-from-suspend which we don't have plumbing +/// for yet, and the resulting API would be misleadingly async. +/// +/// +/// The boxed Offset argument (p1) is unboxed via the +/// shared helper's +/// unbox-impl()J lookup pattern. +/// +/// +[Register("net/compose/OffsetPressCallback")] +internal sealed class OffsetPressCallback : Java.Lang.Object, IFunction3 +{ + static IntPtr s_unbox; + + readonly Action _body; + + public OffsetPressCallback(Action body) => _body = body; + + public Java.Lang.Object Invoke(Java.Lang.Object? p0, Java.Lang.Object? p1, Java.Lang.Object? p2) + { + long packed = 0L; + if (p1 is not null) + { + EnsureUnboxMethod(); + packed = JNIEnv.CallLongMethod(p1.Handle, s_unbox); + } + _body(Offset.FromPacked(packed)); + return Kotlin.Unit.Instance!; + } + + static void EnsureUnboxMethod() + { + if (s_unbox != IntPtr.Zero) return; + var cls = JNIEnv.FindClass("androidx/compose/ui/geometry/Offset"); + s_unbox = JNIEnv.GetMethodID(cls, "unbox-impl", "()J"); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Orientation.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Orientation.cs new file mode 100644 index 000000000..d93ba9d59 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Orientation.cs @@ -0,0 +1,17 @@ +namespace AndroidX.Compose; + +/// +/// Drag / scroll orientation. Mirrors Kotlin's +/// androidx.compose.foundation.gestures.Orientation enum. Passed +/// to +/// (and to any future orientation-aware modifier helpers) to pick the +/// axis the gesture operates on. +/// +public enum Orientation +{ + /// Vertical axis — drag amounts are deltas in the Y direction. + Vertical, + + /// Horizontal axis — drag amounts are deltas in the X direction. + Horizontal, +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedButton.cs new file mode 100644 index 000000000..ecd4b772c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 OutlinedButton. Same shape as . +/// +public sealed partial class OutlinedButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedCard.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedCard.cs new file mode 100644 index 000000000..bb543aeb8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedCard.cs @@ -0,0 +1,7 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 OutlinedCard — a card surrounded by a thin +/// outline instead of relying on tonal/shadow elevation for separation. +/// +public sealed partial class OutlinedCard; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedIconButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedIconButton.cs new file mode 100644 index 000000000..14dc5735b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedIconButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 OutlinedIconButton. Same shape as . +/// +public sealed partial class OutlinedIconButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedIconToggleButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedIconToggleButton.cs new file mode 100644 index 000000000..c19fb5e6d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedIconToggleButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 OutlinedIconToggleButton. Same shape as . +/// +public sealed partial class OutlinedIconToggleButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedSecureTextField.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedSecureTextField.cs new file mode 100644 index 000000000..a6b9b3aa0 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedSecureTextField.cs @@ -0,0 +1,99 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 OutlinedSecureTextField — the outlined variant of +/// for password / secure input. Stripped +/// from the binding for the same reason (@JvmInline value class +/// parameter, hashed JVM name -XvU6IwQ); reached via +/// . +/// +/// +/// Same shape as — see that type's docs for +/// the full list of optional slots and which Kotlin defaults are used for +/// the parameters this facade does not expose. +/// +/// +/// var pwd = Remember(() => new SecureTextFieldState()); +/// new OutlinedSecureTextField(pwd) +/// { +/// Label = new Text("Password"), +/// LeadingIcon = new Text("🔒"), +/// } +/// +/// +public sealed class OutlinedSecureTextField : ComposableNode +{ + readonly SecureTextFieldState _state; + + /// Creates an outlined secure text field bound to the supplied . + public OutlinedSecureTextField(SecureTextFieldState state) => _state = state; + + /// Whether the field accepts user input. Defaults to true. + public bool Enabled { get; set; } = true; + + /// Whether to render the field in its error appearance. Defaults to false. + public bool IsError { get; set; } + + /// Optional label slot (Kotlin label). + public ComposableNode? Label { get; set; } + + /// Optional placeholder slot drawn when the field is empty (Kotlin placeholder). + public ComposableNode? Placeholder { get; set; } + + /// Optional leading icon slot (Kotlin leadingIcon). + public ComposableNode? LeadingIcon { get; set; } + + /// Optional trailing icon slot, typically a "show/hide" toggle (Kotlin trailingIcon). + public ComposableNode? TrailingIcon { get; set; } + + /// Optional supporting text rendered below the field (Kotlin supportingText). + public ComposableNode? SupportingText { get; set; } + + /// Optional shape applied to the field's outlined container (Kotlin shape). + public Shape? Shape { get; set; } + + public override void Render(IComposer composer) + { + var statePeer = _state.Resolve(composer); + + var labelNode = Label; + var label = labelNode is null + ? null + : ComposableLambdas.Wrap3(composer, c => labelNode.Render(c)); + + var placeholderNode = Placeholder; + var placeholder = placeholderNode is null + ? null + : ComposableLambdas.Wrap2(composer, c => placeholderNode.Render(c)); + + var leadingIconNode = LeadingIcon; + var leadingIcon = leadingIconNode is null + ? null + : ComposableLambdas.Wrap2(composer, c => leadingIconNode.Render(c)); + + var trailingIconNode = TrailingIcon; + var trailingIcon = trailingIconNode is null + ? null + : ComposableLambdas.Wrap2(composer, c => trailingIconNode.Render(c)); + + var supportingTextNode = SupportingText; + var supportingText = supportingTextNode is null + ? null + : ComposableLambdas.Wrap2(composer, c => supportingTextNode.Render(c)); + + ComposeBridges.OutlinedSecureTextField( + state: statePeer.Handle, + modifier: BuildModifier(), + enabled: Enabled, + label: label, + placeholder: placeholder, + leadingIcon: leadingIcon, + trailingIcon: trailingIcon, + supportingText: supportingText, + isError: IsError, + shape: Shape, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedTextField.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedTextField.cs new file mode 100644 index 000000000..df1a01fee --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/OutlinedTextField.cs @@ -0,0 +1,144 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Text.Input; + +namespace AndroidX.Compose; + +/// +/// Material 3 OutlinedTextField. Same construction modes and +/// slot properties as ; differs only in visual +/// treatment (outline border instead of filled background). +/// +/// +/// Hand-written for the same reason as — see +/// its <remarks> for the ctor-driven bridge-selection +/// rationale. +/// +public sealed class OutlinedTextField : ComposableNode +{ + readonly string? _value; + readonly Action? _onValueChange; + readonly MutableState? _tfvState; + + /// Floating label (e.g. new Text("Email")). + public ComposableNode? Label { get; set; } + /// Hint shown while the field is empty. + public ComposableNode? Placeholder { get; set; } + /// Icon rendered before the value text. + public ComposableNode? LeadingIcon { get; set; } + /// Icon rendered after the value text. + public ComposableNode? TrailingIcon { get; set; } + /// Static prefix rendered before the value text. + public ComposableNode? Prefix { get; set; } + /// Static suffix rendered after the value text. + public ComposableNode? Suffix { get; set; } + /// Helper text rendered below the field. + public ComposableNode? SupportingText { get; set; } + /// Whether the field accepts focus and input. + public bool? Enabled { get; set; } + /// Whether the field is read-only. + public bool? ReadOnly { get; set; } + /// Whether the field is in an error state. + public bool? IsError { get; set; } + /// Whether the field is constrained to one line. + public bool? SingleLine { get; set; } + /// Maximum number of visible lines. + public int? MaxLines { get; set; } + /// Minimum number of visible lines. + public int? MinLines { get; set; } + /// Optional shape applied to the field's outlined container (Kotlin shape). + public Shape? Shape { get; set; } + /// Optional TextStyle override (Kotlin textStyle) — controls text color, size, weight, etc. + public TextStyle? TextStyle { get; set; } + /// Optional visual transformation (Kotlin visualTransformation) — use PasswordVisualTransformation for password fields. + public AndroidX.Compose.UI.Text.Input.IVisualTransformation? VisualTransformation { get; set; } + /// Optional keyboard options (Kotlin keyboardOptions) — controls IME type, capitalization, autocorrect. + public AndroidX.Compose.Foundation.Text.KeyboardOptions? KeyboardOptions { get; set; } + /// Optional keyboard-action callbacks (Kotlin keyboardActions) — fires onSearch/onDone/onSend/etc. when the user taps the IME action key. + public AndroidX.Compose.Foundation.Text.KeyboardActions? KeyboardActions { get; set; } + + /// String-overload ctor — pass the current value and a callback. + public OutlinedTextField(string value, Action onValueChange) + { + _value = value; + _onValueChange = onValueChange; + } + + /// + /// Bind this OutlinedTextField to a + /// of so user edits trigger recomposition automatically. + /// + public OutlinedTextField(MutableState state) + : this(state.Value ?? string.Empty, v => state.Value = v) + { + } + + /// + /// Bind this OutlinedTextField to a + /// of . Uses Kotlin's + /// OutlinedTextField(TextFieldValue, (TextFieldValue) -> Unit, …) + /// overload so caller-supplied selection state is honoured. + /// + public OutlinedTextField(MutableState state) + { + ArgumentNullException.ThrowIfNull(state); + _tfvState = state; + } + + /// + public override void Render(IComposer composer) + { + if (_tfvState is not null) + RenderWithSelection(composer); + else + RenderString(composer); + } + + void RenderString(IComposer composer) + { + var __onValueChange = new ComposableLambda1(v => _onValueChange!(v?.ToString() ?? string.Empty)); + var __label = Label is null ? null : ComposableLambdas.Wrap2(composer, c => Label.Render(c)); + var __placeholder = Placeholder is null ? null : ComposableLambdas.Wrap2(composer, c => Placeholder.Render(c)); + var __leadingIcon = LeadingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => LeadingIcon.Render(c)); + var __trailingIcon = TrailingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => TrailingIcon.Render(c)); + var __prefix = Prefix is null ? null : ComposableLambdas.Wrap2(composer, c => Prefix.Render(c)); + var __suffix = Suffix is null ? null : ComposableLambdas.Wrap2(composer, c => Suffix.Render(c)); + var __supportingText = SupportingText is null ? null : ComposableLambdas.Wrap2(composer, c => SupportingText.Render(c)); + ComposeBridges.OutlinedTextField(_value!, __onValueChange, BuildModifier(), + Enabled, ReadOnly, TextStyle?.Build(), __label, __placeholder, __leadingIcon, __trailingIcon, + __prefix, __suffix, __supportingText, IsError, + VisualTransformation, KeyboardOptions, KeyboardActions, + SingleLine, MaxLines, MinLines, + Shape, + composer); + } + + void RenderWithSelection(IComposer composer) + { + var state = _tfvState!; + var current = state.Value + ?? throw new InvalidOperationException( + $"{nameof(OutlinedTextField)}: MutableState.Value is null. " + + $"Seed with {nameof(Compose)}.{nameof(ComposeExtensions.NewTextFieldValue)}() before first render."); + + var __onValueChange = new ComposableLambda1(v => + { + state.Value = Java.Lang.Object.GetObject( + v!.Handle, JniHandleOwnership.DoNotTransfer)!; + }); + var __label = Label is null ? null : ComposableLambdas.Wrap2(composer, c => Label.Render(c)); + var __placeholder = Placeholder is null ? null : ComposableLambdas.Wrap2(composer, c => Placeholder.Render(c)); + var __leadingIcon = LeadingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => LeadingIcon.Render(c)); + var __trailingIcon = TrailingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => TrailingIcon.Render(c)); + var __prefix = Prefix is null ? null : ComposableLambdas.Wrap2(composer, c => Prefix.Render(c)); + var __suffix = Suffix is null ? null : ComposableLambdas.Wrap2(composer, c => Suffix.Render(c)); + var __supportingText = SupportingText is null ? null : ComposableLambdas.Wrap2(composer, c => SupportingText.Render(c)); + ComposeBridges.OutlinedTextFieldWithValue(current, __onValueChange, BuildModifier(), + Enabled, ReadOnly, TextStyle?.Build(), __label, __placeholder, __leadingIcon, __trailingIcon, + __prefix, __suffix, __supportingText, IsError, + VisualTransformation, KeyboardOptions, KeyboardActions, + SingleLine, MaxLines, MinLines, + Shape, + composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PaddingValues.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PaddingValues.cs new file mode 100644 index 000000000..d11205402 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PaddingValues.cs @@ -0,0 +1,126 @@ +using Android.Runtime; +using AndroidX.Compose.Foundation.Layout; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around androidx.compose.foundation.layout.PaddingValues. +/// Models inset / asymmetric padding to apply inside scroll containers +/// (, , the lazy +/// grids and pagers) and the Button family's +/// contentPadding slot. +/// +/// +/// +/// Compose's PaddingValues is an interface produced by the +/// PaddingValues(Dp), PaddingValues(Dp, Dp) and +/// PaddingValues(Dp, Dp, Dp, Dp) top-level Kotlin factories. +/// Those factories are all bound (the binder generates them as +/// PaddingKt.PaddingValues(...)), so the wrapper just builds the +/// peer eagerly in its constructor and hands the resulting JNI handle +/// to the bridge generator's reference-type code path (which writes +/// ((Java.Lang.Object)pv).Handle into the JNI slot). +/// +/// +/// Hoist with Remember when constructed inside a +/// Build lambda — every new PaddingValues(...) does a JNI +/// round-trip and a managed peer wrap, and Compose recomposition +/// skipping is reference-equality based, so building a fresh instance +/// per recomposition prevents recomposition-skipping on the consuming +/// composable. +/// +/// +/// new LazyColumn<int>( +/// items: Enumerable.Range(0, 100).ToList(), +/// itemContent: i => new Text($"Row {i}")) +/// { +/// ContentPadding = new PaddingValues(start: 16, top: 24, end: 16, bottom: 24), +/// } +/// +/// +public sealed class PaddingValues : Java.Lang.Object +{ + // Cache the IPaddingValues interface peer so render-path readers + // (LazyColumn / LazyRow / ... pass .Jvm into the bound LazyDslKt + // call) don't pay a JavaCast per recomposition. + readonly IPaddingValues _jvm; + + PaddingValues(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) + { + _jvm = this.JavaCast(); + } + + /// + /// PaddingValues(all: Dp) — equal padding on all four edges. + /// + public PaddingValues(Dp all) + : this(BuildHandle(PaddingKt.PaddingValues(all.Value)), + JniHandleOwnership.TransferLocalRef) { } + + /// + /// PaddingValues(horizontal: Dp, vertical: Dp) — equal + /// padding on the start/end edges () + /// and equal padding on the top/bottom edges + /// (). + /// + public PaddingValues(Dp horizontal, Dp vertical) + : this(BuildHandle(PaddingKt.PaddingValues(horizontal.Value, vertical.Value)), + JniHandleOwnership.TransferLocalRef) { } + + /// + /// PaddingValues(start: Dp, top: Dp, end: Dp, bottom: Dp) — + /// independent padding per edge. Any argument left at its default + /// value (0.dp) leaves that edge unpadded. Use named + /// arguments for readability: + /// new PaddingValues(top: 8, bottom: 16). + /// + public PaddingValues(Dp start = default, Dp top = default, + Dp end = default, Dp bottom = default) + : this(BuildHandle(PaddingKt.PaddingValues(start.Value, top.Value, end.Value, bottom.Value)), + JniHandleOwnership.TransferLocalRef) { } + + // The bound PaddingKt.PaddingValues(...) factories return an + // IPaddingValues whose underlying JNI handle is the freshly- + // constructed peer. We NewLocalRef + TransferLocalRef into the + // base class so the wrapper owns its own reference (the temporary + // IPaddingValues peer will get collected on the next GC pass). + static IntPtr BuildHandle(IPaddingValues result) + => JNIEnv.NewLocalRef(((Java.Lang.Object)result).Handle); + + /// + /// Wrap a runtime PaddingValues JNI handle handed to us by a + /// parent layout (e.g. 's content lambda) + /// without taking ownership of the reference. The resulting wrapper + /// is only valid for the synchronous duration of the parent's + /// content lambda — long enough for descendants to read + /// during the same + /// composition pass, but it must not be captured into long-lived + /// state. + /// + internal static PaddingValues Wrap(IntPtr handle) => + new PaddingValues(handle, JniHandleOwnership.DoNotTransfer); + + /// + /// The underlying bound peer. Used by + /// hand-written facades that call the bound binding directly + /// (e.g. LazyDslKt.LazyColumn(contentPadding: ...)); + /// generated facades / bridges pass the raw handle instead via + /// ((Java.Lang.Object)pv).Handle. + /// + internal IPaddingValues Jvm => _jvm; + + /// + /// calculateTopPadding() — the configured top padding, in + /// . Wraps the bound + /// reader. + /// + public Dp Top => new Dp(_jvm.CalculateTopPadding()); + + /// + /// calculateBottomPadding() — the configured bottom + /// padding, in . Wraps the bound + /// reader. + /// + public Dp Bottom => new Dp(_jvm.CalculateBottomPadding()); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PagerState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PagerState.cs new file mode 100644 index 000000000..72c0330e1 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PagerState.cs @@ -0,0 +1,131 @@ +using AndroidX.Compose.Foundation.Pager; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for / +/// . Wraps the bound +/// androidx.compose.foundation.pager.PagerState — the +/// PagerStateKt.PagerState(currentPage, currentPageOffsetFraction, +/// pageCount) factory is bound and not @Composable, so the +/// JVM peer is built eagerly in the constructor and reads like +/// hit Compose's snapshot system from the +/// first composition pass — no render-order dependency. +/// +/// +/// +/// Construct one inside a composer.Remember +/// callback so the page position survives recompositions. The +/// pageCount lambda must be supplied (the Kotlin +/// runtime calls it on every measure pass) and should close over a +/// stable source that matches the / +/// items list — otherwise the indicator +/// and the rendered pages can drift. +/// +/// +/// var items = new[] { 0, 1, 2 }; +/// var pagerState = Remember(() => new PagerState(pageCount: () => items.Length)); +/// +/// new Column +/// { +/// new HorizontalPager<int>( +/// items: items, +/// itemContent: i => new Text($"Page {i}")) +/// { +/// State = pagerState, +/// }, +/// // Reactive — snapshot read on PagerState.currentPage is +/// // recorded for this composition scope regardless of order. +/// new Text($"Page {pagerState.CurrentPage + 1} of {pagerState.PageCount}"), +/// } +/// +/// +/// Unlike Kotlin's rememberPagerState() (which uses +/// rememberSaveable), this state is held in the +/// ComposeExtensions-scoped Remember cache, so it survives +/// recompositions but not process death / configuration changes. For +/// most apps that's fine; if you need savable scroll position, track +/// it yourself with the rest of your view-model state. +/// +/// +public sealed class PagerState +{ + // Kept on the wrapper so the JVM-side reference to the lambda + // outlives any temporary parameter slot — matches the pattern + // other wrappers use for stored callbacks. + readonly ComposableLambda0Int _pageCountFn; + + /// + /// Create a new . The underlying Kotlin + /// androidx.compose.foundation.pager.PagerState is built + /// immediately via PagerStateKt.PagerState(...), so reads + /// like can be subscribed to from the + /// first composition pass — even when read from C# build code + /// rendered before the pager. + /// + /// + /// Lambda invoked by the pager on every measure pass to determine + /// the total page count. Must close over a stable source that + /// matches the / + /// items list passed alongside this + /// state — recomposition-local list rebuilds will be missed. + /// + /// + /// Index of the page to start on (default 0). + /// + /// + /// Initial fractional offset in [-0.5, 0.5) (default + /// 0f). + /// + public PagerState(Func pageCount, int initialPage = 0, float initialPageOffsetFraction = 0f) + { + ArgumentNullException.ThrowIfNull(pageCount); + _pageCountFn = new ComposableLambda0Int(pageCount); + Jvm = PagerStateKt.PagerState( + currentPage: initialPage, + currentPageOffsetFraction: initialPageOffsetFraction, + pageCount: _pageCountFn); + } + + /// + /// Underlying androidx.compose.foundation.pager.PagerState + /// built by PagerStateKt.PagerState(...). Non-null from + /// construction. + /// + internal AndroidX.Compose.Foundation.Pager.PagerState Jvm { get; } + + /// + /// Index of the page closest to the snapped position. Mirrors + /// Kotlin's PagerState.currentPage. Snapshot-tracked — reads + /// from a live composition scope subscribe to page changes. + /// + public int CurrentPage => Jvm.CurrentPage; + + /// + /// Index of the page the pager has settled on after a fling or + /// programmatic scroll completes. Mirrors Kotlin's + /// PagerState.settledPage. + /// + public int SettledPage => Jvm.SettledPage; + + /// + /// Index of the page the pager is currently animating toward + /// (== when no scroll is in flight). + /// Mirrors Kotlin's PagerState.targetPage. + /// + public int TargetPage => Jvm.TargetPage; + + /// + /// Fractional offset of the current page in [-0.5, 0.5), + /// where 0 means the page is fully snapped. Mirrors Kotlin's + /// PagerState.currentPageOffsetFraction. + /// + public float CurrentPageOffsetFraction => Jvm.CurrentPageOffsetFraction; + + /// + /// Total number of pages reported by the pageCount lambda + /// supplied at construction. Mirrors Kotlin's + /// PagerState.pageCount. + /// + public int PageCount => Jvm.PageCount; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PermanentDrawerSheet.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PermanentDrawerSheet.cs new file mode 100644 index 000000000..f8aca538f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PermanentDrawerSheet.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 PermanentDrawerSheet — the always-visible panel +/// shown by a . +/// ContainerColor defaults to 0L, which falls back to +/// the active theme's surface — matches the upstream Kotlin +/// default (DrawerDefaults.standardContainerColor). +/// +public sealed partial class PermanentDrawerSheet; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PermanentNavigationDrawer.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PermanentNavigationDrawer.cs new file mode 100644 index 000000000..867d397fc --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PermanentNavigationDrawer.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 PermanentNavigationDrawer — always-visible side +/// drawer, intended for tablets and large screens. The Drawer +/// slot typically holds a . No +/// state holder needed (the drawer is always rendered) and no +/// gestures or scrim. +/// +public sealed partial class PermanentNavigationDrawer; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PointerInputBlock.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PointerInputBlock.cs new file mode 100644 index 000000000..b732c27c8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PointerInputBlock.cs @@ -0,0 +1,146 @@ +using Android.Runtime; +using Kotlin.Coroutines; +using Kotlin.Coroutines.Intrinsics; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW exposing a that Compose's +/// pointerInput(key, handler) invokes inside the gesture-detector +/// coroutine. The Java-side +/// net.compose.PointerInputEventHandlerImpl (shipped via +/// <AndroidJavaSource>) implements the bound, but +/// otherwise-unreachable, PointerInputEventHandler interface by +/// forwarding to this Function2 — the only shape we can implement from +/// C# given that the binder strips +/// PointerInputEventHandler.invoke's value-class-mangled +/// signature. +/// +/// +/// +/// Tail-call semantics. Inside we +/// call with the OUTER +/// continuation Kotlin handed us. The raw result — +/// COROUTINE_SUSPENDED, kotlin.Unit, or a +/// kotlin.Result.Failure — is returned verbatim. There is no +/// SuspendBridge involvement and no managed Task: when +/// Compose cancels the outer coroutine (e.g. modifier removed, key +/// changed), detectTapGestures' next suspend point throws +/// CancellationException, which propagates through the +/// continuation we forwarded — no extra plumbing required. +/// +/// +/// Callback freshness. The onTap / onPress +/// / onLongPress / onDoubleTap JCW instances are +/// captured at construction time; updating the C# delegates and +/// rebuilding the modifier will NOT restart this coroutine unless the +/// caller also varies the key parameter (Compose's +/// pointerInput only resets on key change, not on handler +/// instance change). Mirror Kotlin's idiom: pass a varying key when +/// callbacks need to refresh, or read mutable state via +/// State<T> from inside the callbacks. +/// +/// +[Register("net/compose/PointerInputBlock")] +internal sealed class PointerInputBlock : Java.Lang.Object, IFunction2 +{ + static IntPtr s_suspendedHandle; + + readonly OffsetCallback? _onTap; + readonly OffsetPressCallback? _onPress; + readonly OffsetCallback? _onLongPress; + readonly OffsetCallback? _onDoubleTap; + + public PointerInputBlock( + OffsetCallback? onTap, + OffsetPressCallback? onPress, + OffsetCallback? onLongPress, + OffsetCallback? onDoubleTap) + { + _onTap = onTap; + _onPress = onPress; + _onLongPress = onLongPress; + _onDoubleTap = onDoubleTap; + } + + public Java.Lang.Object? Invoke(Java.Lang.Object? scope, Java.Lang.Object? cont) + { + if (scope is null) + throw new InvalidOperationException( + "PointerInputBlock.Invoke received a null PointerInputScope in slot 0"); + if (cont is null) + throw new InvalidOperationException( + "PointerInputBlock.Invoke received a null Continuation in slot 1"); + + var scopeHandle = scope.Handle; + + // Kotlin's createCoroutineUnintercepted hands us a synthesized + // outer continuation whose runtime class isn't in Mono.Android's + // peer registry, so a plain `as IContinuation` returns null. + // JavaCast synthesizes the interface peer from the raw handle. + // Same trick as LaunchedEffectBody.Invoke. + IContinuation continuation; + try + { + continuation = cont.JavaCast(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + "PointerInputBlock.Invoke could not project slot 1 (" + + (cont.Class?.Name ?? "") + + ") as kotlin.coroutines.Continuation", ex); + } + + try + { + var resultHandle = ComposeBridges.DetectTapGestures( + scopeHandle, + _onDoubleTap is null ? null : ((Java.Lang.Object)_onDoubleTap).Handle, + _onLongPress is null ? null : ((Java.Lang.Object)_onLongPress).Handle, + _onPress is null ? null : ((Java.Lang.Object)_onPress).Handle, + _onTap is null ? null : ((Java.Lang.Object)_onTap).Handle, + continuation); + + if (resultHandle == IntPtr.Zero) + return null; + + // The COROUTINE_SUSPENDED sentinel is a Kotlin singleton. + // Promoting it via Java.Lang.Object.GetObject collides with + // Mono's peer cache (the cached managed wrapper holds a + // GLOBAL ref while we'd hand it a LOCAL one). Detect it and + // return a stable cached wrapper instead. Same trick as + // SuspendBridge.IsCoroutineSuspended. + EnsureSuspendedHandle(); + if (JNIEnv.IsSameObject(resultHandle, s_suspendedHandle)) + { + JNIEnv.DeleteLocalRef(resultHandle); + return IntrinsicsKt.COROUTINE_SUSPENDED; + } + + return Java.Lang.Object.GetObject( + resultHandle, JniHandleOwnership.TransferLocalRef); + } + finally + { + GC.KeepAlive(scope); + GC.KeepAlive(cont); + GC.KeepAlive(_onTap); + GC.KeepAlive(_onPress); + GC.KeepAlive(_onLongPress); + GC.KeepAlive(_onDoubleTap); + } + } + + static void EnsureSuspendedHandle() + { + if (s_suspendedHandle != IntPtr.Zero) return; + var inst = IntrinsicsKt.COROUTINE_SUSPENDED; + var gref = JNIEnv.NewGlobalRef(inst.Handle); + if (Interlocked.CompareExchange( + ref s_suspendedHandle, gref, IntPtr.Zero) != IntPtr.Zero) + JNIEnv.DeleteGlobalRef(gref); + GC.KeepAlive(inst); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PrimaryScrollableTabRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PrimaryScrollableTabRow.cs new file mode 100644 index 000000000..2bcc9c0b5 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PrimaryScrollableTabRow.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 PrimaryScrollableTabRow. Like +/// but tabs are laid out at their natural +/// width and the row scrolls horizontally — useful for primary +/// destinations when there are too many tabs to fit on one screen. +/// +public sealed partial class PrimaryScrollableTabRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PrimaryTabRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PrimaryTabRow.cs new file mode 100644 index 000000000..8bf9ba911 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PrimaryTabRow.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 PrimaryTabRow — the M3 variant of +/// with the primary indicator style (rounded pill +/// under the selected tab). Use when the tabs are the top-level +/// destination switcher of a screen. +/// +public sealed partial class PrimaryTabRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ProduceStateScope.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ProduceStateScope.cs new file mode 100644 index 000000000..4a1160724 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ProduceStateScope.cs @@ -0,0 +1,107 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Slot value used by : +/// holds the the caller writes to, +/// plus the producer task / cancellation lifecycle. Implements +/// so it starts the producer when +/// Compose adds the value to the composition +/// () and cancels it when the value is +/// removed ( / ). +/// +/// ProduceStateScope must be the direct slot value +/// (not wrapped in ) — Compose only +/// inspects the exact object handed to +/// for the +/// interface. +/// +[Register("net/compose/ProduceStateScope")] +internal sealed class ProduceStateScope : Java.Lang.Object, IRememberObserver +{ + public readonly MutableState State; + public object?[]? Keys; + + readonly Func, CancellationToken, Task> _producer; + CancellationTokenSource? _cts; + bool _started; + bool _disposed; + + public ProduceStateScope( + T initial, + Func, CancellationToken, Task> producer, + object?[]? keys) + { + State = new MutableState(initial); + _producer = producer; + Keys = keys is null ? null : (object?[])keys.Clone(); + } + + // Peer-rehydration ctor — the .NET-for-Android runtime invokes + // this when an existing JNI handle for our [Register]'d class + // crosses back into managed code without a live peer. We never + // create new scope instances this way (Compose hands back the + // original peer from its slot table), but the runtime requires + // the constructor to exist. + internal ProduceStateScope(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) + { + // Make non-nullable fields satisfied; this peer will never be + // used by managed callers — only the slot-table identity is. + State = null!; + _producer = null!; + } + + /// + /// Replace the active producer task — used when caller keys + /// change between compositions: cancel the running task and + /// kick off a fresh one with the new keys recorded. + /// + public void Restart(object?[]? newKeys) + { + Stop(); + Keys = newKeys is null ? null : (object?[])newKeys.Clone(); + _disposed = false; + _started = false; + Start(); + } + + public void OnRemembered() => Start(); + + public void OnForgotten() => Stop(); + + public void OnAbandoned() => Stop(); + + void Start() + { + if (_started || _disposed) return; + _started = true; + _cts = new CancellationTokenSource(); + var token = _cts.Token; + var task = _producer(State, token); + // Surface producer faults to logcat instead of leaving them + // as unobserved task exceptions. Match ComposeExtensions' + // logging tag so all our diagnostics share a single filter. + _ = task.ContinueWith(static t => + { + if (t.Exception is { } ex) + { + Android.Util.Log.Error( + "AndroidX.Compose", + "ProduceState producer faulted: " + ex); + } + }, TaskScheduler.Default); + } + + void Stop() + { + if (_disposed) return; + _disposed = true; + try { _cts?.Cancel(); } + catch { /* token source may already be disposed */ } + _cts?.Dispose(); + _cts = null; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ProvidedValue.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ProvidedValue.cs new file mode 100644 index 000000000..023d9ee9c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ProvidedValue.cs @@ -0,0 +1,16 @@ +namespace AndroidX.Compose; + +/// +/// A paired with a specific value, +/// produced by . Hand +/// these to via collection-init +/// syntax to install the value for the duration of the provider's +/// child composition. +/// +public sealed class ProvidedValue +{ + internal AndroidX.Compose.Runtime.ProvidedValue Peer { get; } + + internal ProvidedValue(AndroidX.Compose.Runtime.ProvidedValue peer) => + Peer = peer; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PublicAPI.Shipped.txt b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..cc4845527 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt @@ -0,0 +1,2289 @@ +#nullable enable +~override AndroidX.Compose.TextOverflow.Equals(object obj) -> bool +~override AndroidX.Compose.TextOverflow.ToString() -> string +abstract AndroidX.Compose.ComposableNode.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +AndroidX.Compose.AlertDialog +AndroidX.Compose.AlertDialog.AlertDialog(System.Action! onDismissRequest) -> void +AndroidX.Compose.AlertDialog.ConfirmButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.AlertDialog.ConfirmButton.set -> void +AndroidX.Compose.AlertDialog.DismissButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.AlertDialog.DismissButton.set -> void +AndroidX.Compose.AlertDialog.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.AlertDialog.Icon.set -> void +AndroidX.Compose.AlertDialog.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.AlertDialog.Shape.set -> void +AndroidX.Compose.AlertDialog.Text.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.AlertDialog.Text.set -> void +AndroidX.Compose.AlertDialog.Title.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.AlertDialog.Title.set -> void +AndroidX.Compose.Alignment +AndroidX.Compose.Alignment.Horizontal +AndroidX.Compose.Alignment.Vertical +AndroidX.Compose.AndroidView +AndroidX.Compose.AndroidView.AndroidView(System.Func! factory, System.Action? update = null) -> void +AndroidX.Compose.AnimatedContent +AndroidX.Compose.AnimatedContent.AnimatedContent(T targetState, System.Func! content) -> void +AndroidX.Compose.AnimatedContent.TargetState.get -> T +AndroidX.Compose.AnimatedVisibility +AndroidX.Compose.AnimatedVisibility.AnimatedVisibility(bool visible, AndroidX.Compose.Animation.EnterTransition? enter = null, AndroidX.Compose.Animation.ExitTransition? exit = null) -> void +AndroidX.Compose.AnimatedVisibility.Enter.get -> AndroidX.Compose.Animation.EnterTransition? +AndroidX.Compose.AnimatedVisibility.Exit.get -> AndroidX.Compose.Animation.ExitTransition? +AndroidX.Compose.AnimatedVisibility.Visible.get -> bool +AndroidX.Compose.AnnotatedString +AndroidX.Compose.AnnotatedString.AnnotatedString(string! text) -> void +AndroidX.Compose.AnnotatedString.Length.get -> int +AndroidX.Compose.AnnotatedString.Text.get -> string! +AndroidX.Compose.AnnotatedStringBuilder +AndroidX.Compose.AnnotatedStringBuilder.AddLink(AndroidX.Compose.LinkAnnotation! link, int start, int end) -> void +AndroidX.Compose.AnnotatedStringBuilder.AddStringAnnotation(string! tag, string! annotation, int start, int end) -> void +AndroidX.Compose.AnnotatedStringBuilder.AddStyle(AndroidX.Compose.SpanStyle! style, int start, int end) -> void +AndroidX.Compose.AnnotatedStringBuilder.AnnotatedStringBuilder() -> void +AndroidX.Compose.AnnotatedStringBuilder.Append(string! text) -> AndroidX.Compose.AnnotatedStringBuilder! +AndroidX.Compose.AnnotatedStringBuilder.Length.get -> int +AndroidX.Compose.AnnotatedStringBuilder.Pop() -> void +AndroidX.Compose.AnnotatedStringBuilder.Pop(int index) -> void +AndroidX.Compose.AnnotatedStringBuilder.PushLink(AndroidX.Compose.LinkAnnotation! link) -> int +AndroidX.Compose.AnnotatedStringBuilder.PushStyle(AndroidX.Compose.SpanStyle! style) -> int +AndroidX.Compose.AnnotatedStringBuilder.ToAnnotatedString() -> AndroidX.Compose.AnnotatedString! +AndroidX.Compose.AnnotatedText +AndroidX.Compose.AnnotatedText.Align.get -> AndroidX.Compose.TextAlign? +AndroidX.Compose.AnnotatedText.Align.set -> void +AndroidX.Compose.AnnotatedText.AnnotatedText(AndroidX.Compose.AnnotatedString! text) -> void +AndroidX.Compose.AnnotatedText.Color.get -> AndroidX.Compose.Color? +AndroidX.Compose.AnnotatedText.Color.set -> void +AndroidX.Compose.AnnotatedText.Decoration.get -> AndroidX.Compose.TextDecoration? +AndroidX.Compose.AnnotatedText.Decoration.set -> void +AndroidX.Compose.AnnotatedText.FontFamily.get -> AndroidX.Compose.FontFamily? +AndroidX.Compose.AnnotatedText.FontFamily.set -> void +AndroidX.Compose.AnnotatedText.FontSize.get -> AndroidX.Compose.Sp? +AndroidX.Compose.AnnotatedText.FontSize.set -> void +AndroidX.Compose.AnnotatedText.FontStyle.get -> AndroidX.Compose.FontStyle? +AndroidX.Compose.AnnotatedText.FontStyle.set -> void +AndroidX.Compose.AnnotatedText.FontWeight.get -> AndroidX.Compose.FontWeight? +AndroidX.Compose.AnnotatedText.FontWeight.set -> void +AndroidX.Compose.AnnotatedText.LetterSpacing.get -> AndroidX.Compose.Sp? +AndroidX.Compose.AnnotatedText.LetterSpacing.set -> void +AndroidX.Compose.AnnotatedText.LineHeight.get -> AndroidX.Compose.Sp? +AndroidX.Compose.AnnotatedText.LineHeight.set -> void +AndroidX.Compose.AnnotatedText.MaxLines.get -> int? +AndroidX.Compose.AnnotatedText.MaxLines.set -> void +AndroidX.Compose.AnnotatedText.MinLines.get -> int? +AndroidX.Compose.AnnotatedText.MinLines.set -> void +AndroidX.Compose.AnnotatedText.Overflow.get -> AndroidX.Compose.TextOverflow? +AndroidX.Compose.AnnotatedText.Overflow.set -> void +AndroidX.Compose.AnnotatedText.SoftWrap.get -> bool? +AndroidX.Compose.AnnotatedText.SoftWrap.set -> void +AndroidX.Compose.Arrangement +AndroidX.Compose.AssistChip +AndroidX.Compose.AssistChip.AssistChip(System.Action! onClick) -> void +AndroidX.Compose.AssistChip.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.AssistChip.Label.set -> void +AndroidX.Compose.AssistChip.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.AssistChip.LeadingIcon.set -> void +AndroidX.Compose.AssistChip.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.AssistChip.Shape.set -> void +AndroidX.Compose.AssistChip.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.AssistChip.TrailingIcon.set -> void +AndroidX.Compose.BackHandler +AndroidX.Compose.BackHandler.BackHandler(System.Action! onBack, bool enabled) -> void +AndroidX.Compose.BackHandler.BackHandler(System.Action! onBack) -> void +AndroidX.Compose.Badge +AndroidX.Compose.Badge.Badge() -> void +AndroidX.Compose.BadgedBox +AndroidX.Compose.BadgedBox.Badge.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.BadgedBox.Badge.set -> void +AndroidX.Compose.BadgedBox.BadgedBox() -> void +AndroidX.Compose.BadgedBox.Content.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.BadgedBox.Content.set -> void +AndroidX.Compose.BottomAppBar +AndroidX.Compose.BottomAppBar.BottomAppBar() -> void +AndroidX.Compose.BottomAppBar.FloatingActionButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.BottomAppBar.FloatingActionButton.set -> void +AndroidX.Compose.BottomSheetScaffold +AndroidX.Compose.BottomSheetScaffold.BottomSheetScaffold(AndroidX.Compose.SheetStateHolder? sheetState = null) -> void +AndroidX.Compose.BottomSheetScaffold.ConfirmValueChange.get -> System.Func? +AndroidX.Compose.BottomSheetScaffold.ConfirmValueChange.set -> void +AndroidX.Compose.BottomSheetScaffold.SheetContent.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.BottomSheetScaffold.SheetContent.set -> void +AndroidX.Compose.BottomSheetScaffold.SheetDragHandle.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.BottomSheetScaffold.SheetDragHandle.set -> void +AndroidX.Compose.BottomSheetScaffold.TopBar.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.BottomSheetScaffold.TopBar.set -> void +AndroidX.Compose.Box +AndroidX.Compose.Box.Box() -> void +AndroidX.Compose.BoxConstraints +AndroidX.Compose.BoxConstraints.BoxConstraints() -> void +AndroidX.Compose.BoxConstraints.MaxHeight.get -> float +AndroidX.Compose.BoxConstraints.MaxWidth.get -> float +AndroidX.Compose.BoxConstraints.MinHeight.get -> float +AndroidX.Compose.BoxConstraints.MinWidth.get -> float +AndroidX.Compose.BoxWithConstraints +AndroidX.Compose.BoxWithConstraints.BoxWithConstraints(System.Func! content) -> void +AndroidX.Compose.Brush +AndroidX.Compose.Button +AndroidX.Compose.Button.Button(System.Action! onClick) -> void +AndroidX.Compose.Button.Colors.get -> AndroidX.Compose.Material3.ButtonColors? +AndroidX.Compose.Button.Colors.set -> void +AndroidX.Compose.Button.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.Button.ContentPadding.set -> void +AndroidX.Compose.Button.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.Button.Shape.set -> void +AndroidX.Compose.Card +AndroidX.Compose.Card.Card() -> void +AndroidX.Compose.Card.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.Card.Shape.set -> void +AndroidX.Compose.CenterAlignedTopAppBar +AndroidX.Compose.CenterAlignedTopAppBar.Actions.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.CenterAlignedTopAppBar.Actions.set -> void +AndroidX.Compose.CenterAlignedTopAppBar.CenterAlignedTopAppBar() -> void +AndroidX.Compose.CenterAlignedTopAppBar.NavigationIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.CenterAlignedTopAppBar.NavigationIcon.set -> void +AndroidX.Compose.CenterAlignedTopAppBar.ScrollBehavior.get -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior? +AndroidX.Compose.CenterAlignedTopAppBar.ScrollBehavior.set -> void +AndroidX.Compose.CenterAlignedTopAppBar.Title.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.CenterAlignedTopAppBar.Title.set -> void +AndroidX.Compose.Checkbox +AndroidX.Compose.Checkbox.Checkbox(bool checked, System.Action! onCheckedChange, bool enabled = true) -> void +AndroidX.Compose.Checkbox.Colors.get -> AndroidX.Compose.Material3.CheckboxColors? +AndroidX.Compose.Checkbox.Colors.set -> void +AndroidX.Compose.CircularProgressIndicator +AndroidX.Compose.CircularProgressIndicator.CircularProgressIndicator() -> void +AndroidX.Compose.CircularProgressIndicator.Color.get -> AndroidX.Compose.Color? +AndroidX.Compose.CircularProgressIndicator.Color.set -> void +AndroidX.Compose.CircularProgressIndicator.StrokeWidthDp.get -> float? +AndroidX.Compose.CircularProgressIndicator.StrokeWidthDp.set -> void +AndroidX.Compose.CircularProgressIndicator.TrackColor.get -> AndroidX.Compose.Color? +AndroidX.Compose.CircularProgressIndicator.TrackColor.set -> void +AndroidX.Compose.CollectedState +AndroidX.Compose.CollectedState.Value.get -> T +AndroidX.Compose.Color +AndroidX.Compose.Color.A.get -> byte +AndroidX.Compose.Color.B.get -> byte +AndroidX.Compose.Color.Color() -> void +AndroidX.Compose.Color.Color(byte alpha, byte red, byte green, byte blue) -> void +AndroidX.Compose.Color.Color(byte red, byte green, byte blue) -> void +AndroidX.Compose.Color.Color(long packedValue) -> void +AndroidX.Compose.Color.Color(ulong packedValue) -> void +AndroidX.Compose.Color.Equals(AndroidX.Compose.Color other) -> bool +AndroidX.Compose.Color.G.get -> byte +AndroidX.Compose.Color.IsSpecified.get -> bool +AndroidX.Compose.Color.IsUnspecified.get -> bool +AndroidX.Compose.Color.PackedValue.get -> ulong +AndroidX.Compose.Color.R.get -> byte +AndroidX.Compose.Color.WithAlpha(byte alpha) -> AndroidX.Compose.Color +AndroidX.Compose.Color.WithOpacity(float opacity) -> AndroidX.Compose.Color +AndroidX.Compose.Column +AndroidX.Compose.Column.Column() -> void +AndroidX.Compose.Column.Column(AndroidX.Compose.Arrangement? verticalArrangement) -> void +AndroidX.Compose.Column.Column(AndroidX.Compose.Arrangement? verticalArrangement, AndroidX.Compose.Alignment.Horizontal? horizontalAlignment) -> void +AndroidX.Compose.ComposableContainer +AndroidX.Compose.ComposableContainer.Add(AndroidX.Compose.ComposableNode? child) -> void +AndroidX.Compose.ComposableContainer.Add(AndroidX.Compose.Modifier! modifier) -> void +AndroidX.Compose.ComposableContainer.Add(System.Func! builder) -> void +AndroidX.Compose.ComposableContainer.Children.get -> System.Collections.Generic.IReadOnlyList! +AndroidX.Compose.ComposableContainer.ComposableContainer() -> void +AndroidX.Compose.ComposableContainer.RenderChildren(AndroidX.Compose.Runtime.IComposer! composer) -> void +AndroidX.Compose.ComposableNode +AndroidX.Compose.ComposableNode.AppendModifier(AndroidX.Compose.Modifier! modifier) -> void +AndroidX.Compose.ComposableNode.ComposableNode() -> void +AndroidX.Compose.ComposableNode.Modifier.get -> AndroidX.Compose.Modifier? +AndroidX.Compose.ComposableNode.Modifier.set -> void +AndroidX.Compose.ComposableNode.PrependModifier(AndroidX.Compose.Modifier! modifier) -> void +AndroidX.Compose.Composed +AndroidX.Compose.Composed.Composed(System.Func! builder) -> void +AndroidX.Compose.ComposeExtensions +AndroidX.Compose.CompositionLocal +AndroidX.Compose.CompositionLocal +AndroidX.Compose.CompositionLocal.Current(AndroidX.Compose.Runtime.IComposer! composer) -> T +AndroidX.Compose.CompositionLocal.Provides(T value) -> AndroidX.Compose.ProvidedValue! +AndroidX.Compose.CompositionLocalProvider +AndroidX.Compose.CompositionLocalProvider.Add(AndroidX.Compose.ProvidedValue! value) -> void +AndroidX.Compose.CompositionLocalProvider.CompositionLocalProvider() -> void +AndroidX.Compose.ContentScale +AndroidX.Compose.Crossfade +AndroidX.Compose.Crossfade.Crossfade(T targetState, System.Func! content) -> void +AndroidX.Compose.Crossfade.TargetState.get -> T +AndroidX.Compose.CustomTab +AndroidX.Compose.CustomTab.CustomTab(bool selected, System.Action! onClick) -> void +AndroidX.Compose.DatePicker +AndroidX.Compose.DatePicker.DatePicker(AndroidX.Compose.DatePickerState? state = null) -> void +AndroidX.Compose.DatePickerDialog +AndroidX.Compose.DatePickerDialog.Body.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DatePickerDialog.Body.set -> void +AndroidX.Compose.DatePickerDialog.ConfirmButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DatePickerDialog.ConfirmButton.set -> void +AndroidX.Compose.DatePickerDialog.DatePickerDialog(System.Action! onDismissRequest) -> void +AndroidX.Compose.DatePickerDialog.DismissButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DatePickerDialog.DismissButton.set -> void +AndroidX.Compose.DatePickerState +AndroidX.Compose.DatePickerState.DatePickerState() -> void +AndroidX.Compose.DatePickerState.DatePickerState(long? initialSelectedDateMillis = null, Kotlin.Ranges.IntRange? initialYearRange = null, AndroidX.Compose.Material3.ISelectableDates? initialSelectableDates = null) -> void +AndroidX.Compose.DatePickerState.DisplayedMonthMillis.get -> long +AndroidX.Compose.DatePickerState.DisplayedMonthMillis.set -> void +AndroidX.Compose.DatePickerState.InitialDisplayMode.get -> int? +AndroidX.Compose.DatePickerState.InitialDisplayMode.set -> void +AndroidX.Compose.DatePickerState.InitialDisplayedMonthMillis.get -> Java.Lang.Long? +AndroidX.Compose.DatePickerState.InitialDisplayedMonthMillis.set -> void +AndroidX.Compose.DatePickerState.InitialSelectableDates.get -> AndroidX.Compose.Material3.ISelectableDates? +AndroidX.Compose.DatePickerState.InitialSelectableDates.set -> void +AndroidX.Compose.DatePickerState.InitialSelectedDateMillis.get -> Java.Lang.Long? +AndroidX.Compose.DatePickerState.InitialSelectedDateMillis.set -> void +AndroidX.Compose.DatePickerState.InitialYearRange.get -> Kotlin.Ranges.IntRange? +AndroidX.Compose.DatePickerState.InitialYearRange.set -> void +AndroidX.Compose.DatePickerState.SelectedDateMillis.get -> long? +AndroidX.Compose.DatePickerState.SelectedDateMillis.set -> void +AndroidX.Compose.DateRangeSelectableDates +AndroidX.Compose.DateRangeSelectableDates.DateRangeSelectableDates() -> void +AndroidX.Compose.DateRangeSelectableDates.IsSelectableDate(long utcTimeMillis) -> bool +AndroidX.Compose.DateRangeSelectableDates.IsSelectableYear(int year) -> bool +AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.get -> long? +AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.set -> void +AndroidX.Compose.DateRangeSelectableDates.MaxYear.get -> int? +AndroidX.Compose.DateRangeSelectableDates.MaxYear.set -> void +AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.get -> long? +AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.set -> void +AndroidX.Compose.DateRangeSelectableDates.MinYear.get -> int? +AndroidX.Compose.DateRangeSelectableDates.MinYear.set -> void +AndroidX.Compose.DateRangePicker +AndroidX.Compose.DateRangePicker.DateRangePicker(AndroidX.Compose.DateRangePickerState? state = null) -> void +AndroidX.Compose.DateRangePickerDialog +AndroidX.Compose.DateRangePickerDialog.Body.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DateRangePickerDialog.Body.set -> void +AndroidX.Compose.DateRangePickerDialog.ConfirmButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DateRangePickerDialog.ConfirmButton.set -> void +AndroidX.Compose.DateRangePickerDialog.DateRangePickerDialog(System.Action! onDismissRequest) -> void +AndroidX.Compose.DateRangePickerDialog.DismissButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DateRangePickerDialog.DismissButton.set -> void +AndroidX.Compose.DateRangePickerState +AndroidX.Compose.DateRangePickerState.DateRangePickerState() -> void +AndroidX.Compose.DateRangePickerState.DisplayedMonthMillis.get -> long +AndroidX.Compose.DateRangePickerState.DisplayedMonthMillis.set -> void +AndroidX.Compose.DateRangePickerState.SelectedEndDateMillis.get -> long? +AndroidX.Compose.DateRangePickerState.SelectedEndDateMillis.set -> void +AndroidX.Compose.DateRangePickerState.SelectedStartDateMillis.get -> long? +AndroidX.Compose.DateRangePickerState.SelectedStartDateMillis.set -> void +AndroidX.Compose.DateRangePickerState.SetSelection(long? startDateMillis, long? endDateMillis) -> void +AndroidX.Compose.DerivedState +AndroidX.Compose.DerivedState.Value.get -> T +AndroidX.Compose.DisableSelection +AndroidX.Compose.DisableSelection.DisableSelection() -> void +AndroidX.Compose.DismissibleDrawerSheet +AndroidX.Compose.DismissibleDrawerSheet.ContainerColor.get -> AndroidX.Compose.Color +AndroidX.Compose.DismissibleDrawerSheet.ContainerColor.set -> void +AndroidX.Compose.DismissibleDrawerSheet.DismissibleDrawerSheet() -> void +AndroidX.Compose.DismissibleDrawerSheet.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.DismissibleDrawerSheet.Shape.set -> void +AndroidX.Compose.DismissibleNavigationDrawer +AndroidX.Compose.DismissibleNavigationDrawer.ConfirmStateChange.get -> System.Func? +AndroidX.Compose.DismissibleNavigationDrawer.ConfirmStateChange.set -> void +AndroidX.Compose.DismissibleNavigationDrawer.Content.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DismissibleNavigationDrawer.Content.set -> void +AndroidX.Compose.DismissibleNavigationDrawer.DismissibleNavigationDrawer(AndroidX.Compose.DrawerStateHolder? drawerState = null, bool gesturesEnabled = true) -> void +AndroidX.Compose.DismissibleNavigationDrawer.Drawer.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DismissibleNavigationDrawer.Drawer.set -> void +AndroidX.Compose.DismissibleNavigationDrawer.InitialValue.get -> AndroidX.Compose.Material3.DrawerValue? +AndroidX.Compose.DismissibleNavigationDrawer.InitialValue.init -> void +AndroidX.Compose.DisposableEffect +AndroidX.Compose.DisposableEffect.DisposableEffect(object? key1, object? key2, object? key3, System.Func! effect) -> void +AndroidX.Compose.DisposableEffect.DisposableEffect(object? key1, object? key2, System.Func! effect) -> void +AndroidX.Compose.DisposableEffect.DisposableEffect(object? key1, System.Func! effect) -> void +AndroidX.Compose.DockedSearchBar +AndroidX.Compose.DockedSearchBar.DockedSearchBar(bool expanded, System.Action! onExpandedChange) -> void +AndroidX.Compose.DockedSearchBar.DockedSearchBar(string! query, System.Action! onQueryChange, System.Action! onSearch, bool active, System.Action! onActiveChange) -> void +AndroidX.Compose.DockedSearchBar.InputField.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DockedSearchBar.InputField.set -> void +AndroidX.Compose.DockedSearchBar.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DockedSearchBar.LeadingIcon.set -> void +AndroidX.Compose.DockedSearchBar.Placeholder.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DockedSearchBar.Placeholder.set -> void +AndroidX.Compose.DockedSearchBar.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DockedSearchBar.TrailingIcon.set -> void +AndroidX.Compose.Dp +AndroidX.Compose.Dp.CompareTo(AndroidX.Compose.Dp other) -> int +AndroidX.Compose.Dp.Dp() -> void +AndroidX.Compose.Dp.Dp(float value) -> void +AndroidX.Compose.Dp.Equals(AndroidX.Compose.Dp other) -> bool +AndroidX.Compose.Dp.Value.get -> float +AndroidX.Compose.DpExtensions +AndroidX.Compose.DragAndDropEvent +AndroidX.Compose.DragAndDropEvent.AndroidDragEvent.get -> Android.Views.DragEvent! +AndroidX.Compose.DragAndDropEvent.MimeTypes.get -> System.Collections.Generic.IReadOnlyList! +AndroidX.Compose.DragAndDropTarget +AndroidX.Compose.DragAndDropTarget.DragAndDropTarget() -> void +AndroidX.Compose.DragAndDropTarget.DragAndDropTarget(System.Func! onDrop) -> void +AndroidX.Compose.DragAndDropTarget.OnChanged.get -> System.Action? +AndroidX.Compose.DragAndDropTarget.OnChanged.set -> void +AndroidX.Compose.DragAndDropTarget.OnDrop.get -> System.Func? +AndroidX.Compose.DragAndDropTarget.OnDrop.set -> void +AndroidX.Compose.DragAndDropTarget.OnEnded.get -> System.Action? +AndroidX.Compose.DragAndDropTarget.OnEnded.set -> void +AndroidX.Compose.DragAndDropTarget.OnEntered.get -> System.Action? +AndroidX.Compose.DragAndDropTarget.OnEntered.set -> void +AndroidX.Compose.DragAndDropTarget.OnExited.get -> System.Action? +AndroidX.Compose.DragAndDropTarget.OnExited.set -> void +AndroidX.Compose.DragAndDropTarget.OnMoved.get -> System.Action? +AndroidX.Compose.DragAndDropTarget.OnMoved.set -> void +AndroidX.Compose.DragAndDropTarget.OnStarted.get -> System.Action? +AndroidX.Compose.DragAndDropTarget.OnStarted.set -> void +AndroidX.Compose.DraggableState +AndroidX.Compose.DraggableState.DispatchRawDelta(float delta) -> void +AndroidX.Compose.DraggableState.DraggableState(System.Action! onDelta) -> void +AndroidX.Compose.DrawerStateHolder +AndroidX.Compose.DrawerStateHolder.CloseAsync() -> System.Threading.Tasks.Task! +AndroidX.Compose.DrawerStateHolder.CurrentValue.get -> AndroidX.Compose.Material3.DrawerValue! +AndroidX.Compose.DrawerStateHolder.DrawerStateHolder(AndroidX.Compose.Material3.DrawerValue? initialValue = null) -> void +AndroidX.Compose.DrawerStateHolder.InitialValue.get -> AndroidX.Compose.Material3.DrawerValue! +AndroidX.Compose.DrawerStateHolder.IsClosed.get -> bool +AndroidX.Compose.DrawerStateHolder.IsOpen.get -> bool +AndroidX.Compose.DrawerStateHolder.OpenAsync() -> System.Threading.Tasks.Task! +AndroidX.Compose.DrawerStateHolder.TargetValue.get -> AndroidX.Compose.Material3.DrawerValue! +AndroidX.Compose.DrawerValueConfirmStateChange +AndroidX.Compose.DrawerValueConfirmStateChange.Callback.get -> System.Func? +AndroidX.Compose.DrawerValueConfirmStateChange.Callback.set -> void +AndroidX.Compose.DrawerValueConfirmStateChange.DrawerValueConfirmStateChange() -> void +AndroidX.Compose.DrawerValueConfirmStateChange.Invoke(Java.Lang.Object? p0) -> Java.Lang.Object? +AndroidX.Compose.DropdownMenu +AndroidX.Compose.DropdownMenu.DropdownMenu(bool expanded, System.Action! onDismissRequest) -> void +AndroidX.Compose.DropdownMenuItem +AndroidX.Compose.DropdownMenuItem.DropdownMenuItem(AndroidX.Compose.ComposableNode! text, System.Action! onClick) -> void +AndroidX.Compose.DropdownMenuItem.Enabled.get -> bool +AndroidX.Compose.DropdownMenuItem.Enabled.set -> void +AndroidX.Compose.DropdownMenuItem.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DropdownMenuItem.LeadingIcon.set -> void +AndroidX.Compose.DropdownMenuItem.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.DropdownMenuItem.TrailingIcon.set -> void +AndroidX.Compose.ElevatedAssistChip +AndroidX.Compose.ElevatedAssistChip.ElevatedAssistChip(System.Action! onClick) -> void +AndroidX.Compose.ElevatedAssistChip.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ElevatedAssistChip.Label.set -> void +AndroidX.Compose.ElevatedAssistChip.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ElevatedAssistChip.LeadingIcon.set -> void +AndroidX.Compose.ElevatedAssistChip.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.ElevatedAssistChip.Shape.set -> void +AndroidX.Compose.ElevatedAssistChip.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ElevatedAssistChip.TrailingIcon.set -> void +AndroidX.Compose.ElevatedButton +AndroidX.Compose.ElevatedButton.Colors.get -> AndroidX.Compose.Material3.ButtonColors? +AndroidX.Compose.ElevatedButton.Colors.set -> void +AndroidX.Compose.ElevatedButton.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.ElevatedButton.ContentPadding.set -> void +AndroidX.Compose.ElevatedButton.ElevatedButton(System.Action! onClick) -> void +AndroidX.Compose.ElevatedButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.ElevatedButton.Shape.set -> void +AndroidX.Compose.ElevatedCard +AndroidX.Compose.ElevatedCard.ElevatedCard() -> void +AndroidX.Compose.ElevatedCard.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.ElevatedCard.Shape.set -> void +AndroidX.Compose.ElevatedFilterChip +AndroidX.Compose.ElevatedFilterChip.ElevatedFilterChip(bool selected, System.Action! onClick) -> void +AndroidX.Compose.ElevatedFilterChip.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ElevatedFilterChip.Label.set -> void +AndroidX.Compose.ElevatedFilterChip.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ElevatedFilterChip.LeadingIcon.set -> void +AndroidX.Compose.ElevatedFilterChip.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.ElevatedFilterChip.Shape.set -> void +AndroidX.Compose.ElevatedFilterChip.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ElevatedFilterChip.TrailingIcon.set -> void +AndroidX.Compose.ElevatedSuggestionChip +AndroidX.Compose.ElevatedSuggestionChip.ElevatedSuggestionChip(System.Action! onClick) -> void +AndroidX.Compose.ElevatedSuggestionChip.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ElevatedSuggestionChip.Icon.set -> void +AndroidX.Compose.ElevatedSuggestionChip.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ElevatedSuggestionChip.Label.set -> void +AndroidX.Compose.ElevatedSuggestionChip.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.ElevatedSuggestionChip.Shape.set -> void +AndroidX.Compose.ExpandedDockedSearchBar +AndroidX.Compose.ExpandedDockedSearchBar.ExpandedDockedSearchBar(AndroidX.Compose.SearchBarState! state) -> void +AndroidX.Compose.ExpandedDockedSearchBar.InputField.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ExpandedDockedSearchBar.InputField.set -> void +AndroidX.Compose.ExpandedFullScreenSearchBar +AndroidX.Compose.ExpandedFullScreenSearchBar.ExpandedFullScreenSearchBar(AndroidX.Compose.SearchBarState! state) -> void +AndroidX.Compose.ExpandedFullScreenSearchBar.InputField.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ExpandedFullScreenSearchBar.InputField.set -> void +AndroidX.Compose.ExposedDropdownMenu +AndroidX.Compose.ExposedDropdownMenu.ExposedDropdownMenu(bool expanded, System.Action! onDismissRequest) -> void +AndroidX.Compose.ExposedDropdownMenuBox +AndroidX.Compose.ExposedDropdownMenuBox.ExposedDropdownMenuBox(bool expanded, System.Action! onExpandedChange) -> void +AndroidX.Compose.ExtendedFloatingActionButton +AndroidX.Compose.ExtendedFloatingActionButton.ExtendedFloatingActionButton(System.Action! onClick, bool expanded) -> void +AndroidX.Compose.ExtendedFloatingActionButton.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ExtendedFloatingActionButton.Icon.set -> void +AndroidX.Compose.ExtendedFloatingActionButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.ExtendedFloatingActionButton.Shape.set -> void +AndroidX.Compose.ExtendedFloatingActionButton.Text.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ExtendedFloatingActionButton.Text.set -> void +AndroidX.Compose.FilledIconButton +AndroidX.Compose.FilledIconButton.FilledIconButton(System.Action! onClick) -> void +AndroidX.Compose.FilledIconButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.FilledIconButton.Shape.set -> void +AndroidX.Compose.FilledIconToggleButton +AndroidX.Compose.FilledIconToggleButton.FilledIconToggleButton(bool checked, System.Action! onCheckedChange) -> void +AndroidX.Compose.FilledIconToggleButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.FilledIconToggleButton.Shape.set -> void +AndroidX.Compose.FilledTonalButton +AndroidX.Compose.FilledTonalButton.Colors.get -> AndroidX.Compose.Material3.ButtonColors? +AndroidX.Compose.FilledTonalButton.Colors.set -> void +AndroidX.Compose.FilledTonalButton.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.FilledTonalButton.ContentPadding.set -> void +AndroidX.Compose.FilledTonalButton.FilledTonalButton(System.Action! onClick) -> void +AndroidX.Compose.FilledTonalButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.FilledTonalButton.Shape.set -> void +AndroidX.Compose.FilledTonalIconButton +AndroidX.Compose.FilledTonalIconButton.FilledTonalIconButton(System.Action! onClick) -> void +AndroidX.Compose.FilledTonalIconButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.FilledTonalIconButton.Shape.set -> void +AndroidX.Compose.FilledTonalIconToggleButton +AndroidX.Compose.FilledTonalIconToggleButton.FilledTonalIconToggleButton(bool checked, System.Action! onCheckedChange) -> void +AndroidX.Compose.FilledTonalIconToggleButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.FilledTonalIconToggleButton.Shape.set -> void +AndroidX.Compose.FilterChip +AndroidX.Compose.FilterChip.FilterChip(bool selected, System.Action! onClick) -> void +AndroidX.Compose.FilterChip.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.FilterChip.Label.set -> void +AndroidX.Compose.FilterChip.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.FilterChip.LeadingIcon.set -> void +AndroidX.Compose.FilterChip.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.FilterChip.Shape.set -> void +AndroidX.Compose.FilterChip.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.FilterChip.TrailingIcon.set -> void +AndroidX.Compose.FlexibleBottomAppBar +AndroidX.Compose.FlexibleBottomAppBar.FlexibleBottomAppBar() -> void +AndroidX.Compose.FloatingActionButton +AndroidX.Compose.FloatingActionButton.FloatingActionButton(System.Action! onClick) -> void +AndroidX.Compose.FloatingActionButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.FloatingActionButton.Shape.set -> void +AndroidX.Compose.FlowColumn +AndroidX.Compose.FlowColumn.FlowColumn() -> void +AndroidX.Compose.FlowRow +AndroidX.Compose.FlowRow.FlowRow() -> void +AndroidX.Compose.FocusRequester +AndroidX.Compose.FocusRequester.CaptureFocus() -> bool +AndroidX.Compose.FocusRequester.FocusRequester() -> void +AndroidX.Compose.FocusRequester.FreeFocus() -> bool +AndroidX.Compose.FocusRequester.RequestFocus() -> void +AndroidX.Compose.FocusState +AndroidX.Compose.FocusState.FocusState() -> void +AndroidX.Compose.FocusState.FocusState(bool isFocused, bool hasFocus, bool isCaptured) -> void +AndroidX.Compose.FocusState.HasFocus.get -> bool +AndroidX.Compose.FocusState.IsCaptured.get -> bool +AndroidX.Compose.FocusState.IsFocused.get -> bool +AndroidX.Compose.FontFamily +AndroidX.Compose.FontStyle +AndroidX.Compose.FontWeight +AndroidX.Compose.GridCells +AndroidX.Compose.HorizontalCenteredHeroCarousel +AndroidX.Compose.HorizontalCenteredHeroCarousel.HorizontalCenteredHeroCarousel(System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.HorizontalCenteredHeroCarousel.ItemSpacing.get -> float +AndroidX.Compose.HorizontalCenteredHeroCarousel.ItemSpacing.set -> void +AndroidX.Compose.HorizontalCenteredHeroCarousel.MaxItemWidth.get -> float +AndroidX.Compose.HorizontalCenteredHeroCarousel.MaxItemWidth.set -> void +AndroidX.Compose.HorizontalCenteredHeroCarousel.State.get -> AndroidX.Compose.Material3.Carousel.CarouselState? +AndroidX.Compose.HorizontalCenteredHeroCarousel.State.set -> void +AndroidX.Compose.HorizontalCenteredHeroCarousel.UserScrollEnabled.get -> bool? +AndroidX.Compose.HorizontalCenteredHeroCarousel.UserScrollEnabled.set -> void +AndroidX.Compose.HorizontalDivider +AndroidX.Compose.HorizontalDivider.Color.get -> AndroidX.Compose.Color? +AndroidX.Compose.HorizontalDivider.Color.set -> void +AndroidX.Compose.HorizontalDivider.HorizontalDivider() -> void +AndroidX.Compose.HorizontalDivider.ThicknessDp.get -> float? +AndroidX.Compose.HorizontalDivider.ThicknessDp.set -> void +AndroidX.Compose.HorizontalMultiBrowseCarousel +AndroidX.Compose.HorizontalMultiBrowseCarousel.HorizontalMultiBrowseCarousel(System.Collections.Generic.IReadOnlyList! items, float preferredItemWidth, System.Func! itemContent) -> void +AndroidX.Compose.HorizontalMultiBrowseCarousel.ItemSpacing.get -> float +AndroidX.Compose.HorizontalMultiBrowseCarousel.ItemSpacing.set -> void +AndroidX.Compose.HorizontalMultiBrowseCarousel.State.get -> AndroidX.Compose.Material3.Carousel.CarouselState? +AndroidX.Compose.HorizontalMultiBrowseCarousel.State.set -> void +AndroidX.Compose.HorizontalMultiBrowseCarousel.UserScrollEnabled.get -> bool? +AndroidX.Compose.HorizontalMultiBrowseCarousel.UserScrollEnabled.set -> void +AndroidX.Compose.HorizontalPager +AndroidX.Compose.HorizontalPager.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.HorizontalPager.ContentPadding.set -> void +AndroidX.Compose.HorizontalPager.HorizontalPager(System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.HorizontalPager.State.get -> AndroidX.Compose.PagerState? +AndroidX.Compose.HorizontalPager.State.set -> void +AndroidX.Compose.HorizontalUncontainedCarousel +AndroidX.Compose.HorizontalUncontainedCarousel.HorizontalUncontainedCarousel(System.Collections.Generic.IReadOnlyList! items, float itemWidth, System.Func! itemContent) -> void +AndroidX.Compose.HorizontalUncontainedCarousel.ItemSpacing.get -> float +AndroidX.Compose.HorizontalUncontainedCarousel.ItemSpacing.set -> void +AndroidX.Compose.HorizontalUncontainedCarousel.State.get -> AndroidX.Compose.Material3.Carousel.CarouselState? +AndroidX.Compose.HorizontalUncontainedCarousel.State.set -> void +AndroidX.Compose.HorizontalUncontainedCarousel.UserScrollEnabled.get -> bool? +AndroidX.Compose.HorizontalUncontainedCarousel.UserScrollEnabled.set -> void +AndroidX.Compose.Icon +AndroidX.Compose.Icon.Icon(AndroidX.Compose.UI.Graphics.Painter.Painter! painter, string? contentDescription) -> void +AndroidX.Compose.Icon.Icon(AndroidX.Compose.UI.Graphics.Vector.ImageVector! imageVector, string? contentDescription) -> void +AndroidX.Compose.Icon.Icon(int drawableResourceId, string? contentDescription) -> void +AndroidX.Compose.Icon.Tint.get -> AndroidX.Compose.Color? +AndroidX.Compose.Icon.Tint.set -> void +AndroidX.Compose.IconButton +AndroidX.Compose.IconButton.IconButton(System.Action! onClick) -> void +AndroidX.Compose.Icons +AndroidX.Compose.Icons.AutoMirrored +AndroidX.Compose.Icons.AutoMirrored.Default +AndroidX.Compose.Icons.AutoMirrored.Filled +AndroidX.Compose.Icons.AutoMirrored.Outlined +AndroidX.Compose.Icons.AutoMirrored.Rounded +AndroidX.Compose.Icons.AutoMirrored.Sharp +AndroidX.Compose.Icons.AutoMirrored.TwoTone +AndroidX.Compose.Icons.Default +AndroidX.Compose.Icons.Filled +AndroidX.Compose.Icons.Outlined +AndroidX.Compose.Icons.Rounded +AndroidX.Compose.Icons.Sharp +AndroidX.Compose.Icons.TwoTone +AndroidX.Compose.IconToggleButton +AndroidX.Compose.IconToggleButton.IconToggleButton(bool checked, System.Action! onCheckedChange) -> void +AndroidX.Compose.Image +AndroidX.Compose.Image.Alignment.get -> AndroidX.Compose.Alignment? +AndroidX.Compose.Image.Alignment.set -> void +AndroidX.Compose.Image.Alpha.get -> float? +AndroidX.Compose.Image.Alpha.set -> void +AndroidX.Compose.Image.ContentScale.get -> AndroidX.Compose.ContentScale? +AndroidX.Compose.Image.ContentScale.set -> void +AndroidX.Compose.Image.Image(AndroidX.Compose.UI.Graphics.Painter.Painter! painter, string? contentDescription) -> void +AndroidX.Compose.Image.Image(AndroidX.Compose.UI.Graphics.Painter.Painter! painter) -> void +AndroidX.Compose.Image.Image(int drawableResourceId, string? contentDescription) -> void +AndroidX.Compose.Image.Image(int drawableResourceId) -> void +AndroidX.Compose.InputChip +AndroidX.Compose.InputChip.Avatar.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.InputChip.Avatar.set -> void +AndroidX.Compose.InputChip.InputChip(bool selected, System.Action! onClick) -> void +AndroidX.Compose.InputChip.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.InputChip.Label.set -> void +AndroidX.Compose.InputChip.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.InputChip.LeadingIcon.set -> void +AndroidX.Compose.InputChip.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.InputChip.Shape.set -> void +AndroidX.Compose.InputChip.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.InputChip.TrailingIcon.set -> void +AndroidX.Compose.IState +AndroidX.Compose.IState.Value.get -> T +AndroidX.Compose.IStateFlow +AndroidX.Compose.ImeAction +AndroidX.Compose.KeyboardOptionsCompanion +AndroidX.Compose.KeyboardActionsHelper +AndroidX.Compose.KeyboardType +AndroidX.Compose.LargeFlexibleTopAppBar +AndroidX.Compose.LargeFlexibleTopAppBar.Actions.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LargeFlexibleTopAppBar.Actions.set -> void +AndroidX.Compose.LargeFlexibleTopAppBar.LargeFlexibleTopAppBar() -> void +AndroidX.Compose.LargeFlexibleTopAppBar.NavigationIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LargeFlexibleTopAppBar.NavigationIcon.set -> void +AndroidX.Compose.LargeFlexibleTopAppBar.ScrollBehavior.get -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior? +AndroidX.Compose.LargeFlexibleTopAppBar.ScrollBehavior.set -> void +AndroidX.Compose.LargeFlexibleTopAppBar.Subtitle.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LargeFlexibleTopAppBar.Subtitle.set -> void +AndroidX.Compose.LargeFlexibleTopAppBar.Title.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LargeFlexibleTopAppBar.Title.set -> void +AndroidX.Compose.LargeFloatingActionButton +AndroidX.Compose.LargeFloatingActionButton.LargeFloatingActionButton(System.Action! onClick) -> void +AndroidX.Compose.LargeFloatingActionButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.LargeFloatingActionButton.Shape.set -> void +AndroidX.Compose.LargeTopAppBar +AndroidX.Compose.LargeTopAppBar.Actions.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LargeTopAppBar.Actions.set -> void +AndroidX.Compose.LargeTopAppBar.LargeTopAppBar() -> void +AndroidX.Compose.LargeTopAppBar.NavigationIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LargeTopAppBar.NavigationIcon.set -> void +AndroidX.Compose.LargeTopAppBar.ScrollBehavior.get -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior? +AndroidX.Compose.LargeTopAppBar.ScrollBehavior.set -> void +AndroidX.Compose.LargeTopAppBar.Subtitle.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LargeTopAppBar.Subtitle.set -> void +AndroidX.Compose.LargeTopAppBar.Title.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LargeTopAppBar.Title.set -> void +AndroidX.Compose.LaunchedEffect +AndroidX.Compose.LaunchedEffect.LaunchedEffect(object? key1, object? key2, object? key3, System.Func! body) -> void +AndroidX.Compose.LaunchedEffect.LaunchedEffect(object? key1, object? key2, System.Func! body) -> void +AndroidX.Compose.LaunchedEffect.LaunchedEffect(object? key1, System.Func! body) -> void +AndroidX.Compose.LazyColumn +AndroidX.Compose.LazyColumn.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.LazyColumn.ContentPadding.set -> void +AndroidX.Compose.LazyColumn.LazyColumn(System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.LazyColumn.ReverseLayout.get -> bool +AndroidX.Compose.LazyColumn.ReverseLayout.set -> void +AndroidX.Compose.LazyColumn.State.get -> AndroidX.Compose.LazyListState? +AndroidX.Compose.LazyColumn.State.set -> void +AndroidX.Compose.LazyHorizontalGrid +AndroidX.Compose.LazyHorizontalGrid.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.LazyHorizontalGrid.ContentPadding.set -> void +AndroidX.Compose.LazyHorizontalGrid.LazyHorizontalGrid(AndroidX.Compose.Foundation.Lazy.Grid.IGridCells! rows, System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.LazyHorizontalGrid.State.get -> AndroidX.Compose.Foundation.Lazy.Grid.LazyGridState? +AndroidX.Compose.LazyHorizontalGrid.State.set -> void +AndroidX.Compose.LazyHorizontalStaggeredGrid +AndroidX.Compose.LazyHorizontalStaggeredGrid.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.LazyHorizontalStaggeredGrid.ContentPadding.set -> void +AndroidX.Compose.LazyHorizontalStaggeredGrid.LazyHorizontalStaggeredGrid(AndroidX.Compose.Foundation.Lazy.Staggeredgrid.IStaggeredGridCells! rows, System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.LazyHorizontalStaggeredGrid.State.get -> AndroidX.Compose.Foundation.Lazy.Staggeredgrid.LazyStaggeredGridState? +AndroidX.Compose.LazyHorizontalStaggeredGrid.State.set -> void +AndroidX.Compose.LazyListState +AndroidX.Compose.LazyListState.AnimateScrollToItemAsync(int index, int scrollOffset = 0, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AndroidX.Compose.LazyListState.CanScrollBackward.get -> bool +AndroidX.Compose.LazyListState.CanScrollForward.get -> bool +AndroidX.Compose.LazyListState.FirstVisibleItemIndex.get -> int +AndroidX.Compose.LazyListState.FirstVisibleItemScrollOffset.get -> int +AndroidX.Compose.LazyListState.IsScrollInProgress.get -> bool +AndroidX.Compose.LazyListState.LastScrolledBackward.get -> bool +AndroidX.Compose.LazyListState.LastScrolledForward.get -> bool +AndroidX.Compose.LazyListState.LazyListState(int initialFirstVisibleItemIndex = 0, int initialFirstVisibleItemScrollOffset = 0) -> void +AndroidX.Compose.LazyListState.ScrollToItemAsync(int index, int scrollOffset = 0, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AndroidX.Compose.LazyRow +AndroidX.Compose.LazyRow.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.LazyRow.ContentPadding.set -> void +AndroidX.Compose.LazyRow.HorizontalArrangement.get -> AndroidX.Compose.Arrangement? +AndroidX.Compose.LazyRow.HorizontalArrangement.set -> void +AndroidX.Compose.LazyRow.LazyRow(System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.LazyRow.State.get -> AndroidX.Compose.LazyListState? +AndroidX.Compose.LazyRow.State.set -> void +AndroidX.Compose.LazyVerticalGrid +AndroidX.Compose.LazyVerticalGrid.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.LazyVerticalGrid.ContentPadding.set -> void +AndroidX.Compose.LazyVerticalGrid.LazyVerticalGrid(AndroidX.Compose.Foundation.Lazy.Grid.IGridCells! columns, System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.LazyVerticalGrid.State.get -> AndroidX.Compose.Foundation.Lazy.Grid.LazyGridState? +AndroidX.Compose.LazyVerticalGrid.State.set -> void +AndroidX.Compose.LazyVerticalStaggeredGrid +AndroidX.Compose.LazyVerticalStaggeredGrid.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.LazyVerticalStaggeredGrid.ContentPadding.set -> void +AndroidX.Compose.LazyVerticalStaggeredGrid.LazyVerticalStaggeredGrid(AndroidX.Compose.Foundation.Lazy.Staggeredgrid.IStaggeredGridCells! columns, System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.LazyVerticalStaggeredGrid.State.get -> AndroidX.Compose.Foundation.Lazy.Staggeredgrid.LazyStaggeredGridState? +AndroidX.Compose.LazyVerticalStaggeredGrid.State.set -> void +AndroidX.Compose.LeadingIconTab +AndroidX.Compose.LeadingIconTab.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LeadingIconTab.Icon.set -> void +AndroidX.Compose.LeadingIconTab.LeadingIconTab(bool selected, System.Action! onClick) -> void +AndroidX.Compose.LeadingIconTab.Text.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.LeadingIconTab.Text.set -> void +AndroidX.Compose.LinearProgressIndicator +AndroidX.Compose.LinearProgressIndicator.Color.get -> AndroidX.Compose.Color? +AndroidX.Compose.LinearProgressIndicator.Color.set -> void +AndroidX.Compose.LinearProgressIndicator.LinearProgressIndicator() -> void +AndroidX.Compose.LinearProgressIndicator.Progress.get -> float? +AndroidX.Compose.LinearProgressIndicator.Progress.set -> void +AndroidX.Compose.LinearProgressIndicator.TrackColor.get -> AndroidX.Compose.Color? +AndroidX.Compose.LinearProgressIndicator.TrackColor.set -> void +AndroidX.Compose.LinkAnnotation +AndroidX.Compose.ListItem +AndroidX.Compose.ListItem.Headline.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ListItem.Headline.set -> void +AndroidX.Compose.ListItem.Leading.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ListItem.Leading.set -> void +AndroidX.Compose.ListItem.ListItem() -> void +AndroidX.Compose.ListItem.Overline.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ListItem.Overline.set -> void +AndroidX.Compose.ListItem.Supporting.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ListItem.Supporting.set -> void +AndroidX.Compose.ListItem.Trailing.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ListItem.Trailing.set -> void +AndroidX.Compose.LocalColorScheme +AndroidX.Compose.LocalConfiguration +AndroidX.Compose.LocalContext +AndroidX.Compose.LocalLifecycleOwner +AndroidX.Compose.LocalResources +AndroidX.Compose.LocalView +AndroidX.Compose.MaterialTheme +AndroidX.Compose.MaterialTheme.ColorScheme.get -> AndroidX.Compose.Material3.ColorScheme? +AndroidX.Compose.MaterialTheme.ColorScheme.set -> void +AndroidX.Compose.MaterialTheme.Dark.get -> bool? +AndroidX.Compose.MaterialTheme.Dark.set -> void +AndroidX.Compose.MaterialTheme.MaterialTheme() -> void +AndroidX.Compose.MaterialTheme.Shapes.get -> AndroidX.Compose.Material3.Shapes? +AndroidX.Compose.MaterialTheme.Shapes.set -> void +AndroidX.Compose.MaterialTheme.Typography.get -> AndroidX.Compose.Material3.Typography? +AndroidX.Compose.MaterialTheme.Typography.set -> void +AndroidX.Compose.MaterialTheme.UseDynamicColor.get -> bool +AndroidX.Compose.MaterialTheme.UseDynamicColor.set -> void +AndroidX.Compose.MediumFlexibleTopAppBar +AndroidX.Compose.MediumFlexibleTopAppBar.Actions.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.MediumFlexibleTopAppBar.Actions.set -> void +AndroidX.Compose.MediumFlexibleTopAppBar.MediumFlexibleTopAppBar() -> void +AndroidX.Compose.MediumFlexibleTopAppBar.NavigationIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.MediumFlexibleTopAppBar.NavigationIcon.set -> void +AndroidX.Compose.MediumFlexibleTopAppBar.ScrollBehavior.get -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior? +AndroidX.Compose.MediumFlexibleTopAppBar.ScrollBehavior.set -> void +AndroidX.Compose.MediumFlexibleTopAppBar.Subtitle.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.MediumFlexibleTopAppBar.Subtitle.set -> void +AndroidX.Compose.MediumFlexibleTopAppBar.Title.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.MediumFlexibleTopAppBar.Title.set -> void +AndroidX.Compose.MediumTopAppBar +AndroidX.Compose.MediumTopAppBar.Actions.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.MediumTopAppBar.Actions.set -> void +AndroidX.Compose.MediumTopAppBar.MediumTopAppBar() -> void +AndroidX.Compose.MediumTopAppBar.NavigationIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.MediumTopAppBar.NavigationIcon.set -> void +AndroidX.Compose.MediumTopAppBar.ScrollBehavior.get -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior? +AndroidX.Compose.MediumTopAppBar.ScrollBehavior.set -> void +AndroidX.Compose.MediumTopAppBar.Subtitle.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.MediumTopAppBar.Subtitle.set -> void +AndroidX.Compose.MediumTopAppBar.Title.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.MediumTopAppBar.Title.set -> void +AndroidX.Compose.ModalBottomSheet +AndroidX.Compose.ModalBottomSheet.ConfirmValueChange.get -> System.Func? +AndroidX.Compose.ModalBottomSheet.ConfirmValueChange.set -> void +AndroidX.Compose.ModalBottomSheet.DragHandle.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ModalBottomSheet.DragHandle.set -> void +AndroidX.Compose.ModalBottomSheet.ModalBottomSheet(System.Action! onDismissRequest, AndroidX.Compose.SheetStateHolder? sheetState = null) -> void +AndroidX.Compose.ModalBottomSheet.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.ModalBottomSheet.Shape.set -> void +AndroidX.Compose.ModalDrawerSheet +AndroidX.Compose.ModalDrawerSheet.ContainerColor.get -> AndroidX.Compose.Color +AndroidX.Compose.ModalDrawerSheet.ContainerColor.set -> void +AndroidX.Compose.ModalDrawerSheet.ModalDrawerSheet() -> void +AndroidX.Compose.ModalDrawerSheet.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.ModalDrawerSheet.Shape.set -> void +AndroidX.Compose.ModalNavigationDrawer +AndroidX.Compose.ModalNavigationDrawer.ConfirmStateChange.get -> System.Func? +AndroidX.Compose.ModalNavigationDrawer.ConfirmStateChange.set -> void +AndroidX.Compose.ModalNavigationDrawer.Content.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ModalNavigationDrawer.Content.set -> void +AndroidX.Compose.ModalNavigationDrawer.Drawer.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ModalNavigationDrawer.Drawer.set -> void +AndroidX.Compose.ModalNavigationDrawer.InitialValue.get -> AndroidX.Compose.Material3.DrawerValue? +AndroidX.Compose.ModalNavigationDrawer.InitialValue.init -> void +AndroidX.Compose.ModalNavigationDrawer.ModalNavigationDrawer(AndroidX.Compose.DrawerStateHolder? drawerState = null, bool gesturesEnabled = true) -> void +AndroidX.Compose.ModalWideNavigationRail +AndroidX.Compose.ModalWideNavigationRail.Header.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.ModalWideNavigationRail.Header.set -> void +AndroidX.Compose.ModalWideNavigationRail.ModalWideNavigationRail(AndroidX.Compose.WideNavigationRailState? state = null) -> void +AndroidX.Compose.Modifier +AndroidX.Compose.Modifier.Then(AndroidX.Compose.Modifier! other) -> AndroidX.Compose.Modifier! +AndroidX.Compose.ModifierExtensions +AndroidX.Compose.MultiChoiceSegmentedButtonRow +AndroidX.Compose.MultiChoiceSegmentedButtonRow.MultiChoiceSegmentedButtonRow() -> void +AndroidX.Compose.MutableNumberState +AndroidX.Compose.MutableNumberState.MutableNumberState(T initial) -> void +AndroidX.Compose.MutableState +AndroidX.Compose.MutableState.MutableState(T initial) -> void +AndroidX.Compose.MutableStateFlow +AndroidX.Compose.MutableStateFlow.MutableStateFlow(T initialValue) -> void +AndroidX.Compose.MutableStateFlow.TryEmit(T value) -> bool +AndroidX.Compose.MutableStateFlow.Update(System.Func! transform) -> T +AndroidX.Compose.MutableStateFlow.Value.get -> T +AndroidX.Compose.MutableStateFlow.Value.set -> void +AndroidX.Compose.MutableStateList +AndroidX.Compose.MutableStateList.Add(T item) -> void +AndroidX.Compose.MutableStateList.Clear() -> void +AndroidX.Compose.MutableStateList.Contains(T item) -> bool +AndroidX.Compose.MutableStateList.CopyTo(T[]! array, int arrayIndex) -> void +AndroidX.Compose.MutableStateList.Count.get -> int +AndroidX.Compose.MutableStateList.GetEnumerator() -> System.Collections.Generic.IEnumerator! +AndroidX.Compose.MutableStateList.IndexOf(T item) -> int +AndroidX.Compose.MutableStateList.Insert(int index, T item) -> void +AndroidX.Compose.MutableStateList.IsReadOnly.get -> bool +AndroidX.Compose.MutableStateList.MutableStateList() -> void +AndroidX.Compose.MutableStateList.MutableStateList(params T[]! elements) -> void +AndroidX.Compose.MutableStateList.MutableStateList(System.Collections.Generic.IEnumerable! initial) -> void +AndroidX.Compose.MutableStateList.Remove(T item) -> bool +AndroidX.Compose.MutableStateList.RemoveAt(int index) -> void +AndroidX.Compose.MutableStateList.this[int index].get -> T +AndroidX.Compose.MutableStateList.this[int index].set -> void +AndroidX.Compose.MutableStateMap +AndroidX.Compose.MutableStateMap.Add(System.Collections.Generic.KeyValuePair item) -> void +AndroidX.Compose.MutableStateMap.Add(TKey key, TValue value) -> void +AndroidX.Compose.MutableStateMap.Clear() -> void +AndroidX.Compose.MutableStateMap.Contains(System.Collections.Generic.KeyValuePair item) -> bool +AndroidX.Compose.MutableStateMap.ContainsKey(TKey key) -> bool +AndroidX.Compose.MutableStateMap.CopyTo(System.Collections.Generic.KeyValuePair[]! array, int arrayIndex) -> void +AndroidX.Compose.MutableStateMap.Count.get -> int +AndroidX.Compose.MutableStateMap.GetEnumerator() -> System.Collections.Generic.IEnumerator>! +AndroidX.Compose.MutableStateMap.IsReadOnly.get -> bool +AndroidX.Compose.MutableStateMap.Keys.get -> System.Collections.Generic.ICollection! +AndroidX.Compose.MutableStateMap.MutableStateMap() -> void +AndroidX.Compose.MutableStateMap.MutableStateMap(System.Collections.Generic.IEnumerable>! initial) -> void +AndroidX.Compose.MutableStateMap.Remove(System.Collections.Generic.KeyValuePair item) -> bool +AndroidX.Compose.MutableStateMap.Remove(TKey key) -> bool +AndroidX.Compose.MutableStateMap.this[TKey key].get -> TValue +AndroidX.Compose.MutableStateMap.this[TKey key].set -> void +AndroidX.Compose.MutableStateMap.TryGetValue(TKey key, out TValue value) -> bool +AndroidX.Compose.MutableStateMap.Values.get -> System.Collections.Generic.ICollection! +AndroidX.Compose.NavBackStackEntry +AndroidX.Compose.NavBackStackEntry.Arguments.get -> Android.OS.Bundle? +AndroidX.Compose.NavBackStackEntry.Route.get -> string? +AndroidX.Compose.NavController +AndroidX.Compose.NavController.CurrentBackStackEntry.get -> AndroidX.Compose.NavBackStackEntry? +AndroidX.Compose.NavController.NavController() -> void +AndroidX.Compose.NavController.Navigate(string! route, AndroidX.Compose.NavOptions! options) -> void +AndroidX.Compose.NavController.Navigate(string! route) -> void +AndroidX.Compose.NavController.NavigateUp() -> bool +AndroidX.Compose.NavController.PopBackStack() -> bool +AndroidX.Compose.NavController.PopBackStack(string! route, bool inclusive) -> bool +AndroidX.Compose.NavDestination +AndroidX.Compose.NavDestination.Add(AndroidX.Compose.ComposableNode? child) -> void +AndroidX.Compose.NavDestination.Add(AndroidX.Compose.Modifier! modifier) -> void +AndroidX.Compose.NavDestination.NavDestination(string! route, System.Func! content) -> void +AndroidX.Compose.NavDestination.NavDestination(string! route) -> void +AndroidX.Compose.NavDestination.Route.get -> string! +AndroidX.Compose.NavHost +AndroidX.Compose.NavHost.Add(AndroidX.Compose.Modifier! modifier) -> void +AndroidX.Compose.NavHost.Add(AndroidX.Compose.NavDestination? route) -> void +AndroidX.Compose.NavHost.NavController.get -> AndroidX.Compose.NavController! +AndroidX.Compose.NavHost.NavHost(string! startDestination, AndroidX.Compose.NavController? navController = null) -> void +AndroidX.Compose.NavHost.StartDestination.get -> string! +AndroidX.Compose.NavigationBar +AndroidX.Compose.NavigationBar.NavigationBar() -> void +AndroidX.Compose.NavigationBarItem +AndroidX.Compose.NavigationBarItem.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.NavigationBarItem.Icon.set -> void +AndroidX.Compose.NavigationBarItem.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.NavigationBarItem.Label.set -> void +AndroidX.Compose.NavigationBarItem.NavigationBarItem(bool selected, System.Action! onClick, bool enabled = true, bool alwaysShowLabel = true) -> void +AndroidX.Compose.NavigationDrawerItem +AndroidX.Compose.NavigationDrawerItem.Badge.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.NavigationDrawerItem.Badge.set -> void +AndroidX.Compose.NavigationDrawerItem.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.NavigationDrawerItem.Icon.set -> void +AndroidX.Compose.NavigationDrawerItem.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.NavigationDrawerItem.Label.set -> void +AndroidX.Compose.NavigationDrawerItem.NavigationDrawerItem(bool selected, System.Action! onClick) -> void +AndroidX.Compose.NavigationRail +AndroidX.Compose.NavigationRail.NavigationRail() -> void +AndroidX.Compose.NavigationRailItem +AndroidX.Compose.NavigationRailItem.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.NavigationRailItem.Icon.set -> void +AndroidX.Compose.NavigationRailItem.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.NavigationRailItem.Label.set -> void +AndroidX.Compose.NavigationRailItem.NavigationRailItem(bool selected, System.Action! onClick) -> void +AndroidX.Compose.NavOptions +AndroidX.Compose.NavOptions.LaunchSingleTop.get -> bool +AndroidX.Compose.NavOptions.LaunchSingleTop.set -> void +AndroidX.Compose.NavOptions.NavOptions() -> void +AndroidX.Compose.NavOptions.PopUpToInclusive.get -> bool +AndroidX.Compose.NavOptions.PopUpToInclusive.set -> void +AndroidX.Compose.NavOptions.PopUpToRoute.get -> string? +AndroidX.Compose.NavOptions.PopUpToRoute.set -> void +AndroidX.Compose.NavOptions.PopUpToSaveState.get -> bool +AndroidX.Compose.NavOptions.PopUpToSaveState.set -> void +AndroidX.Compose.NavOptions.RestoreState.get -> bool +AndroidX.Compose.NavOptions.RestoreState.set -> void +AndroidX.Compose.Offset +AndroidX.Compose.Offset.Equals(AndroidX.Compose.Offset other) -> bool +AndroidX.Compose.Offset.Offset() -> void +AndroidX.Compose.Offset.Offset(float x, float y) -> void +AndroidX.Compose.Offset.X.get -> float +AndroidX.Compose.Offset.Y.get -> float +AndroidX.Compose.Orientation +AndroidX.Compose.Orientation.Horizontal = 1 -> AndroidX.Compose.Orientation +AndroidX.Compose.Orientation.Vertical = 0 -> AndroidX.Compose.Orientation +AndroidX.Compose.OutlinedButton +AndroidX.Compose.OutlinedButton.Colors.get -> AndroidX.Compose.Material3.ButtonColors? +AndroidX.Compose.OutlinedButton.Colors.set -> void +AndroidX.Compose.OutlinedButton.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.OutlinedButton.ContentPadding.set -> void +AndroidX.Compose.OutlinedButton.OutlinedButton(System.Action! onClick) -> void +AndroidX.Compose.OutlinedButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.OutlinedButton.Shape.set -> void +AndroidX.Compose.OutlinedCard +AndroidX.Compose.OutlinedCard.OutlinedCard() -> void +AndroidX.Compose.OutlinedCard.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.OutlinedCard.Shape.set -> void +AndroidX.Compose.OutlinedIconButton +AndroidX.Compose.OutlinedIconButton.OutlinedIconButton(System.Action! onClick) -> void +AndroidX.Compose.OutlinedIconButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.OutlinedIconButton.Shape.set -> void +AndroidX.Compose.OutlinedIconToggleButton +AndroidX.Compose.OutlinedIconToggleButton.OutlinedIconToggleButton(bool checked, System.Action! onCheckedChange) -> void +AndroidX.Compose.OutlinedIconToggleButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.OutlinedIconToggleButton.Shape.set -> void +AndroidX.Compose.OutlinedSecureTextField +AndroidX.Compose.OutlinedSecureTextField.Enabled.get -> bool +AndroidX.Compose.OutlinedSecureTextField.Enabled.set -> void +AndroidX.Compose.OutlinedSecureTextField.IsError.get -> bool +AndroidX.Compose.OutlinedSecureTextField.IsError.set -> void +AndroidX.Compose.OutlinedSecureTextField.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedSecureTextField.Label.set -> void +AndroidX.Compose.OutlinedSecureTextField.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedSecureTextField.LeadingIcon.set -> void +AndroidX.Compose.OutlinedSecureTextField.OutlinedSecureTextField(AndroidX.Compose.SecureTextFieldState! state) -> void +AndroidX.Compose.OutlinedSecureTextField.Placeholder.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedSecureTextField.Placeholder.set -> void +AndroidX.Compose.OutlinedSecureTextField.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.OutlinedSecureTextField.Shape.set -> void +AndroidX.Compose.OutlinedSecureTextField.SupportingText.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedSecureTextField.SupportingText.set -> void +AndroidX.Compose.OutlinedSecureTextField.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedSecureTextField.TrailingIcon.set -> void +AndroidX.Compose.OutlinedTextField +AndroidX.Compose.OutlinedTextField.Enabled.get -> bool? +AndroidX.Compose.OutlinedTextField.Enabled.set -> void +AndroidX.Compose.OutlinedTextField.IsError.get -> bool? +AndroidX.Compose.OutlinedTextField.IsError.set -> void +AndroidX.Compose.OutlinedTextField.KeyboardActions.get -> AndroidX.Compose.Foundation.Text.KeyboardActions? +AndroidX.Compose.OutlinedTextField.KeyboardActions.set -> void +AndroidX.Compose.OutlinedTextField.KeyboardOptions.get -> AndroidX.Compose.Foundation.Text.KeyboardOptions? +AndroidX.Compose.OutlinedTextField.KeyboardOptions.set -> void +AndroidX.Compose.OutlinedTextField.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedTextField.Label.set -> void +AndroidX.Compose.OutlinedTextField.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedTextField.LeadingIcon.set -> void +AndroidX.Compose.OutlinedTextField.MaxLines.get -> int? +AndroidX.Compose.OutlinedTextField.MaxLines.set -> void +AndroidX.Compose.OutlinedTextField.MinLines.get -> int? +AndroidX.Compose.OutlinedTextField.MinLines.set -> void +AndroidX.Compose.OutlinedTextField.OutlinedTextField(AndroidX.Compose.MutableState! state) -> void +AndroidX.Compose.OutlinedTextField.OutlinedTextField(AndroidX.Compose.MutableState! state) -> void +AndroidX.Compose.OutlinedTextField.OutlinedTextField(string! value, System.Action! onValueChange) -> void +AndroidX.Compose.OutlinedTextField.Placeholder.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedTextField.Placeholder.set -> void +AndroidX.Compose.OutlinedTextField.Prefix.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedTextField.Prefix.set -> void +AndroidX.Compose.OutlinedTextField.ReadOnly.get -> bool? +AndroidX.Compose.OutlinedTextField.ReadOnly.set -> void +AndroidX.Compose.OutlinedTextField.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.OutlinedTextField.Shape.set -> void +AndroidX.Compose.OutlinedTextField.SingleLine.get -> bool? +AndroidX.Compose.OutlinedTextField.SingleLine.set -> void +AndroidX.Compose.OutlinedTextField.Suffix.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedTextField.Suffix.set -> void +AndroidX.Compose.OutlinedTextField.SupportingText.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedTextField.SupportingText.set -> void +AndroidX.Compose.OutlinedTextField.TextStyle.get -> AndroidX.Compose.TextStyle? +AndroidX.Compose.OutlinedTextField.TextStyle.set -> void +AndroidX.Compose.OutlinedTextField.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.OutlinedTextField.TrailingIcon.set -> void +AndroidX.Compose.OutlinedTextField.VisualTransformation.get -> AndroidX.Compose.UI.Text.Input.IVisualTransformation? +AndroidX.Compose.OutlinedTextField.VisualTransformation.set -> void +AndroidX.Compose.PaddingValues +AndroidX.Compose.PaddingValues.Bottom.get -> AndroidX.Compose.Dp +AndroidX.Compose.PaddingValues.PaddingValues(AndroidX.Compose.Dp all) -> void +AndroidX.Compose.PaddingValues.PaddingValues(AndroidX.Compose.Dp horizontal, AndroidX.Compose.Dp vertical) -> void +AndroidX.Compose.PaddingValues.PaddingValues(AndroidX.Compose.Dp start = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp top = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp end = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp bottom = default(AndroidX.Compose.Dp)) -> void +AndroidX.Compose.PaddingValues.Top.get -> AndroidX.Compose.Dp +AndroidX.Compose.PagerState +AndroidX.Compose.PagerState.CurrentPage.get -> int +AndroidX.Compose.PagerState.CurrentPageOffsetFraction.get -> float +AndroidX.Compose.PagerState.PageCount.get -> int +AndroidX.Compose.PagerState.PagerState(System.Func! pageCount, int initialPage = 0, float initialPageOffsetFraction = 0) -> void +AndroidX.Compose.PagerState.SettledPage.get -> int +AndroidX.Compose.PagerState.TargetPage.get -> int +AndroidX.Compose.PermanentDrawerSheet +AndroidX.Compose.PermanentDrawerSheet.ContainerColor.get -> AndroidX.Compose.Color +AndroidX.Compose.PermanentDrawerSheet.ContainerColor.set -> void +AndroidX.Compose.PermanentDrawerSheet.PermanentDrawerSheet() -> void +AndroidX.Compose.PermanentDrawerSheet.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.PermanentDrawerSheet.Shape.set -> void +AndroidX.Compose.PermanentNavigationDrawer +AndroidX.Compose.PermanentNavigationDrawer.Content.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.PermanentNavigationDrawer.Content.set -> void +AndroidX.Compose.PermanentNavigationDrawer.Drawer.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.PermanentNavigationDrawer.Drawer.set -> void +AndroidX.Compose.PermanentNavigationDrawer.PermanentNavigationDrawer() -> void +AndroidX.Compose.PrimaryScrollableTabRow +AndroidX.Compose.PrimaryScrollableTabRow.PrimaryScrollableTabRow(int selectedTabIndex) -> void +AndroidX.Compose.PrimaryTabRow +AndroidX.Compose.PrimaryTabRow.PrimaryTabRow(int selectedTabIndex) -> void +AndroidX.Compose.ProvidedValue +AndroidX.Compose.PullToRefreshBox +AndroidX.Compose.PullToRefreshBox.PullToRefreshBox(bool isRefreshing, System.Action! onRefresh, AndroidX.Compose.PullToRefreshState? state = null) -> void +AndroidX.Compose.PullToRefreshState +AndroidX.Compose.PullToRefreshState.DistanceFraction.get -> float +AndroidX.Compose.PullToRefreshState.IsAnimating.get -> bool +AndroidX.Compose.PullToRefreshState.PullToRefreshState() -> void +AndroidX.Compose.RadioButton +AndroidX.Compose.RadioButton.RadioButton(bool selected, System.Action! onClick, bool enabled = true) -> void +AndroidX.Compose.RangeSlider +AndroidX.Compose.RangeSlider.RangeSlider((float Start, float End) value, System.Action<(float Start, float End)>! onValueChange) -> void +AndroidX.Compose.Resource +AndroidX.Compose.Resource.Resource() -> void +AndroidX.Compose.RoundedCornerShape +AndroidX.Compose.RoundedCornerShape.RoundedCornerShape(AndroidX.Compose.Dp cornerSize) -> void +AndroidX.Compose.RoundedCornerShape.RoundedCornerShape(AndroidX.Compose.Dp topStart, AndroidX.Compose.Dp topEnd, AndroidX.Compose.Dp bottomEnd, AndroidX.Compose.Dp bottomStart) -> void +AndroidX.Compose.RoundedCornerShape.RoundedCornerShape(int cornerPercent) -> void +AndroidX.Compose.RoundedCornerShape.RoundedCornerShape(int topStartPercent, int topEndPercent, int bottomEndPercent, int bottomStartPercent) -> void +AndroidX.Compose.Row +AndroidX.Compose.Row.Row() -> void +AndroidX.Compose.Row.Row(AndroidX.Compose.Arrangement? horizontalArrangement, AndroidX.Compose.Alignment.Vertical? verticalAlignment) -> void +AndroidX.Compose.Row.Row(AndroidX.Compose.Arrangement? horizontalArrangement) -> void +AndroidX.Compose.Scaffold +AndroidX.Compose.Scaffold.Body.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Scaffold.Body.set -> void +AndroidX.Compose.Scaffold.BodyContent.get -> System.Func? +AndroidX.Compose.Scaffold.BodyContent.set -> void +AndroidX.Compose.Scaffold.BottomBar.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Scaffold.BottomBar.set -> void +AndroidX.Compose.Scaffold.FloatingActionButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Scaffold.FloatingActionButton.set -> void +AndroidX.Compose.Scaffold.Scaffold() -> void +AndroidX.Compose.Scaffold.SnackbarHost.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Scaffold.SnackbarHost.set -> void +AndroidX.Compose.Scaffold.TopBar.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Scaffold.TopBar.set -> void +AndroidX.Compose.ScrollableTabRow +AndroidX.Compose.ScrollableTabRow.ScrollableTabRow(int selectedTabIndex) -> void +AndroidX.Compose.ScrollState +AndroidX.Compose.ScrollState.AnimateScrollToAsync(int value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AndroidX.Compose.ScrollState.CanScrollBackward.get -> bool +AndroidX.Compose.ScrollState.CanScrollForward.get -> bool +AndroidX.Compose.ScrollState.IsScrollInProgress.get -> bool +AndroidX.Compose.ScrollState.MaxValue.get -> int +AndroidX.Compose.ScrollState.ScrollState(int initial = 0) -> void +AndroidX.Compose.ScrollState.ScrollToAsync(int value, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AndroidX.Compose.ScrollState.Value.get -> int +AndroidX.Compose.ScrollState.ViewportSize.get -> int +AndroidX.Compose.SearchBar +AndroidX.Compose.SearchBar.InputField.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SearchBar.InputField.set -> void +AndroidX.Compose.SearchBar.SearchBar(AndroidX.Compose.SearchBarState! state) -> void +AndroidX.Compose.SearchBarInputField +AndroidX.Compose.SearchBarInputField.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SearchBarInputField.LeadingIcon.set -> void +AndroidX.Compose.SearchBarInputField.OnSearch.get -> System.Action? +AndroidX.Compose.SearchBarInputField.OnSearch.set -> void +AndroidX.Compose.SearchBarInputField.Placeholder.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SearchBarInputField.Placeholder.set -> void +AndroidX.Compose.SearchBarInputField.SearchBarInputField(AndroidX.Compose.SearchBarTextFieldState! textState, AndroidX.Compose.SearchBarState! searchState) -> void +AndroidX.Compose.SearchBarInputField.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SearchBarInputField.TrailingIcon.set -> void +AndroidX.Compose.SearchBarState +AndroidX.Compose.SearchBarState.SearchBarState() -> void +AndroidX.Compose.SearchBarTextFieldState +AndroidX.Compose.SearchBarTextFieldState.SearchBarTextFieldState(string! initialText = "") -> void +AndroidX.Compose.SearchBarTextFieldState.Text.get -> string! +AndroidX.Compose.SecondaryScrollableTabRow +AndroidX.Compose.SecondaryScrollableTabRow.SecondaryScrollableTabRow(int selectedTabIndex) -> void +AndroidX.Compose.SecondaryTabRow +AndroidX.Compose.SecondaryTabRow.SecondaryTabRow(int selectedTabIndex) -> void +AndroidX.Compose.SecureTextField +AndroidX.Compose.SecureTextField.Enabled.get -> bool +AndroidX.Compose.SecureTextField.Enabled.set -> void +AndroidX.Compose.SecureTextField.IsError.get -> bool +AndroidX.Compose.SecureTextField.IsError.set -> void +AndroidX.Compose.SecureTextField.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SecureTextField.Label.set -> void +AndroidX.Compose.SecureTextField.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SecureTextField.LeadingIcon.set -> void +AndroidX.Compose.SecureTextField.Placeholder.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SecureTextField.Placeholder.set -> void +AndroidX.Compose.SecureTextField.SecureTextField(AndroidX.Compose.SecureTextFieldState! state) -> void +AndroidX.Compose.SecureTextField.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.SecureTextField.Shape.set -> void +AndroidX.Compose.SecureTextField.SupportingText.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SecureTextField.SupportingText.set -> void +AndroidX.Compose.SecureTextField.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SecureTextField.TrailingIcon.set -> void +AndroidX.Compose.SecureTextFieldState +AndroidX.Compose.SecureTextFieldState.SecureTextFieldState(string! initialText = "") -> void +AndroidX.Compose.SecureTextFieldState.Text.get -> string! +AndroidX.Compose.SegmentedButton +AndroidX.Compose.SegmentedButton.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SegmentedButton.Icon.set -> void +AndroidX.Compose.SegmentedButton.SegmentedButton(bool checked, System.Action! onCheckedChange) -> void +AndroidX.Compose.SegmentedButton.SegmentedButton(bool selected, System.Action! onClick) -> void +AndroidX.Compose.SelectionContainer +AndroidX.Compose.SelectionContainer.SelectionContainer() -> void +AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.Button = 0 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.Carousel = 8 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.Checkbox = 1 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.DropdownList = 6 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.Image = 5 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.RadioButton = 3 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.Switch = 2 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.Tab = 4 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsRole.ValuePicker = 7 -> AndroidX.Compose.SemanticsRole +AndroidX.Compose.SemanticsScope +AndroidX.Compose.SemanticsScope.ContentDescription(string! description) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.Heading() -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.OnClick(string? label, System.Action! action) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.OnClick(string? label, System.Func! action) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.OnClick(System.Action! action) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.OnClick(System.Func! action) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.Role(AndroidX.Compose.SemanticsRole role) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.Selected(bool isSelected) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.StateDescription(string! description) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.SemanticsScope.TestTagsAsResourceId(bool value) -> AndroidX.Compose.SemanticsScope! +AndroidX.Compose.Shape +AndroidX.Compose.SheetStateHolder +AndroidX.Compose.SheetStateHolder.CurrentValue.get -> AndroidX.Compose.Material3.SheetValue! +AndroidX.Compose.SheetStateHolder.ExpandAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AndroidX.Compose.SheetStateHolder.HasExpandedState.get -> bool +AndroidX.Compose.SheetStateHolder.HasPartiallyExpandedState.get -> bool +AndroidX.Compose.SheetStateHolder.HideAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AndroidX.Compose.SheetStateHolder.IsVisible.get -> bool +AndroidX.Compose.SheetStateHolder.PartialExpandAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AndroidX.Compose.SheetStateHolder.SheetStateHolder(bool skipPartiallyExpanded = false) -> void +AndroidX.Compose.SheetStateHolder.ShowAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AndroidX.Compose.SheetStateHolder.SkipPartiallyExpanded.get -> bool +AndroidX.Compose.SheetStateHolder.TargetValue.get -> AndroidX.Compose.Material3.SheetValue! +AndroidX.Compose.SheetValueConfirmStateChange +AndroidX.Compose.SheetValueConfirmStateChange.Callback.get -> System.Func? +AndroidX.Compose.SheetValueConfirmStateChange.Callback.set -> void +AndroidX.Compose.SheetValueConfirmStateChange.Invoke(Java.Lang.Object? p0) -> Java.Lang.Object? +AndroidX.Compose.SheetValueConfirmStateChange.SheetValueConfirmStateChange() -> void +AndroidX.Compose.SideEffect +AndroidX.Compose.SideEffect.SideEffect(System.Action! effect) -> void +AndroidX.Compose.SingleChoiceSegmentedButtonRow +AndroidX.Compose.SingleChoiceSegmentedButtonRow.SingleChoiceSegmentedButtonRow() -> void +AndroidX.Compose.Slider +AndroidX.Compose.Slider.Colors.get -> AndroidX.Compose.Material3.SliderColors? +AndroidX.Compose.Slider.Colors.set -> void +AndroidX.Compose.Slider.Slider(float value, System.Action! onValueChange, bool enabled = true, int steps = 0) -> void +AndroidX.Compose.Slider.ValueRange.get -> Kotlin.Ranges.IClosedFloatingPointRange? +AndroidX.Compose.Slider.ValueRange.set -> void +AndroidX.Compose.SmallFloatingActionButton +AndroidX.Compose.SmallFloatingActionButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.SmallFloatingActionButton.Shape.set -> void +AndroidX.Compose.SmallFloatingActionButton.SmallFloatingActionButton(System.Action! onClick) -> void +AndroidX.Compose.Snackbar +AndroidX.Compose.Snackbar.Action.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Snackbar.Action.set -> void +AndroidX.Compose.Snackbar.Body.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Snackbar.Body.set -> void +AndroidX.Compose.Snackbar.DismissAction.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Snackbar.DismissAction.set -> void +AndroidX.Compose.Snackbar.Snackbar() -> void +AndroidX.Compose.SnackbarHost +AndroidX.Compose.SnackbarHost.SnackbarHost(AndroidX.Compose.SnackbarHostState! hostState) -> void +AndroidX.Compose.SnackbarHostState +AndroidX.Compose.SnackbarHostState.SnackbarHostState() -> void +AndroidX.Compose.Sp +AndroidX.Compose.Sp.CompareTo(AndroidX.Compose.Sp other) -> int +AndroidX.Compose.Sp.Equals(AndroidX.Compose.Sp other) -> bool +AndroidX.Compose.Sp.PackedValue.get -> long +AndroidX.Compose.Sp.Sp() -> void +AndroidX.Compose.Sp.Sp(int sp) -> void +AndroidX.Compose.Sp.Sp(long packedValue) -> void +AndroidX.Compose.Spacer +AndroidX.Compose.Spacer.Spacer() -> void +AndroidX.Compose.Spacer.Spacer(AndroidX.Compose.Modifier! modifier) -> void +AndroidX.Compose.SpanStyle +AndroidX.Compose.SpanStyle.Background.get -> AndroidX.Compose.Color? +AndroidX.Compose.SpanStyle.Background.set -> void +AndroidX.Compose.SpanStyle.Color.get -> AndroidX.Compose.Color? +AndroidX.Compose.SpanStyle.Color.set -> void +AndroidX.Compose.SpanStyle.Decoration.get -> AndroidX.Compose.TextDecoration? +AndroidX.Compose.SpanStyle.Decoration.set -> void +AndroidX.Compose.SpanStyle.FontFamily.get -> AndroidX.Compose.FontFamily? +AndroidX.Compose.SpanStyle.FontFamily.set -> void +AndroidX.Compose.SpanStyle.FontSize.get -> AndroidX.Compose.Sp? +AndroidX.Compose.SpanStyle.FontSize.set -> void +AndroidX.Compose.SpanStyle.FontStyle.get -> AndroidX.Compose.FontStyle? +AndroidX.Compose.SpanStyle.FontStyle.set -> void +AndroidX.Compose.SpanStyle.FontWeight.get -> AndroidX.Compose.FontWeight? +AndroidX.Compose.SpanStyle.FontWeight.set -> void +AndroidX.Compose.SpanStyle.LetterSpacing.get -> AndroidX.Compose.Sp? +AndroidX.Compose.SpanStyle.LetterSpacing.set -> void +AndroidX.Compose.SpanStyle.SpanStyle() -> void +AndroidX.Compose.SpExtensions +AndroidX.Compose.StaggeredGridCells +AndroidX.Compose.StateFlowExtensions +AndroidX.Compose.SuggestionChip +AndroidX.Compose.SuggestionChip.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SuggestionChip.Icon.set -> void +AndroidX.Compose.SuggestionChip.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.SuggestionChip.Label.set -> void +AndroidX.Compose.SuggestionChip.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.SuggestionChip.Shape.set -> void +AndroidX.Compose.SuggestionChip.SuggestionChip(System.Action! onClick) -> void +AndroidX.Compose.Surface +AndroidX.Compose.Surface.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.Surface.Shape.set -> void +AndroidX.Compose.Surface.Surface() -> void +AndroidX.Compose.Switch +AndroidX.Compose.Switch.Colors.get -> AndroidX.Compose.Material3.SwitchColors? +AndroidX.Compose.Switch.Colors.set -> void +AndroidX.Compose.Switch.Switch(bool checked, System.Action! onCheckedChange, bool enabled = true) -> void +AndroidX.Compose.Tab +AndroidX.Compose.Tab.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Tab.Icon.set -> void +AndroidX.Compose.Tab.Tab(bool selected, System.Action! onClick) -> void +AndroidX.Compose.Tab.Text.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Tab.Text.set -> void +AndroidX.Compose.TabRow +AndroidX.Compose.TabRow.TabRow(int selectedTabIndex) -> void +AndroidX.Compose.Text +AndroidX.Compose.Text.Align.get -> AndroidX.Compose.TextAlign? +AndroidX.Compose.Text.Align.set -> void +AndroidX.Compose.Text.Color.get -> AndroidX.Compose.Color? +AndroidX.Compose.Text.Color.set -> void +AndroidX.Compose.Text.Decoration.get -> AndroidX.Compose.TextDecoration? +AndroidX.Compose.Text.Decoration.set -> void +AndroidX.Compose.Text.FontFamily.get -> AndroidX.Compose.FontFamily? +AndroidX.Compose.Text.FontFamily.set -> void +AndroidX.Compose.Text.FontSize.get -> AndroidX.Compose.Sp? +AndroidX.Compose.Text.FontSize.set -> void +AndroidX.Compose.Text.FontStyle.get -> AndroidX.Compose.FontStyle? +AndroidX.Compose.Text.FontStyle.set -> void +AndroidX.Compose.Text.FontWeight.get -> AndroidX.Compose.FontWeight? +AndroidX.Compose.Text.FontWeight.set -> void +AndroidX.Compose.Text.LetterSpacing.get -> AndroidX.Compose.Sp? +AndroidX.Compose.Text.LetterSpacing.set -> void +AndroidX.Compose.Text.LineHeight.get -> AndroidX.Compose.Sp? +AndroidX.Compose.Text.LineHeight.set -> void +AndroidX.Compose.Text.MaxLines.get -> int? +AndroidX.Compose.Text.MaxLines.set -> void +AndroidX.Compose.Text.MinLines.get -> int? +AndroidX.Compose.Text.MinLines.set -> void +AndroidX.Compose.Text.Overflow.get -> AndroidX.Compose.TextOverflow? +AndroidX.Compose.Text.Overflow.set -> void +AndroidX.Compose.Text.SoftWrap.get -> bool? +AndroidX.Compose.Text.SoftWrap.set -> void +AndroidX.Compose.Text.Text(string! text) -> void +AndroidX.Compose.TextAlign +AndroidX.Compose.TextButton +AndroidX.Compose.TextButton.Colors.get -> AndroidX.Compose.Material3.ButtonColors? +AndroidX.Compose.TextButton.Colors.set -> void +AndroidX.Compose.TextButton.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.TextButton.ContentPadding.set -> void +AndroidX.Compose.TextButton.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.TextButton.Shape.set -> void +AndroidX.Compose.TextButton.TextButton(System.Action! onClick) -> void +AndroidX.Compose.TextDecoration +AndroidX.Compose.TextField +AndroidX.Compose.TextField.Enabled.get -> bool? +AndroidX.Compose.TextField.Enabled.set -> void +AndroidX.Compose.TextField.IsError.get -> bool? +AndroidX.Compose.TextField.IsError.set -> void +AndroidX.Compose.TextField.KeyboardOptions.get -> AndroidX.Compose.Foundation.Text.KeyboardOptions? +AndroidX.Compose.TextField.KeyboardOptions.set -> void +AndroidX.Compose.TextField.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TextField.Label.set -> void +AndroidX.Compose.TextField.LeadingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TextField.LeadingIcon.set -> void +AndroidX.Compose.TextField.MaxLines.get -> int? +AndroidX.Compose.TextField.MaxLines.set -> void +AndroidX.Compose.TextField.MinLines.get -> int? +AndroidX.Compose.TextField.MinLines.set -> void +AndroidX.Compose.TextField.Placeholder.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TextField.Placeholder.set -> void +AndroidX.Compose.TextField.Prefix.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TextField.Prefix.set -> void +AndroidX.Compose.TextField.ReadOnly.get -> bool? +AndroidX.Compose.TextField.ReadOnly.set -> void +AndroidX.Compose.TextField.Shape.get -> AndroidX.Compose.Shape? +AndroidX.Compose.TextField.Shape.set -> void +AndroidX.Compose.TextField.SingleLine.get -> bool? +AndroidX.Compose.TextField.SingleLine.set -> void +AndroidX.Compose.TextField.Suffix.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TextField.Suffix.set -> void +AndroidX.Compose.TextField.SupportingText.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TextField.SupportingText.set -> void +AndroidX.Compose.TextField.TextField(AndroidX.Compose.MutableState! state) -> void +AndroidX.Compose.TextField.TextField(AndroidX.Compose.MutableState! state) -> void +AndroidX.Compose.TextField.TextField(string! value, System.Action! onValueChange) -> void +AndroidX.Compose.TextField.TextStyle.get -> AndroidX.Compose.TextStyle? +AndroidX.Compose.TextField.TextStyle.set -> void +AndroidX.Compose.TextField.TrailingIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TextField.TrailingIcon.set -> void +AndroidX.Compose.TextField.VisualTransformation.get -> AndroidX.Compose.UI.Text.Input.IVisualTransformation? +AndroidX.Compose.TextField.VisualTransformation.set -> void +AndroidX.Compose.TextOverflow +AndroidX.Compose.TextOverflow.Deconstruct(out int Value) -> void +AndroidX.Compose.TextOverflow.Equals(AndroidX.Compose.TextOverflow other) -> bool +AndroidX.Compose.TextOverflow.TextOverflow() -> void +AndroidX.Compose.TextOverflow.TextOverflow(int Value) -> void +AndroidX.Compose.TextOverflow.Value.get -> int +AndroidX.Compose.TextOverflow.Value.init -> void +AndroidX.Compose.TextStyle +AndroidX.Compose.TextStyle.Color.get -> AndroidX.Compose.Color? +AndroidX.Compose.TextStyle.Color.set -> void +AndroidX.Compose.TextStyle.FontFamily.get -> AndroidX.Compose.FontFamily? +AndroidX.Compose.TextStyle.FontFamily.set -> void +AndroidX.Compose.TextStyle.FontSize.get -> AndroidX.Compose.Sp? +AndroidX.Compose.TextStyle.FontSize.set -> void +AndroidX.Compose.TextStyle.FontStyle.get -> AndroidX.Compose.FontStyle? +AndroidX.Compose.TextStyle.FontStyle.set -> void +AndroidX.Compose.TextStyle.FontWeight.get -> AndroidX.Compose.FontWeight? +AndroidX.Compose.TextStyle.FontWeight.set -> void +AndroidX.Compose.TextStyle.LetterSpacing.get -> AndroidX.Compose.Sp? +AndroidX.Compose.TextStyle.LetterSpacing.set -> void +AndroidX.Compose.TextStyle.LineHeight.get -> AndroidX.Compose.Sp? +AndroidX.Compose.TextStyle.LineHeight.set -> void +AndroidX.Compose.TextStyle.TextAlign.get -> AndroidX.Compose.TextAlign? +AndroidX.Compose.TextStyle.TextAlign.set -> void +AndroidX.Compose.TextStyle.TextDecoration.get -> AndroidX.Compose.TextDecoration? +AndroidX.Compose.TextStyle.TextDecoration.set -> void +AndroidX.Compose.TextStyle.TextStyle() -> void +AndroidX.Compose.TileMode +AndroidX.Compose.TileMode.Clamp = 0 -> AndroidX.Compose.TileMode +AndroidX.Compose.TileMode.Decal = 3 -> AndroidX.Compose.TileMode +AndroidX.Compose.TileMode.Mirror = 2 -> AndroidX.Compose.TileMode +AndroidX.Compose.TileMode.Repeated = 1 -> AndroidX.Compose.TileMode +AndroidX.Compose.TimeInput +AndroidX.Compose.TimeInput.TimeInput(AndroidX.Compose.TimePickerState? state = null) -> void +AndroidX.Compose.TimePicker +AndroidX.Compose.TimePicker.TimePicker(AndroidX.Compose.TimePickerState? state = null) -> void +AndroidX.Compose.TimePickerDialog +AndroidX.Compose.TimePickerDialog.Body.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TimePickerDialog.Body.set -> void +AndroidX.Compose.TimePickerDialog.ConfirmButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TimePickerDialog.ConfirmButton.set -> void +AndroidX.Compose.TimePickerDialog.DismissButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TimePickerDialog.DismissButton.set -> void +AndroidX.Compose.TimePickerDialog.ModeToggleButton.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TimePickerDialog.ModeToggleButton.set -> void +AndroidX.Compose.TimePickerDialog.TimePickerDialog(System.Action! onDismissRequest) -> void +AndroidX.Compose.TimePickerDialog.Title.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TimePickerDialog.Title.set -> void +AndroidX.Compose.TimePickerState +AndroidX.Compose.TimePickerState.Hour.get -> int +AndroidX.Compose.TimePickerState.Hour.set -> void +AndroidX.Compose.TimePickerState.Is24Hour.get -> bool +AndroidX.Compose.TimePickerState.Minute.get -> int +AndroidX.Compose.TimePickerState.Minute.set -> void +AndroidX.Compose.TimePickerState.TimePickerState(int initialHour = 12, int initialMinute = 0, bool is24Hour = true) -> void +AndroidX.Compose.Tooltip +AndroidX.Compose.Tooltip.Anchor.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Tooltip.Anchor.set -> void +AndroidX.Compose.Tooltip.Tip.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.Tooltip.Tip.set -> void +AndroidX.Compose.Tooltip.Tooltip(bool isPersistent = false) -> void +AndroidX.Compose.TopAppBar +AndroidX.Compose.TopAppBar.Actions.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TopAppBar.Actions.set -> void +AndroidX.Compose.TopAppBar.NavigationIcon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TopAppBar.NavigationIcon.set -> void +AndroidX.Compose.TopAppBar.ScrollBehavior.get -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior? +AndroidX.Compose.TopAppBar.ScrollBehavior.set -> void +AndroidX.Compose.TopAppBar.Subtitle.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TopAppBar.Subtitle.set -> void +AndroidX.Compose.TopAppBar.Title.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TopAppBar.Title.set -> void +AndroidX.Compose.TopAppBar.TopAppBar() -> void +AndroidX.Compose.TopSearchBar +AndroidX.Compose.TopSearchBar.InputField.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.TopSearchBar.InputField.set -> void +AndroidX.Compose.TopSearchBar.TopSearchBar(AndroidX.Compose.SearchBarState! state) -> void +AndroidX.Compose.TransformOrigin +AndroidX.Compose.Transitions +AndroidX.Compose.TriStateCheckbox +AndroidX.Compose.TriStateCheckbox.TriStateCheckbox(AndroidX.Compose.UI.State.ToggleableState! state, System.Action! onClick, bool enabled = true) -> void +AndroidX.Compose.VerticalDivider +AndroidX.Compose.VerticalDivider.Color.get -> AndroidX.Compose.Color? +AndroidX.Compose.VerticalDivider.Color.set -> void +AndroidX.Compose.VerticalDivider.ThicknessDp.get -> float? +AndroidX.Compose.VerticalDivider.ThicknessDp.set -> void +AndroidX.Compose.VerticalDivider.VerticalDivider() -> void +AndroidX.Compose.VerticalPager +AndroidX.Compose.VerticalPager.ContentPadding.get -> AndroidX.Compose.PaddingValues? +AndroidX.Compose.VerticalPager.ContentPadding.set -> void +AndroidX.Compose.VerticalPager.State.get -> AndroidX.Compose.PagerState? +AndroidX.Compose.VerticalPager.State.set -> void +AndroidX.Compose.VerticalPager.VerticalPager(System.Collections.Generic.IReadOnlyList! items, System.Func! itemContent) -> void +AndroidX.Compose.ViewModel +AndroidX.Compose.ViewModel.IsCleared.get -> bool +AndroidX.Compose.ViewModel.LaunchAsync(System.Func! body) -> System.Threading.Tasks.Task! +AndroidX.Compose.ViewModel.Scope.get -> System.Threading.CancellationToken +AndroidX.Compose.ViewModel.ViewModel() -> void +AndroidX.Compose.WideNavigationRail +AndroidX.Compose.WideNavigationRail.WideNavigationRail() -> void +AndroidX.Compose.WideNavigationRailItem +AndroidX.Compose.WideNavigationRailItem.Icon.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.WideNavigationRailItem.Icon.set -> void +AndroidX.Compose.WideNavigationRailItem.Label.get -> AndroidX.Compose.ComposableNode? +AndroidX.Compose.WideNavigationRailItem.Label.set -> void +AndroidX.Compose.WideNavigationRailItem.WideNavigationRailItem(bool selected, System.Action! onClick, bool enabled = true) -> void +AndroidX.Compose.WideNavigationRailState +AndroidX.Compose.WideNavigationRailState.CurrentValue.get -> AndroidX.Compose.Material3.WideNavigationRailValue! +AndroidX.Compose.WideNavigationRailState.InitialValue.get -> AndroidX.Compose.Material3.WideNavigationRailValue! +AndroidX.Compose.WideNavigationRailState.TargetValue.get -> AndroidX.Compose.Material3.WideNavigationRailValue! +AndroidX.Compose.WideNavigationRailState.WideNavigationRailState(AndroidX.Compose.Material3.WideNavigationRailValue? initialValue = null) -> void +override AndroidX.Compose.AlertDialog.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.AndroidView.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.AnimatedContent.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.AnimatedVisibility.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.AnnotatedText.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.AssistChip.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.BackHandler.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Badge.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.BadgedBox.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.BottomAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.BottomSheetScaffold.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Box.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.BoxWithConstraints.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Button.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Card.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.CenterAlignedTopAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Checkbox.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.CircularProgressIndicator.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.CollectedState.ToString() -> string! +override AndroidX.Compose.Color.Equals(object? obj) -> bool +override AndroidX.Compose.Color.GetHashCode() -> int +override AndroidX.Compose.Color.ToString() -> string! +override AndroidX.Compose.Column.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Composed.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.CompositionLocalProvider.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Crossfade.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.CustomTab.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DatePicker.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DatePickerDialog.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DateRangePicker.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DateRangePickerDialog.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DerivedState.ToString() -> string! +override AndroidX.Compose.DisableSelection.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DismissibleDrawerSheet.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DismissibleNavigationDrawer.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DisposableEffect.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DockedSearchBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Dp.Equals(object? obj) -> bool +override AndroidX.Compose.Dp.GetHashCode() -> int +override AndroidX.Compose.Dp.ToString() -> string! +override AndroidX.Compose.DropdownMenu.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.DropdownMenuItem.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ElevatedAssistChip.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ElevatedButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ElevatedCard.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ElevatedFilterChip.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ElevatedSuggestionChip.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ExpandedDockedSearchBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ExpandedFullScreenSearchBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ExposedDropdownMenu.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ExposedDropdownMenuBox.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ExtendedFloatingActionButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FilledIconButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FilledIconToggleButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FilledTonalButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FilledTonalIconButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FilledTonalIconToggleButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FilterChip.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FlexibleBottomAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FloatingActionButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FlowColumn.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.FlowRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.HorizontalCenteredHeroCarousel.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.HorizontalDivider.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.HorizontalMultiBrowseCarousel.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.HorizontalPager.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.HorizontalUncontainedCarousel.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Icon.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.IconButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.IconToggleButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Image.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.InputChip.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LargeFlexibleTopAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LargeFloatingActionButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LargeTopAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LaunchedEffect.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LazyColumn.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LazyHorizontalGrid.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LazyHorizontalStaggeredGrid.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LazyRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LazyVerticalGrid.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LazyVerticalStaggeredGrid.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LeadingIconTab.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.LinearProgressIndicator.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ListItem.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.MaterialTheme.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.MediumFlexibleTopAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.MediumTopAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ModalBottomSheet.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ModalDrawerSheet.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ModalNavigationDrawer.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ModalWideNavigationRail.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.MultiChoiceSegmentedButtonRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.MutableState.ToString() -> string! +override AndroidX.Compose.NavHost.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.NavigationBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.NavigationBarItem.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.NavigationDrawerItem.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.NavigationRail.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.NavigationRailItem.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Offset.Equals(object? obj) -> bool +override AndroidX.Compose.Offset.GetHashCode() -> int +override AndroidX.Compose.Offset.ToString() -> string! +override AndroidX.Compose.OutlinedButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.OutlinedCard.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.OutlinedIconButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.OutlinedIconToggleButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.OutlinedSecureTextField.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.OutlinedTextField.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.PermanentDrawerSheet.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.PermanentNavigationDrawer.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.PrimaryScrollableTabRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.PrimaryTabRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.PullToRefreshBox.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.RadioButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.RangeSlider.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Row.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Scaffold.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.ScrollableTabRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SearchBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SearchBarInputField.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SecondaryScrollableTabRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SecondaryTabRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SecureTextField.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SegmentedButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SelectionContainer.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SideEffect.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SingleChoiceSegmentedButtonRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Slider.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SmallFloatingActionButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Snackbar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SnackbarHost.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Sp.Equals(object? obj) -> bool +override AndroidX.Compose.Sp.GetHashCode() -> int +override AndroidX.Compose.Sp.ToString() -> string! +override AndroidX.Compose.Spacer.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.SuggestionChip.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Surface.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Switch.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Tab.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TabRow.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Text.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TextButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TextField.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TextOverflow.GetHashCode() -> int +override AndroidX.Compose.TimeInput.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TimePicker.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TimePickerDialog.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Tooltip.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TopAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TopSearchBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.TriStateCheckbox.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.VerticalDivider.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.VerticalPager.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.WideNavigationRail.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.WideNavigationRailItem.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override sealed AndroidX.Compose.ViewModel.OnCleared() -> void +static AndroidX.Compose.Alignment.BottomCenter.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.BottomEnd.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.BottomStart.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.Center.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.CenterEnd.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.CenterStart.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.Horizontal.CenterHorizontally.get -> AndroidX.Compose.Alignment.Horizontal! +static AndroidX.Compose.Alignment.Horizontal.End.get -> AndroidX.Compose.Alignment.Horizontal! +static AndroidX.Compose.Alignment.Horizontal.Start.get -> AndroidX.Compose.Alignment.Horizontal! +static AndroidX.Compose.Alignment.TopCenter.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.TopEnd.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.TopStart.get -> AndroidX.Compose.Alignment! +static AndroidX.Compose.Alignment.Vertical.Bottom.get -> AndroidX.Compose.Alignment.Vertical! +static AndroidX.Compose.Alignment.Vertical.CenterVertically.get -> AndroidX.Compose.Alignment.Vertical! +static AndroidX.Compose.Alignment.Vertical.Top.get -> AndroidX.Compose.Alignment.Vertical! +static AndroidX.Compose.Arrangement.Bottom.get -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.Center.get -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.End.get -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.SpaceAround.get -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.SpaceBetween.get -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.SpacedBy(AndroidX.Compose.Dp dp) -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.SpacedBy(int dp) -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.SpaceEvenly.get -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.Start.get -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Arrangement.Top.get -> AndroidX.Compose.Arrangement! +static AndroidX.Compose.Brush.HorizontalGradient(AndroidX.Compose.Color[]! colors, float startX = 0, float endX = Infinity, AndroidX.Compose.TileMode tileMode = AndroidX.Compose.TileMode.Clamp) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.HorizontalGradient(params AndroidX.Compose.Color[]! colors) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.LinearGradient(AndroidX.Compose.Color[]! colors, AndroidX.Compose.Offset start, AndroidX.Compose.Offset end, AndroidX.Compose.TileMode tileMode = AndroidX.Compose.TileMode.Clamp) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.LinearGradient(params AndroidX.Compose.Color[]! colors) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.RadialGradient(AndroidX.Compose.Color[]! colors, AndroidX.Compose.Offset center, float radius = Infinity, AndroidX.Compose.TileMode tileMode = AndroidX.Compose.TileMode.Clamp) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.RadialGradient(params AndroidX.Compose.Color[]! colors) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.SolidColor(AndroidX.Compose.Color color) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.SweepGradient(AndroidX.Compose.Color[]! colors, AndroidX.Compose.Offset center) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.SweepGradient(params AndroidX.Compose.Color[]! colors) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.VerticalGradient(AndroidX.Compose.Color[]! colors, float startY = 0, float endY = Infinity, AndroidX.Compose.TileMode tileMode = AndroidX.Compose.TileMode.Clamp) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Brush.VerticalGradient(params AndroidX.Compose.Color[]! colors) -> AndroidX.Compose.UI.Graphics.Brush! +static AndroidX.Compose.Color.Black.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.Blue.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.Cyan.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.DarkGray.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.FromArgb(byte alpha, byte red, byte green, byte blue) -> AndroidX.Compose.Color +static AndroidX.Compose.Color.FromArgb(uint argb) -> AndroidX.Compose.Color +static AndroidX.Compose.Color.FromHex(string! hex) -> AndroidX.Compose.Color +static AndroidX.Compose.Color.FromRgb(byte red, byte green, byte blue) -> AndroidX.Compose.Color +static AndroidX.Compose.Color.Gray.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.Green.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.implicit operator AndroidX.Compose.Color(long packedValue) -> AndroidX.Compose.Color +static AndroidX.Compose.Color.implicit operator long(AndroidX.Compose.Color c) -> long +static AndroidX.Compose.Color.LightGray.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.Magenta.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.operator !=(AndroidX.Compose.Color left, AndroidX.Compose.Color right) -> bool +static AndroidX.Compose.Color.operator ==(AndroidX.Compose.Color left, AndroidX.Compose.Color right) -> bool +static AndroidX.Compose.Color.Red.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.Transparent.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.Unspecified.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.White.get -> AndroidX.Compose.Color +static AndroidX.Compose.Color.Yellow.get -> AndroidX.Compose.Color +static AndroidX.Compose.Composed.implicit operator AndroidX.Compose.Composed!(System.Func! builder) -> AndroidX.Compose.Composed! +static AndroidX.Compose.ComposeExtensions.BooleanResource(this AndroidX.Compose.Runtime.IComposer! composer, int id) -> bool +static AndroidX.Compose.ComposeExtensions.ButtonColors(this AndroidX.Compose.Runtime.IComposer! composer, long? containerColor = null, long? contentColor = null, long? disabledContainerColor = null, long? disabledContentColor = null, int line = 0, string! file = "") -> AndroidX.Compose.Material3.ButtonColors! +static AndroidX.Compose.ComposeExtensions.CheckboxColors(this AndroidX.Compose.Runtime.IComposer! composer, long? checkedColor = null, long? uncheckedColor = null, long? checkmarkColor = null, int line = 0, string! file = "") -> AndroidX.Compose.Material3.CheckboxColors! +static AndroidX.Compose.ComposeExtensions.CollectAsStateWithLifecycle(this Xamarin.KotlinX.Coroutines.Flow.IFlow! flow, T initialValue, AndroidX.Compose.Runtime.IComposer! composer) -> AndroidX.Compose.CollectedState! +static AndroidX.Compose.ComposeExtensions.CollectAsStateWithLifecycle(this Xamarin.KotlinX.Coroutines.Flow.IStateFlow! stateFlow, AndroidX.Compose.Runtime.IComposer! composer) -> AndroidX.Compose.CollectedState! +static AndroidX.Compose.ComposeExtensions.ColorResource(this AndroidX.Compose.Runtime.IComposer! composer, int id) -> long +static AndroidX.Compose.ComposeExtensions.ColorScheme(this AndroidX.Compose.Runtime.IComposer! composer) -> AndroidX.Compose.Material3.ColorScheme! +static AndroidX.Compose.ComposeExtensions.CurrentWindowAdaptiveInfo(this AndroidX.Compose.Runtime.IComposer! composer, bool supportLargeAndXLargeWidth = false) -> AndroidX.Compose.Material3.Adaptive.WindowAdaptiveInfo! +static AndroidX.Compose.ComposeExtensions.DerivedStateOf(System.Func! calculation) -> AndroidX.Compose.DerivedState! +static AndroidX.Compose.ComposeExtensions.DerivedStateOf(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! calculation) -> AndroidX.Compose.DerivedState! +static AndroidX.Compose.ComposeExtensions.DimensionResource(this AndroidX.Compose.Runtime.IComposer! composer, int id) -> AndroidX.Compose.Dp +static AndroidX.Compose.ComposeExtensions.DisposableEffect(this AndroidX.Compose.Runtime.IComposer! composer, object? key1, object? key2, object? key3, System.Func! effect) -> void +static AndroidX.Compose.ComposeExtensions.DisposableEffect(this AndroidX.Compose.Runtime.IComposer! composer, object? key1, object? key2, System.Func! effect) -> void +static AndroidX.Compose.ComposeExtensions.DisposableEffect(this AndroidX.Compose.Runtime.IComposer! composer, object? key1, System.Func! effect) -> void +static AndroidX.Compose.ComposeExtensions.EnableEdgeToEdge(this AndroidX.Activity.ComponentActivity! activity) -> void +static AndroidX.Compose.ComposeExtensions.EnterAlwaysScrollBehavior(this AndroidX.Compose.Runtime.IComposer! composer, AndroidX.Compose.Material3.TopAppBarState! state, int line = 0, string! file = "") -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior! +static AndroidX.Compose.ComposeExtensions.ExitUntilCollapsedScrollBehavior(this AndroidX.Compose.Runtime.IComposer! composer, AndroidX.Compose.Material3.TopAppBarState! state, int line = 0, string! file = "") -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior! +static AndroidX.Compose.ComposeExtensions.IntegerArrayResource(this AndroidX.Compose.Runtime.IComposer! composer, int id) -> int[]! +static AndroidX.Compose.ComposeExtensions.IntegerResource(this AndroidX.Compose.Runtime.IComposer! composer, int id) -> int +static AndroidX.Compose.ComposeExtensions.LaunchedEffect(this AndroidX.Compose.Runtime.IComposer! composer, object? key1, object? key2, object? key3, System.Func! body) -> void +static AndroidX.Compose.ComposeExtensions.LaunchedEffect(this AndroidX.Compose.Runtime.IComposer! composer, object? key1, object? key2, System.Func! body) -> void +static AndroidX.Compose.ComposeExtensions.LaunchedEffect(this AndroidX.Compose.Runtime.IComposer! composer, object? key1, System.Func! body) -> void +static AndroidX.Compose.ComposeExtensions.MutableStateFlowOf(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, int line = 0, string! file = "") -> AndroidX.Compose.MutableStateFlow! +static AndroidX.Compose.ComposeExtensions.MutableStateOf(this AndroidX.Compose.Runtime.IComposer! composer, double initial, int line = 0, string! file = "") -> AndroidX.Compose.MutableNumberState! +static AndroidX.Compose.ComposeExtensions.MutableStateOf(this AndroidX.Compose.Runtime.IComposer! composer, float initial, int line = 0, string! file = "") -> AndroidX.Compose.MutableNumberState! +static AndroidX.Compose.ComposeExtensions.MutableStateOf(this AndroidX.Compose.Runtime.IComposer! composer, int initial, int line = 0, string! file = "") -> AndroidX.Compose.MutableNumberState! +static AndroidX.Compose.ComposeExtensions.MutableStateOf(this AndroidX.Compose.Runtime.IComposer! composer, long initial, int line = 0, string! file = "") -> AndroidX.Compose.MutableNumberState! +static AndroidX.Compose.ComposeExtensions.MutableStateOf(this AndroidX.Compose.Runtime.IComposer! composer, T initial, int line = 0, string! file = "") -> AndroidX.Compose.MutableState! +static AndroidX.Compose.ComposeExtensions.NewTextFieldValue(string! text = "", long selection = 0, AndroidX.Compose.UI.Text.TextRange? composition = null) -> AndroidX.Compose.UI.Text.Input.TextFieldValue! +static AndroidX.Compose.ComposeExtensions.NewTextFieldValue(this AndroidX.Compose.Runtime.IComposer! composer, string! text = "", long selection = 0, AndroidX.Compose.UI.Text.TextRange? composition = null) -> AndroidX.Compose.UI.Text.Input.TextFieldValue! +static AndroidX.Compose.ComposeExtensions.PainterResource(this AndroidX.Compose.Runtime.IComposer! composer, int id) -> AndroidX.Compose.UI.Graphics.Painter.Painter! +static AndroidX.Compose.ComposeExtensions.PinnedScrollBehavior(this AndroidX.Compose.Runtime.IComposer! composer, AndroidX.Compose.Material3.TopAppBarState! state, int line = 0, string! file = "") -> AndroidX.Compose.Material3.ITopAppBarScrollBehavior! +static AndroidX.Compose.ComposeExtensions.PluralStringResource(this AndroidX.Compose.Runtime.IComposer! composer, int id, int count, params object?[]! formatArgs) -> string! +static AndroidX.Compose.ComposeExtensions.PluralStringResource(this AndroidX.Compose.Runtime.IComposer! composer, int id, int count) -> string! +static AndroidX.Compose.ComposeExtensions.ProduceState(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, object? key1, object? key2, object? key3, System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> AndroidX.Compose.MutableState! +static AndroidX.Compose.ComposeExtensions.ProduceState(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, object? key1, object? key2, System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> AndroidX.Compose.MutableState! +static AndroidX.Compose.ComposeExtensions.ProduceState(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, object? key1, System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> AndroidX.Compose.MutableState! +static AndroidX.Compose.ComposeExtensions.ProduceState(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> AndroidX.Compose.MutableState! +static AndroidX.Compose.ComposeExtensions.ProduceStateKeyed(this AndroidX.Compose.Runtime.IComposer! composer, T initialValue, object?[]! keys, System.Func!, System.Threading.CancellationToken, System.Threading.Tasks.Task!>! producer, int line = 0, string! file = "") -> AndroidX.Compose.MutableState! +static AndroidX.Compose.ComposeExtensions.Remember(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.Remember(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.Remember(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, object? key2, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.Remember(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, object? key2, object? key3, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.RememberDraggableState(this AndroidX.Compose.Runtime.IComposer! composer, System.Action! onDelta, int line = 0, string! file = "") -> AndroidX.Compose.DraggableState! +static AndroidX.Compose.ComposeExtensions.RememberKeyed(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object?[]! keys, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.RememberLazyListState(this AndroidX.Compose.Runtime.IComposer! composer, int initialFirstVisibleItemIndex = 0, int initialFirstVisibleItemScrollOffset = 0, int line = 0, string! file = "") -> AndroidX.Compose.LazyListState! +static AndroidX.Compose.ComposeExtensions.RememberSaveable(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.RememberSaveable(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.RememberSaveable(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, object? key2, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.RememberSaveable(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, object? key2, object? key3, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.RememberSaveableKeyed(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object?[]! keys, int line = 0, string! file = "") -> T +static AndroidX.Compose.ComposeExtensions.RememberTopAppBarState(this AndroidX.Compose.Runtime.IComposer! composer, float initialHeightOffsetLimit = -Infinity, float initialHeightOffset = 0, float initialContentOffset = 0, int line = 0, string! file = "") -> AndroidX.Compose.Material3.TopAppBarState! +static AndroidX.Compose.ComposeExtensions.SetContent(this AndroidX.Activity.ComponentActivity! activity, System.Func! content) -> void +static AndroidX.Compose.ComposeExtensions.SetContent(this AndroidX.Compose.UI.Platform.ComposeView! view, System.Func! content) -> void +static AndroidX.Compose.ComposeExtensions.Shapes(this AndroidX.Compose.Runtime.IComposer! composer) -> AndroidX.Compose.Material3.Shapes! +static AndroidX.Compose.ComposeExtensions.SideEffect(this AndroidX.Compose.Runtime.IComposer! composer, System.Action! effect) -> void +static AndroidX.Compose.ComposeExtensions.SliderColors(this AndroidX.Compose.Runtime.IComposer! composer, long? thumbColor = null, long? activeTrackColor = null, long? activeTickColor = null, long? inactiveTrackColor = null, long? inactiveTickColor = null, long? disabledThumbColor = null, long? disabledActiveTrackColor = null, long? disabledActiveTickColor = null, long? disabledInactiveTrackColor = null, long? disabledInactiveTickColor = null, int line = 0, string! file = "") -> AndroidX.Compose.Material3.SliderColors! +static AndroidX.Compose.ComposeExtensions.SnapshotFlow(System.Func! producer) -> System.Collections.Generic.IAsyncEnumerable! +static AndroidX.Compose.ComposeExtensions.StringArrayResource(this AndroidX.Compose.Runtime.IComposer! composer, int id) -> string![]! +static AndroidX.Compose.ComposeExtensions.StringResource(this AndroidX.Compose.Runtime.IComposer! composer, int id, params object?[]! formatArgs) -> string! +static AndroidX.Compose.ComposeExtensions.StringResource(this AndroidX.Compose.Runtime.IComposer! composer, int id) -> string! +static AndroidX.Compose.ComposeExtensions.SwitchColors(this AndroidX.Compose.Runtime.IComposer! composer, long? checkedThumbColor = null, long? checkedTrackColor = null, long? uncheckedThumbColor = null, long? uncheckedTrackColor = null, int line = 0, string! file = "") -> AndroidX.Compose.Material3.SwitchColors! +static AndroidX.Compose.ComposeExtensions.Typography(this AndroidX.Compose.Runtime.IComposer! composer) -> AndroidX.Compose.Material3.Typography! +static AndroidX.Compose.ComposeExtensions.ViewModel(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, int line = 0, string! file = "") -> T! +static AndroidX.Compose.ComposeExtensions.ViewModel(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, int line = 0, string! file = "") -> T! +static AndroidX.Compose.ComposeExtensions.ViewModel(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, object? key2, int line = 0, string! file = "") -> T! +static AndroidX.Compose.ComposeExtensions.ViewModel(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object? key1, object? key2, object? key3, int line = 0, string! file = "") -> T! +static AndroidX.Compose.ComposeExtensions.ViewModelKeyed(this AndroidX.Compose.Runtime.IComposer! composer, System.Func! factory, object?[]! keys, int line = 0, string! file = "") -> T! +static AndroidX.Compose.CompositionLocal.Of(System.Func! defaultFactory) -> AndroidX.Compose.CompositionLocal! +static AndroidX.Compose.CompositionLocal.StaticOf(System.Func! defaultFactory) -> AndroidX.Compose.CompositionLocal! +static AndroidX.Compose.CompositionLocal.Of(System.Func! defaultFactory) -> AndroidX.Compose.CompositionLocal! +static AndroidX.Compose.CompositionLocal.StaticOf(System.Func! defaultFactory) -> AndroidX.Compose.CompositionLocal! +static AndroidX.Compose.ContentScale.Crop.get -> AndroidX.Compose.ContentScale! +static AndroidX.Compose.ContentScale.FillBounds.get -> AndroidX.Compose.ContentScale! +static AndroidX.Compose.ContentScale.FillHeight.get -> AndroidX.Compose.ContentScale! +static AndroidX.Compose.ContentScale.FillWidth.get -> AndroidX.Compose.ContentScale! +static AndroidX.Compose.ContentScale.Fit.get -> AndroidX.Compose.ContentScale! +static AndroidX.Compose.ContentScale.Inside.get -> AndroidX.Compose.ContentScale! +static AndroidX.Compose.ContentScale.None.get -> AndroidX.Compose.ContentScale! +static AndroidX.Compose.Dp.implicit operator AndroidX.Compose.Dp(float value) -> AndroidX.Compose.Dp +static AndroidX.Compose.Dp.implicit operator AndroidX.Compose.Dp(int value) -> AndroidX.Compose.Dp +static AndroidX.Compose.Dp.operator -(AndroidX.Compose.Dp a, AndroidX.Compose.Dp b) -> AndroidX.Compose.Dp +static AndroidX.Compose.Dp.operator -(AndroidX.Compose.Dp a) -> AndroidX.Compose.Dp +static AndroidX.Compose.Dp.operator !=(AndroidX.Compose.Dp left, AndroidX.Compose.Dp right) -> bool +static AndroidX.Compose.Dp.operator *(AndroidX.Compose.Dp a, float scalar) -> AndroidX.Compose.Dp +static AndroidX.Compose.Dp.operator *(float scalar, AndroidX.Compose.Dp a) -> AndroidX.Compose.Dp +static AndroidX.Compose.Dp.operator /(AndroidX.Compose.Dp a, AndroidX.Compose.Dp b) -> float +static AndroidX.Compose.Dp.operator /(AndroidX.Compose.Dp a, float scalar) -> AndroidX.Compose.Dp +static AndroidX.Compose.Dp.operator +(AndroidX.Compose.Dp a, AndroidX.Compose.Dp b) -> AndroidX.Compose.Dp +static AndroidX.Compose.Dp.operator <(AndroidX.Compose.Dp a, AndroidX.Compose.Dp b) -> bool +static AndroidX.Compose.Dp.operator <=(AndroidX.Compose.Dp a, AndroidX.Compose.Dp b) -> bool +static AndroidX.Compose.Dp.operator ==(AndroidX.Compose.Dp left, AndroidX.Compose.Dp right) -> bool +static AndroidX.Compose.Dp.operator >(AndroidX.Compose.Dp a, AndroidX.Compose.Dp b) -> bool +static AndroidX.Compose.Dp.operator >=(AndroidX.Compose.Dp a, AndroidX.Compose.Dp b) -> bool +static AndroidX.Compose.Dp.Pack(AndroidX.Compose.Dp? value) -> float +static AndroidX.Compose.Dp.Zero.get -> AndroidX.Compose.Dp +static AndroidX.Compose.DpExtensions.Dp(this float value) -> AndroidX.Compose.Dp +static AndroidX.Compose.DpExtensions.Dp(this int value) -> AndroidX.Compose.Dp +static AndroidX.Compose.FontFamily.Cursive.get -> AndroidX.Compose.FontFamily! +static AndroidX.Compose.FontFamily.Default.get -> AndroidX.Compose.FontFamily! +static AndroidX.Compose.FontFamily.Monospace.get -> AndroidX.Compose.FontFamily! +static AndroidX.Compose.FontFamily.SansSerif.get -> AndroidX.Compose.FontFamily! +static AndroidX.Compose.FontFamily.Serif.get -> AndroidX.Compose.FontFamily! +static AndroidX.Compose.FontStyle.Italic.get -> AndroidX.Compose.FontStyle! +static AndroidX.Compose.FontStyle.Normal.get -> AndroidX.Compose.FontStyle! +static AndroidX.Compose.FontWeight.Black.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.FontWeight.Bold.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.FontWeight.ExtraBold.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.FontWeight.ExtraLight.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.FontWeight.Light.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.FontWeight.Medium.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.FontWeight.Normal.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.FontWeight.SemiBold.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.FontWeight.Thin.get -> AndroidX.Compose.FontWeight! +static AndroidX.Compose.GridCells.Adaptive(float minSizeDp) -> AndroidX.Compose.Foundation.Lazy.Grid.IGridCells! +static AndroidX.Compose.GridCells.Fixed(int count) -> AndroidX.Compose.Foundation.Lazy.Grid.IGridCells! +static AndroidX.Compose.Icons.AutoMirrored.Default.ArrowBack.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Default.ArrowForward.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Default.ExitToApp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Default.KeyboardArrowLeft.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Default.KeyboardArrowRight.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Default.List.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Default.Send.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Filled.ArrowBack.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Filled.ArrowForward.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Filled.ExitToApp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Filled.KeyboardArrowLeft.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Filled.KeyboardArrowRight.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Filled.List.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Filled.Send.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Outlined.ArrowBack.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Outlined.ArrowForward.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Outlined.ExitToApp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Outlined.KeyboardArrowLeft.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Outlined.KeyboardArrowRight.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Outlined.List.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Outlined.Send.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Rounded.ArrowBack.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Rounded.ArrowForward.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Rounded.ExitToApp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Rounded.KeyboardArrowLeft.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Rounded.KeyboardArrowRight.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Rounded.List.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Rounded.Send.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Sharp.ArrowBack.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Sharp.ArrowForward.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Sharp.ExitToApp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Sharp.KeyboardArrowLeft.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Sharp.KeyboardArrowRight.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Sharp.List.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.Sharp.Send.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.TwoTone.ArrowBack.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.TwoTone.ArrowForward.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.TwoTone.ExitToApp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.TwoTone.KeyboardArrowLeft.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.TwoTone.KeyboardArrowRight.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.TwoTone.List.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.AutoMirrored.TwoTone.Send.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.AccountBox.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.AccountCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Add.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.AddCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.ArrowDropDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Build.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Call.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Check.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.CheckCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Clear.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Close.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Create.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.DateRange.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Delete.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Done.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Edit.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Email.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Face.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Favorite.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.FavoriteBorder.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Home.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Info.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.KeyboardArrowDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.KeyboardArrowUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.LocationOn.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Lock.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.MailOutline.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Menu.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.MoreVert.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Notifications.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Person.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Phone.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Place.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.PlayArrow.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Refresh.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Search.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Settings.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Share.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.ShoppingCart.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Star.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.ThumbUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Default.Warning.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.AccountBox.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.AccountCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Add.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.AddCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.ArrowDropDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Build.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Call.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Check.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.CheckCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Clear.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Close.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Create.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.DateRange.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Delete.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Done.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Edit.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Email.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Face.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Favorite.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.FavoriteBorder.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Home.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Info.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.KeyboardArrowDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.KeyboardArrowUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.LocationOn.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Lock.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.MailOutline.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Menu.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.MoreVert.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Notifications.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Person.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Phone.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Place.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.PlayArrow.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Refresh.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Search.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Settings.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Share.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.ShoppingCart.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Star.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.ThumbUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Filled.Warning.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.AccountBox.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.AccountCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Add.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.AddCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.ArrowDropDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Build.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Call.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Check.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.CheckCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Clear.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Close.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Create.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.DateRange.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Delete.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Done.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Edit.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Email.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Face.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Favorite.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.FavoriteBorder.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Home.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Info.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.KeyboardArrowDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.KeyboardArrowUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.LocationOn.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Lock.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.MailOutline.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Menu.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.MoreVert.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Notifications.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Person.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Phone.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Place.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.PlayArrow.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Refresh.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Search.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Settings.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Share.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.ShoppingCart.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Star.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.ThumbUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Outlined.Warning.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.AccountBox.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.AccountCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Add.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.AddCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.ArrowDropDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Build.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Call.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Check.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.CheckCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Clear.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Close.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Create.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.DateRange.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Delete.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Done.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Edit.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Email.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Face.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Favorite.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.FavoriteBorder.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Home.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Info.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.KeyboardArrowDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.KeyboardArrowUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.LocationOn.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Lock.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.MailOutline.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Menu.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.MoreVert.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Notifications.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Person.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Phone.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Place.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.PlayArrow.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Refresh.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Search.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Settings.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Share.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.ShoppingCart.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Star.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.ThumbUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Rounded.Warning.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.AccountBox.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.AccountCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Add.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.AddCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.ArrowDropDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Build.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Call.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Check.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.CheckCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Clear.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Close.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Create.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.DateRange.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Delete.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Done.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Edit.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Email.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Face.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Favorite.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.FavoriteBorder.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Home.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Info.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.KeyboardArrowDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.KeyboardArrowUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.LocationOn.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Lock.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.MailOutline.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Menu.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.MoreVert.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Notifications.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Person.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Phone.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Place.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.PlayArrow.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Refresh.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Search.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Settings.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Share.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.ShoppingCart.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Star.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.ThumbUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.Sharp.Warning.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.AccountBox.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.AccountCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Add.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.AddCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.ArrowDropDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Build.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Call.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Check.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.CheckCircle.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Clear.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Close.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Create.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.DateRange.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Delete.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Done.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Edit.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Email.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Face.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Favorite.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.FavoriteBorder.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Home.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Info.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.KeyboardArrowDown.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.KeyboardArrowUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.LocationOn.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Lock.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.MailOutline.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Menu.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.MoreVert.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Notifications.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Person.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Phone.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Place.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.PlayArrow.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Refresh.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Search.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Settings.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Share.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.ShoppingCart.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Star.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.ThumbUp.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.Icons.TwoTone.Warning.get -> AndroidX.Compose.UI.Graphics.Vector.ImageVector! +static AndroidX.Compose.ImeAction.Default.get -> int +static AndroidX.Compose.ImeAction.Done.get -> int +static AndroidX.Compose.ImeAction.Go.get -> int +static AndroidX.Compose.ImeAction.Next.get -> int +static AndroidX.Compose.ImeAction.None.get -> int +static AndroidX.Compose.ImeAction.Previous.get -> int +static AndroidX.Compose.ImeAction.Search.get -> int +static AndroidX.Compose.ImeAction.Send.get -> int +static AndroidX.Compose.ImeAction.Unspecified.get -> int +static AndroidX.Compose.KeyboardOptionsCompanion.Default.get -> AndroidX.Compose.Foundation.Text.KeyboardOptions! +static AndroidX.Compose.KeyboardOptionsCompanion.Get() -> AndroidX.Compose.Foundation.Text.KeyboardOptions.Companion! +static AndroidX.Compose.KeyboardActionsHelper.Create(System.Action? onDone = null, System.Action? onGo = null, System.Action? onNext = null, System.Action? onPrevious = null, System.Action? onSearch = null, System.Action? onSend = null) -> AndroidX.Compose.Foundation.Text.KeyboardActions! +static AndroidX.Compose.KeyboardType.Ascii.get -> int +static AndroidX.Compose.KeyboardType.Decimal.get -> int +static AndroidX.Compose.KeyboardType.Email.get -> int +static AndroidX.Compose.KeyboardType.Number.get -> int +static AndroidX.Compose.KeyboardType.NumberPassword.get -> int +static AndroidX.Compose.KeyboardType.Password.get -> int +static AndroidX.Compose.KeyboardType.Phone.get -> int +static AndroidX.Compose.KeyboardType.Text.get -> int +static AndroidX.Compose.KeyboardType.Unspecified.get -> int +static AndroidX.Compose.KeyboardType.Uri.get -> int +static AndroidX.Compose.LinkAnnotation.Clickable(string! tag, System.Action! onClick, AndroidX.Compose.SpanStyle? style = null) -> AndroidX.Compose.LinkAnnotation! +static AndroidX.Compose.LinkAnnotation.Url(string! url, AndroidX.Compose.SpanStyle? style = null) -> AndroidX.Compose.LinkAnnotation! +static AndroidX.Compose.LocalColorScheme.Current(AndroidX.Compose.Runtime.IComposer! composer) -> AndroidX.Compose.Material3.ColorScheme! +static AndroidX.Compose.LocalColorScheme.Provides(AndroidX.Compose.Material3.ColorScheme! value) -> AndroidX.Compose.ProvidedValue! +static AndroidX.Compose.LocalConfiguration.Current(AndroidX.Compose.Runtime.IComposer! composer) -> Android.Content.Res.Configuration! +static AndroidX.Compose.LocalConfiguration.Provides(Android.Content.Res.Configuration! value) -> AndroidX.Compose.ProvidedValue! +static AndroidX.Compose.LocalContext.Current(AndroidX.Compose.Runtime.IComposer! composer) -> Android.Content.Context! +static AndroidX.Compose.LocalContext.Provides(Android.Content.Context! value) -> AndroidX.Compose.ProvidedValue! +static AndroidX.Compose.LocalLifecycleOwner.Current(AndroidX.Compose.Runtime.IComposer! composer) -> AndroidX.Lifecycle.ILifecycleOwner! +static AndroidX.Compose.LocalLifecycleOwner.Provides(AndroidX.Lifecycle.ILifecycleOwner! value) -> AndroidX.Compose.ProvidedValue! +static AndroidX.Compose.LocalResources.Current(AndroidX.Compose.Runtime.IComposer! composer) -> Android.Content.Res.Resources! +static AndroidX.Compose.LocalResources.Provides(Android.Content.Res.Resources! value) -> AndroidX.Compose.ProvidedValue! +static AndroidX.Compose.LocalView.Current(AndroidX.Compose.Runtime.IComposer! composer) -> Android.Views.View! +static AndroidX.Compose.LocalView.Provides(Android.Views.View! value) -> AndroidX.Compose.ProvidedValue! +static AndroidX.Compose.MaterialTheme.BuildShapes(AndroidX.Compose.Shape? extraSmall = null, AndroidX.Compose.Shape? small = null, AndroidX.Compose.Shape? medium = null, AndroidX.Compose.Shape? large = null, AndroidX.Compose.Shape? extraLarge = null) -> AndroidX.Compose.Material3.Shapes! +static AndroidX.Compose.MaterialTheme.BuildTypography(AndroidX.Compose.TextStyle? displayLarge = null, AndroidX.Compose.TextStyle? displayMedium = null, AndroidX.Compose.TextStyle? displaySmall = null, AndroidX.Compose.TextStyle? headlineLarge = null, AndroidX.Compose.TextStyle? headlineMedium = null, AndroidX.Compose.TextStyle? headlineSmall = null, AndroidX.Compose.TextStyle? titleLarge = null, AndroidX.Compose.TextStyle? titleMedium = null, AndroidX.Compose.TextStyle? titleSmall = null, AndroidX.Compose.TextStyle? bodyLarge = null, AndroidX.Compose.TextStyle? bodyMedium = null, AndroidX.Compose.TextStyle? bodySmall = null, AndroidX.Compose.TextStyle? labelLarge = null, AndroidX.Compose.TextStyle? labelMedium = null, AndroidX.Compose.TextStyle? labelSmall = null) -> AndroidX.Compose.Material3.Typography! +static AndroidX.Compose.MaterialTheme.DarkColorScheme() -> AndroidX.Compose.Material3.ColorScheme! +static AndroidX.Compose.MaterialTheme.DarkColorScheme(AndroidX.Compose.Color? primary = null, AndroidX.Compose.Color? onPrimary = null, AndroidX.Compose.Color? primaryContainer = null, AndroidX.Compose.Color? onPrimaryContainer = null, AndroidX.Compose.Color? inversePrimary = null, AndroidX.Compose.Color? secondary = null, AndroidX.Compose.Color? onSecondary = null, AndroidX.Compose.Color? secondaryContainer = null, AndroidX.Compose.Color? onSecondaryContainer = null, AndroidX.Compose.Color? tertiary = null, AndroidX.Compose.Color? onTertiary = null, AndroidX.Compose.Color? tertiaryContainer = null, AndroidX.Compose.Color? onTertiaryContainer = null, AndroidX.Compose.Color? background = null, AndroidX.Compose.Color? onBackground = null, AndroidX.Compose.Color? surface = null, AndroidX.Compose.Color? onSurface = null, AndroidX.Compose.Color? surfaceVariant = null, AndroidX.Compose.Color? onSurfaceVariant = null, AndroidX.Compose.Color? surfaceTint = null, AndroidX.Compose.Color? inverseSurface = null, AndroidX.Compose.Color? inverseOnSurface = null, AndroidX.Compose.Color? error = null, AndroidX.Compose.Color? onError = null, AndroidX.Compose.Color? errorContainer = null, AndroidX.Compose.Color? onErrorContainer = null, AndroidX.Compose.Color? outline = null, AndroidX.Compose.Color? outlineVariant = null, AndroidX.Compose.Color? scrim = null, AndroidX.Compose.Color? surfaceBright = null, AndroidX.Compose.Color? surfaceContainer = null, AndroidX.Compose.Color? surfaceContainerHigh = null, AndroidX.Compose.Color? surfaceContainerHighest = null, AndroidX.Compose.Color? surfaceContainerLow = null, AndroidX.Compose.Color? surfaceContainerLowest = null, AndroidX.Compose.Color? surfaceDim = null, AndroidX.Compose.Color? primaryFixed = null, AndroidX.Compose.Color? primaryFixedDim = null, AndroidX.Compose.Color? onPrimaryFixed = null, AndroidX.Compose.Color? onPrimaryFixedVariant = null, AndroidX.Compose.Color? secondaryFixed = null, AndroidX.Compose.Color? secondaryFixedDim = null, AndroidX.Compose.Color? onSecondaryFixed = null, AndroidX.Compose.Color? onSecondaryFixedVariant = null, AndroidX.Compose.Color? tertiaryFixed = null, AndroidX.Compose.Color? tertiaryFixedDim = null, AndroidX.Compose.Color? onTertiaryFixed = null, AndroidX.Compose.Color? onTertiaryFixedVariant = null) -> AndroidX.Compose.Material3.ColorScheme! +static AndroidX.Compose.MaterialTheme.DynamicDarkColorScheme(Android.Content.Context? context = null) -> AndroidX.Compose.Material3.ColorScheme! +static AndroidX.Compose.MaterialTheme.DynamicLightColorScheme(Android.Content.Context? context = null) -> AndroidX.Compose.Material3.ColorScheme! +static AndroidX.Compose.MaterialTheme.IsSystemInDarkTheme(AndroidX.Compose.Runtime.IComposer! composer) -> bool +static AndroidX.Compose.MaterialTheme.LightColorScheme() -> AndroidX.Compose.Material3.ColorScheme! +static AndroidX.Compose.MaterialTheme.LightColorScheme(AndroidX.Compose.Color? primary = null, AndroidX.Compose.Color? onPrimary = null, AndroidX.Compose.Color? primaryContainer = null, AndroidX.Compose.Color? onPrimaryContainer = null, AndroidX.Compose.Color? inversePrimary = null, AndroidX.Compose.Color? secondary = null, AndroidX.Compose.Color? onSecondary = null, AndroidX.Compose.Color? secondaryContainer = null, AndroidX.Compose.Color? onSecondaryContainer = null, AndroidX.Compose.Color? tertiary = null, AndroidX.Compose.Color? onTertiary = null, AndroidX.Compose.Color? tertiaryContainer = null, AndroidX.Compose.Color? onTertiaryContainer = null, AndroidX.Compose.Color? background = null, AndroidX.Compose.Color? onBackground = null, AndroidX.Compose.Color? surface = null, AndroidX.Compose.Color? onSurface = null, AndroidX.Compose.Color? surfaceVariant = null, AndroidX.Compose.Color? onSurfaceVariant = null, AndroidX.Compose.Color? surfaceTint = null, AndroidX.Compose.Color? inverseSurface = null, AndroidX.Compose.Color? inverseOnSurface = null, AndroidX.Compose.Color? error = null, AndroidX.Compose.Color? onError = null, AndroidX.Compose.Color? errorContainer = null, AndroidX.Compose.Color? onErrorContainer = null, AndroidX.Compose.Color? outline = null, AndroidX.Compose.Color? outlineVariant = null, AndroidX.Compose.Color? scrim = null, AndroidX.Compose.Color? surfaceBright = null, AndroidX.Compose.Color? surfaceContainer = null, AndroidX.Compose.Color? surfaceContainerHigh = null, AndroidX.Compose.Color? surfaceContainerHighest = null, AndroidX.Compose.Color? surfaceContainerLow = null, AndroidX.Compose.Color? surfaceContainerLowest = null, AndroidX.Compose.Color? surfaceDim = null, AndroidX.Compose.Color? primaryFixed = null, AndroidX.Compose.Color? primaryFixedDim = null, AndroidX.Compose.Color? onPrimaryFixed = null, AndroidX.Compose.Color? onPrimaryFixedVariant = null, AndroidX.Compose.Color? secondaryFixed = null, AndroidX.Compose.Color? secondaryFixedDim = null, AndroidX.Compose.Color? onSecondaryFixed = null, AndroidX.Compose.Color? onSecondaryFixedVariant = null, AndroidX.Compose.Color? tertiaryFixed = null, AndroidX.Compose.Color? tertiaryFixedDim = null, AndroidX.Compose.Color? onTertiaryFixed = null, AndroidX.Compose.Color? onTertiaryFixedVariant = null) -> AndroidX.Compose.Material3.ColorScheme! +static AndroidX.Compose.Modifier.AbsoluteOffset(AndroidX.Compose.Dp? x = null, AndroidX.Compose.Dp? y = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Align(AndroidX.Compose.Alignment! alignment) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Align(AndroidX.Compose.Alignment.Horizontal! alignment) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Align(AndroidX.Compose.Alignment.Vertical! alignment) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Alpha(float alpha) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.AspectRatio(float ratio, bool matchHeightConstraintsFirst = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Background(AndroidX.Compose.Color color, AndroidX.Compose.Shape? shape) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Background(AndroidX.Compose.Color color) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Border(AndroidX.Compose.Dp width, AndroidX.Compose.Color color, AndroidX.Compose.Dp cornerRadius) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Border(AndroidX.Compose.Dp width, AndroidX.Compose.Color color, AndroidX.Compose.Shape? shape) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Border(AndroidX.Compose.Dp width, AndroidX.Compose.Color color) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.CaptionBarPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.ClearAndSetSemantics(string! contentDescription) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.ClearAndSetSemantics(string? contentDescription, AndroidX.Compose.SemanticsRole? role) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.ClearAndSetSemantics(System.Action! properties) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Clickable(System.Action! onClick) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Clip(AndroidX.Compose.Dp cornerRadius) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Clip(AndroidX.Compose.Shape! shape) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.CombinedClickable(System.Action! onClick, System.Action? onLongClick = null, System.Action? onDoubleClick = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Companion.get -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.DefaultMinSize(AndroidX.Compose.Dp? minWidth = null, AndroidX.Compose.Dp? minHeight = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.DetectDragGestures(System.Action! onDrag, System.Action? onDragStart = null, System.Action? onDragEnd = null, System.Action? onDragCancel = null, object? key = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.DetectTapGestures(System.Action? onTap = null, System.Action? onPress = null, System.Action? onLongPress = null, System.Action? onDoubleTap = null, object? key = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.DetectTransformGestures(System.Action! onGesture, bool panZoomLock = false, object? key = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.DisplayCutoutPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.DragAndDropTarget(System.Func! shouldStartDragAndDrop, AndroidX.Compose.DragAndDropTarget! target) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Draggable(AndroidX.Compose.DraggableState! state, AndroidX.Compose.Orientation orientation, bool enabled = true) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.FillMaxHeight(float fraction = 1) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.FillMaxSize(float fraction = 1) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.FillMaxWidth(float fraction = 1) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Focusable(bool enabled = true) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.FocusGroup() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.FocusRequester(AndroidX.Compose.FocusRequester! requester) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.GraphicsLayer(float? scaleX = null, float? scaleY = null, float? alpha = null, float? translationX = null, float? translationY = null, float? shadowElevation = null, float? rotationX = null, float? rotationY = null, float? rotationZ = null, float? cameraDistance = null, long? transformOrigin = null, AndroidX.Compose.Shape? shape = null, bool? clip = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Height(AndroidX.Compose.Dp height) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.HeightIn(AndroidX.Compose.Dp? min = null, AndroidX.Compose.Dp? max = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.HorizontalScroll(AndroidX.Compose.ScrollState! state, bool enabled = true, bool reverseScrolling = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.ImePadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.MandatorySystemGesturesPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.MatchParentSize() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.MinimumInteractiveComponentSize() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.NavigationBarsPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.NestedScroll(AndroidX.Compose.UI.Input.NestedScroll.INestedScrollConnection! connection) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Offset(AndroidX.Compose.Dp? x = null, AndroidX.Compose.Dp? y = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.OnFocusChanged(System.Action! onFocusChanged) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Padding(AndroidX.Compose.Dp all) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Padding(AndroidX.Compose.Dp horizontal = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp vertical = default(AndroidX.Compose.Dp)) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Padding(AndroidX.Compose.Dp start = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp top = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp end = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp bottom = default(AndroidX.Compose.Dp)) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Padding(AndroidX.Compose.PaddingValues! paddingValues) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.RequiredHeight(AndroidX.Compose.Dp height) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.RequiredSize(AndroidX.Compose.Dp size) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.RequiredSize(AndroidX.Compose.Dp width, AndroidX.Compose.Dp height) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.RequiredWidth(AndroidX.Compose.Dp width) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Rotate(float degrees) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.SafeContentPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.SafeDrawingPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.SafeGesturesPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Scale(float scale) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Scale(float scaleX, float scaleY) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Selectable(bool selected, System.Action! onClick) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Semantics(AndroidX.Compose.SemanticsRole role) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Semantics(bool mergeDescendants, string! contentDescription) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Semantics(bool mergeDescendants, string? contentDescription, AndroidX.Compose.SemanticsRole? role) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Semantics(bool mergeDescendants, System.Action! properties) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Semantics(string! contentDescription) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Semantics(string? contentDescription, AndroidX.Compose.SemanticsRole? role) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Semantics(System.Action! properties) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Shadow(AndroidX.Compose.Dp elevation, AndroidX.Compose.Shape? shape = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Size(AndroidX.Compose.Dp size) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Size(AndroidX.Compose.Dp width, AndroidX.Compose.Dp height) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.SizeIn(AndroidX.Compose.Dp? minWidth = null, AndroidX.Compose.Dp? minHeight = null, AndroidX.Compose.Dp? maxWidth = null, AndroidX.Compose.Dp? maxHeight = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.StatusBarsPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.SystemBarsPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.SystemGesturesPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.TestTag(string! tag) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Toggleable(bool value, System.Action! onValueChange) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.VerticalScroll(AndroidX.Compose.ScrollState! state, bool enabled = true, bool reverseScrolling = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.WaterfallPadding() -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Weight(float weight, bool fill = true) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.Width(AndroidX.Compose.Dp width) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.WidthIn(AndroidX.Compose.Dp? min = null, AndroidX.Compose.Dp? max = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.WrapContentHeight(bool unbounded = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.WrapContentSize(bool unbounded = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.WrapContentWidth(bool unbounded = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.Modifier.ZIndex(float z) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.AbsoluteOffset(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp? x = null, AndroidX.Compose.Dp? y = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Align(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Alignment! alignment) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Align(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Alignment.Horizontal! alignment) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Align(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Alignment.Vertical! alignment) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Alpha(this AndroidX.Compose.Modifier! modifier, float alpha) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.AspectRatio(this AndroidX.Compose.Modifier! modifier, float ratio, bool matchHeightConstraintsFirst = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Background(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Color color, AndroidX.Compose.Shape? shape) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Background(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Color color) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Background(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.UI.Graphics.Brush! brush, AndroidX.Compose.Shape? shape = null, float alpha = 1) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Border(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp width, AndroidX.Compose.Color color, AndroidX.Compose.Dp cornerRadius) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Border(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp width, AndroidX.Compose.Color color, AndroidX.Compose.Shape? shape) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Border(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp width, AndroidX.Compose.Color color) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Border(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp width, AndroidX.Compose.UI.Graphics.Brush! brush, AndroidX.Compose.Shape? shape = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.CaptionBarPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.ClearAndSetSemantics(this AndroidX.Compose.Modifier! modifier, string! contentDescription) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.ClearAndSetSemantics(this AndroidX.Compose.Modifier! modifier, string? contentDescription, AndroidX.Compose.SemanticsRole? role) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.ClearAndSetSemantics(this AndroidX.Compose.Modifier! modifier, System.Action! properties) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Clickable(this AndroidX.Compose.Modifier! modifier, System.Action! onClick) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Clip(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp cornerRadius) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Clip(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Shape! shape) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.CombinedClickable(this AndroidX.Compose.Modifier! modifier, System.Action! onClick, System.Action? onLongClick = null, System.Action? onDoubleClick = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.DefaultMinSize(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp? minWidth = null, AndroidX.Compose.Dp? minHeight = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.DetectDragGestures(this AndroidX.Compose.Modifier! modifier, System.Action! onDrag, System.Action? onDragStart = null, System.Action? onDragEnd = null, System.Action? onDragCancel = null, object? key = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.DetectDragGesturesAfterLongPress(this AndroidX.Compose.Modifier! modifier, System.Action! onDrag, System.Action? onDragStart = null, System.Action? onDragEnd = null, System.Action? onDragCancel = null, object? key = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.DetectTapGestures(this AndroidX.Compose.Modifier! modifier, System.Action? onTap = null, System.Action? onPress = null, System.Action? onLongPress = null, System.Action? onDoubleTap = null, object? key = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.DetectTransformGestures(this AndroidX.Compose.Modifier! modifier, System.Action! onGesture, bool panZoomLock = false, object? key = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.DisplayCutoutPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.DragAndDropTarget(this AndroidX.Compose.Modifier! modifier, System.Func! shouldStartDragAndDrop, AndroidX.Compose.DragAndDropTarget! target) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Draggable(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.DraggableState! state, AndroidX.Compose.Orientation orientation, bool enabled = true) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.FillMaxHeight(this AndroidX.Compose.Modifier! modifier, float fraction = 1) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.FillMaxSize(this AndroidX.Compose.Modifier! modifier, float fraction = 1) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.FillMaxWidth(this AndroidX.Compose.Modifier! modifier, float fraction = 1) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Focusable(this AndroidX.Compose.Modifier! modifier, bool enabled = true) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.FocusGroup(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.FocusRequester(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.FocusRequester! requester) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.GraphicsLayer(this AndroidX.Compose.Modifier! modifier, float? scaleX = null, float? scaleY = null, float? alpha = null, float? translationX = null, float? translationY = null, float? shadowElevation = null, float? rotationX = null, float? rotationY = null, float? rotationZ = null, float? cameraDistance = null, long? transformOrigin = null, AndroidX.Compose.Shape? shape = null, bool? clip = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Height(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp height) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.HeightIn(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp? min = null, AndroidX.Compose.Dp? max = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.HorizontalScroll(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.ScrollState! state, bool enabled = true, bool reverseScrolling = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.ImePadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.MandatorySystemGesturesPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.MatchParentSize(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.MinimumInteractiveComponentSize(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.NavigationBarsPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.NestedScroll(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.UI.Input.NestedScroll.INestedScrollConnection! connection) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Offset(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp? x = null, AndroidX.Compose.Dp? y = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.OnFocusChanged(this AndroidX.Compose.Modifier! modifier, System.Action! onFocusChanged) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Padding(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp all) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Padding(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp horizontal = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp vertical = default(AndroidX.Compose.Dp)) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Padding(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp start = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp top = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp end = default(AndroidX.Compose.Dp), AndroidX.Compose.Dp bottom = default(AndroidX.Compose.Dp)) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Padding(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.PaddingValues! paddingValues) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.RequiredHeight(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp height) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.RequiredSize(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp size) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.RequiredSize(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp width, AndroidX.Compose.Dp height) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.RequiredWidth(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp width) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Rotate(this AndroidX.Compose.Modifier! modifier, float degrees) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.SafeContentPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.SafeDrawingPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.SafeGesturesPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Scale(this AndroidX.Compose.Modifier! modifier, float scale) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Scale(this AndroidX.Compose.Modifier! modifier, float scaleX, float scaleY) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Selectable(this AndroidX.Compose.Modifier! modifier, bool selected, System.Action! onClick) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Semantics(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.SemanticsRole role) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Semantics(this AndroidX.Compose.Modifier! modifier, bool mergeDescendants, string! contentDescription) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Semantics(this AndroidX.Compose.Modifier! modifier, bool mergeDescendants, string? contentDescription, AndroidX.Compose.SemanticsRole? role) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Semantics(this AndroidX.Compose.Modifier! modifier, bool mergeDescendants, System.Action! properties) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Semantics(this AndroidX.Compose.Modifier! modifier, string! contentDescription) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Semantics(this AndroidX.Compose.Modifier! modifier, string? contentDescription, AndroidX.Compose.SemanticsRole? role) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Semantics(this AndroidX.Compose.Modifier! modifier, System.Action! properties) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Shadow(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp elevation, AndroidX.Compose.Shape? shape = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Size(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp size) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Size(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp width, AndroidX.Compose.Dp height) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.SizeIn(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp? minWidth = null, AndroidX.Compose.Dp? minHeight = null, AndroidX.Compose.Dp? maxWidth = null, AndroidX.Compose.Dp? maxHeight = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.StatusBarsPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.SystemBarsPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.SystemGesturesPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.TestTag(this AndroidX.Compose.Modifier! modifier, string! tag) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Toggleable(this AndroidX.Compose.Modifier! modifier, bool value, System.Action! onValueChange) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.VerticalScroll(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.ScrollState! state, bool enabled = true, bool reverseScrolling = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.WaterfallPadding(this AndroidX.Compose.Modifier! modifier) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Weight(this AndroidX.Compose.Modifier! modifier, float weight, bool fill = true) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.Width(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp width) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.WidthIn(this AndroidX.Compose.Modifier! modifier, AndroidX.Compose.Dp? min = null, AndroidX.Compose.Dp? max = null) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.WrapContentHeight(this AndroidX.Compose.Modifier! modifier, bool unbounded = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.WrapContentSize(this AndroidX.Compose.Modifier! modifier, bool unbounded = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.WrapContentWidth(this AndroidX.Compose.Modifier! modifier, bool unbounded = false) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.ModifierExtensions.ZIndex(this AndroidX.Compose.Modifier! modifier, float z) -> AndroidX.Compose.Modifier! +static AndroidX.Compose.MutableNumberState.operator --(AndroidX.Compose.MutableNumberState! s) -> AndroidX.Compose.MutableNumberState! +static AndroidX.Compose.MutableNumberState.operator ++(AndroidX.Compose.MutableNumberState! s) -> AndroidX.Compose.MutableNumberState! +static AndroidX.Compose.MutableState.implicit operator T(AndroidX.Compose.MutableState! state) -> T +static AndroidX.Compose.Offset.Infinite.get -> AndroidX.Compose.Offset +static AndroidX.Compose.Offset.operator !=(AndroidX.Compose.Offset a, AndroidX.Compose.Offset b) -> bool +static AndroidX.Compose.Offset.operator ==(AndroidX.Compose.Offset a, AndroidX.Compose.Offset b) -> bool +static AndroidX.Compose.Offset.Unspecified.get -> AndroidX.Compose.Offset +static AndroidX.Compose.Offset.Zero.get -> AndroidX.Compose.Offset +static AndroidX.Compose.Shape.Circle() -> AndroidX.Compose.Shape! +static AndroidX.Compose.Shape.CutCorners(AndroidX.Compose.Dp cornerSize) -> AndroidX.Compose.Shape! +static AndroidX.Compose.Shape.CutCornersPercent(int percent) -> AndroidX.Compose.Shape! +static AndroidX.Compose.Shape.Rectangle.get -> AndroidX.Compose.Shape! +static AndroidX.Compose.Shape.RoundedCorners(AndroidX.Compose.Dp cornerRadius) -> AndroidX.Compose.Shape! +static AndroidX.Compose.Shape.RoundedCorners(AndroidX.Compose.Dp topStart, AndroidX.Compose.Dp topEnd, AndroidX.Compose.Dp bottomEnd, AndroidX.Compose.Dp bottomStart) -> AndroidX.Compose.Shape! +static AndroidX.Compose.Shape.RoundedPercent(int percent) -> AndroidX.Compose.Shape! +static AndroidX.Compose.Sp.implicit operator AndroidX.Compose.Sp(int value) -> AndroidX.Compose.Sp +static AndroidX.Compose.Sp.operator -(AndroidX.Compose.Sp a) -> AndroidX.Compose.Sp +static AndroidX.Compose.Sp.operator !=(AndroidX.Compose.Sp left, AndroidX.Compose.Sp right) -> bool +static AndroidX.Compose.Sp.operator *(AndroidX.Compose.Sp a, float scalar) -> AndroidX.Compose.Sp +static AndroidX.Compose.Sp.operator *(float scalar, AndroidX.Compose.Sp a) -> AndroidX.Compose.Sp +static AndroidX.Compose.Sp.operator /(AndroidX.Compose.Sp a, float scalar) -> AndroidX.Compose.Sp +static AndroidX.Compose.Sp.operator <(AndroidX.Compose.Sp a, AndroidX.Compose.Sp b) -> bool +static AndroidX.Compose.Sp.operator <=(AndroidX.Compose.Sp a, AndroidX.Compose.Sp b) -> bool +static AndroidX.Compose.Sp.operator ==(AndroidX.Compose.Sp left, AndroidX.Compose.Sp right) -> bool +static AndroidX.Compose.Sp.operator >(AndroidX.Compose.Sp a, AndroidX.Compose.Sp b) -> bool +static AndroidX.Compose.Sp.operator >=(AndroidX.Compose.Sp a, AndroidX.Compose.Sp b) -> bool +static AndroidX.Compose.Sp.Pack(AndroidX.Compose.Sp? value) -> long +static AndroidX.Compose.Sp.Zero.get -> AndroidX.Compose.Sp +static AndroidX.Compose.Spacer.Height(AndroidX.Compose.Dp dp) -> AndroidX.Compose.Spacer! +static AndroidX.Compose.Spacer.Size(AndroidX.Compose.Dp dp) -> AndroidX.Compose.Spacer! +static AndroidX.Compose.Spacer.Size(AndroidX.Compose.Dp width, AndroidX.Compose.Dp height) -> AndroidX.Compose.Spacer! +static AndroidX.Compose.Spacer.Weight(float weight, bool fill = true) -> AndroidX.Compose.Spacer! +static AndroidX.Compose.Spacer.Width(AndroidX.Compose.Dp dp) -> AndroidX.Compose.Spacer! +static AndroidX.Compose.SpExtensions.Sp(this int value) -> AndroidX.Compose.Sp +static AndroidX.Compose.StaggeredGridCells.Adaptive(float minSizeDp) -> AndroidX.Compose.Foundation.Lazy.Staggeredgrid.IStaggeredGridCells! +static AndroidX.Compose.StaggeredGridCells.Fixed(int count) -> AndroidX.Compose.Foundation.Lazy.Staggeredgrid.IStaggeredGridCells! +static AndroidX.Compose.StaggeredGridCells.FixedSize(float sizeDp) -> AndroidX.Compose.Foundation.Lazy.Staggeredgrid.IStaggeredGridCells! +static AndroidX.Compose.StateFlowExtensions.CollectAsStateWithLifecycle(this AndroidX.Compose.IStateFlow! flow, T initialValue) -> AndroidX.Compose.IState! +static AndroidX.Compose.StateFlowExtensions.CollectAsStateWithLifecycle(this AndroidX.Compose.IStateFlow! flow) -> AndroidX.Compose.IState! +static AndroidX.Compose.TextAlign.Center.get -> AndroidX.Compose.TextAlign! +static AndroidX.Compose.TextAlign.End.get -> AndroidX.Compose.TextAlign! +static AndroidX.Compose.TextAlign.Justify.get -> AndroidX.Compose.TextAlign! +static AndroidX.Compose.TextAlign.Left.get -> AndroidX.Compose.TextAlign! +static AndroidX.Compose.TextAlign.Right.get -> AndroidX.Compose.TextAlign! +static AndroidX.Compose.TextAlign.Start.get -> AndroidX.Compose.TextAlign! +static AndroidX.Compose.TextAlign.Unspecified.get -> AndroidX.Compose.TextAlign! +static AndroidX.Compose.TextDecoration.LineThrough.get -> AndroidX.Compose.TextDecoration! +static AndroidX.Compose.TextDecoration.None.get -> AndroidX.Compose.TextDecoration! +static AndroidX.Compose.TextDecoration.Underline.get -> AndroidX.Compose.TextDecoration! +static AndroidX.Compose.TextOverflow.Clip.get -> AndroidX.Compose.TextOverflow +static AndroidX.Compose.TextOverflow.Ellipsis.get -> AndroidX.Compose.TextOverflow +static AndroidX.Compose.TextOverflow.MiddleEllipsis.get -> AndroidX.Compose.TextOverflow +static AndroidX.Compose.TextOverflow.operator !=(AndroidX.Compose.TextOverflow left, AndroidX.Compose.TextOverflow right) -> bool +static AndroidX.Compose.TextOverflow.operator ==(AndroidX.Compose.TextOverflow left, AndroidX.Compose.TextOverflow right) -> bool +static AndroidX.Compose.TextOverflow.Pack(AndroidX.Compose.TextOverflow? value) -> int +static AndroidX.Compose.TextOverflow.StartEllipsis.get -> AndroidX.Compose.TextOverflow +static AndroidX.Compose.TextOverflow.Visible.get -> AndroidX.Compose.TextOverflow +static AndroidX.Compose.TransformOrigin.Center.get -> long +static AndroidX.Compose.TransformOrigin.Pack(float pivotFractionX, float pivotFractionY) -> long +static AndroidX.Compose.Transitions.FadeIn(float initialAlpha = 0, AndroidX.Compose.Animation.Core.IFiniteAnimationSpec? animationSpec = null) -> AndroidX.Compose.Animation.EnterTransition! +static AndroidX.Compose.Transitions.FadeOut(float targetAlpha = 0, AndroidX.Compose.Animation.Core.IFiniteAnimationSpec? animationSpec = null) -> AndroidX.Compose.Animation.ExitTransition! +static AndroidX.Compose.Transitions.ScaleIn(float initialScale = 0, AndroidX.Compose.Animation.Core.IFiniteAnimationSpec? animationSpec = null) -> AndroidX.Compose.Animation.EnterTransition! +static AndroidX.Compose.Transitions.ScaleOut(float targetScale = 0, AndroidX.Compose.Animation.Core.IFiniteAnimationSpec? animationSpec = null) -> AndroidX.Compose.Animation.ExitTransition! +static AndroidX.Compose.Transitions.SlideInHorizontally(System.Func? initialOffsetX = null, AndroidX.Compose.Animation.Core.IFiniteAnimationSpec? animationSpec = null) -> AndroidX.Compose.Animation.EnterTransition! +static AndroidX.Compose.Transitions.SlideInVertically(System.Func? initialOffsetY = null, AndroidX.Compose.Animation.Core.IFiniteAnimationSpec? animationSpec = null) -> AndroidX.Compose.Animation.EnterTransition! +static AndroidX.Compose.Transitions.SlideOutHorizontally(System.Func? targetOffsetX = null, AndroidX.Compose.Animation.Core.IFiniteAnimationSpec? animationSpec = null) -> AndroidX.Compose.Animation.ExitTransition! +static AndroidX.Compose.Transitions.SlideOutVertically(System.Func? targetOffsetY = null, AndroidX.Compose.Animation.Core.IFiniteAnimationSpec? animationSpec = null) -> AndroidX.Compose.Animation.ExitTransition! +virtual AndroidX.Compose.MutableState.Value.get -> T +virtual AndroidX.Compose.MutableState.Value.set -> void +virtual AndroidX.Compose.ViewModel.OnClearedCore() -> void diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PullToRefreshBox.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PullToRefreshBox.cs new file mode 100644 index 000000000..62c6c04b5 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PullToRefreshBox.cs @@ -0,0 +1,64 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 PullToRefreshBox. A container that wraps scrollable +/// content (typically a ) with the Material +/// pull-to-refresh gesture: the user drags past the threshold and +/// Compose invokes onRefresh. The caller owns the busy flag +/// (isRefreshing) and clears it when the async reload finishes. +/// +/// +/// Pass an explicit to +/// observe pull progress ( / +/// ) for a custom indicator +/// or analytics; omit it and Compose creates one internally via +/// rememberPullToRefreshState(). The stock Material 3 spinner is +/// always used — indicator customization is not yet exposed (the +/// underlying container + optional Function3 slot is the same +/// hybrid shape the facade generator can't model for BottomAppBar; +/// see .github/copilot-instructions.md). +/// +/// +/// var refreshing = Remember(() => new MutableState<bool>(false)); +/// +/// new PullToRefreshBox( +/// isRefreshing: refreshing.Value, +/// onRefresh: () => StartReload(refreshing)) +/// { +/// new LazyColumn<int>(items: rows, itemContent: i => new Text($"Row {i}")) +/// { +/// Modifier = Modifier.FillMaxSize(), +/// }, +/// } +/// +/// +/// The generated Render() calls +/// ComposeBridges.RememberPullToRefreshState to obtain the JVM +/// state handle, populates the wrapper's Jvm field on first +/// render (so subsequent property reads on the wrapper hit the live +/// state), and forwards the handle into the +/// androidx.compose.material3.pulltorefresh.PullToRefreshKt.PullToRefreshBox +/// composable. +/// +/// PullToRefreshBox is transparent to scaffold padding: it does NOT +/// participate in 's +/// implicit forwarding because there is no single correct destination +/// (caller may want the spinner inset OR the items inset, and the +/// answer differs per screen). Mirror Kotlin's idiom and route +/// padding explicitly via : +/// +/// new Scaffold +/// { +/// TopBar = ..., +/// BodyContent = padding => new PullToRefreshBox(...) +/// { +/// new LazyColumn<Row>(items, itemContent: r => ...) +/// { +/// ContentPadding = padding, +/// Modifier = Modifier.FillMaxSize(), +/// }, +/// }, +/// } +/// +/// +public sealed partial class PullToRefreshBox; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PullToRefreshState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PullToRefreshState.cs new file mode 100644 index 000000000..211456a0b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/PullToRefreshState.cs @@ -0,0 +1,61 @@ +using AndroidX.Compose.Material3.PullToRefresh; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for . The +/// underlying JVM +/// androidx.compose.material3.pulltorefresh.PullToRefreshState +/// is created lazily the first time a +/// bound to this state is rendered; reads of +/// or before that point return safe defaults +/// (0f / false). +/// +/// +/// Same shape as : a thin wrapper +/// whose Jvm field is populated by the generator-emitted state- +/// holder facade on first render. Typical usage is to Remember +/// an instance, pass it as the state ctor arg of a +/// , and (optionally) drive UI off the +/// progress properties — e.g. a custom indicator that fades in as +/// approaches 1. +/// +/// +/// var refreshing = Remember(() => new MutableState<bool>(false)); +/// var ptrState = Remember(() => new PullToRefreshState()); +/// +/// new PullToRefreshBox( +/// isRefreshing: refreshing.Value, +/// onRefresh: () => StartReload(refreshing), +/// state: ptrState) +/// { +/// new LazyColumn<int>(items: rows, itemContent: i => new Text($"Row {i}")) +/// { +/// Modifier = Modifier.FillMaxSize(), +/// }, +/// } +/// +/// +public sealed class PullToRefreshState +{ + internal IPullToRefreshState? Jvm; + + /// + /// Current pull progress, in the range [0f, 1f+]. 0f + /// at rest; reaches 1f at the refresh threshold; can exceed + /// 1f on over-pull. Returns 0f until the first + /// render binds this state to the + /// JVM picker. Read inside a composable to drive a custom + /// indicator's visual feedback. + /// + public float DistanceFraction => Jvm?.DistanceFraction ?? 0f; + + /// + /// true while the indicator is animating into / out of its + /// rest position (typically right after the user releases past the + /// threshold, before onRefresh fires, and while the indicator + /// retracts after the caller clears the busy flag). Returns + /// false until the state is bound. + /// + public bool IsAnimating => Jvm?.IsAnimating ?? false; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RadioButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RadioButton.cs new file mode 100644 index 000000000..1521d9860 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RadioButton.cs @@ -0,0 +1,11 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 RadioButton. Typically used inside a +/// alongside a label, with the +/// parent state tracking which option is selected: +/// +/// new RadioButton(selected: pick.Value == "A", onClick: () => pick.Value = "A") +/// +/// +public sealed partial class RadioButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RangeSlider.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RangeSlider.cs new file mode 100644 index 000000000..67f8be8a1 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RangeSlider.cs @@ -0,0 +1,65 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; +using Android.Runtime; +using Kotlin.Ranges; + +namespace AndroidX.Compose; + +/// +/// Material 3 RangeSlider. The current value is a +/// (float Start, float End) tuple — at Render time we +/// build a Kotlin ClosedFloatingPointRange<Float> from it +/// and unpack the same shape on every callback: +/// +/// new RangeSlider( +/// value: (range.Value.Start, range.Value.End), +/// onValueChange: r => range.Value = r) +/// +/// Calls the simpler (ClosedFloatingPointRange<Float>, ...) +/// overload — the richer overloads with custom thumb/track slots or a +/// RangeSliderState first param aren't exposed in this facade. +/// +public sealed class RangeSlider : ComposableNode +{ + readonly (float Start, float End) _value; + readonly Action<(float Start, float End)> _onValueChange; + + public RangeSlider( + (float Start, float End) value, + Action<(float Start, float End)> onValueChange) + { + _value = value; + _onValueChange = onValueChange; + } + + public override void Render(IComposer composer) + { + var range = RangesKt.RangeTo(_value.Start, _value.End); + + var onChange = new ComposableLambda1(v => + { + var r = v?.JavaCast(); + if (r is null) return; + var lo = ((Java.Lang.Float)r.Start).FloatValue(); + var hi = ((Java.Lang.Float)r.EndInclusive).FloatValue(); + _onValueChange((lo, hi)); + }); + + var modifier = BuildModifier(); + int defaults = (int)RangeSliderDefault.All; + if (modifier is not null) defaults &= ~(int)RangeSliderDefault.Modifier; + + SliderKt.RangeSlider( + value: range, + onValueChange: onChange, + modifier: modifier, + enabled: true, + valueRange: null, + p5: 0, + onValueChangeFinished: null, + colors: null, + _composer: composer, + steps: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RememberHolder.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RememberHolder.cs new file mode 100644 index 000000000..65963f77a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RememberHolder.cs @@ -0,0 +1,73 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Single-field wrapper that lets +/// store an arbitrary managed +/// T (often a ) in Compose's slot +/// table, which only holds references. +/// +/// Registered with the .NET-for-Android peer cache ([Register] +/// + the IntPtr/JniHandleOwnership ctor) so that when Compose hands +/// the slot value back through +/// on a recomposition, the runtime rehydrates the same managed +/// RememberHolder peer rather than a plain +/// shell — which would lose the +/// field and force the factory to re-run. +/// +/// When a keyed overload is used, +/// the supplied keys are cloned into so a later +/// recomposition can compare them via ; mismatch +/// invalidates the slot and forces the factory to re-run. +/// +[Register("net/compose/RememberHolder")] +internal sealed class RememberHolder : Java.Lang.Object +{ + /// The remembered managed value (boxed if a value type). + public object? Value; + + /// + /// Defensive snapshot of the caller-supplied keys, or null + /// for the keyless overload. Cloned at construction so later + /// caller mutation of the original array doesn't corrupt the + /// "previous keys" comparison. + /// + public object?[]? Keys; + + public RememberHolder() { } + + public RememberHolder(object? value) => Value = value; + + public RememberHolder(object? value, object?[]? keys) + { + Value = value; + Keys = keys is null ? null : (object?[])keys.Clone(); + } + + // Peer-rehydration ctor: called by the .NET-for-Android runtime when + // a JNI handle for our [Register]'d class crosses back into managed + // code and its managed peer needs to be (re)created. We do NOT touch + // Value here — the original instance's Value field stays attached to + // whichever peer the runtime returns from its peer cache. + internal RememberHolder(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + /// + /// Structural-equality comparison of two key arrays, matching + /// Kotlin's remember(k1, k2, …) { … } invalidation rules: + /// two null arrays are equal, length mismatch is unequal, + /// elements are compared with . + /// + internal static bool KeysEqual(object?[]? a, object?[]? b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null) return false; + if (a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) + { + if (!object.Equals(a[i], b[i])) return false; + } + return true; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RenderContext.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RenderContext.cs new file mode 100644 index 000000000..e16294b87 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RenderContext.cs @@ -0,0 +1,102 @@ +namespace AndroidX.Compose; + +/// +/// Thread-static stash of the current Compose receiver scope (RowScope / +/// ColumnScope) handle. Set by container composables that consume a +/// scope-receiver Function3 content lambda; read by *Item composables +/// whose underlying Kotlin static method takes the scope as its first +/// argument. Composition runs synchronously on a single thread, so a +/// [ThreadStatic] is sufficient — and a struct-disposable scope +/// guard keeps push/pop balanced even when child Render throws. +/// +internal static class RenderContext +{ + [ThreadStatic] + static IntPtr s_scope; + + [ThreadStatic] + static ScopeKind s_scopeKind; + + [ThreadStatic] + static int s_rowChildIndex; + + [ThreadStatic] + static int s_rowChildCount; + + public static IntPtr CurrentScope => s_scope; + + /// + /// Kind of the currently-active scope receiver, or + /// when no scope has been pushed. + /// Read by scope-extension modifiers (e.g. Modifier.Weight) + /// to pick the right Kotlin helper. + /// + public static ScopeKind CurrentScopeKind => s_scopeKind; + + /// + /// Index of the currently-rendering child within an enclosing row + /// container. Read by composables () + /// whose Kotlin defaults depend on position (e.g. start/end shape). + /// Only meaningful while > 0. + /// + public static int CurrentRowChildIndex => s_rowChildIndex; + + /// Total child count of the enclosing row container, or 0. + public static int CurrentRowChildCount => s_rowChildCount; + + public static ScopeFrame PushScope(IntPtr scope, ScopeKind kind = ScopeKind.Other) + { + var prevScope = s_scope; + var prevKind = s_scopeKind; + s_scope = scope; + s_scopeKind = kind; + return new ScopeFrame(prevScope, prevKind); + } + + /// + /// Publish row-position state for child composables. Returns a frame + /// that restores the previous values on dispose. The row container + /// calls before each child Render. + /// + public static RowFrame PushRow(int count) + { + var prevIndex = s_rowChildIndex; + var prevCount = s_rowChildCount; + s_rowChildCount = count; + s_rowChildIndex = 0; + return new RowFrame(prevIndex, prevCount); + } + + internal readonly struct ScopeFrame : IDisposable + { + readonly IntPtr _previousScope; + readonly ScopeKind _previousKind; + public ScopeFrame(IntPtr previousScope, ScopeKind previousKind) + { + _previousScope = previousScope; + _previousKind = previousKind; + } + public void Dispose() + { + s_scope = _previousScope; + s_scopeKind = _previousKind; + } + } + + internal readonly struct RowFrame : IDisposable + { + readonly int _prevIndex; + readonly int _prevCount; + public RowFrame(int prevIndex, int prevCount) + { + _prevIndex = prevIndex; + _prevCount = prevCount; + } + public void SetIndex(int index) => s_rowChildIndex = index; + public void Dispose() + { + s_rowChildIndex = _prevIndex; + s_rowChildCount = _prevCount; + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RoundedCornerShape.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RoundedCornerShape.cs new file mode 100644 index 000000000..503d7e329 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/RoundedCornerShape.cs @@ -0,0 +1,83 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// androidx.compose.foundation.shape.RoundedCornerShape as a +/// first-class value. Pass an instance to any +/// facade's Shape slot — Card, Surface, +/// Button, TextField, AlertDialog, the chip +/// family, the FAB family, ModalBottomSheet, and so on. +/// +/// +/// +/// Mirrors the Kotlin overload set. -taking ctors +/// produce density-aware radii (a 16dp corner stays 16dp regardless of +/// screen density). -taking ctors take a percentage of +/// the shorter side — 0 = square, 50 = pill / circle. +/// +/// +/// Overload tip: new RoundedCornerShape(16) binds to the +/// percent ctor (matching Kotlin's bare-Int convention); use +/// new RoundedCornerShape(16.Dp()) or +/// new RoundedCornerShape(new Dp(16)) for a 16dp radius. +/// +/// +public sealed class RoundedCornerShape : Shape +{ + /// + /// Equal radius on all four corners, expressed in density- + /// independent pixels. + /// + public RoundedCornerShape(Dp cornerSize) + : base(ComposeBridges.RoundedCornerShape(cornerSize.Value), + JniHandleOwnership.TransferLocalRef) + { + } + + /// + /// Equal radius on all four corners, expressed as a percentage + /// (0–50) of the shorter side. 0 renders a square, + /// 50 renders a circle (square bounds) or pill (rectangular + /// bounds). + /// + public RoundedCornerShape(int cornerPercent) + : base(ComposeBridges.RoundedCornerShapePercent(cornerPercent), + JniHandleOwnership.TransferLocalRef) + { + } + + /// + /// Independent radii per corner, expressed in density-independent + /// pixels. Argument order matches Kotlin: top-start, top-end, + /// bottom-end, bottom-start — clockwise from the top-leading + /// corner. + /// + /// + /// The canonical chat-bubble shape uses one flattened corner to + /// connect to its avatar tile — e.g. + /// new RoundedCornerShape(4.Dp(), 20.Dp(), 20.Dp(), 20.Dp()) + /// for a bubble whose top-leading corner is squared off where it + /// meets the speaker's avatar. + /// + public RoundedCornerShape(Dp topStart, Dp topEnd, Dp bottomEnd, Dp bottomStart) + : base(ComposeBridges.RoundedCornerShape4( + topStart.Value, topEnd.Value, bottomEnd.Value, bottomStart.Value), + JniHandleOwnership.TransferLocalRef) + { + } + + /// + /// Independent percent radii per corner. Argument order matches + /// Kotlin: top-start, top-end, bottom-end, bottom-start (clockwise + /// from the top-leading corner). Each value is a percentage (0–50) + /// of the shorter side. + /// + public RoundedCornerShape(int topStartPercent, int topEndPercent, + int bottomEndPercent, int bottomStartPercent) + : base(ComposeBridges.RoundedCornerShape4Percent( + topStartPercent, topEndPercent, bottomEndPercent, bottomStartPercent), + JniHandleOwnership.TransferLocalRef) + { + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Row.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Row.cs new file mode 100644 index 000000000..4d408d21d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Row.cs @@ -0,0 +1,109 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Foundation Row composable — lays its children out +/// horizontally. Mirror of ; same collection-init +/// shape: +/// +/// new Row { new Text("A"), new Spacer { Modifier = ... }, new Text("B") } +/// +/// Pass an optional to control how children +/// are distributed along the horizontal axis, and an optional +/// to control how shorter children +/// align across the Row's vertical extent: +/// +/// new Row( +/// horizontalArrangement: Arrangement.SpaceBetween, +/// verticalAlignment: Alignment.CenterVertically) { /* … */ } +/// +/// +public sealed class Row : ComposableContainer +{ + readonly Arrangement? _horizontalArrangement; + readonly Alignment.Vertical? _verticalAlignment; + + /// + /// Lay out children left-to-right with Compose's default + /// horizontal arrangement (Arrangement.Start) and default + /// vertical alignment (Alignment.Top). + /// + public Row() : this(null, null) { } + + /// + /// Lay out children left-to-right using + /// to distribute them along + /// the horizontal axis. Pass (or use the + /// parameterless ctor) for Compose's default. + /// + /// + /// One of the static factories. Must wrap + /// a horizontal-capable Kotlin arrangement + /// (, , + /// , , + /// , , + /// or ). Passing a + /// vertical-only arrangement (, + /// ) throws + /// . + /// + public Row(Arrangement? horizontalArrangement) : this(horizontalArrangement, null) { } + + /// + /// Lay out children left-to-right using + /// and + /// . Either may be + /// to keep Compose's default for that axis + /// (Arrangement.Start / Alignment.Top). + /// + /// + /// One of the static factories — see the + /// single-argument constructor for the full list of permitted + /// values. + /// + /// + /// One of the singletons + /// (, + /// , + /// ). Controls how children + /// shorter than the Row's measured height are placed along its + /// vertical axis — useful when mixing visually tall and short + /// children (e.g. a Switch next to a single-line + /// Text). + /// + public Row(Arrangement? horizontalArrangement, Alignment.Vertical? verticalAlignment) + { + if (horizontalArrangement is not null && horizontalArrangement.Horizontal is null) + { + throw new ArgumentException( + $"{nameof(horizontalArrangement)} must wrap a horizontal " + + $"or horizontal-or-vertical Compose Arrangement; got a " + + $"vertical-only value (e.g. Arrangement.Top / Arrangement.Bottom).", + nameof(horizontalArrangement)); + } + + _horizontalArrangement = horizontalArrangement; + _verticalAlignment = verticalAlignment; + } + + public override void Render(IComposer composer) + { + var horizontal = _horizontalArrangement?.Horizontal; + var vertical = _verticalAlignment?.Java; + var modifier = BuildModifier(); + + int defaults = (int)RowDefault.All; + if (modifier is not null) defaults &= ~(int)RowDefault.Modifier; + if (horizontal is not null) defaults &= ~(int)RowDefault.HorizontalArrangement; + if (vertical is not null) defaults &= ~(int)RowDefault.VerticalAlignment; + + var content = ComposableLambdas.Wrap3(composer, (scope, c) => + { + using var __scopeFrame = RenderContext.PushScope(scope, ScopeKind.Row); + RenderChildren(c); + }); + + ComposeBridges.Row(modifier, horizontal, vertical, content, defaults, composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SaveableBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SaveableBridges.cs new file mode 100644 index 000000000..047be0785 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SaveableBridges.cs @@ -0,0 +1,250 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +// Hand-written raw-JNI bridges for the +// `androidx.compose.runtime.saveable` package. The Xamarin +// NuGet (`Xamarin.AndroidX.Compose.Runtime.Saveable.Android`) ships +// the Java library but does not generate C# bindings, so every +// Saveable entry point we use here goes through `JNIEnv.*` calls. +// Same model as `SuspendBridges.cs`. +// +// When/if `dotnet/android-libraries` adds bindings for this package, +// these methods can be replaced with direct binding calls and this +// file can shrink to whatever is still mangled or unbound. +internal static partial class ComposeBridges +{ + static IntPtr s_autoSaver_class; + static IntPtr s_autoSaver_method; + static IntPtr s_autoSaver_handle; + + static IntPtr s_rememberSaveableSimple_class; + static IntPtr s_rememberSaveableSimple_method; + + static IntPtr s_rememberSaveableState_class; + static IntPtr s_rememberSaveableState_method; + + static IntPtr s_emptyObjectArray; + + // androidx.compose.runtime.saveable.SaverKt.autoSaver(): Saver + // + // The default `Saver` Compose uses for any `Bundle`-saveable value + // (primitives, strings, parcelables, …). Cached as a global ref — + // returns the same singleton on every call from Kotlin, and we + // need it as the `stateSaver` argument for the MutableState-overload + // of `rememberSaveable`. + internal static IntPtr SaverAutoSaver() + { + if (s_autoSaver_handle != IntPtr.Zero) + return s_autoSaver_handle; + + if (s_autoSaver_method == IntPtr.Zero) + { + s_autoSaver_class = JNIEnv.FindClass("androidx/compose/runtime/saveable/SaverKt"); + s_autoSaver_method = JNIEnv.GetStaticMethodID( + s_autoSaver_class, + "autoSaver", + "()Landroidx/compose/runtime/saveable/Saver;"); + } + + var local = JNIEnv.CallStaticObjectMethod(s_autoSaver_class, s_autoSaver_method); + try + { + s_autoSaver_handle = JNIEnv.NewGlobalRef(local); + } + finally + { + JNIEnv.DeleteLocalRef(local); + } + return s_autoSaver_handle; + } + + // Lazily-cached empty `Object[]` for the `vararg inputs` parameter + // of `rememberSaveable`. Compose treats inputs as "dependency + // tracking" — if any element changes, the cached factory result + // is discarded. We always pass an empty array (= "never invalidate + // on input change"); future overloads with `params object?[]` + // can build the array on demand. + internal static IntPtr EmptyObjectArray() + { + if (s_emptyObjectArray != IntPtr.Zero) + return s_emptyObjectArray; + + var objectClass = Java.Lang.Class.FromType(typeof(Java.Lang.Object)); + var local = JNIEnv.NewObjectArray(0, objectClass.Handle, IntPtr.Zero); + try + { + s_emptyObjectArray = JNIEnv.NewGlobalRef(local); + } + finally + { + JNIEnv.DeleteLocalRef(local); + } + return s_emptyObjectArray; + } + + // androidx.compose.runtime.saveable.RememberSaveableKt.rememberSaveable( + // Object[] inputs, + // Function0 init, + // Composer composer, + // int $changed): Object + // + // The no-saver overload — Compose internally uses `autoSaver()`, + // which handles every Bundle-saveable value (primitives, strings, + // parcelables). Returns a JNI local ref to the boxed result. + internal static unsafe IntPtr RememberSaveableSimple( + IntPtr inputs, + ObjectFunction0 init, + AndroidX.Compose.Runtime.IComposer composer, + int changed) + { + if (s_rememberSaveableSimple_method == IntPtr.Zero) + { + s_rememberSaveableSimple_class = JNIEnv.FindClass( + "androidx/compose/runtime/saveable/RememberSaveableKt"); + s_rememberSaveableSimple_method = JNIEnv.GetStaticMethodID( + s_rememberSaveableSimple_class, + "rememberSaveable", + "([Ljava/lang/Object;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Ljava/lang/Object;"); + } + + try + { + JValue* args = stackalloc JValue[4]; + args[0] = new JValue(inputs); + args[1] = new JValue(init.Handle); + args[2] = new JValue(((Java.Lang.Object)composer).Handle); + args[3] = new JValue(changed); + return JNIEnv.CallStaticObjectMethod( + s_rememberSaveableSimple_class, + s_rememberSaveableSimple_method, + args); + } + finally + { + GC.KeepAlive(init); + GC.KeepAlive(composer); + } + } + + // androidx.compose.runtime.saveable.RememberSaveableKt.rememberSaveable( + // Object[] inputs, + // Saver stateSaver, + // Function0> init, + // Composer composer, + // int $changed): MutableState + // + // The MutableState overload — Compose internally wraps + // `stateSaver` with `mutableStateSaver(stateSaver)` and persists + // only the inner value. On restore Compose calls + // `mutableStateOf(restoredValue, policy)` to rebuild the state + // wrapper, so the returned `IMutableState` is *not* guaranteed + // to be a primitive-specialized `IMutableIntState`/etc. — it's + // the boxed default unless the caller supplied a custom saver + // that re-creates the specialized type. + internal static unsafe IntPtr RememberSaveableMutableState( + IntPtr inputs, + IntPtr stateSaver, + ObjectFunction0 init, + AndroidX.Compose.Runtime.IComposer composer, + int changed) + { + if (s_rememberSaveableState_method == IntPtr.Zero) + { + s_rememberSaveableState_class = JNIEnv.FindClass( + "androidx/compose/runtime/saveable/RememberSaveableKt"); + s_rememberSaveableState_method = JNIEnv.GetStaticMethodID( + s_rememberSaveableState_class, + "rememberSaveable", + "([Ljava/lang/Object;Landroidx/compose/runtime/saveable/Saver;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/MutableState;"); + } + + try + { + JValue* args = stackalloc JValue[5]; + args[0] = new JValue(inputs); + args[1] = new JValue(stateSaver); + args[2] = new JValue(init.Handle); + args[3] = new JValue(((Java.Lang.Object)composer).Handle); + args[4] = new JValue(changed); + return JNIEnv.CallStaticObjectMethod( + s_rememberSaveableState_class, + s_rememberSaveableState_method, + args); + } + finally + { + GC.KeepAlive(init); + GC.KeepAlive(composer); + } + } + + // Builds a Kotlin-compatible `Object[]` from a managed `object?[]` + // for the `inputs` (vararg) parameter of `rememberSaveable`. Each + // element is boxed via the same primitive→Java.Lang.Object switch + // MutableState uses, so primitives compare structurally rather + // than by reference. Returns a JNI local ref the caller MUST free + // (call DeleteLocalRef inside a finally). Also creates local refs + // for each boxed element; we delete each one immediately after + // setting it into the array (the array owns its own ref). + // + // Null/empty keys array → `EmptyObjectArray()`, a cached global + // ref — `ownsHandle` is false, signalling the caller MUST NOT + // delete the returned handle. + internal static IntPtr BuildKeysArray(object?[]? keys, out bool ownsHandle) + { + if (keys is null || keys.Length == 0) + { + ownsHandle = false; + return EmptyObjectArray(); + } + + ownsHandle = true; + var objectClass = Java.Lang.Class.FromType(typeof(Java.Lang.Object)); + var array = JNIEnv.NewObjectArray(keys.Length, objectClass.Handle, IntPtr.Zero); + for (int i = 0; i < keys.Length; i++) + { + var boxed = BoxKey(keys[i], out var ownsBoxed); + if (boxed is null) + { + JNIEnv.SetObjectArrayElement(array, i, IntPtr.Zero); + continue; + } + JNIEnv.SetObjectArrayElement(array, i, boxed.Handle); + // Only dispose freshly created boxing wrappers — disposing + // a pass-through caller-owned Java.Lang.Object would + // invalidate their managed peer. + if (ownsBoxed) boxed.Dispose(); + } + return array; + } + + static Java.Lang.Object? BoxKey(object? key, out bool owns) + { + switch (key) + { + case null: + owns = false; + return null; + case Java.Lang.Object o: + // Caller-owned — do NOT dispose; the array still + // takes its own JNI ref via SetObjectArrayElement. + owns = false; + return o; + case string s: owns = true; return new Java.Lang.String(s); + case bool b: owns = true; return Java.Lang.Boolean.ValueOf(b); + case char c: owns = true; return Java.Lang.Character.ValueOf(c); + case sbyte sb: owns = true; return Java.Lang.Byte.ValueOf(sb); + case byte by: owns = true; return Java.Lang.Short.ValueOf((short)by); + case short sh: owns = true; return Java.Lang.Short.ValueOf(sh); + case ushort us: owns = true; return Java.Lang.Integer.ValueOf(us); + case int i: owns = true; return Java.Lang.Integer.ValueOf(i); + case uint ui: owns = true; return Java.Lang.Long.ValueOf(ui); + case long l: owns = true; return Java.Lang.Long.ValueOf(l); + case ulong ul: owns = true; return Java.Lang.Long.ValueOf(unchecked((long)ul)); + case float f: owns = true; return Java.Lang.Float.ValueOf(f); + case double d: owns = true; return Java.Lang.Double.ValueOf(d); + default: owns = true; return new Java.Lang.String(key.ToString() ?? string.Empty); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Scaffold.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Scaffold.cs new file mode 100644 index 000000000..1226dcf7a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Scaffold.cs @@ -0,0 +1,171 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 Scaffold. Hosts a primary with +/// optional Material chrome — top app bar, bottom bar, snackbar host, +/// and floating action button — pinned to their conventional edges of +/// the screen. +/// +/// The Kotlin overload is stripped from the binding because +/// floatingActionButtonPosition (FabPosition), +/// containerColor and contentColor (Color) are +/// @JvmInline value class parameters; we call it through a JNI +/// bridge in against the mangled name +/// Scaffold-TvnljyQ. +/// +/// +/// new Scaffold +/// { +/// TopBar = new Text("My App"), +/// BottomBar = new NavigationBar { ... }, +/// Body = tabContent, +/// } +/// +/// +public sealed class Scaffold : ComposableNode +{ + /// Optional: persistent top app bar slot. + public ComposableNode? TopBar { get; set; } + + /// Optional: persistent bottom bar slot (e.g. ). + public ComposableNode? BottomBar { get; set; } + + /// Optional: snackbar host slot, typically anchored above . + public ComposableNode? SnackbarHost { get; set; } + + /// Optional: floating action button slot. + public ComposableNode? FloatingActionButton { get; set; } + + /// Required: the main body, laid out under the top bar and above the bottom bar. + /// + /// The scaffold-supplied PaddingValues is forwarded to this + /// node via the internal + /// overload — + /// the default impl on applies it as + /// a Modifier.padding on the body's frame; + /// (and the other lazy containers) override it to route the handle + /// into their Kotlin contentPadding: argument so items scroll + /// under the bars. When the body sits behind a transparent wrapper + /// (e.g. ) and you need the inner + /// scrollable to receive the padding directly, prefer + /// instead — it hands you the + /// explicitly the way Kotlin's + /// content: @Composable (PaddingValues) -> Unit does. + /// + public ComposableNode? Body { get; set; } + + /// + /// Explicit body lambda that receives the scaffold-supplied + /// so the caller can route it into the + /// scrollable of their choice. Mirrors Kotlin's + /// content: @Composable (PaddingValues) -> Unit slot + /// exactly. Use this when the body is a transparent wrapper around + /// a scrollable (e.g. wrapping a + /// ) and you want the items to scroll + /// under the top / bottom bars instead of stopping at them. + /// + /// + /// + /// new Scaffold + /// { + /// TopBar = ..., + /// BottomBar = ..., + /// BodyContent = padding => new PullToRefreshBox(...) + /// { + /// new LazyColumn<Row>(items, itemContent: r => ...) + /// { + /// ContentPadding = padding, + /// Modifier = Modifier.FillMaxSize(), + /// }, + /// }, + /// } + /// + /// The passed to the lambda is only + /// valid for the synchronous duration of the call — assign it to + /// on a node rendered + /// inside the same lambda; don't capture it into long-lived state. + /// When both and are + /// set, wins and is + /// ignored. + /// + public Func? BodyContent { get; set; } + + public override void Render(IComposer composer) + { + if (Body is null && BodyContent is null) + throw new InvalidOperationException( + "Scaffold.Body or Scaffold.BodyContent is required (the Kotlin parameter has no default)."); + + // Material 3's Scaffold passes PaddingValues as the first arg of + // its content lambda. Two paths: + // + // * BodyContent (preferred when the inner scrollable wants + // explicit control — e.g. PullToRefreshBox around a + // LazyColumn): mirror Kotlin 1:1, hand the caller a managed + // PaddingValues wrapper to plumb wherever they want. + // * Body (the simple case): forward the raw handle directly + // through ComposableNode.Render(IComposer, IntPtr) so body's + // BuildModifier can prepend a `Modifier.padding(values)` op + // via JNI without allocating a managed Modifier wrapper per + // measure pass (issue #46). ScaffoldLayout is a + // SubcomposeLayout — this lambda runs on every measure pass, + // so the saved allocation adds up quickly during scrolls, + // rotations, and IME animations. + Kotlin.Jvm.Functions.IFunction3 content; + if (BodyContent is not null) + { + var builder = BodyContent; + content = ComposableLambdas.Wrap3(composer, (paddingHandle, c) => + { + if (paddingHandle == IntPtr.Zero) + { + builder(null!)?.Render(c); + return; + } + // PaddingValues.Wrap with DoNotTransfer — the handle is + // owned by the Kotlin Scaffold for the duration of this + // lambda; we just borrow it. + var managed = PaddingValues.Wrap(paddingHandle); + builder(managed)?.Render(c); + }); + } + else + { + var body = Body!; + content = ComposableLambdas.Wrap3(composer, (paddingHandle, c) => + body.Render(c, paddingHandle)); + } + + // Always pass non-null slot lambdas. Toggling between Compose's + // default `{}` (a synthetic Java SAM lambda) and our IFunction2 + // identity confuses M3's internal `rememberComposableLambda`, + // which casts the slot value to ComposableLambdaImpl on every + // recomposition — a flip in slot type triggers a runtime + // ClassCastException. Emit nothing when the user didn't supply + // a slot; functionally identical to M3's `{}` default. + var topBar = ComposableLambdas.Wrap2(composer, c => TopBar?.Render(c)); + var bottomBar = ComposableLambdas.Wrap2(composer, c => BottomBar?.Render(c)); + var snackbarHost = ComposableLambdas.Wrap2(composer, c => SnackbarHost?.Render(c)); + var fab = ComposableLambdas.Wrap2(composer, c => FloatingActionButton?.Render(c)); + + int defaults = (int)ScaffoldDefault.All; + var modifier = BuildModifier(); + if (modifier is not null) defaults &= ~(int)ScaffoldDefault.Modifier; + defaults &= ~(int)ScaffoldDefault.TopBar; + defaults &= ~(int)ScaffoldDefault.BottomBar; + defaults &= ~(int)ScaffoldDefault.SnackbarHost; + defaults &= ~(int)ScaffoldDefault.FloatingActionButton; + + ComposeBridges.Scaffold( + modifier: modifier, + topBar: topBar, + bottomBar: bottomBar, + snackbarHost: snackbarHost, + floatingActionButton: fab, + content: content, + defaults: defaults, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScopeKind.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScopeKind.cs new file mode 100644 index 000000000..833c908c6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScopeKind.cs @@ -0,0 +1,44 @@ +namespace AndroidX.Compose; + +/// +/// Identifies which Compose receiver-scope is currently active so +/// scope-extension calls (e.g. Modifier.Weight) can dispatch +/// to the right Kotlin helper. +/// publishes both the scope handle and its kind for the duration of +/// a container's content lambda. +/// +internal enum ScopeKind +{ + /// No scope is active. + None = 0, + + /// + /// The scope is an androidx.compose.foundation.layout.RowScope + /// (or one of its conforming subtypes used by app-bar/navigation + /// rows — anything that implements the RowScope interface). + /// + Row, + + /// + /// The scope is an + /// androidx.compose.foundation.layout.ColumnScope. + /// + Column, + + /// + /// The scope is an + /// androidx.compose.foundation.layout.BoxScope. Read by + /// alignment-extension modifiers (Modifier.Align, + /// Modifier.MatchParentSize) to dispatch through the right + /// Kotlin interface method. + /// + Box, + + /// + /// The scope is some other receiver type (e.g. + /// SingleChoiceSegmentedButtonRowScope) that doesn't + /// participate in Row/Column weight. Modifiers like + /// Weight must not be used here. + /// + Other, +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScrollState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScrollState.cs new file mode 100644 index 000000000..926e8e761 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScrollState.cs @@ -0,0 +1,144 @@ + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for +/// and . Wraps the bound +/// androidx.compose.foundation.ScrollState — the Kotlin class +/// has a public single-arg constructor and is plain enough for the +/// binder to expose, so no JNI bridge is needed. +/// +/// +/// +/// Construct one inside a composer.Remember +/// callback so the scroll position survives recompositions: +/// +/// var scroll = Remember(() => new ScrollState()); +/// +/// new Column +/// { +/// Modifier.VerticalScroll(scroll), +/// // ... potentially-tall column children ... +/// } +/// +/// +/// +/// Unlike Kotlin's rememberScrollState() (which uses +/// rememberSaveable), this state is held in the +/// ComposeExtensions-scoped Remember cache, so it survives +/// recompositions but not process death / configuration changes. For +/// most apps that's fine; if you need savable scroll state, track it +/// yourself with the rest of your view-model state. +/// +/// +/// Programmatic scrolling is exposed via the Async methods — +/// jumps instantly, +/// runs the default spring animation. Both bridge the underlying +/// Kotlin suspend function through so they +/// integrate with C# async/await. The returned task may +/// complete on the Compose main thread; awaiters resume on whatever +/// the +/// await captured. +/// +/// +public sealed class ScrollState +{ + internal AndroidX.Compose.Foundation.ScrollState Jvm { get; } + + /// + /// Create a new with the given initial + /// pixel offset. Defaults to 0 (scrolled to the start). + /// + public ScrollState(int initial = 0) + { + Jvm = new AndroidX.Compose.Foundation.ScrollState(initial); + } + + /// + /// Current scroll position, in pixels. Mirrors Kotlin's + /// ScrollState.value. + /// + public int Value => Jvm.Value; + + /// + /// Maximum scrollable offset, in pixels, or + /// until the scrolling container has + /// been laid out. Mirrors Kotlin's ScrollState.maxValue. + /// + public int MaxValue => Jvm.MaxValue; + + /// + /// Size of the visible viewport along the scroll axis, in pixels. + /// Mirrors Kotlin's ScrollState.viewportSize. Returns + /// 0 until layout has run. + /// + public int ViewportSize => Jvm.ViewportSize; + + /// + /// true while a fling or programmatic scroll is in flight. + /// Mirrors Kotlin's ScrollState.isScrollInProgress. + /// + public bool IsScrollInProgress => Jvm.IsScrollInProgress; + + /// + /// true when the content can scroll further forward (down + /// for , right for + /// on LTR layouts). + /// + public bool CanScrollForward => Jvm.CanScrollForward; + + /// + /// true when the content can scroll further backward (up + /// for , left for + /// on LTR layouts). + /// + public bool CanScrollBackward => Jvm.CanScrollBackward; + + /// + /// Jump instantly to the given pixel + /// (clamped to [0, MaxValue]). Mirrors Kotlin's + /// ScrollState.scrollTo(value). + /// + /// Target pixel offset. + /// + /// Cancels the returned task with + /// . See + /// remarks for the current (C#-only) + /// cancellation semantics. + /// + /// + /// A task whose result is the actual delta the scroll covered (the + /// Kotlin return) — useful when the requested value was clamped. + /// Faulted with the wrapped Throwable if Kotlin reports + /// Result.Failure. + /// + public Task ScrollToAsync(int value, CancellationToken cancellationToken = default) => + SuspendBridge.Invoke( + cont => ComposeBridges.ScrollStateScrollTo( + ((Java.Lang.Object)Jvm).Handle, value, cont), + static boxed => boxed is Java.Lang.Float f + ? f.FloatValue() + : throw new InvalidCastException( + $"Expected java.lang.Float from ScrollState.scrollTo; got '{boxed?.Class?.Name ?? "null"}'"), + cancellationToken); + + /// + /// Animate to the given pixel using the + /// default Compose spring animation. Mirrors Kotlin's + /// ScrollState.animateScrollTo(value); the returned task + /// completes when the animation lands. + /// + /// Target pixel offset. + /// + /// Cancels the returned task with + /// . Note that + /// the underlying Kotlin animation keeps running to its natural + /// completion — see remarks for the + /// current (C#-only) cancellation semantics. + /// + public Task AnimateScrollToAsync(int value, CancellationToken cancellationToken = default) => + SuspendBridge.Invoke(cont => + ComposeBridges.ScrollStateAnimateScrollTo( + ((Java.Lang.Object)Jvm).Handle, value, animationSpec: null, cont), + cancellationToken); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScrollableTabRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScrollableTabRow.cs new file mode 100644 index 000000000..62f4f3281 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ScrollableTabRow.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 ScrollableTabRow. Like but the +/// tabs are laid out at their natural width and the row scrolls +/// horizontally — useful when there are too many tabs to fit on one +/// screen. +/// +public sealed partial class ScrollableTabRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBar.cs new file mode 100644 index 000000000..df9b7d3cf --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBar.cs @@ -0,0 +1,72 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 SearchBar (state-based). The always-visible +/// collapsed input field that pairs with an +/// or +/// popup sharing the same +/// . +/// +/// +/// is required (the Kotlin parameter has no +/// default). Use a so the input +/// field can wire focus / click events to the +/// internally — a bare +/// renders but does not expand the +/// popup on tap. +/// +/// +/// var search = Remember(() => new SearchBarState()); +/// var input = Remember(() => new SearchBarTextFieldState()); +/// +/// new SearchBar(state: search) +/// { +/// InputField = new SearchBarInputField(input, search) +/// { +/// Placeholder = new Text("Search"), +/// LeadingIcon = new Text("🔍"), +/// }, +/// } +/// +/// +public sealed class SearchBar : ComposableNode +{ + readonly SearchBarState _state; + + public SearchBar(SearchBarState state) => _state = state; + + /// Required: composable that renders the search input field. + public ComposableNode? InputField { get; set; } + + public override void Render(IComposer composer) + { + if (InputField is null) + throw new InvalidOperationException( + "SearchBar.InputField is required (the Kotlin parameter has no default)."); + + var stateHandle = ResolveStateHandle(_state, composer); + var inputField = ComposableLambdas.Wrap2(composer, c => InputField.Render(c)); + ComposeBridges.SearchBar(stateHandle, inputField, BuildModifier(), composer); + } + + // Lazy-resolve the bound JVM peer for the shared SearchBarState. Compose's + // remember slots are positional, so calling rememberSearchBarState from + // each facade (collapsed bar + expanded popup) would allocate TWO + // independent SearchBarState peers — breaking the shared-state design. + // Cache the peer on the C# wrapper so only the FIRST half to render + // invokes the JNI bridge; the other half reuses the cached handle. + // Shared with TopSearchBar, ExpandedDockedSearchBar, + // ExpandedFullScreenSearchBar. + internal static IntPtr ResolveStateHandle(SearchBarState state, IComposer composer) + { + if (state.Jvm is not null) + return state.Jvm.Handle; + + var handle = ComposeBridges.RememberSearchBarState(composer); + state.Jvm = Java.Lang.Object.GetObject(handle, JniHandleOwnership.DoNotTransfer)!; + return handle; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarInputField.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarInputField.cs new file mode 100644 index 000000000..d51a29860 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarInputField.cs @@ -0,0 +1,107 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// The Material 3 search input field that drives the state-based +/// family. Plug into the InputField slot +/// of , , +/// , or +/// ; the shared +/// + +/// wire focus / click events to the popup's expand/collapse animations +/// internally. +/// +/// +/// Compose Kotlin source: SearchBarDefaults.InputField(textFieldState, +/// searchBarState, …). Without this facade a bare +/// placed in the input-field slot +/// will not know how to talk to — the bar +/// would render but never expand. +/// +/// +/// var search = Remember(() => new SearchBarState()); +/// var input = Remember(() => new SearchBarTextFieldState()); +/// +/// new SearchBar(state: search) +/// { +/// InputField = new SearchBarInputField(input, search) +/// { +/// OnSearch = q => Log.Info("search", q), +/// Placeholder = new Text("Search fruits"), +/// LeadingIcon = new Text("🔍"), +/// }, +/// } +/// +/// +public sealed class SearchBarInputField : ComposableNode +{ + readonly SearchBarTextFieldState _textState; + readonly SearchBarState _searchState; + + /// + /// Creates the input field bound to a shared + /// (the typed text) and + /// (the expand/collapse state). Pass + /// the same two state objects to every / + /// expanded popup half so they share text + open state. + /// + public SearchBarInputField(SearchBarTextFieldState textState, SearchBarState searchState) + { + _textState = textState; + _searchState = searchState; + } + + /// + /// Optional callback invoked when the user presses the IME Search + /// action. Receives the current text. If omitted, a no-op callback + /// is supplied — required by the Kotlin API which does not tolerate + /// a null onSearch. + /// + public Action? OnSearch { get; set; } + + /// + /// Optional placeholder rendered when the field is empty + /// (Compose Kotlin placeholder slot). + /// + public ComposableNode? Placeholder { get; set; } + + /// + /// Optional leading icon, typically a magnifying-glass glyph + /// (Compose Kotlin leadingIcon slot). No icon is drawn when + /// omitted — the bar still functions but loses the canonical M3 + /// search-bar look. + /// + public ComposableNode? LeadingIcon { get; set; } + + /// + /// Optional trailing icon, typically a clear/close button shown + /// when the popup is expanded (Compose Kotlin trailingIcon + /// slot). + /// + public ComposableNode? TrailingIcon { get; set; } + + public override void Render(IComposer composer) + { + var textPeer = _textState.Resolve(composer); + var searchHandle = SearchBar.ResolveStateHandle(_searchState, composer); + + var onSearch = OnSearch is null + ? (Kotlin.Jvm.Functions.IFunction1)NoOpSearchCallback.Instance + : new ComposableLambda1(p => OnSearch(p?.ToString() ?? "")); + + var placeholder = Placeholder is null ? null : ComposableLambdas.Wrap2(composer, c => Placeholder.Render(c)); + var leadingIcon = LeadingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => LeadingIcon.Render(c)); + var trailingIcon = TrailingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => TrailingIcon.Render(c)); + + ComposeBridges.SearchBarDefaultsInputField( + textPeer.Handle, + searchHandle, + onSearch, + BuildModifier(), + placeholder, + leadingIcon, + trailingIcon, + composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarState.cs new file mode 100644 index 000000000..f30dfb158 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarState.cs @@ -0,0 +1,64 @@ +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for / +/// and their paired expanded popups +/// ( / +/// ). The underlying JVM +/// androidx.compose.material3.SearchBarState is created lazily +/// the first time a bound to this state is +/// rendered. +/// +/// +/// Compose's new state-based SearchBar API splits the always-visible +/// collapsed input bar () from the popup with +/// the search results ( or +/// ). Both halves are *always* +/// rendered, referencing the same ; Compose +/// internally toggles the popup's visibility from the state. +/// +/// +/// var search = Remember(() => new SearchBarState()); +/// var input = Remember(() => new SearchBarTextFieldState()); +/// +/// new Box +/// { +/// new SearchBar(state: search) +/// { +/// InputField = new SearchBarInputField(input, search) +/// { +/// Placeholder = new Text("Search"), +/// LeadingIcon = new Text("🔍"), +/// }, +/// }, +/// new ExpandedFullScreenSearchBar(state: search) +/// { +/// InputField = new SearchBarInputField(input, search), +/// new Text("Result A"), +/// new Text("Result B"), +/// }, +/// } +/// +/// +/// The wrapper holds the JVM peer (managed-side Java.Lang.Object) +/// so the underlying state object stays alive across recompositions and +/// callbacks. The peer is set the first time a bound +/// renders. +/// +public sealed class SearchBarState +{ + // Holds the bound JVM peer for the androidx.compose.material3.SearchBarState + // object. Kept as Java.Lang.Object (rather than a binding interface) because + // the M3 binding doesn't generate an interface for the final SearchBarState + // class; storing the peer is enough to keep the JNI reference alive and to + // re-extract the raw handle for subsequent SearchBar / Expanded*SearchBar + // renders that share this state. + internal Java.Lang.Object? Jvm; + + /// + /// Raw JNI handle of the bound state object, or + /// until the first + /// render binds this state. + /// + internal IntPtr Handle => Jvm?.Handle ?? IntPtr.Zero; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarTextFieldState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarTextFieldState.cs new file mode 100644 index 000000000..c426e04d5 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SearchBarTextFieldState.cs @@ -0,0 +1,86 @@ +using AndroidX.Compose.Foundation.Text.Input; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for the text inside a +/// . Mirrors : +/// the underlying JVM +/// androidx.compose.foundation.text.input.TextFieldState is created +/// lazily the first time a bound to this +/// state is rendered, via Compose's rememberTextFieldState. +/// +/// +/// Both halves of the SearchBar pair — the collapsed +/// and the expanded popup +/// ( / +/// ) — pass the SAME +/// to their input fields so the +/// typed text is shared between them. +/// +/// Read inside composition to filter a result +/// list; it goes through to the live JVM peer's text property +/// (which is itself a Compose snapshot state), so typing in the field +/// triggers recomposition. +/// +/// +/// var search = Remember(() => new SearchBarState()); +/// var input = Remember(() => new SearchBarTextFieldState()); +/// +/// new Box +/// { +/// new SearchBar(state: search) +/// { +/// InputField = new SearchBarInputField(input, search) +/// { +/// Placeholder = new Text("Search fruits"), +/// LeadingIcon = new Text("🔍"), +/// }, +/// }, +/// new ExpandedFullScreenSearchBar(state: search) +/// { +/// InputField = new SearchBarInputField(input, search), +/// new Text($"Matches: {input.Text}"), +/// }, +/// } +/// +/// +public sealed class SearchBarTextFieldState +{ + readonly string _initialText; + + // Bound peer for the JVM androidx.compose.foundation.text.input.TextFieldState. + // Set on first SearchBarInputField render and reused by every subsequent + // render so collapsed + expanded halves share one TextFieldState. + internal TextFieldState? Jvm; + + /// + /// Creates a state holder with an optional initial text value + /// (defaults to the empty string). + /// + public SearchBarTextFieldState(string initialText = "") => + _initialText = initialText; + + /// + /// Current text in the search input. Reads through to the live + /// JVM TextFieldState.text (a Compose snapshot value), so + /// reading this inside composition subscribes to recomposition. + /// Returns the initial value before the first render binds the peer. + /// + public string Text => Jvm?.Text ?? _initialText; + + // Lazy-resolve the bound JVM peer. Multiple SearchBarInputField + // siblings sharing this state hit the JNI bridge once on the FIRST + // render and reuse the peer for every subsequent half. + internal TextFieldState Resolve(IComposer composer) + { + if (Jvm is not null) + return Jvm; + + // Bit 1 = default initialSelection (Kotlin places the cursor at + // the end of initialText). initialText is provided explicitly. + Jvm = TextFieldStateKt.RememberTextFieldState(_initialText, 0L, composer, 0, 2); + return Jvm; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecondaryScrollableTabRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecondaryScrollableTabRow.cs new file mode 100644 index 000000000..7c1c6103c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecondaryScrollableTabRow.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 SecondaryScrollableTabRow. Like +/// but tabs are laid out at their natural +/// width and the row scrolls horizontally — useful for secondary +/// sub-navigation when there are too many tabs to fit on one screen. +/// +public sealed partial class SecondaryScrollableTabRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecondaryTabRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecondaryTabRow.cs new file mode 100644 index 000000000..0344763f6 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecondaryTabRow.cs @@ -0,0 +1,8 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 SecondaryTabRow — the M3 variant of +/// with the secondary indicator style (full-width +/// underline). Use for tab groups nested inside a screen. +/// +public sealed partial class SecondaryTabRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecureTextField.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecureTextField.cs new file mode 100644 index 000000000..57b452931 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecureTextField.cs @@ -0,0 +1,114 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 SecureTextField — a single-line filled text field +/// designed for password / secure input. Obscures typed characters with +/// a bullet glyph, disables cut/copy/drag, and pre-configures the IME +/// for password input. Stripped from the binding because +/// textObfuscationMode is a @JvmInline value class (hashed +/// JVM name -XvU6IwQ); reached via . +/// +/// +/// State-based: pass a the same way +/// takes a +/// . Read state.Text to drive +/// other UI off the typed value (e.g. a "Sign in" button's enabled flag). +/// +/// The Kotlin signature has 23 parameters; this facade exposes the +/// most commonly toggled ones (enabled, isError) plus the +/// optional content slots (label, placeholder, +/// leadingIcon, trailingIcon, supportingText). All +/// other Kotlin parameters — textStyle, labelPosition, +/// prefix, suffix, inputTransformation, +/// textObfuscationMode, textObfuscationCharacter, +/// keyboardOptions, onKeyboardAction, onTextLayout, +/// colors, contentPadding, +/// interactionSource — fall back to their Compose defaults +/// (which configure a RevealLastTyped obfuscation mode and a +/// password-typed IME). +/// +/// +/// var pwd = Remember(() => new SecureTextFieldState()); +/// new SecureTextField(pwd) +/// { +/// Label = new Text("Password"), +/// LeadingIcon = new Text("🔒"), +/// } +/// +/// +public sealed class SecureTextField : ComposableNode +{ + readonly SecureTextFieldState _state; + + /// Creates a secure text field bound to the supplied . + public SecureTextField(SecureTextFieldState state) => _state = state; + + /// Whether the field accepts user input. Defaults to true. + public bool Enabled { get; set; } = true; + + /// Whether to render the field in its error appearance. Defaults to false. + public bool IsError { get; set; } + + /// Optional label slot (Kotlin label). + public ComposableNode? Label { get; set; } + + /// Optional placeholder slot drawn when the field is empty (Kotlin placeholder). + public ComposableNode? Placeholder { get; set; } + + /// Optional leading icon slot (Kotlin leadingIcon). + public ComposableNode? LeadingIcon { get; set; } + + /// Optional trailing icon slot, typically a "show/hide" toggle (Kotlin trailingIcon). + public ComposableNode? TrailingIcon { get; set; } + + /// Optional supporting text rendered below the field (Kotlin supportingText). + public ComposableNode? SupportingText { get; set; } + + /// Optional shape applied to the field's container (Kotlin shape). + public Shape? Shape { get; set; } + + public override void Render(IComposer composer) + { + var statePeer = _state.Resolve(composer); + + var labelNode = Label; + var label = labelNode is null + ? null + : ComposableLambdas.Wrap3(composer, c => labelNode.Render(c)); + + var placeholderNode = Placeholder; + var placeholder = placeholderNode is null + ? null + : ComposableLambdas.Wrap2(composer, c => placeholderNode.Render(c)); + + var leadingIconNode = LeadingIcon; + var leadingIcon = leadingIconNode is null + ? null + : ComposableLambdas.Wrap2(composer, c => leadingIconNode.Render(c)); + + var trailingIconNode = TrailingIcon; + var trailingIcon = trailingIconNode is null + ? null + : ComposableLambdas.Wrap2(composer, c => trailingIconNode.Render(c)); + + var supportingTextNode = SupportingText; + var supportingText = supportingTextNode is null + ? null + : ComposableLambdas.Wrap2(composer, c => supportingTextNode.Render(c)); + + ComposeBridges.SecureTextField( + state: statePeer.Handle, + modifier: BuildModifier(), + enabled: Enabled, + label: label, + placeholder: placeholder, + leadingIcon: leadingIcon, + trailingIcon: trailingIcon, + supportingText: supportingText, + isError: IsError, + shape: Shape, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecureTextFieldState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecureTextFieldState.cs new file mode 100644 index 000000000..dafeee08c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SecureTextFieldState.cs @@ -0,0 +1,81 @@ +using AndroidX.Compose.Foundation.Text.Input; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for the text inside a +/// or . +/// Mirrors : the underlying JVM +/// androidx.compose.foundation.text.input.TextFieldState is created +/// lazily the first time a bound to this +/// state is rendered, via Compose's rememberTextFieldState. +/// +/// +/// Read inside composition to drive other UI off the +/// typed value (e.g. enable a "Sign in" button when the password is +/// non-empty); reads go through to the live JVM peer's text +/// property, which is itself a Compose snapshot state, so subscribing +/// inside composition triggers recomposition. +/// +/// The initialText constructor argument only seeds the +/// JVM peer the first time a bound to +/// this state is rendered. Once the peer exists, the seed value is no +/// longer consulted — to clear or reset the text, mutate the live JVM +/// state via the typed UI or persist your own -backed +/// mirror. +/// +/// +/// var pwd = Remember(() => new SecureTextFieldState()); +/// new Column +/// { +/// new SecureTextField(pwd) { Label = new Text("Password") }, +/// new Button(onClick: () => SignIn(pwd.Text), enabled: pwd.Text.Length >= 8) +/// { +/// new Text("Sign in"), +/// }, +/// } +/// +/// +public sealed class SecureTextFieldState +{ + readonly string _initialText; + + // Bound peer for the JVM androidx.compose.foundation.text.input.TextFieldState. + // Set on first SecureTextField render and reused by every subsequent + // recomposition of the same node so the JVM-side text + cursor state + // survives across recompositions. + internal TextFieldState? Jvm; + + /// + /// Creates a state holder with an optional initial text value + /// (defaults to the empty string). The initial value is only used + /// the first time a bound to this state + /// is rendered — once the JVM peer is resolved, mutations to the + /// passed-in string are ignored. + /// + public SecureTextFieldState(string initialText = "") => + _initialText = initialText; + + /// + /// Current text in the secure input. Reads through to the live + /// JVM TextFieldState.text (a Compose snapshot value), so + /// reading inside composition subscribes to recomposition. Returns + /// the initial value before the first render binds the peer. + /// + public string Text => Jvm?.Text ?? _initialText; + + // Lazy-resolve the bound JVM peer. Subsequent SecureTextField renders + // sharing this state hit the JNI bridge once on the FIRST render and + // reuse the peer for every recomposition. + internal TextFieldState Resolve(IComposer composer) + { + if (Jvm is not null) + return Jvm; + + // Bit 1 = default initialSelection (Kotlin places the cursor at + // the end of initialText). initialText is provided explicitly. + Jvm = TextFieldStateKt.RememberTextFieldState(_initialText, 0L, composer, 0, 2); + return Jvm; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SegmentedButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SegmentedButton.cs new file mode 100644 index 000000000..f3f4cdb93 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SegmentedButton.cs @@ -0,0 +1,126 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 SegmentedButton — a radio-button-shaped selector +/// segment. Must be a child of a +/// or +/// ; the parent publishes its +/// scope receiver via . +/// +/// Use the onClick ctor for the single-choice (radio) flavor +/// and the onCheckedChange ctor for the multi-choice (toggle) +/// flavor — the matching scope must already be on the stack. +/// +/// Children are rendered as the label slot (required). Set +/// via object-initializer for an optional leading +/// indicator. +/// +/// new SingleChoiceSegmentedButtonRow +/// { +/// new SegmentedButton(selected: tab == 0, onClick: () => tab.Value = 0) { new Text("Day") }, +/// new SegmentedButton(selected: tab == 1, onClick: () => tab.Value = 1) { new Text("Week") }, +/// } +/// +/// +public sealed class SegmentedButton : ComposableContainer +{ + readonly bool _selected; + readonly Action? _onClick; + readonly Action? _onCheckedChange; + + /// Single-choice (radio) ctor for . + public SegmentedButton(bool selected, Action onClick) + { + _selected = selected; + _onClick = onClick; + } + + /// Multi-choice (toggle) ctor for . + public SegmentedButton(bool @checked, Action onCheckedChange) + { + _selected = @checked; + _onCheckedChange = onCheckedChange; + } + + /// Optional leading icon slot. + public ComposableNode? Icon { get; set; } + + public override void Render(IComposer composer) + { + if (Children.Count == 0) + throw new InvalidOperationException( + "SegmentedButton requires at least one child (the label slot has no Kotlin default)."); + + var label = ComposableLambdas.Wrap2(composer, c => + { + for (int i = 0; i < Children.Count; i++) + { + var child = Children[i]; + c.StartReplaceableGroup(HashCode.Combine(i, child.GetType())); + try { child.Render(c); } + finally { c.EndReplaceableGroup(); } + } + }); + var iconNode = Icon; + var icon = iconNode is null ? null : ComposableLambdas.Wrap2(composer, c => iconNode.Render(c)); + var modifier = BuildModifier(); + + var scope = RenderContext.CurrentScope; + var count = RenderContext.CurrentRowChildCount; + var index = RenderContext.CurrentRowChildIndex; + if (scope == IntPtr.Zero || count == 0) + throw new InvalidOperationException( + "SegmentedButton must be rendered inside a SingleChoiceSegmentedButtonRow " + + "or MultiChoiceSegmentedButtonRow — no row scope/position is on the stack."); + + // Kotlin signature has no default for `shape`, so we must supply + // one. SegmentedButtonDefaults.itemShape returns the correct + // start/middle/end rounded-corner shape based on (index, count). + var shape = ComposeBridges.ItemShape(index, count, composer); + + if (_onCheckedChange is not null) + { + int defaults = (int)MultiChoiceSegmentedButtonDefault.All; + if (modifier is not null) defaults &= ~(int)MultiChoiceSegmentedButtonDefault.Modifier; + if (icon is not null) defaults &= ~(int)MultiChoiceSegmentedButtonDefault.Icon; + + var cb = new ComposableLambda1(arg => + { + if (arg is not Java.Lang.Boolean jb) + throw new InvalidOperationException( + $"MultiChoiceSegmentedButton.onCheckedChange expected a Java.Lang.Boolean, got '{arg?.GetType().FullName ?? "null"}'."); + _onCheckedChange(jb.BooleanValue()); + }); + ComposeBridges.MultiChoiceSegmentedButton( + multiChoiceScope: scope, + @checked: _selected, + onCheckedChange: cb, + shape: shape, + label: label, + modifier: modifier, + icon: icon, + defaults: defaults, + composer: composer); + } + else + { + int defaults = (int)SingleChoiceSegmentedButtonDefault.All; + if (modifier is not null) defaults &= ~(int)SingleChoiceSegmentedButtonDefault.Modifier; + if (icon is not null) defaults &= ~(int)SingleChoiceSegmentedButtonDefault.Icon; + + var click = new ComposableLambda0(_onClick!); + ComposeBridges.SingleChoiceSegmentedButton( + singleChoiceScope: scope, + selected: _selected, + onClick: click, + shape: shape, + label: label, + modifier: modifier, + icon: icon, + defaults: defaults, + composer: composer); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SelectionContainer.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SelectionContainer.cs new file mode 100644 index 000000000..e4cdd1710 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SelectionContainer.cs @@ -0,0 +1,16 @@ +namespace AndroidX.Compose; + +/// +/// Foundation SelectionContainer — wraps a subtree so the +/// rendered nodes inside become user-selectable +/// (long-press to start a selection, drag the handles to extend it, +/// then copy via the system action bar). Without a surrounding +/// SelectionContainer, AndroidX.Compose's is +/// strictly display-only. +/// +/// new SelectionContainer { new Text("Long-press me to select.") } +/// +/// Nest a inside to opt a specific +/// region back out of selection. +/// +public sealed partial class SelectionContainer; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SemanticsRole.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SemanticsRole.cs new file mode 100644 index 000000000..a0a33aa64 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SemanticsRole.cs @@ -0,0 +1,37 @@ +namespace AndroidX.Compose; + +/// +/// Mirror of Kotlin's androidx.compose.ui.semantics.Role — the +/// accessibility role advertised to TalkBack via +/// Modifier.semantics { role = ... }. Compose defines +/// Role as a @JvmInline value class over an ; +/// the underlying values match Kotlin's source order +/// ( = 0 … = 8) and have +/// been stable since the feature was introduced. +/// +/// Use with or +/// to attach a +/// role to any composable that the framework didn't already classify +/// (e.g. tagging a custom as ). +/// +public enum SemanticsRole +{ + /// An element that triggers an action when clicked. + Button = 0, + /// An on/off two-state checkbox. + Checkbox = 1, + /// An on/off two-state switch. + Switch = 2, + /// A radio button that is part of a mutually-exclusive group. + RadioButton = 3, + /// A selectable tab in a tab strip. + Tab = 4, + /// A static image (announced as "image" by TalkBack). + Image = 5, + /// An element that opens a dropdown / popup menu of choices. + DropdownList = 6, + /// An incremental value picker (e.g. date/time spinner). + ValuePicker = 7, + /// A horizontally swipeable carousel of items. + Carousel = 8, +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SemanticsScope.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SemanticsScope.cs new file mode 100644 index 000000000..654bdd342 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SemanticsScope.cs @@ -0,0 +1,225 @@ +namespace AndroidX.Compose; + +/// +/// Fluent receiver passed to +/// and +/// its overloads. Mirrors Kotlin's +/// androidx.compose.ui.semantics.SemanticsPropertyReceiver — +/// the lambda-receiver type Kotlin code sees as +/// Modifier.semantics { selected = true; role = Role.Tab; ... }. +/// +/// Each method invokes the corresponding +/// SemanticsPropertiesKt setter against the underlying +/// JNI SemanticsPropertyReceiver handle and returns +/// this, so a single call can chain several properties: +/// +/// +/// Modifier.Semantics(s => s +/// .Selected(isSelected) +/// .Role(SemanticsRole.Tab) +/// .ContentDescription("Email from " + sender) +/// .OnClick("Open email", () => { open(); return true; })); +/// +/// +/// The scope is short-lived — Compose may invoke the configuration +/// lambda multiple times (e.g. when semantics are re-applied), and +/// each invocation gets a fresh bound to +/// the JNI handle that's live for that call. After the user's +/// Action<SemanticsScope> returns the scope is +/// invalidated; capturing it and calling a method later throws +/// . +/// +public sealed class SemanticsScope +{ + IntPtr _receiver; + readonly int _threadId; + bool _active; + + internal SemanticsScope(IntPtr receiver) + { + _receiver = receiver; + _threadId = Environment.CurrentManagedThreadId; + _active = true; + } + + internal void Invalidate() + { + _active = false; + _receiver = IntPtr.Zero; + } + + IntPtr Handle + { + get + { + if (!_active) + throw new ObjectDisposedException(nameof(SemanticsScope), + "SemanticsScope can only be used inside the Action " + + "callback passed to Modifier.Semantics(...) / ClearAndSetSemantics(...). " + + "Capturing the scope and calling a method later is not supported."); + if (Environment.CurrentManagedThreadId != _threadId) + throw new InvalidOperationException( + "SemanticsScope must be used on the same managed thread that received it. " + + "Compose invokes the builder callback synchronously on the composition thread; " + + "do not dispatch SemanticsScope calls to a Task / Thread / Dispatcher."); + return _receiver; + } + } + + /// + /// heading() — tags this node as a heading. TalkBack + /// announces "heading" alongside the content description and lets + /// users navigate between headings via the rotor / swipe gestures. + /// Compose's Foundation baseline is a single boolean; we don't + /// expose per-level headings. + /// + /// This scope, to chain further calls. + public SemanticsScope Heading() + { + ComposeBridges.SemanticsSetHeading(Handle); + return this; + } + + /// + /// testTagsAsResourceId = value — opts this subtree into + /// surfacing through + /// Android's AccessibilityNodeInfo.viewIdResourceName. + /// Without this flag, UIAutomator / Espresso / Appium-Android + /// can't find Compose nodes via By.id(...) — the testTag + /// lives only in Compose's semantics tree, invisible to the + /// platform a11y APIs that automation harnesses read. + /// MAUI Compose-folded handlers set this on every node carrying + /// an AutomationId so existing Appium FindByAutomationId + /// tests keep working. Property is annotated + /// @ExperimentalComposeUiApi upstream but stable in + /// practice — it's been the canonical Compose-↔-Espresso bridge + /// since 2021. + /// + /// Whether testTags on this node and its + /// merged descendants should be exposed as resource IDs. + /// This scope, to chain further calls. + public SemanticsScope TestTagsAsResourceId(bool value) + { + ComposeBridges.SemanticsSetTestTagsAsResourceId(Handle, value); + return this; + } + + /// + /// selected = isSelected — marks the node as part of a + /// selectable group (tab, list item, chip). TalkBack announces + /// "selected" / "not selected" alongside the node's content + /// description. + /// + /// Whether this node is currently selected. + /// This scope, to chain further calls. + public SemanticsScope Selected(bool isSelected) + { + ComposeBridges.SemanticsSetSelected(Handle, isSelected); + return this; + } + + /// + /// role = role — tags the node with an accessibility + /// (e.g. + /// ) so TalkBack announces the + /// element class. Useful when wrapping a custom composable that + /// behaves like a button/tab/etc. but isn't the real + /// / control. + /// + /// The accessibility role to advertise. + /// This scope, to chain further calls. + public SemanticsScope Role(SemanticsRole role) + { + ComposeBridges.SemanticsSetRole(Handle, (int)role); + return this; + } + + /// + /// contentDescription = description — the human-readable + /// label TalkBack reads aloud when the node is focused. For + /// graphical elements (icons, images) this is the only way the + /// user knows what the node is. + /// + /// Non-null description text. + /// This scope, to chain further calls. + public SemanticsScope ContentDescription(string description) + { + ArgumentNullException.ThrowIfNull(description); + ComposeBridges.SemanticsSetContentDescription(Handle, description); + return this; + } + + /// + /// stateDescription = description — a short string + /// describing the node's current state, read after the content + /// description (e.g. "expanded", "3 of 5"). Use for nodes whose + /// announced label changes with state but whose identity does + /// not. + /// + /// Non-null state description text. + /// This scope, to chain further calls. + public SemanticsScope StateDescription(string description) + { + ArgumentNullException.ThrowIfNull(description); + ComposeBridges.SemanticsSetStateDescription(Handle, description); + return this; + } + + /// + /// onClick(label = label) { action() } — registers a + /// custom accessibility click action with a label TalkBack + /// announces in the actions menu (e.g. "Open email"). The + /// returns true if the action + /// was handled, false to let the system fall back to the + /// node's default click behavior. + /// + /// Action label, or null to use the + /// platform-default label ("activate"). + /// Callback invoked when the user triggers + /// the action from the a11y menu. Must return whether the action + /// was handled. + /// This scope, to chain further calls. + public SemanticsScope OnClick(string? label, Func action) + { + ArgumentNullException.ThrowIfNull(action); + var f0 = new ObjectFunction0(() => action() + ? (Java.Lang.Object)Java.Lang.Boolean.True! + : Java.Lang.Boolean.False!); + ComposeBridges.SemanticsOnClick(Handle, label, f0); + return this; + } + + /// + /// Convenience overload of + /// that always + /// reports the action as handled (returns true). Use when + /// you don't need to defer back to the system's default click. + /// + /// Action label, or null for default. + /// Callback invoked when the user triggers + /// the action. + /// This scope, to chain further calls. + public SemanticsScope OnClick(string? label, Action action) + { + ArgumentNullException.ThrowIfNull(action); + return OnClick(label, () => { action(); return true; }); + } + + /// + /// Label-less overload of + /// — registers + /// the action with the platform-default label ("activate"). + /// + /// Callback returning whether the action was handled. + /// This scope, to chain further calls. + public SemanticsScope OnClick(Func action) => OnClick(label: null, action); + + /// + /// Label-less overload of + /// — always + /// reports the action as handled. + /// + /// Callback invoked when the user triggers the action. + /// This scope, to chain further calls. + public SemanticsScope OnClick(Action action) => OnClick(label: null, action); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Shape.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Shape.cs new file mode 100644 index 000000000..d6f1e8cf1 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Shape.cs @@ -0,0 +1,128 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around androidx.compose.ui.graphics.Shape. +/// Compose's Shape is an interface; concrete shapes like +/// RoundedCornerShape live in ui-graphics-android, which +/// is bound by +/// in newer NuGets. Until we adopt that bump, this thin wrapper holds +/// a Kotlin Shape handle so the bridge generator's reference- +/// type code path can pass it to L JNI slots. +/// +/// Use the factory to build a +/// RoundedCornerShape from a corner-radius ; +/// the factory delegates to the existing +/// ComposeBridges.RoundedCornerShape bridge. +/// +public class Shape : Java.Lang.Object +{ + private protected Shape(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + /// + /// androidx.compose.foundation.shape.RoundedCornerShape(corner: Dp) + /// — equal radius on all four corners. + /// + public static Shape RoundedCorners(Dp cornerRadius) + { + IntPtr handle = ComposeBridges.RoundedCornerShape(cornerRadius.Value); + return new Shape(handle, JniHandleOwnership.TransferLocalRef); + } + + /// + /// androidx.compose.foundation.shape.RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) + /// — independent radii per corner expressed in . The + /// argument order matches Kotlin: top-start, top-end, bottom-end, + /// bottom-start (clockwise from the top-leading corner). Pass + /// 0.Dp() for a square corner. + /// + /// + /// The canonical "chat bubble" shape uses one flattened corner to + /// visually connect to its avatar tile — e.g. + /// Shape.RoundedCorners(4, 20, 20, 20) for a bubble whose + /// top-leading corner is squared off where it meets the speaker's + /// avatar. + /// + public static Shape RoundedCorners(Dp topStart, Dp topEnd, Dp bottomEnd, Dp bottomStart) + { + IntPtr handle = ComposeBridges.RoundedCornerShape4( + topStart.Value, topEnd.Value, bottomEnd.Value, bottomStart.Value); + return new Shape(handle, JniHandleOwnership.TransferLocalRef); + } + + /// + /// androidx.compose.foundation.shape.RoundedCornerShape(percent: Int) + /// — equal radius on all four corners, expressed as a percentage of + /// the smaller dimension. 0 = rectangle, 50 = pill / + /// circle (use for the canonical name). + /// + public static Shape RoundedPercent(int percent) + { + IntPtr handle = ComposeBridges.RoundedCornerShapePercent(percent); + return new Shape(handle, JniHandleOwnership.TransferLocalRef); + } + + /// + /// RoundedCornerShape(50) — fully rounded, producing a + /// circle (when the bounds are square) or a pill (when they're + /// rectangular). + /// + public static Shape Circle() => RoundedPercent(50); + + /// + /// androidx.compose.foundation.shape.CutCornerShape(size: Dp) + /// — chamfered (straight-line) corners on all four sides. + /// + public static Shape CutCorners(Dp cornerSize) + { + IntPtr handle = ComposeBridges.CutCornerShapeDp(cornerSize.Value); + return new Shape(handle, JniHandleOwnership.TransferLocalRef); + } + + /// + /// androidx.compose.foundation.shape.CutCornerShape(percent: Int) + /// — chamfered corners expressed as a percentage of the smaller + /// dimension. + /// + public static Shape CutCornersPercent(int percent) + { + IntPtr handle = ComposeBridges.CutCornerShapePercent(percent); + return new Shape(handle, JniHandleOwnership.TransferLocalRef); + } + + /// + /// androidx.compose.ui.graphics.RectangleShape — Compose's + /// canonical "no clipping, just a rectangle" shape singleton. Pass + /// this anywhere a is expected when you want + /// the default (rectangular) outline — useful when overriding a + /// facade's default rounded shape, or when supplying a + /// Modifier.Background(Brush, Shape) with no rounding. + /// + /// + /// The underlying Kotlin value is a singleton, so this property + /// always returns the same Java peer (held as a global ref behind + /// the scenes). + /// + public static Shape Rectangle { get; } = RectangleShapeFactory(); + + static Shape RectangleShapeFactory() + { + // `RectangleShapeKt.RectangleShape` is bound — it returns the + // shared Kotlin singleton. Wrap its handle in our facade type + // without transferring ownership; the bound peer keeps the + // underlying JNI ref alive. + var bound = AndroidX.Compose.UI.Graphics.RectangleShapeKt.RectangleShape; + try + { + return new Shape( + ((Java.Lang.Object)bound).Handle, + JniHandleOwnership.DoNotTransfer); + } + finally + { + GC.KeepAlive(bound); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ShapesBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ShapesBridges.cs new file mode 100644 index 000000000..f5a7ac9bb --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ShapesBridges.cs @@ -0,0 +1,72 @@ +using Android.Runtime; +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +// Hand-written JNI bridge for the Material 3 `Shapes` synthetic +// default constructor. Used by MaterialTheme.Shapes(...) so callers +// can override individual shape slots (extraSmall / small / medium / +// large / extraLarge) while leaving the rest at their M3 baseline +// values. +// +// The Shapes class itself is bound, but only its parameterless +// constructor is exposed. The full primary constructor takes +// 5 CornerBasedShape params plus Kotlin's synthetic `$default` int +// + DefaultConstructorMarker. Why raw JNI instead of +// `[ComposeBridge(JvmName = "")]`: the constructor bridge shape +// in the generator (CN2006) doesn't currently support a trailing +// `$default` int + Object marker. Hand-rolling it here is a +// six-line JNI call. +internal static partial class ComposeBridges +{ + const string ShapesDefaultCtorSig = + "(Landroidx/compose/foundation/shape/CornerBasedShape;" + + "Landroidx/compose/foundation/shape/CornerBasedShape;" + + "Landroidx/compose/foundation/shape/CornerBasedShape;" + + "Landroidx/compose/foundation/shape/CornerBasedShape;" + + "Landroidx/compose/foundation/shape/CornerBasedShape;" + + "ILkotlin/jvm/internal/DefaultConstructorMarker;)V"; + + static IntPtr s_shapesCtor_class; + static IntPtr s_shapesCtor_method; + + /// + /// Build a Material 3 with per-slot + /// overrides. Each is either a + /// CornerBasedShape JNI handle to use for that slot, or + /// to fall back to the M3 + /// baseline default for that slot via Kotlin's synthetic + /// $default mechanism. + /// + /// + /// 5-bit mask: bit N set means "leave slot N at the Kotlin + /// default" (the corresponding handle is ignored). Bit 0 is + /// extraSmall, bit 4 is extraLarge. + /// + internal static unsafe Shapes BuildShapes( + IntPtr extraSmall, + IntPtr small, + IntPtr medium, + IntPtr large, + IntPtr extraLarge, + int defaults) + { + if (s_shapesCtor_method == IntPtr.Zero) + { + s_shapesCtor_class = JNIEnv.FindClass("androidx/compose/material3/Shapes"); + s_shapesCtor_method = JNIEnv.GetMethodID(s_shapesCtor_class, "", ShapesDefaultCtorSig); + } + + JValue* args = stackalloc JValue[7]; + args[0] = new JValue(extraSmall); + args[1] = new JValue(small); + args[2] = new JValue(medium); + args[3] = new JValue(large); + args[4] = new JValue(extraLarge); + args[5] = new JValue(defaults); + args[6] = new JValue(IntPtr.Zero); // DefaultConstructorMarker + + IntPtr handle = JNIEnv.NewObject(s_shapesCtor_class, s_shapesCtor_method, args); + return Java.Lang.Object.GetObject(handle, JniHandleOwnership.TransferLocalRef)!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SheetStateHolder.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SheetStateHolder.cs new file mode 100644 index 000000000..5b76ea807 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SheetStateHolder.cs @@ -0,0 +1,144 @@ +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for . +/// Wraps Kotlin's SheetState (created via +/// rememberModalBottomSheetState) so a facade can carry an +/// initial setting across +/// recompositions and expose the live sheet position back to C#. +/// +/// +/// Construct one inside Remember so the same instance +/// survives recomposition: +/// +/// var sheet = Remember(() => new SheetStateHolder(skipPartiallyExpanded: true)); +/// +/// new ModalBottomSheet(onDismissRequest: () => show.Value = false, sheetState: sheet) +/// { +/// ConfirmValueChange = v => !formIsDirty || v != SheetValue.Hidden, +/// new Column { ... }, +/// } +/// +/// Imperative open / close from C# uses the +/// / / +/// / +/// helpers — the returned completes when the +/// animation lands. +/// +public sealed class SheetStateHolder +{ + internal SheetState? Jvm; + + /// + /// When true, the sheet skips the half-expanded resting + /// state and either hides fully or expands fully. Mirrors Kotlin + /// rememberModalBottomSheetState(skipPartiallyExpanded = …). + /// Defaults to false. + /// + public bool SkipPartiallyExpanded { get; } + + /// + /// Construct a holder, optionally opting out of the partially- + /// expanded resting state. + /// + public SheetStateHolder(bool skipPartiallyExpanded = false) + { + SkipPartiallyExpanded = skipPartiallyExpanded; + } + + /// + /// The sheet's current . Returns + /// until the holder is bound to a + /// live peer (i.e. the first render has happened). + /// + public SheetValue CurrentValue => Jvm?.CurrentValue ?? SheetValue.Hidden!; + + /// + /// The sheet's target during animation, + /// or the resting state otherwise. Returns + /// until bound. + /// + public SheetValue TargetValue => Jvm?.TargetValue ?? SheetValue.Hidden!; + + /// + /// true when the sheet is non-hidden — equivalent to + /// CurrentValue != SheetValue.Hidden. Returns false + /// until bound. + /// + public bool IsVisible => Jvm?.IsVisible ?? false; + + /// + /// true when the sheet's anchored set includes a fully- + /// expanded resting position. Returns false until bound. + /// + public bool HasExpandedState => Jvm?.HasExpandedState ?? false; + + /// + /// true when the sheet's anchored set includes a half- + /// expanded resting position. Returns false until bound, + /// or when is in effect. + /// + public bool HasPartiallyExpandedState => Jvm?.HasPartiallyExpandedState ?? false; + + /// + /// Animate the sheet to the half-expanded state (or fully expand + /// when partial expand is disabled). Mirrors Kotlin + /// SheetState.show(). Throws + /// if invoked before the + /// holder is bound to a live peer. + /// + public Task ShowAsync(CancellationToken cancellationToken = default) + { + var jvm = RequireJvm(nameof(ShowAsync)); + return SuspendBridge.Invoke(cont => + ComposeBridges.SheetStateShow(((Java.Lang.Object)jvm).Handle, cont), + cancellationToken); + } + + /// + /// Animate the sheet to . Mirrors + /// Kotlin SheetState.hide(). See + /// for the binding caveat. + /// + public Task HideAsync(CancellationToken cancellationToken = default) + { + var jvm = RequireJvm(nameof(HideAsync)); + return SuspendBridge.Invoke(cont => + ComposeBridges.SheetStateHide(((Java.Lang.Object)jvm).Handle, cont), + cancellationToken); + } + + /// + /// Animate the sheet to the fully expanded state. Mirrors + /// Kotlin SheetState.expand(). See + /// for the binding caveat. + /// + public Task ExpandAsync(CancellationToken cancellationToken = default) + { + var jvm = RequireJvm(nameof(ExpandAsync)); + return SuspendBridge.Invoke(cont => + ComposeBridges.SheetStateExpand(((Java.Lang.Object)jvm).Handle, cont), + cancellationToken); + } + + /// + /// Animate the sheet to . + /// No-op when the sheet was constructed with + /// = true. Mirrors + /// Kotlin SheetState.partialExpand(). See + /// for the binding caveat. + /// + public Task PartialExpandAsync(CancellationToken cancellationToken = default) + { + var jvm = RequireJvm(nameof(PartialExpandAsync)); + return SuspendBridge.Invoke(cont => + ComposeBridges.SheetStatePartialExpand(((Java.Lang.Object)jvm).Handle, cont), + cancellationToken); + } + + SheetState RequireJvm(string member) => + Jvm ?? throw new InvalidOperationException( + "SheetStateHolder." + member + " requires the holder to be bound to a live sheet; call it after the first render."); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SheetValueConfirmStateChange.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SheetValueConfirmStateChange.cs new file mode 100644 index 000000000..40cb79895 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SheetValueConfirmStateChange.cs @@ -0,0 +1,56 @@ +using Android.Runtime; +using AndroidX.Compose.Material3; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Function1<SheetValue, Boolean> adapter used as the +/// confirmValueChange callback when constructing a +/// SheetState via rememberModalBottomSheetState / +/// rememberStandardBottomSheetState. The callback is part of +/// the remember cache key in Kotlin, so this type is allocated +/// once per instance and +/// reused across recompositions — a fresh adapter each pass would +/// invalidate the cached state holder and the sheet would forget its +/// position. is read on every JNI invocation, +/// so callers may mutate it freely without re-allocating. When +/// is null the adapter behaves as the +/// Kotlin default { true } — every transition is allowed. +/// +/// +/// The name follows the source generator's convention +/// <TName>ConfirmStateChange so a +/// [ConfirmStateChange(typeof(SheetValue))] attribute on a +/// Remember bridge parameter resolves to this adapter automatically. +/// Public so generated facade classes (which live alongside this +/// type in AndroidX.Compose) can declare it as a +/// readonly field; it is not part of the developer-facing API +/// and should not be constructed directly. +/// +[Register("net/compose/SheetValueConfirmStateChange")] +public sealed class SheetValueConfirmStateChange : Java.Lang.Object, IFunction1 +{ + /// + /// Developer-supplied veto delegate, assigned by the + /// generator-emitted facade in its Render preamble from + /// the facade's ConfirmValueChange property. Treated as + /// { true } when null. + /// + public Func? Callback { get; set; } + + /// + /// Kotlin Function1.invoke entry point. Marshals the JNI + /// argument to , invokes + /// , and returns a boxed + /// . + /// + public Java.Lang.Object? Invoke(Java.Lang.Object? p0) + { + var cb = Callback; + if (cb is null) + return Java.Lang.Boolean.True; + var value = Android.Runtime.Extensions.JavaCast(p0!); + return cb(value) ? Java.Lang.Boolean.True : Java.Lang.Boolean.False; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ShouldStartDragAndDropCallback.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ShouldStartDragAndDropCallback.cs new file mode 100644 index 000000000..14ab3e3f8 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ShouldStartDragAndDropCallback.cs @@ -0,0 +1,27 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +// JCW adapter for the `shouldStartDragAndDrop: (DragAndDropEvent) -> Boolean` +// parameter on `Modifier.dragAndDropTarget(...)`. Wraps a managed +// `Func` and returns a boxed Java.Lang.Boolean so +// Kotlin's Function1 contract is honoured. +[Register("net/compose/ShouldStartDragAndDropCallback")] +internal sealed class ShouldStartDragAndDropCallback : Java.Lang.Object, IFunction1 +{ + readonly Func _callback; + + public ShouldStartDragAndDropCallback(Func callback) + { + _callback = callback; + } + + public Java.Lang.Object? Invoke(Java.Lang.Object? p0) + { + if (p0 is null) + return Java.Lang.Boolean.False; + var jvm = Android.Runtime.Extensions.JavaCast(p0); + return _callback(new DragAndDropEvent(jvm)) ? Java.Lang.Boolean.True : Java.Lang.Boolean.False; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SideEffect.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SideEffect.cs new file mode 100644 index 000000000..f92358b3c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SideEffect.cs @@ -0,0 +1,34 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Tree-syntax wrapper around . +/// Add to a container to run a callback on every successful +/// recomposition of that container, after the composition has been +/// applied. Useful when you'd rather express the effect as a sibling +/// in your composable tree than as a statement inside a custom +/// Render override: +/// +/// new Column +/// { +/// new Text(_count.Value.ToString()), +/// new SideEffect(() => Log.Info("rendered count = " + _count.Value)), +/// } +/// +/// +public sealed class SideEffect : ComposableNode +{ + readonly Action _effect; + + /// Create the node. runs + /// every successful recomposition. + public SideEffect(Action effect) + { + ArgumentNullException.ThrowIfNull(effect); + _effect = effect; + } + + public override void Render(IComposer composer) + => composer.SideEffect(_effect); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SingleChoiceSegmentedButtonRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SingleChoiceSegmentedButtonRow.cs new file mode 100644 index 000000000..b07890f11 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SingleChoiceSegmentedButtonRow.cs @@ -0,0 +1,20 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 SingleChoiceSegmentedButtonRow. Container for +/// children that exposes a +/// SingleChoiceSegmentedButtonRowScope receiver — the scope is +/// captured here and published via so child +/// s can pass it to the underlying +/// scope-extension Kotlin static. The container also publishes the +/// per-child row index so each segmented button can resolve its +/// start / middle / end shape. +/// +/// new SingleChoiceSegmentedButtonRow +/// { +/// new SegmentedButton(selected: tab == 0, onClick: () => tab.Value = 0) { new Text("Day") }, +/// new SegmentedButton(selected: tab == 1, onClick: () => tab.Value = 1) { new Text("Week") }, +/// } +/// +/// +public sealed partial class SingleChoiceSegmentedButtonRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Slider.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Slider.cs new file mode 100644 index 000000000..cec3a3a5b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Slider.cs @@ -0,0 +1,20 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 Slider: +/// +/// new Slider(value: pos.Value, onValueChange: v => pos.Value = v) +/// +/// Calls the simple (Float, (Float) -> Unit) overload — the +/// richer overloads (with custom thumb / track slots, or +/// a SliderState first param) aren't exposed in this facade. +/// +/// Optional ValueRange property surfaces Kotlin's +/// ClosedFloatingPointRange<Float> — build with +/// Kotlin.Ranges.RangesKt.RangeTo(min, max). Optional +/// Colors property accepts a +/// ; build via +/// composer.SliderColors(...) to override individual color slots +/// without restating the full theme. +/// +public sealed partial class Slider; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SmallFloatingActionButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SmallFloatingActionButton.cs new file mode 100644 index 000000000..2f754a2df --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SmallFloatingActionButton.cs @@ -0,0 +1,16 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 SmallFloatingActionButton. Identical shape to +/// — just the smaller (40 dp) +/// container. Use the collection-initializer form for a single icon +/// child: +/// +/// +/// new SmallFloatingActionButton(onClick: () => count.Value++) +/// { +/// new Text("+"), +/// } +/// +/// +public sealed partial class SmallFloatingActionButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Snackbar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Snackbar.cs new file mode 100644 index 000000000..8cb4b9d43 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Snackbar.cs @@ -0,0 +1,14 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 Snackbar. A transient message bar typically +/// rendered in Scaffold.SnackbarHost: +/// +/// new Snackbar +/// { +/// Action = new Button(onClick: ...) { new Text("Undo") }, +/// Body = new Text("Item deleted"), +/// } +/// +/// +public sealed partial class Snackbar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnackbarHost.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnackbarHost.cs new file mode 100644 index 000000000..2ce6e0379 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnackbarHost.cs @@ -0,0 +1,52 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 SnackbarHost. Anchors a +/// driven by a . Typically +/// dropped into : +/// +/// var hostState = Remember(() => new SnackbarHostState()); +/// +/// new Scaffold +/// { +/// SnackbarHost = new SnackbarHost(hostState), +/// Body = ..., +/// } +/// +/// +/// +/// Triggering snackbars via +/// requires Kotlin coroutines and isn't yet wired up in this binding. +/// For most cases, render a directly into the +/// slot, gated by a +/// . When external code (Kotlin/Java) drives +/// the host state, this host paints whatever the state has queued via +/// the M3 Snackbar(SnackbarData) overload. +/// +public sealed class SnackbarHost : ComposableNode +{ + readonly SnackbarHostState _hostState; + + public SnackbarHost(SnackbarHostState hostState) => _hostState = hostState; + + public override void Render(IComposer composer) + { + // SnackbarHost's Function3 receives the current SnackbarData as p0. + // Forward it to the M3 default — Snackbar(snackbarData) — so an + // externally-driven host state actually paints. The lambda is a + // no-op when p0 is null (no queued data). + var snackbar = ComposableLambdas.Wrap3(composer, (data, c) => + { + if (data == IntPtr.Zero) return; + ComposeBridges.SnackbarFromData(data, modifier: null, composer: c); + }); + + ComposeBridges.SnackbarHost( + hostState: ((Java.Lang.Object)_hostState.Jvm).Handle, + modifier: BuildModifier(), + snackbar: snackbar, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnackbarHostState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnackbarHostState.cs new file mode 100644 index 000000000..58c618b8a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnackbarHostState.cs @@ -0,0 +1,21 @@ +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for . Wraps +/// the bound androidx.compose.material3.SnackbarHostState — the +/// Kotlin class has a public no-arg constructor and is plain enough +/// for the binder to expose, so no JNI bridge is needed. +/// +/// +/// The Kotlin showSnackbar trigger is a suspending function +/// (takes a Continuation) and isn't directly callable from C# +/// without a coroutine bridge. For now, prefer toggling a +/// + rendering a +/// conditionally inside the slot. +/// This wrapper exists so the bound state can still be passed to a +/// when one is needed. +/// +public sealed class SnackbarHostState +{ + internal AndroidX.Compose.Material3.SnackbarHostState Jvm { get; } = new(); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowCollectorAdapter.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowCollectorAdapter.cs new file mode 100644 index 000000000..643d4e01d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowCollectorAdapter.cs @@ -0,0 +1,64 @@ +using System.Threading.Channels; +using Android.Runtime; +using Kotlin.Coroutines; +using Xamarin.KotlinX.Coroutines.Flow; + +namespace AndroidX.Compose; + +/// +/// JCW implementing Kotlin's kotlinx.coroutines.flow.FlowCollector +/// for . Each +/// emit(value, continuation) call from Kotlin unboxes the +/// value, pushes it onto a single-slot bounded +/// , and returns +/// Unit.INSTANCE synchronously — i.e. emit never +/// suspends Kotlin's continuation. +/// +/// +/// +/// The channel is with +/// capacity 1 so a slow C# consumer naturally conflates to the +/// latest value. This matches Compose's own behaviour: between +/// snapshot applies, only the most recent producer result matters. +/// It also makes emit non-blocking, which is critical when +/// the Kotlin coroutine ends up dispatched onto the same main +/// thread the C# await foreach resumes on (blocking +/// emit there would deadlock). +/// +/// +[Register("net/compose/SnapshotFlowCollectorAdapter")] +internal sealed class SnapshotFlowCollectorAdapter : Java.Lang.Object, IFlowCollector +{ + readonly ChannelWriter _writer; + + public SnapshotFlowCollectorAdapter(ChannelWriter writer) + { + _writer = writer; + } + + public Java.Lang.Object Emit(Java.Lang.Object? value, IContinuation p1) + { + try + { + var unboxed = MutableState.FromJava(value); + // BoundedChannelFullMode.DropOldest guarantees TryWrite + // succeeds — the channel always has room because the + // oldest entry is silently discarded when full. + _writer.TryWrite(unboxed); + } + catch (Exception ex) + { + // A faulting Emit would otherwise propagate into the + // Kotlin flow as a CancellationException-shaped exit, + // but the C# consumer would never see the underlying + // cause. Surface it through channel completion so the + // awaiter's await foreach throws it directly. + _writer.TryComplete(ex); + } + // Synchronous completion: returning a non-suspended value + // tells Kotlin's flow machinery that emit finished without + // needing to park the continuation. snapshotFlow then loops + // back to its next snapshot-apply wait. + return Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowContinuation.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowContinuation.cs new file mode 100644 index 000000000..0ec6f6879 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowContinuation.cs @@ -0,0 +1,135 @@ +using Android.Runtime; +using Kotlin.Coroutines; +using Xamarin.KotlinX.Coroutines; + +namespace AndroidX.Compose; + +/// +/// JCW implementing Kotlin's for the +/// long-running Flow.collect call that drives +/// . Unlike +/// , which models a one-shot suspend +/// resumed back into a , +/// this continuation: +/// +/// Exposes a combining +/// AndroidUiDispatcher.Main with a Kotlin +/// so that cancelling the job tears down the flow's +/// snapshotFlow internals (its apply observer registration, +/// its internal changes channel, etc.). +/// On +/// — fired once when Kotlin's collect coroutine finishes for any +/// reason — completes the consumer's channel writer so the +/// await foreach exits cleanly (with the underlying exception +/// if the flow faulted with anything other than a normal +/// cancellation). +/// +/// +[Register("net/compose/SnapshotFlowContinuation")] +internal sealed class SnapshotFlowContinuation : Java.Lang.Object, IContinuation +{ + readonly IJob _job; + readonly TaskCompletionSource _tcs; + + /// + /// Task that completes when Kotlin's collect coroutine has fully + /// resumed (i.e. has + /// fired). The result is irrelevant — callers await it just + /// to know the Kotlin side has unwound (used by + /// ). + /// + public Task Completion => _tcs.Task; + + public SnapshotFlowContinuation(IJob job) + { + _job = job; + _tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// + /// Coroutine context = AndroidUiDispatcher.Main combined + /// with our . The Main dispatcher provides the + /// MonotonicFrameClock Compose needs internally; the Job + /// is what lets us cancel from C#. + /// + public ICoroutineContext Context + { + get + { + var mainHandle = ComposeBridges.AndroidUiDispatcherMain(); + var main = Java.Lang.Object.GetObject( + mainHandle, JniHandleOwnership.DoNotTransfer)!; + return main.Plus(_job); + } + } + + /// + /// Callback invoked once when Kotlin's collect coroutine + /// resumes. Receives null for normal completion / + /// cancellation, or the underlying + /// when the flow faulted with anything other than a + /// CancellationException. + /// + /// + /// Set this exactly once before the Kotlin coroutine can ever + /// reach a resume point (i.e. immediately after constructing the + /// continuation, before passing it to flow.Collect). It's + /// read once on the resume path and isn't expected to change. + /// + internal Action? OnResumed { get; set; } + + /// + /// Forces to complete without going + /// through Kotlin's + /// path. Use this only when the caller knows the suspend + /// function completed synchronously (returned a non-suspended + /// value before ever scheduling a resume), since Kotlin then + /// never invokes the continuation and we'd otherwise wait + /// forever for the resume that's never coming. + /// + internal void MarkSynchronouslyCompleted() + { + try { OnResumed?.Invoke(null); } + catch (Exception ex) { _tcs.TrySetException(ex); return; } + _tcs.TrySetResult(null); + } + + public void ResumeWith(Java.Lang.Object p0) + { + Exception? error = null; + try + { + if (p0 is not null && KotlinResult.IsFailure(p0.Handle)) + { + var ex = KotlinResult.ExtractException(p0); + // CancellationException is the normal "we were told + // to stop" signal — surface as a clean stream close, + // not as an exception on the awaiter. + if (ex is not Java.Util.Concurrent.CancellationException) + error = ex; + } + } + catch (Exception ex) + { + // KotlinResult helpers can throw on malformed boxes. + // Surface the diagnostic to the awaiter rather than + // swallowing it. + error = ex; + } + + try + { + OnResumed?.Invoke(error); + } + catch (Exception ex) + { + // The enumerator's completion handler should be + // exception-free, but if it ever isn't we still need to + // unblock awaiters of Completion. + _tcs.TrySetException(ex); + return; + } + + _tcs.TrySetResult(null); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowEnumerable.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowEnumerable.cs new file mode 100644 index 000000000..a41d6ea4e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowEnumerable.cs @@ -0,0 +1,22 @@ + +namespace AndroidX.Compose; + +/// +/// Backing implementation for +/// . Each call to +/// spins up a +/// fresh Kotlin snapshotFlow collection so multiple consumers +/// of the same producer don't share state. +/// +internal sealed class SnapshotFlowEnumerable : IAsyncEnumerable +{ + readonly Func _producer; + + public SnapshotFlowEnumerable(Func producer) + { + _producer = producer; + } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + new SnapshotFlowEnumerator(_producer, cancellationToken); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowEnumerator.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowEnumerator.cs new file mode 100644 index 000000000..cf6cc651f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SnapshotFlowEnumerator.cs @@ -0,0 +1,257 @@ +using System.Runtime.InteropServices; +using System.Threading.Channels; +using AndroidX.Compose.Runtime; +using Xamarin.KotlinX.Coroutines; + +namespace AndroidX.Compose; + +/// +/// Async iterator for . +/// On first it starts a Kotlin +/// snapshotFlow(producer).collect(...) coroutine on the +/// Compose main dispatcher, with a Kotlin in the +/// continuation context so the consumer's +/// (or +/// ) can tear the flow down for real. +/// +internal sealed class SnapshotFlowEnumerator : IAsyncEnumerator +{ + readonly Func _producer; + readonly CancellationToken _userToken; + readonly Channel _channel; + + // JCWs we have to keep rooted for the lifetime of the Kotlin + // coroutine: dropping any of these while Kotlin is still holding + // its JNI ref would leave the runtime pointing at a freed peer. + ObjectFunction0? _producerJcw; + SnapshotFlowCollectorAdapter? _collectorJcw; + SnapshotFlowContinuation? _continuation; + ICompletableJob? _job; + GCHandle _pin; + int _pinReleased; + + CancellationTokenRegistration _ctr; + bool _started; + bool _disposed; + T? _current; + + public SnapshotFlowEnumerator(Func producer, CancellationToken userToken) + { + _producer = producer; + _userToken = userToken; + // SingleWriter intentionally false: Emit (Kotlin dispatcher + // thread), OnResumed (resume thread), and DisposeAsync + // (caller thread) can each call TryComplete / TryWrite. + _channel = Channel.CreateBounded(new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + } + + public T Current => _current!; + + public async ValueTask MoveNextAsync() + { + if (!_started) + { + _started = true; + try + { + StartCollection(); + } + catch (Exception ex) + { + // Setup failure — surface to the awaiter and stop + // the iteration. Mark started so a subsequent call + // sees the now-completed channel instead of trying + // again. + _channel.Writer.TryComplete(ex); + } + } + + try + { + if (await _channel.Reader.WaitToReadAsync(_userToken).ConfigureAwait(false)) + { + if (_channel.Reader.TryRead(out var value)) + { + _current = value; + return true; + } + } + } + catch (ChannelClosedException ex) when (ex.InnerException is not null) + { + // WaitToReadAsync surfaces the completion exception as + // its own ChannelClosedException wrapper; unwrap so the + // awaiter sees the original error from the producer or + // collector. + throw ex.InnerException; + } + + return false; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + _ctr.Dispose(); + + // Cancel Kotlin-side so snapshotFlow's apply observer + // unregisters and its internal channel closes. If Kotlin + // never started, _job is null and there's nothing to cancel. + try { _job?.Cancel(null); } catch { } + + // Close the channel from our side too in case Kotlin never + // got far enough to drive ResumeWith (e.g. setup failure). + _channel.Writer.TryComplete(); + + if (_continuation is { } cont) + { + try + { + // Wait — bounded — for Kotlin to actually unwind. + // Without this, the JCWs (and their JNI peers) could + // be GC'd while Kotlin still holds references, which + // tends to crash with "expected Local but found ..." + // from CheckJNI. + var completedFirst = await Task.WhenAny( + cont.Completion, + Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false); + if (completedFirst != cont.Completion) + { + // Kotlin didn't acknowledge cancellation within the + // grace window. Log and keep going; the JCWs stay + // rooted via _pin until the resume eventually + // fires, which is leak-not-crash. + Android.Util.Log.Warn( + "AndroidX.Compose", + "SnapshotFlow: Kotlin coroutine didn't unwind within 2s after cancellation; JCWs will stay rooted until the eventual resume."); + return; + } + } + catch + { + // Completion task can fault if ResumeWith fails; + // we've already cancelled and best-effort cleaned up. + } + } + + ReleasePin(); + } + + void StartCollection() + { + // 1. Fresh Kotlin Job so we can cancel the coroutine for + // real (vs. just abandoning the C# awaiter). + _job = JobKt.Job(null); + + // 2. JCWs. Allocate them once and root via _pin so neither + // Kotlin nor we drop them while collect is running. + _producerJcw = new ObjectFunction0(() => MutableState.ToJava(_producer())); + _collectorJcw = new SnapshotFlowCollectorAdapter(_channel.Writer); + _continuation = new SnapshotFlowContinuation(_job); + + // Self-pin so fire-and-forget consumers (no one holding the + // enumerator) can't have GC reclaim the JCWs while the + // coroutine is still emitting. + _pin = GCHandle.Alloc(this); + + // 3. Hook channel completion to the continuation's resume. + // Closing the writer wakes the awaiter; passing the + // optional exception faults the await foreach. + // Also release the self-pin once Kotlin has actually + // unwound — if the consumer disposed first and + // DisposeAsync timed out, this is the only place the + // GCHandle gets freed. + _continuation.OnResumed = error => + { + if (error is not null) + _channel.Writer.TryComplete(error); + else + _channel.Writer.TryComplete(); + ReleasePin(); + }; + + // 4. User cancellation → Kotlin job cancel. The job's + // cancellation propagates into the flow's snapshot-apply + // waiter, which throws CancellationException, which + // surfaces via the continuation's ResumeWith path. + if (_userToken.CanBeCanceled) + { + _ctr = _userToken.Register(static state => + { + var self = (SnapshotFlowEnumerator)state!; + try { self._job?.Cancel(null); } catch { } + }, this); + + if (_userToken.IsCancellationRequested) + { + _job.Cancel(null); + _channel.Writer.TryComplete(); + _continuation.MarkSynchronouslyCompleted(); + return; + } + } + + // 5. Drive the collect. snapshotFlow().collect() suspends + // immediately — the first frame's snapshot tracking work + // runs on the dispatcher we hand it via _continuation's + // context, and emit fires from there on each apply. + // Returning anything other than COROUTINE_SUSPENDED would + // be the synchronous-completion path (only happens if + // Kotlin throws inline before the first suspend). + var flow = SnapshotStateKt.SnapshotFlow(_producerJcw); + bool synchronouslyCompleted = false; + try + { + var boxed = flow.Collect(_collectorJcw, _continuation); + var handle = boxed?.Handle ?? IntPtr.Zero; + if (handle != IntPtr.Zero && !SuspendBridge.IsCoroutineSuspended(handle)) + { + synchronouslyCompleted = true; + // Only dispose the non-sentinel wrapper. The + // COROUTINE_SUSPENDED sentinel is a Kotlin singleton + // whose Java.Lang.Object peer in Mono's cache holds + // a *global* ref; calling Dispose on it would + // DeleteLocalRef a global handle and CheckJNI would + // abort. The cached wrapper outlives every call to + // collect by design, so leaking the local ref isn't + // a concern here. (See SuspendBridge for the same + // pattern.) + boxed!.Dispose(); + } + } + catch (Exception ex) + { + _channel.Writer.TryComplete(ex); + _continuation.MarkSynchronouslyCompleted(); + return; + } + + if (synchronouslyCompleted) + { + // Synchronous completion (rare for snapshotFlow — it's + // designed to be infinite). Treat as immediate end-of- + // stream. Kotlin won't invoke our continuation in this + // case, so manually drive OnResumed so DisposeAsync's + // wait on Completion can finish. + _continuation.MarkSynchronouslyCompleted(); + } + } + + void ReleasePin() + { + // Idempotent: DisposeAsync and OnResumed can race to be + // first; whichever loses sees _pinReleased already non-zero + // and skips the Free. + if (Interlocked.Exchange(ref _pinReleased, 1) != 0) + return; + if (_pin.IsAllocated) + _pin.Free(); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SourceLocationKey.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SourceLocationKey.cs new file mode 100644 index 000000000..ae3435a19 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SourceLocationKey.cs @@ -0,0 +1,46 @@ +namespace AndroidX.Compose; + +/// +/// Deterministic slot-table key derived from a [CallerLineNumber] +/// / [CallerFilePath] pair. Used by every group/lambda key the +/// facade hands to Compose so the same call site produces the same +/// int in every process — a hard requirement for +/// , +/// whose SaveableStateRegistry key is built from the full chain +/// of ancestor group keys leading to the call. +/// +/// +/// and +/// are randomized per process on +/// modern .NET, so they can't be used here — the saved key embedded +/// in onSaveInstanceState would never match the key recomputed +/// after the activity is recreated, and every rememberSaveable +/// slot would silently reinitialise on restore. This helper uses +/// FNV-1a 32-bit over the UTF-16 code units of the path, then mixes +/// in the line number, giving a stable identifier with the same +/// collision footprint as a regular hash. +/// +internal static class SourceLocationKey +{ + const uint FnvOffset = 2166136261u; + const uint FnvPrime = 16777619u; + + public static int Compute(int line, string file) + { + uint hash = FnvOffset; + if (file is not null) + { + for (int i = 0; i < file.Length; i++) + { + hash ^= file[i]; + hash *= FnvPrime; + } + } + // Mix the line number in by XORing the full 32-bit value and + // running one more FNV step, so distinct lines in the same + // file land in distinct buckets. + hash ^= (uint)line; + hash *= FnvPrime; + return unchecked((int)hash); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Sp.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Sp.cs new file mode 100644 index 000000000..d8c8124c2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Sp.cs @@ -0,0 +1,127 @@ +namespace AndroidX.Compose; + +/// +/// Scale-independent pixel — C# mirror of Kotlin's +/// androidx.compose.ui.unit.TextUnit in Sp form +/// (@JvmInline value class TextUnit(val packedValue: Long)). +/// +/// +/// +/// Compose's TextUnit is an inline value class around a Long; +/// the long packs a sign bit, a type tag (Sp/Em/Unspecified), and a value. +/// This struct preserves that wire shape — is the +/// exact long that TextUnitKt.GetSp(int) returns — so it crosses +/// JNI unchanged. +/// +/// +/// Use or the implicit conversion from +/// (which both delegate to the bound TextUnitKt.GetSp factory) to +/// construct a typed Sp value. +/// +/// +public readonly struct Sp : IEquatable, IComparable +{ + /// + /// The raw packed TextUnit.packedValue. Pass this directly to + /// any JNI bridge that expects a long TextUnit argument. + /// + public long PackedValue { get; } + + /// + /// Construct an Sp from the raw packed TextUnit long. Use the + /// other overloads (which take an ) for ergonomic + /// construction; this one is for round-tripping packed values from + /// existing bound APIs. + /// + public Sp(long packedValue) + { + PackedValue = packedValue; + } + + /// + /// Construct an Sp from a scale-independent pixel count by calling + /// the bound TextUnitKt.GetSp(int) factory. + /// + public Sp(int sp) + : this(AndroidX.Compose.UI.Unit.TextUnitKt.GetSp(sp)) + { + } + + /// The zero-sp constant (equivalent to 0.sp in Kotlin). + public static Sp Zero => new(0); + + /// Implicit conversion from for ergonomic call sites. + public static implicit operator Sp(int value) => new Sp(value); + + /// + public bool Equals(Sp other) => PackedValue == other.PackedValue; + + /// + public override bool Equals(object? obj) => obj is Sp sp && Equals(sp); + + /// + public override int GetHashCode() => PackedValue.GetHashCode(); + + /// Equality operator. + public static bool operator ==(Sp left, Sp right) => left.Equals(right); + + /// Inequality operator. + public static bool operator !=(Sp left, Sp right) => !left.Equals(right); + + /// + /// + /// Delegates to Kotlin's TextUnit.compareTo. Defined for same-tag + /// values (all instances are Sp-tagged, so this is + /// always safe between two values). + /// + public int CompareTo(Sp other) => + AndroidX.Compose.UI.Unit.TextUnit.CompareTo(PackedValue, other.PackedValue); + + /// + /// Scale an value by a scalar factor. Mirrors Kotlin's + /// TextUnit.times(Float). + /// + public static Sp operator *(Sp a, float scalar) => + new(AndroidX.Compose.UI.Unit.TextUnit.Times(a.PackedValue, scalar)); + + /// Scale an value by a scalar factor. + public static Sp operator *(float scalar, Sp a) => + new(AndroidX.Compose.UI.Unit.TextUnit.Times(a.PackedValue, scalar)); + + /// + /// Divide an value by a scalar factor. Mirrors Kotlin's + /// TextUnit.div(Float). + /// + public static Sp operator /(Sp a, float scalar) => + new(AndroidX.Compose.UI.Unit.TextUnit.Div(a.PackedValue, scalar)); + + /// + /// Negate an value. Mirrors Kotlin's + /// TextUnit.unaryMinus(). + /// + public static Sp operator -(Sp a) => + new(AndroidX.Compose.UI.Unit.TextUnit.UnaryMinus(a.PackedValue)); + + /// Less-than comparison. + public static bool operator <(Sp a, Sp b) => a.CompareTo(b) < 0; + + /// Greater-than comparison. + public static bool operator >(Sp a, Sp b) => a.CompareTo(b) > 0; + + /// Less-than-or-equal comparison. + public static bool operator <=(Sp a, Sp b) => a.CompareTo(b) <= 0; + + /// Greater-than-or-equal comparison. + public static bool operator >=(Sp a, Sp b) => a.CompareTo(b) >= 0; + + /// + public override string ToString() => $"Sp(0x{PackedValue:X16})"; + + /// + /// Pack a nullable into the packed TextUnit + /// long the JNI slot expects. null -> 0L; the + /// auto-mask leaves the matching $default bit set so + /// Kotlin's real default applies. + /// + public static long Pack(Sp? value) => value?.PackedValue ?? 0L; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SpExtensions.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SpExtensions.cs new file mode 100644 index 000000000..f1d813c27 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SpExtensions.cs @@ -0,0 +1,22 @@ +namespace AndroidX.Compose; + +/// +/// Extension method that lets you write 16.Sp() as the C# +/// counterpart of Kotlin's 16.sp property syntax, producing a +/// typed from any numeric literal or variable. +/// +/// +/// Like , this is the right choice when you +/// need an explicit on the right-hand side — hoisting +/// a typography constant into a typed local (var caption = 12.Sp();) +/// or participating in arithmetic (base * 1.2f). +/// +public static class SpExtensions +{ + /// + /// Convert an count of scale-independent pixels into a + /// typed . Equivalent to Kotlin's Int.sp. + /// Delegates to the bound TextUnitKt.GetSp(int) factory. + /// + public static Sp Sp(this int value) => new(value); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Spacer.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Spacer.cs new file mode 100644 index 000000000..50c77a7fd --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Spacer.cs @@ -0,0 +1,63 @@ +namespace AndroidX.Compose; + +/// +/// Foundation Spacer composable — an empty layout used to +/// reserve space (typically via a sizing modifier such as +/// Modifier.Size(8) or Modifier.Width(16)) between +/// siblings inside a or . +/// +/// +/// Unlike most Compose composables, Kotlin's Spacer(modifier) +/// has NO default for its modifier parameter — the JVM method has no +/// $default companion. The wrapper materialises +/// Modifier.Companion (the empty modifier) when the caller +/// doesn't supply a chain so the binding receives a non-null value. +/// +public sealed partial class Spacer +{ + /// Empty Spacer (zero-sized). Apply a sizing modifier to give it presence. + public Spacer() { } + + /// Convenience overload that sets . + public Spacer(Modifier modifier) => Modifier = modifier; + + /// + /// Creates a with a fixed width and no height. Equivalent + /// to new Spacer(Modifier.Width(dp)); the -to- + /// implicit conversion lets callers write Spacer.Width(8). + /// + public static Spacer Width(Dp dp) => new(Modifier.Companion.Width(dp)); + + /// + /// Creates a with a fixed height and no width. Equivalent + /// to new Spacer(Modifier.Height(dp)); the -to- + /// implicit conversion lets callers write Spacer.Height(8). + /// + public static Spacer Height(Dp dp) => new(Modifier.Companion.Height(dp)); + + /// + /// Creates a square of the given size. Equivalent to + /// new Spacer(Modifier.Size(dp)). + /// + public static Spacer Size(Dp dp) => new(Modifier.Companion.Size(dp)); + + /// + /// Creates a rectangular with the given width and height. + /// Equivalent to new Spacer(Modifier.Size(width, height)). + /// + public static Spacer Size(Dp width, Dp height) => new(Modifier.Companion.Size(width, height)); + + /// + /// Creates a that takes a share of the leftover main-axis + /// space inside its parent or — the + /// canonical "push siblings apart" idiom. Equivalent to + /// new Spacer(Modifier.Weight(weight, fill)). As with + /// , only valid inside a Row/Column or + /// Row/Column-shaped scope. + /// + /// Relative share of the leftover space (must be positive). + /// When (the default) the Spacer + /// fills its allotted slot; lets it be smaller. + public static Spacer Weight(float weight, bool fill = true) => + new(Modifier.Companion.Weight(weight, fill)); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SpanStyle.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SpanStyle.cs new file mode 100644 index 000000000..bb17bb93d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SpanStyle.cs @@ -0,0 +1,79 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Builder-style C# wrapper around +/// androidx.compose.ui.text.SpanStyle. The Kotlin type has no +/// public no-arg constructor — only Copy(...) overloads — so this +/// builder materializes a binding +/// by starting from TextStyle.Default.toSpanStyle() and overriding +/// only the slots the caller set. Any field left at +/// is forwarded verbatim from the default — same trick used by +/// for the larger paragraph-style surface. +/// +/// +/// Pass instances to or +/// to apply per-run +/// styling inside an . The builder +/// allocates one binding SpanStyle instance per call to +/// ; cache the materialized result if the same +/// style is reused across many spans. +/// +public sealed class SpanStyle +{ + /// Foreground text color. Leave to inherit. + public Color? Color { get; set; } + + /// Font size (sp). Leave to inherit. + public Sp? FontSize { get; set; } + + /// Font weight. Leave to inherit. + public FontWeight? FontWeight { get; set; } + + /// Font style (Normal / Italic). Leave to inherit. + public FontStyle? FontStyle { get; set; } + + /// Font family. Leave to inherit. + public FontFamily? FontFamily { get; set; } + + /// Letter spacing (sp). Leave to inherit. + public Sp? LetterSpacing { get; set; } + + /// Background color for this span. Leave to inherit. + public Color? Background { get; set; } + + /// Text decoration (Underline, LineThrough, etc.). Leave to inherit. + public TextDecoration? Decoration { get; set; } + + static T? Cast(Java.Lang.Object? wrapper) where T : Java.Lang.Object => + wrapper is null ? null : Java.Lang.Object.GetObject(wrapper.Handle, JniHandleOwnership.DoNotTransfer); + + /// + /// Materialize the binding . + /// Properties left at on this builder are + /// copied verbatim from TextStyle.Default.toSpanStyle(); + /// properties the caller set replace the corresponding slot. + /// + internal AndroidX.Compose.UI.Text.SpanStyle Build() + { + var d = TextStyleCompanion.DefaultSpan; + return d.Copy( + color: Color is { } c ? (long)c : d.Color, + fontSize: FontSize is { } fs ? Sp.Pack(fs) : d.FontSize, + fontWeight: FontWeight is null ? d.FontWeight : Cast(FontWeight), + fontStyle: FontStyle is null ? d.FontStyle : Cast(FontStyle), + fontSynthesis: d.FontSynthesis, + fontFamily: FontFamily is null ? d.FontFamily : Cast(FontFamily), + fontFeatureSettings: d.FontFeatureSettings, + letterSpacing: LetterSpacing is { } ls ? Sp.Pack(ls) : d.LetterSpacing, + baselineShift: d.BaselineShift, + textGeometricTransform: d.TextGeometricTransform, + localeList: d.LocaleList, + background: Background is { } bg ? (long)bg : d.Background, + textDecoration: Decoration is null ? d.TextDecoration : Cast(Decoration), + shadow: d.Shadow, + platformStyle: d.PlatformStyle, + drawStyle: d.DrawStyle); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/StaggeredGridCells.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/StaggeredGridCells.cs new file mode 100644 index 000000000..87a8c046d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/StaggeredGridCells.cs @@ -0,0 +1,45 @@ +using AndroidX.Compose.Foundation.Lazy.Staggeredgrid; + +namespace AndroidX.Compose; + +/// +/// Factory helpers for , the +/// column/row-count strategy for +/// and +/// . Mirrors the Kotlin +/// sealed class StaggeredGridCells with its Fixed, +/// Adaptive, and FixedSize subclasses: +/// +/// new LazyVerticalStaggeredGrid<int>(StaggeredGridCells.Fixed(2), items, …) +/// new LazyVerticalStaggeredGrid<int>(StaggeredGridCells.Adaptive(120f), items, …) +/// new LazyVerticalStaggeredGrid<int>(StaggeredGridCells.FixedSize(80f), items, …) +/// +/// +public static class StaggeredGridCells +{ + /// + /// Lay out a fixed number of equally-sized cells across the cross + /// axis (column count for , + /// row count for ). + /// + public static IStaggeredGridCells Fixed(int count) => new StaggeredGridCellsFixed(count); + + /// + /// Lay out as many equally-sized cells as fit, given a minimum cell + /// size (in dp) along the cross axis. The Kotlin + /// StaggeredGridCells.Adaptive(Dp) constructor is stripped + /// from the binding (inline-class Dp), so this goes through + /// a hand-written JNI bridge in ComposeBridges. + /// + public static IStaggeredGridCells Adaptive(float minSizeDp) => + ComposeBridges.StaggeredGridCellsAdaptive(minSizeDp); + + /// + /// Lay out cells with a fixed cross-axis size (in dp), filling as + /// many as fit. Like , the Kotlin + /// FixedSize(Dp) constructor is stripped (inline-class + /// Dp), so this routes through a JNI bridge. + /// + public static IStaggeredGridCells FixedSize(float sizeDp) => + ComposeBridges.StaggeredGridCellsFixedSize(sizeDp); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/StateFlowExtensions.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/StateFlowExtensions.cs new file mode 100644 index 000000000..77c2f2f7e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/StateFlowExtensions.cs @@ -0,0 +1,65 @@ +namespace AndroidX.Compose; + +/// +/// Compose-side bridge helpers for +/// — the C# parity of Kotlin's +/// androidx.lifecycle.compose.collectAsStateWithLifecycle +/// extensions on Flow<T> / StateFlow<T>. +/// +public static class StateFlowExtensions +{ + /// + /// Returns a read-only that reflects + /// 's current value and triggers + /// recomposition when the flow emits. Call inside a composition + /// body to subscribe the surrounding scope to the flow's + /// snapshot version counter. + /// + /// + /// + /// Lifecycle parity caveat: Kotlin's + /// collectAsStateWithLifecycle only collects while the + /// host LifecycleOwner is at least + /// Lifecycle.State.STARTED, pausing collection on + /// background. AndroidX.Compose's flow is snapshot-backed and always + /// "live" — there is no separate coroutine to start or stop — + /// so this method is effectively + /// collectAsState with a name that signals intent. The + /// behavioural difference is invisible to the typical UDF + /// view-model pattern (the flow's value is just a field; the + /// host doesn't pay for background updates because there are + /// none to pay for). + /// + /// + /// The returned is the same instance + /// as the flow itself, narrowed to its read-only interface — + /// no extra allocation, no extra subscription bookkeeping. + /// + /// + public static IState CollectAsStateWithLifecycle(this IStateFlow flow) + { + ArgumentNullException.ThrowIfNull(flow); + // Reading Value here registers the surrounding composition + // scope with the underlying tick counter, mirroring the + // dependency-tracking effect of Kotlin's + // `flow.collectAsStateWithLifecycle()`. Subsequent reads + // through the returned IState also subscribe — first + // read is just the eager one. + _ = flow.Value; + return flow; + } + + /// + /// Overload accepting an for + /// API parity with Kotlin's Flow<T>-typed + /// collectAsStateWithLifecycle(initialValue). AndroidX.Compose + /// always has a current value, so + /// the initial value is ignored — the flow's actual current + /// value wins on first read. + /// + public static IState CollectAsStateWithLifecycle(this IStateFlow flow, T initialValue) + { + _ = initialValue; + return CollectAsStateWithLifecycle(flow); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuggestionChip.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuggestionChip.cs new file mode 100644 index 000000000..c49d1c7ea --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuggestionChip.cs @@ -0,0 +1,7 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 SuggestionChip. Single-icon variant — only an +/// Icon slot is exposed (vs. AssistChip's leading + trailing). +/// +public sealed partial class SuggestionChip; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Surface.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Surface.cs new file mode 100644 index 000000000..57f9d2591 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Surface.cs @@ -0,0 +1,7 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 non-interactive Surface. Applies background +/// color, elevation, and clipping to its content. +/// +public sealed partial class Surface; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendBridge.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendBridge.cs new file mode 100644 index 000000000..960787738 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendBridge.cs @@ -0,0 +1,218 @@ +using Android.Runtime; +using Kotlin.Coroutines.Intrinsics; + +namespace AndroidX.Compose; + +/// +/// Shared plumbing for calling Kotlin suspend functions from +/// C# async/await. Each call allocates one +/// , hands it to the Kotlin function, +/// and either: +/// +/// completes synchronously when Kotlin returns +/// anything other than the COROUTINE_SUSPENDED sentinel, or +/// defers to the continuation when Kotlin returns +/// the sentinel (i.e. it will resume the continuation later from its +/// own dispatcher). +/// +/// In both cases the boxed kotlin.Result<T> is funneled +/// through so failure +/// detection and unboxing live in one place. +/// +/// +/// +/// The returned may complete on the Kotlin resume +/// thread (usually the Compose main thread for UI-bound suspend +/// functions like ScrollState.scrollTo). Awaiters use +/// on +/// the underlying TCS, so they won't be inlined onto that thread — +/// they'll resume on whatever the +/// await captured. +/// +/// +/// Cancellation: an optional cancels +/// the returned (transitioning it to +/// , so the await throws +/// ) as soon as the +/// token fires. The Kotlin suspend body keeps running to its natural +/// completion — we don't yet plumb a Job into +/// , so there is no +/// Kotlin-side cancel. The boxed result of the eventual resume is +/// disposed silently. True Kotlin-side cancel (calling +/// job.cancel() so animation/scroll bodies stop at the next +/// suspend point) is tracked as a follow-up. +/// +/// +internal static class SuspendBridge +{ + // Cached global ref to the COROUTINE_SUSPENDED singleton. Kotlin + // identifies "I'll resume you later" by *reference equality* with + // this exact object, so we cache one global ref and compare via + // JNIEnv.IsSameObject (raw pointer compares aren't safe across + // JNI ref types). + static IntPtr s_suspendedHandle; + + /// + /// Call a Kotlin suspend function and project its boxed + /// result through into a typed + /// . + /// + /// + /// Invokes the Kotlin suspend function with the supplied + /// as the trailing argument and + /// returns either the boxed result (synchronous completion) or + /// the COROUTINE_SUSPENDED sentinel. + /// + /// + /// Converts the success-value box (e.g. java.lang.Float) to + /// the target C# type. May be passed null for suspend + /// functions that return Unit. The supplied box is owned + /// by and disposed after this delegate + /// returns; do not capture or return the box itself. + /// + /// + /// Cancels the returned task. See the type-level remarks for the + /// (current) semantics: only the C# awaiter sees the cancel; the + /// Kotlin suspend body keeps running. + /// + public static Task Invoke( + Func call, + Func unbox, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(call); + ArgumentNullException.ThrowIfNull(unbox); + + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + var cont = new SuspendContinuation(cancellationToken); + + // The ctor's Register call invokes the cancellation callback + // synchronously if the token fires between the pre-check above + // and registration. Re-check before invoking Kotlin so we don't + // start a (visible) scroll/animation we'd then have to wait on. + if (cancellationToken.IsCancellationRequested) + { + cont.Dispose(); + return Task.FromCanceled(cancellationToken); + } + + IntPtr syncHandle; + try + { + syncHandle = call(cont); + } + catch (Exception ex) + { + // Exceptions thrown before the suspend function suspends + // (e.g. JNI lookup failures, Kotlin throwing inline before + // creating a Result.Failure) shouldn't surface as + // synchronous throws from an async-style API. + cont.Dispose(); + return Task.FromException(ex); + } + + // We work in raw JNI handles here rather than wrapping syncHandle + // in a Java.Lang.Object up-front. The COROUTINE_SUSPENDED + // sentinel is a Kotlin singleton, and Mono's peer cache resolves + // every local ref to it back to a single cached Java.Lang.Object + // whose Handle is a *global* ref. If we wrap with TransferLocalRef + // and later dispose, the dispose path calls DeleteLocalRef on a + // global, which CheckJNI aborts. Raw-handle work-flow side-steps + // the issue entirely. + if (syncHandle != IntPtr.Zero) + { + if (IsCoroutineSuspended(syncHandle)) + { + // Kotlin will resume cont later. Free the local ref to + // the sentinel; the cached global lives in + // s_suspendedHandle for future comparisons. Leave the + // continuation alive — ResumeWith disposes it when + // Kotlin actually resumes. + JNIEnv.DeleteLocalRef(syncHandle); + } + else + { + // Synchronous completion — funnel through the same + // promote-to-global + TCS path as the Kotlin-async + // callback (SuspendContinuation.ResumeWith), then + // dispose ourselves since Kotlin will never call + // ResumeWith on this path. + cont.CompleteWithLocalHandle(syncHandle); + cont.Dispose(); + } + } + else + { + // Suspend returned a Java null. Treat as a successful null + // result (rare in practice; most suspends either suspend or + // return a boxed Unit / boxed primitive). + cont.CompleteWithLocalHandle(IntPtr.Zero); + cont.Dispose(); + } + + return AwaitAndUnbox(cont, unbox); + } + + /// + /// Non-generic overload for suspend functions that return Kotlin + /// Unit (e.g. ScrollState.animateScrollTo). + /// + public static Task Invoke( + Func call, + CancellationToken cancellationToken = default) => + Invoke(call, static _ => null, cancellationToken); + + // Awaits the TCS, runs the failure-vs-success split, and disposes + // the boxed result. Lives in its own async method so a cancelled + // TCS surfaces as a Canceled (not Faulted) Task — the async state + // machine routes an uncaught OperationCanceledException straight + // into AsyncTaskMethodBuilder.SetCanceled. + static async Task AwaitAndUnbox(SuspendContinuation cont, Func unbox) + { + Java.Lang.Object? boxed; + try + { + boxed = await cont.Tcs.Task.ConfigureAwait(false); + } + finally + { + // Belt-and-suspenders: the state machine already roots cont + // across the await, but this also covers the synchronous- + // completion path where Dispose already ran (the + // pin is gone, so only the state-machine field keeps cont + // alive while we touch its TCS). + GC.KeepAlive(cont); + } + + try + { + if (boxed is not null && KotlinResult.IsFailure(boxed.Handle)) + throw KotlinResult.ExtractException(boxed); + return unbox(boxed); + } + finally + { + boxed?.Dispose(); + } + } + + internal static bool IsCoroutineSuspended(IntPtr handle) + { + if (handle == IntPtr.Zero) return false; + EnsureSuspendedHandle(); + return JNIEnv.IsSameObject(handle, s_suspendedHandle); + } + + internal static void EnsureSuspendedHandle() + { + if (s_suspendedHandle != IntPtr.Zero) return; + var inst = IntrinsicsKt.COROUTINE_SUSPENDED; + var gref = JNIEnv.NewGlobalRef(inst.Handle); + // CAS so concurrent first-callers don't leak a global ref. + if (Interlocked.CompareExchange(ref s_suspendedHandle, gref, IntPtr.Zero) != IntPtr.Zero) + JNIEnv.DeleteGlobalRef(gref); + GC.KeepAlive(inst); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendBridges.cs new file mode 100644 index 000000000..eb0f39e7e --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendBridges.cs @@ -0,0 +1,284 @@ +using Android.Runtime; +using Kotlin.Coroutines; + +namespace AndroidX.Compose; + +// JNI bridges for Kotlin `suspend` functions. Bodies are emitted by +// ComposeBridgeGenerator from `[ComposeBridge(Suspend = true)]`. The +// trailing `IContinuation cont` parameter is detected as a Kotlin +// continuation slot; the generator takes care of method-id caching, +// the JValue array, the receiver/extension-receiver, $default mask +// auto-build, the synthetic marker (for $default extensions), and +// `try`/`finally { GC.KeepAlive(cont) }`. +// +// Suspend bridges always return raw `IntPtr` — wrapping the +// COROUTINE_SUSPENDED sentinel via `Java.Lang.Object.GetObject(.., +// TransferLocalRef)` collides with Mono's peer cache (the cached +// managed wrapper holds a GLOBAL ref while we'd hand it a LOCAL one) +// and aborts under CheckJNI. The generator enforces this (CN2009). +// +// `AndroidUiDispatcherMain` below stays hand-written because it's a +// static-field getter chasing a nested Companion type, not a method +// call — it doesn't fit any `[ComposeBridge]` shape. +internal static partial class ComposeBridges +{ + // androidx.compose.foundation.ScrollState.scrollTo(int, Continuation): Object + // + // The two-arg suspend overload IS exposed in the binding (returns + // Java.Lang.Object), but we call it via raw JNI here so the + // returned handle stays as a plain IntPtr — SuspendBridge needs + // raw-handle semantics to avoid the COROUTINE_SUSPENDED peer-cache + // pitfall. + [ComposeBridge(Suspend = true, + Class = "androidx/compose/foundation/ScrollState", + JvmName = "scrollTo", + Signature = "(ILkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr ScrollStateScrollTo( + IntPtr state, int value, IContinuation cont); + + // androidx.compose.foundation.ScrollState.animateScrollTo$default( + // ScrollState this, int value, AnimationSpec spec, + // Continuation cont, int $default, Object marker): Object + // + // Why raw JNI: animateScrollTo lives in the Kotlin extension class + // `androidx/compose/foundation/gestures/AnimateScrollExtensionsKt`, + // which is stripped wholesale from `Xamarin.AndroidX.Compose.Foundation.Android.dll` + // — the `AnimationSpec` parameter triggers Kotlin's generic + // + `@JvmInline` mangling (`animateScrollTo-XXXXXXXX`) that the + // generator-bindings parser drops. Same root cause as the mangled + // Compose Material3 overloads tracked by dotnet/java-interop#1440. + // + // The single nullable `animationSpec` slot is left to the auto-mask + // (passing null → $default bit set → Kotlin substitutes SpringSpec()). + [ComposeBridge(Suspend = true, + Class = "androidx/compose/foundation/ScrollState", + JvmName = "animateScrollTo$default", + Signature = "(Landroidx/compose/foundation/ScrollState;ILandroidx/compose/animation/core/AnimationSpec;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;", + Defaults = typeof(ScrollStateAnimateScrollToDefault))] + internal static partial IntPtr ScrollStateAnimateScrollTo( + IntPtr state, int value, IntPtr? animationSpec, IContinuation cont); + + // androidx.compose.material3.DrawerState.open(Continuation): Object + [ComposeBridge(Suspend = true, + Class = "androidx/compose/material3/DrawerState", + JvmName = "open", + Signature = "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr DrawerStateOpen(IntPtr state, IContinuation cont); + + // androidx.compose.material3.DrawerState.close(Continuation): Object + [ComposeBridge(Suspend = true, + Class = "androidx/compose/material3/DrawerState", + JvmName = "close", + Signature = "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr DrawerStateClose(IntPtr state, IContinuation cont); + + // androidx.compose.material3.SheetState.show(Continuation): Object + [ComposeBridge(Suspend = true, + Class = "androidx/compose/material3/SheetState", + JvmName = "show", + Signature = "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr SheetStateShow(IntPtr state, IContinuation cont); + + // androidx.compose.material3.SheetState.hide(Continuation): Object + [ComposeBridge(Suspend = true, + Class = "androidx/compose/material3/SheetState", + JvmName = "hide", + Signature = "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr SheetStateHide(IntPtr state, IContinuation cont); + + // androidx.compose.material3.SheetState.expand(Continuation): Object + [ComposeBridge(Suspend = true, + Class = "androidx/compose/material3/SheetState", + JvmName = "expand", + Signature = "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr SheetStateExpand(IntPtr state, IContinuation cont); + + // androidx.compose.material3.SheetState.partialExpand(Continuation): Object + [ComposeBridge(Suspend = true, + Class = "androidx/compose/material3/SheetState", + JvmName = "partialExpand", + Signature = "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr SheetStatePartialExpand(IntPtr state, IContinuation cont); + + // androidx.compose.foundation.lazy.LazyListState + // .scrollToItem(int index, int scrollOffset, Continuation): Object + [ComposeBridge(Suspend = true, + Class = "androidx/compose/foundation/lazy/LazyListState", + JvmName = "scrollToItem", + Signature = "(IILkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr LazyListStateScrollToItem( + IntPtr state, int index, int scrollOffset, IContinuation cont); + + // androidx.compose.foundation.lazy.LazyListState + // .animateScrollToItem(int index, int scrollOffset, Continuation): Object + // + // Requires a MonotonicFrameClock in the continuation context + // (calls `withFrameNanos` internally) — supplied by + // SuspendContinuation.Context which returns AndroidUiDispatcher.Main. + [ComposeBridge(Suspend = true, + Class = "androidx/compose/foundation/lazy/LazyListState", + JvmName = "animateScrollToItem", + Signature = "(IILkotlin/coroutines/Continuation;)Ljava/lang/Object;")] + internal static partial IntPtr LazyListStateAnimateScrollToItem( + IntPtr state, int index, int scrollOffset, IContinuation cont); + + // androidx.compose.foundation.gestures.TapGestureDetectorKt + // .detectTapGestures$default( + // PointerInputScope scope, + // ((Offset) -> Unit)? onDoubleTap = null, + // ((Offset) -> Unit)? onLongPress = null, + // (suspend PressGestureScope.(Offset) -> Unit) onPress = NoPressGesture, + // ((Offset) -> Unit)? onTap = null, + // Continuation cont, int $default, Object marker): Object + // + // Unlike the other bridges in this file, the caller (PointerInputBlock) + // is itself inside a Kotlin suspend body and supplies the OUTER + // continuation directly — this is a *tail call* into Kotlin. No + // SuspendBridge / SuspendContinuation involvement; the raw IntPtr + // we return (COROUTINE_SUSPENDED, kotlin.Unit, or kotlin.Result$Failure) + // is forwarded unchanged. + // + // Each callback handle is `IntPtr?` so the auto-default-mask flips the + // corresponding bit when null — Kotlin substitutes its real default + // (null for the nullable slots, NoPressGesture for onPress). + [ComposeBridge(Suspend = true, + Class = "androidx/compose/foundation/gestures/TapGestureDetectorKt", + JvmName = "detectTapGestures$default", + Signature = "(Landroidx/compose/ui/input/pointer/PointerInputScope;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;", + Defaults = typeof(DetectTapGesturesDefault))] + internal static partial IntPtr DetectTapGestures( + IntPtr scope, + IntPtr? onDoubleTap, + IntPtr? onLongPress, + IntPtr? onPress, + IntPtr? onTap, + IContinuation cont); + + // androidx.compose.foundation.gestures.DragGestureDetectorKt + // .detectDragGestures$default( + // PointerInputScope scope, + // ((Offset) -> Unit) onDragStart = {}, + // (() -> Unit) onDragEnd = {}, + // (() -> Unit) onDragCancel = {}, + // ((PointerInputChange, Offset) -> Unit) onDrag, + // Continuation cont, int $default, Object marker): Object + // + // Same tail-call shape as detectTapGestures — DragGestureBlock is + // the outer Function2 invoked from `pointerInput`, and forwards + // its OUTER continuation here. Three of the four callbacks are + // defaultable; auto-default-mask leaves a bit set when null is + // passed (Kotlin substitutes its empty-lambda default). onDrag + // is required and not in the default mask. + [ComposeBridge(Suspend = true, + Class = "androidx/compose/foundation/gestures/DragGestureDetectorKt", + JvmName = "detectDragGestures$default", + Signature = "(Landroidx/compose/ui/input/pointer/PointerInputScope;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;", + Defaults = typeof(DetectDragGesturesDefault))] + internal static partial IntPtr DetectDragGestures( + IntPtr scope, + IntPtr? onDragStart, + IntPtr? onDragEnd, + IntPtr? onDragCancel, + IntPtr onDrag, + IContinuation cont); + + // androidx.compose.foundation.gestures.DragGestureDetectorKt + // .detectDragGesturesAfterLongPress$default( + // PointerInputScope scope, + // ((Offset) -> Unit) onDragStart = {}, + // (() -> Unit) onDragEnd = {}, + // (() -> Unit) onDragCancel = {}, + // ((PointerInputChange, Offset) -> Unit) onDrag, + // Continuation cont, int $default, Object marker): Object + // + // Identical parameter layout to detectDragGestures (same class, same + // four callbacks in the same order) — the only behavioural difference + // is Kotlin waits for the long-press timeout before recognising the + // first drag, so the same DragGestureBlock tail-call shape and the + // same DetectDragGesturesDefault mask apply. The gold Jetchat + // RecordButton drives exactly this detector (RecordButton.kt:156). + [ComposeBridge(Suspend = true, + Class = "androidx/compose/foundation/gestures/DragGestureDetectorKt", + JvmName = "detectDragGesturesAfterLongPress$default", + Signature = "(Landroidx/compose/ui/input/pointer/PointerInputScope;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;", + Defaults = typeof(DetectDragGesturesDefault))] + internal static partial IntPtr DetectDragGesturesAfterLongPress( + IntPtr scope, + IntPtr? onDragStart, + IntPtr? onDragEnd, + IntPtr? onDragCancel, + IntPtr onDrag, + IContinuation cont); + + // androidx.compose.foundation.gestures.TransformGestureDetectorKt + // .detectTransformGestures$default( + // PointerInputScope scope, + // boolean panZoomLock = false, + // ((Offset, Offset, Float, Float) -> Unit) onGesture, + // Continuation cont, int $default, Object marker): Object + // + // panZoomLock is the only defaultable param. onGesture is required. + [ComposeBridge(Suspend = true, + Class = "androidx/compose/foundation/gestures/TransformGestureDetectorKt", + JvmName = "detectTransformGestures$default", + Signature = "(Landroidx/compose/ui/input/pointer/PointerInputScope;ZLkotlin/jvm/functions/Function4;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;", + Defaults = typeof(DetectTransformGesturesDefault))] + internal static partial IntPtr DetectTransformGestures( + IntPtr scope, + bool panZoomLock, + IntPtr onGesture, + IContinuation cont); + + static IntPtr s_androidUiDispatcherMain_handle; + + // androidx.compose.ui.platform.AndroidUiDispatcher.Companion.getMain(): kotlin.coroutines.CoroutineContext + // + // Returns the singleton CoroutineContext that combines a main-thread + // dispatcher with a MonotonicFrameClock backed by Choreographer. Required + // for any Compose suspend function that calls `withFrameNanos` — + // `animateScrollTo`, `animateTo`, etc. — because those look up + // `coroutineContext[MonotonicFrameClock]` and throw without it. + // + // The C# binding exposes AndroidUiDispatcher.Companion as a nested type + // with an internal constructor and no static accessor on the parent — + // so we resolve Main via raw JNI and cache the result as a global ref. + // Must be initialised on a Looper thread the first time, which is + // naturally satisfied because Compose suspend calls originate from + // button onClicks running on the main thread. + // + // Stays hand-written: this is a static-field getter walking a nested + // Companion type, not a method call — doesn't fit any + // `[ComposeBridge]` shape. + internal static IntPtr AndroidUiDispatcherMain() + { + if (s_androidUiDispatcherMain_handle != IntPtr.Zero) + return s_androidUiDispatcherMain_handle; + + var dispatcherClass = JNIEnv.FindClass("androidx/compose/ui/platform/AndroidUiDispatcher"); + var companionFid = JNIEnv.GetStaticFieldID( + dispatcherClass, "Companion", + "Landroidx/compose/ui/platform/AndroidUiDispatcher$Companion;"); + var companionLocal = JNIEnv.GetStaticObjectField(dispatcherClass, companionFid); + try + { + var companionClass = JNIEnv.FindClass("androidx/compose/ui/platform/AndroidUiDispatcher$Companion"); + var getMainMid = JNIEnv.GetMethodID( + companionClass, "getMain", "()Lkotlin/coroutines/CoroutineContext;"); + var ctxLocal = JNIEnv.CallObjectMethod(companionLocal, getMainMid); + try + { + s_androidUiDispatcherMain_handle = JNIEnv.NewGlobalRef(ctxLocal); + } + finally + { + JNIEnv.DeleteLocalRef(ctxLocal); + } + } + finally + { + JNIEnv.DeleteLocalRef(companionLocal); + } + + return s_androidUiDispatcherMain_handle; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendContinuation.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendContinuation.cs new file mode 100644 index 000000000..0bbb21b1a --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/SuspendContinuation.cs @@ -0,0 +1,206 @@ +using System.Runtime.InteropServices; +using Android.Runtime; +using Kotlin.Coroutines; + +namespace AndroidX.Compose; + +/// +/// Java Callable Wrapper that lets a Kotlin suspend function +/// resume back into managed code via a +/// . The wrapped +/// kotlin.Result<T> handed to +/// is stored verbatim and +/// unboxed by . +/// +/// +/// +/// Allocate one instance per suspend call (continuations cannot be +/// reused). The class is internal — call sites should go through +/// +/// instead of constructing one directly. +/// +/// +/// Lifetime: held by a strong allocated in +/// the constructor so the managed peer survives until Kotlin invokes +/// — fire-and-forget callers +/// (no one holding the returned ) would otherwise +/// race the GC. The handle is a normal strong reference, not pinned +/// in memory. is the single, idempotent +/// cleanup entry point: it releases the pin, the +/// , and the JNI peer. +/// +/// +/// Cancellation: when a non-default +/// is supplied, a registration on the token cancels the backing TCS. +/// That propagates to the caller's await as +/// immediately, but +/// the Kotlin suspend function keeps running to its natural +/// completion — we don't wire a Job into +/// yet. When Kotlin eventually resumes, +/// sees the TCS is already +/// completed and disposes the boxed result without surfacing it. +/// +/// +[Register("net/compose/SuspendContinuation")] +internal sealed class SuspendContinuation : Java.Lang.Object, IContinuation +{ + GCHandle _selfPin; + CancellationTokenRegistration _ctr; + bool _disposed; + + /// + /// Backing TCS exposed for . + /// + /// keeps awaiters from running inline on whatever Kotlin dispatcher + /// fires the resume (usually the Compose main thread). + /// + public TaskCompletionSource Tcs { get; } = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public SuspendContinuation(CancellationToken cancellationToken = default) + { + _selfPin = GCHandle.Alloc(this); + if (cancellationToken.CanBeCanceled) + { + // Allocation-free (state, token) overload: no closure per + // suspend call. Disposing the registration in + // Dispose blocks until any in-flight invocation of + // this callback completes; the callback only touches the + // (thread-safe) TCS, so there's no deadlock path back into + // Dispose. + _ctr = cancellationToken.Register( + static (state, token) => + { + var self = (SuspendContinuation)state!; + self.Tcs.TrySetCanceled(token); + }, + this); + } + } + + /// + /// Returns the androidx.compose.ui.platform.AndroidUiDispatcher.Main + /// context — combines a Compose main-thread dispatcher with a + /// MonotonicFrameClock. Required so suspend functions that + /// rely on withFrameNanos (the entire animation family — + /// animateScrollTo, animateTo, animate) can + /// drive frames; otherwise their internal + /// coroutineContext[MonotonicFrameClock] lookup throws. + /// + /// + /// The AndroidUiDispatcher.Companion nested type is bound but + /// has an internal constructor and no static accessor on + /// AndroidUiDispatcher, so we resolve Companion.Main + /// via raw JNI. Replace with a direct binding call once + /// AndroidUiDispatcher.Companion becomes accessible. + /// + public ICoroutineContext Context + { + get + { + var handle = ComposeBridges.AndroidUiDispatcherMain(); + return Java.Lang.Object.GetObject( + handle, JniHandleOwnership.DoNotTransfer)!; + } + } + + /// + /// Kotlin's resume callback. The boxed kotlin.Result<T> + /// is either the raw success value (often a boxed primitive) or a + /// kotlin.Result$Failure wrapping a Throwable. + /// + /// + /// The argument wraps a borrowed JNI local ref that's only valid + /// for the duration of this callback frame. We promote it to a + /// global ref before storing it in the TCS so the continuation + /// can safely read it after the JNI frame returns. + /// + public void ResumeWith(Java.Lang.Object boxedResult) + { + try + { + try + { + CompleteWithLocalHandle(boxedResult?.Handle ?? IntPtr.Zero, deleteLocal: false); + } + catch (Exception ex) + { + // NewGlobalRef / GetObject can throw; without this catch + // the TCS would never complete and any awaiter would + // hang forever. Surface the failure as the task result. + Tcs.TrySetException(ex); + } + } + finally + { + // Continuations are single-resume; Kotlin is done with the + // JCW now. Standard IDisposable cleanup releases the pin, + // the CTR, and the JNI peer all in one shot. + Dispose(); + } + } + + /// + /// Funnel a raw JNI local ref into the TCS. Promotes the handle to + /// a global ref, wraps it in a managed peer with + /// , and stores + /// the peer in . + /// + /// Local ref to the boxed result, or IntPtr.Zero. + /// + /// When true, the supplied local ref is freed after + /// promotion. Pass true for the synchronous-completion path + /// in (we own the local that + /// came back from CallStaticObjectMethod); pass false + /// when called from the JCW marshaller, which owns its own + /// argument locals. + /// + internal void CompleteWithLocalHandle(IntPtr handle, bool deleteLocal = true) + { + Java.Lang.Object? owned = null; + if (handle != IntPtr.Zero) + { + var gref = JNIEnv.NewGlobalRef(handle); + owned = Java.Lang.Object.GetObject(gref, JniHandleOwnership.TransferGlobalRef); + if (deleteLocal) + JNIEnv.DeleteLocalRef(handle); + } + if (!Tcs.TrySetResult(owned)) + { + // Double-resume (Kotlin shouldn't, but be defensive): + // the TCS already has a result, so we own this wrapper + // and must release the global ref ourselves. + owned?.Dispose(); + } + } + + /// + /// Releases the GCHandle self-pin, disposes the + /// , and lets + /// release the JNI + /// peer. Idempotent and safe to call from any completion path + /// (Kotlin resume in , the sync paths in + /// , the finalizer). + /// + /// + /// Disposing the JNI peer here is safe because in every code + /// path that reaches Dispose, Kotlin is already done with this + /// continuation: it either never received it (sync-throw before + /// the JNI call), already returned a synchronous result + /// (sync-completion), or already invoked resumeWith + /// (continuations are single-resume). Calling Dispose + /// before the suspend resumes would race Kotlin's JNI hold on + /// the JCW — this method is not for that. + /// + protected override void Dispose(bool disposing) + { + if (!Interlocked.Exchange(ref _disposed, true)) + { + if (disposing) + _ctr.Dispose(); + if (_selfPin.IsAllocated) + _selfPin.Free(); + } + base.Dispose(disposing); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Switch.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Switch.cs new file mode 100644 index 000000000..b4bdcae93 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Switch.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 Switch: +/// +/// new Switch(@checked: state.Value, onCheckedChange: v => state.Value = v) +/// +/// +public sealed partial class Switch; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Tab.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Tab.cs new file mode 100644 index 000000000..50b0a5d14 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Tab.cs @@ -0,0 +1,10 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 Tab. Place inside a / +/// / / +/// ScrollableTabRow. Text and Icon are both +/// optional but at least one should be supplied for the tab to be +/// visible. +/// +public sealed partial class Tab; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TabRow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TabRow.cs new file mode 100644 index 000000000..9dd1d083c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TabRow.cs @@ -0,0 +1,21 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 TabRow. Container for fixed-width +/// / children that +/// distribute across the available width: +/// +/// new TabRow(selectedTabIndex: tab.Value) +/// { +/// new Tab(selected: tab.Value == 0, onClick: () => tab.Value = 0) +/// { +/// Text = new Text("Home"), +/// }, +/// new Tab(selected: tab.Value == 1, onClick: () => tab.Value = 1) +/// { +/// Text = new Text("Settings"), +/// }, +/// } +/// +/// +public sealed partial class TabRow; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Text.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Text.cs new file mode 100644 index 000000000..73c1c8afe --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Text.cs @@ -0,0 +1,4 @@ +namespace AndroidX.Compose; + +/// Material 3 Text composable. +public sealed partial class Text; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextAlign.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextAlign.cs new file mode 100644 index 000000000..8a5eade4c --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextAlign.cs @@ -0,0 +1,61 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// 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. +/// ComposeCompanionGenerator emits both halves of that dance +/// from the InlineClass = true on the +/// below. +/// +/// Will swap to bound AndroidX.Compose.UI.Text.Style.TextAlign +/// once +/// ships and we adopt the next Xamarin.AndroidX.Compose.UI.Text.Android +/// release. +/// +[ComposeCompanion("androidx/compose/ui/text/style/TextAlign", InlineClass = true)] +public sealed partial class TextAlign : Java.Lang.Object +{ + TextAlign(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + /// Align text to the left edge of the container. + [ComposeCompanionGetter("getLeft-e0LSkKk")] + public static partial TextAlign Left { get; } + + /// Align text to the right edge of the container. + [ComposeCompanionGetter("getRight-e0LSkKk")] + public static partial TextAlign Right { get; } + + /// Center text within the container. + [ComposeCompanionGetter("getCenter-e0LSkKk")] + public static partial TextAlign Center { get; } + + /// Stretch lines to fill the container width. + [ComposeCompanionGetter("getJustify-e0LSkKk")] + public static partial TextAlign Justify { get; } + + /// Align text to the layout-direction start edge. + [ComposeCompanionGetter("getStart-e0LSkKk")] + public static partial TextAlign Start { get; } + + /// Align text to the layout-direction end edge. + [ComposeCompanionGetter("getEnd-e0LSkKk")] + public static partial TextAlign End { get; } + + /// The unspecified-alignment sentinel value (Kotlin's default for nullable slots). + [ComposeCompanionGetter("getUnspecified-e0LSkKk")] + public static partial TextAlign Unspecified { get; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextButton.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextButton.cs new file mode 100644 index 000000000..9897e5502 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextButton.cs @@ -0,0 +1,6 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 TextButton. Same shape as . +/// +public sealed partial class TextButton; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextDecoration.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextDecoration.cs new file mode 100644 index 000000000..e7638c2ed --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextDecoration.cs @@ -0,0 +1,34 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// C# wrapper around androidx.compose.ui.text.style.TextDecoration. +/// Compose's TextDecoration is a real Kotlin class (not a value +/// class), with companion constants for None, Underline, +/// and LineThrough. Same plumbing story as +/// — ui-text-android ships as a Java-library-only stub today, so +/// we resolve via JNI (boilerplate emitted by +/// ComposeCompanionGenerator). Will swap to bound +/// AndroidX.Compose.UI.Text.Style.TextDecoration once +/// +/// ships. +/// +[ComposeCompanion("androidx/compose/ui/text/style/TextDecoration")] +public sealed partial class TextDecoration : Java.Lang.Object +{ + TextDecoration(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } + + /// TextDecoration.None. + [ComposeCompanionGetter("getNone")] + public static partial TextDecoration None { get; } + + /// TextDecoration.Underline. + [ComposeCompanionGetter("getUnderline")] + public static partial TextDecoration Underline { get; } + + /// TextDecoration.LineThrough. + [ComposeCompanionGetter("getLineThrough")] + public static partial TextDecoration LineThrough { get; } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextField.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextField.cs new file mode 100644 index 000000000..f2c26c8d3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextField.cs @@ -0,0 +1,162 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Text.Input; + +namespace AndroidX.Compose; + +/// +/// Material 3 TextField (filled variant). Three construction +/// modes: +/// +/// Plain value + onValueChange pair — string-overload +/// of Kotlin's TextField. +/// of — convenience +/// that wires a directly so user edits +/// trigger recomposition. +/// of — +/// uses Kotlin's TextField(TextFieldValue, …) overload so callers +/// can place the caret explicitly after programmatic edits (e.g. append +/// an emoji and move the cursor to the end of the buffer). Build values +/// via +/// and TextFieldValue.Copy(...). See issue #204. +/// +/// +/// +/// Hand-written rather than [ComposeFacade]-generated because the +/// facade routes between two distinct [ComposeBridge] methods +/// (TextField string overload vs TextFieldWithValue) based +/// on which ctor was used. The current generator's BranchOn mode +/// only models slot-presence branching, not ctor-driven bridge +/// selection. is the existing precedent for the same +/// pattern. +/// +public sealed class TextField : ComposableNode +{ + readonly string? _value; + readonly Action? _onValueChange; + readonly MutableState? _tfvState; + + /// Floating label (e.g. new Text("Email")). + public ComposableNode? Label { get; set; } + /// Hint shown while the field is empty. + public ComposableNode? Placeholder { get; set; } + /// Icon rendered before the value text. + public ComposableNode? LeadingIcon { get; set; } + /// Icon rendered after the value text. + public ComposableNode? TrailingIcon { get; set; } + /// Static prefix rendered before the value text. + public ComposableNode? Prefix { get; set; } + /// Static suffix rendered after the value text. + public ComposableNode? Suffix { get; set; } + /// Helper text rendered below the field. + public ComposableNode? SupportingText { get; set; } + /// Whether the field accepts focus and input. + public bool? Enabled { get; set; } + /// Whether the field is read-only. + public bool? ReadOnly { get; set; } + /// Whether the field is in an error state. + public bool? IsError { get; set; } + /// Whether the field is constrained to one line. + public bool? SingleLine { get; set; } + /// Maximum number of visible lines. + public int? MaxLines { get; set; } + /// Minimum number of visible lines. + public int? MinLines { get; set; } + /// Optional shape applied to the field's container (Kotlin shape). + public Shape? Shape { get; set; } + /// Optional TextStyle override (Kotlin textStyle) — controls text color, size, weight, etc. + public TextStyle? TextStyle { get; set; } + /// Optional visual transformation (Kotlin visualTransformation) — use PasswordVisualTransformation for password fields. + public AndroidX.Compose.UI.Text.Input.IVisualTransformation? VisualTransformation { get; set; } + /// Optional keyboard options (Kotlin keyboardOptions) — controls IME type, capitalization, autocorrect. + public AndroidX.Compose.Foundation.Text.KeyboardOptions? KeyboardOptions { get; set; } + + /// String-overload ctor — pass the current value and a callback. + public TextField(string value, Action onValueChange) + { + _value = value; + _onValueChange = onValueChange; + } + + /// + /// Bind this TextField to a of + /// so user edits trigger recomposition automatically. + /// + public TextField(MutableState state) + : this(state.Value ?? string.Empty, v => state.Value = v) + { + } + + /// + /// Bind this TextField to a of + /// . Uses Kotlin's + /// TextField(TextFieldValue, (TextFieldValue) -> Unit, …) + /// overload so caller-supplied selection state is honoured (e.g. for + /// "place caret at end after inserting" behaviour). + /// + public TextField(MutableState state) + { + ArgumentNullException.ThrowIfNull(state); + _tfvState = state; + } + + /// + public override void Render(IComposer composer) + { + if (_tfvState is not null) + RenderWithSelection(composer); + else + RenderString(composer); + } + + void RenderString(IComposer composer) + { + var __onValueChange = new ComposableLambda1(v => _onValueChange!(v?.ToString() ?? string.Empty)); + var __label = Label is null ? null : ComposableLambdas.Wrap2(composer, c => Label.Render(c)); + var __placeholder = Placeholder is null ? null : ComposableLambdas.Wrap2(composer, c => Placeholder.Render(c)); + var __leadingIcon = LeadingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => LeadingIcon.Render(c)); + var __trailingIcon = TrailingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => TrailingIcon.Render(c)); + var __prefix = Prefix is null ? null : ComposableLambdas.Wrap2(composer, c => Prefix.Render(c)); + var __suffix = Suffix is null ? null : ComposableLambdas.Wrap2(composer, c => Suffix.Render(c)); + var __supportingText = SupportingText is null ? null : ComposableLambdas.Wrap2(composer, c => SupportingText.Render(c)); + ComposeBridges.TextField(_value!, __onValueChange, BuildModifier(), + Enabled, ReadOnly, TextStyle?.Build(), __label, __placeholder, __leadingIcon, __trailingIcon, + __prefix, __suffix, __supportingText, IsError, + VisualTransformation, KeyboardOptions, + SingleLine, MaxLines, MinLines, + Shape, + composer); + } + + void RenderWithSelection(IComposer composer) + { + var state = _tfvState!; + var current = state.Value + ?? throw new InvalidOperationException( + $"{nameof(TextField)}: MutableState.Value is null. " + + $"Seed with {nameof(Compose)}.{nameof(ComposeExtensions.NewTextFieldValue)}() before first render."); + + var __onValueChange = new ComposableLambda1(v => + { + // Compose hands us the fresh Kotlin TextFieldValue peer; the + // peer registry maps it back to the bound binding type so we + // can store it directly. + state.Value = Java.Lang.Object.GetObject( + v!.Handle, JniHandleOwnership.DoNotTransfer)!; + }); + var __label = Label is null ? null : ComposableLambdas.Wrap2(composer, c => Label.Render(c)); + var __placeholder = Placeholder is null ? null : ComposableLambdas.Wrap2(composer, c => Placeholder.Render(c)); + var __leadingIcon = LeadingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => LeadingIcon.Render(c)); + var __trailingIcon = TrailingIcon is null ? null : ComposableLambdas.Wrap2(composer, c => TrailingIcon.Render(c)); + var __prefix = Prefix is null ? null : ComposableLambdas.Wrap2(composer, c => Prefix.Render(c)); + var __suffix = Suffix is null ? null : ComposableLambdas.Wrap2(composer, c => Suffix.Render(c)); + var __supportingText = SupportingText is null ? null : ComposableLambdas.Wrap2(composer, c => SupportingText.Render(c)); + ComposeBridges.TextFieldWithValue(current, __onValueChange, BuildModifier(), + Enabled, ReadOnly, TextStyle?.Build(), __label, __placeholder, __leadingIcon, __trailingIcon, + __prefix, __suffix, __supportingText, IsError, + VisualTransformation, KeyboardOptions, + SingleLine, MaxLines, MinLines, + Shape, + composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextFieldValueBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextFieldValueBridges.cs new file mode 100644 index 000000000..d2b1dd444 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextFieldValueBridges.cs @@ -0,0 +1,72 @@ +using Android.Runtime; +using AndroidX.Compose.UI.Text; +using AndroidX.Compose.UI.Text.Input; + +namespace AndroidX.Compose; + +// JNI bridge for the one piece of androidx.compose.ui.text.input.TextFieldValue +// that the binder strips: the constructor. +// +// Everything else — Text/Selection/Composition/AnnotatedString properties, +// Copy(text, selection, composition) overloads, GetSelectedText / +// GetTextAfterSelection / GetTextBeforeSelection extensions — is exposed +// directly by Xamarin.AndroidX.Compose.UI.Text.Android.dll. Don't wrap them. +// +// The Kotlin primary ctor is stripped because its `selection: TextRange` +// parameter is a @JvmInline value class wrapping Long; the JVM signature +// becomes (String, long, TextRange?, …) which the binder declines to +// surface as a managed ctor (it would need to know how to lower a managed +// `long`/`TextRange?` pair back into the inline-class boundary). +// +// androidx.compose.ui.text.input.TextFieldValue +// (Ljava/lang/String;JLandroidx/compose/ui/text/TextRange; +// ILkotlin/jvm/internal/DefaultConstructorMarker;)V +// ^ $default mask +// bit 0 = text +// bit 1 = selection +// bit 2 = composition +// +// The `TextRange` parameter at slot 2 is the *boxed* TextRange object +// (composition is `TextRange?` in Kotlin so it's the reference form, not +// the unboxed `long`). Callers pass the bound TextRange directly when +// supplying a composition; otherwise we set bit 2 of $default. +internal static partial class ComposeBridges +{ + static IntPtr s_tfv_class; + static IntPtr s_tfv_ctor; + + static void EnsureTextFieldValueClass() + { + if (s_tfv_class != IntPtr.Zero) return; + s_tfv_class = JNIEnv.FindClass("androidx/compose/ui/text/input/TextFieldValue"); + s_tfv_ctor = JNIEnv.GetMethodID(s_tfv_class, "", + "(Ljava/lang/String;JLandroidx/compose/ui/text/TextRange;ILkotlin/jvm/internal/DefaultConstructorMarker;)V"); + } + + // Construct a Kotlin TextFieldValue and return the bound binding wrapper. + // Always invokes the synthetic default-mask ctor so we can pass null for + // composition without knowing which non-synthetic overloads exist. + internal static unsafe TextFieldValue NewTextFieldValueImpl(string text, long selection, TextRange? composition) + { + EnsureTextFieldValueClass(); + + IntPtr textRef = JNIEnv.NewString(text); + int defaults = composition is null ? (1 << 2) : 0; + try + { + JValue* args = stackalloc JValue[5]; + args[0] = new JValue(textRef); + args[1] = new JValue(selection); + args[2] = new JValue(composition is null ? IntPtr.Zero : ((Java.Lang.Object)composition).Handle); + args[3] = new JValue(defaults); + args[4] = new JValue(IntPtr.Zero); + IntPtr peer = JNIEnv.NewObject(s_tfv_class, s_tfv_ctor, args); + return Java.Lang.Object.GetObject(peer, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + JNIEnv.DeleteLocalRef(textRef); + GC.KeepAlive(composition); + } + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextOverflow.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextOverflow.cs new file mode 100644 index 000000000..110ae8772 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextOverflow.cs @@ -0,0 +1,42 @@ +namespace AndroidX.Compose; + +/// +/// 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. null → 0; 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/Comet/src/vendor/Microsoft.AndroidX.Compose/TextStyle.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextStyle.cs new file mode 100644 index 000000000..18432ecc7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextStyle.cs @@ -0,0 +1,97 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Builder-style C# wrapper around androidx.compose.ui.text.TextStyle. +/// Materializes a binding +/// by starting from the Material 3 baseline (TextStyle.Default) +/// and overriding only the properties the caller set. Nine slots are +/// exposed (the most common set required by typography customization); +/// every other field — BaselineShift, Shadow, +/// TextGeometricTransform, etc. — is forwarded from the default. +/// +/// +/// Used as the input to MaterialTheme.BuildTypography(...). +/// Each instance is independent — call +/// once per Typography slot. +/// +public sealed class TextStyle +{ + /// Foreground text color. Leave to inherit. + public Color? Color { get; set; } + + /// Font size (sp). Leave to inherit. + public Sp? FontSize { get; set; } + + /// Font weight. Leave to inherit. + public FontWeight? FontWeight { get; set; } + + /// Font style (Normal / Italic). Leave to inherit. + public FontStyle? FontStyle { get; set; } + + /// Font family. Leave to inherit. + public FontFamily? FontFamily { get; set; } + + /// Letter spacing (sp). Leave to inherit. + public Sp? LetterSpacing { get; set; } + + /// Line height (sp). Leave to inherit. + public Sp? LineHeight { get; set; } + + /// Horizontal text alignment. Leave to inherit. + public TextAlign? TextAlign { get; set; } + + /// Text decoration (None / Underline / LineThrough). Leave to inherit. + public TextDecoration? TextDecoration { get; set; } + + /// Packed androidx.compose.ui.text.style.LineBreak value + /// (obtain from ). Leave to inherit. + public int? LineBreak { get; set; } + + static T? Cast(Java.Lang.Object? wrapper) where T : Java.Lang.Object => + wrapper is null ? null : Java.Lang.Object.GetObject(wrapper.Handle, JniHandleOwnership.DoNotTransfer); + + static int UnboxTextAlign(TextAlign? wrapper, int fallback) + { + if (wrapper is null) return fallback; + var binding = Java.Lang.Object.GetObject(wrapper.Handle, JniHandleOwnership.DoNotTransfer); + return binding!.Value; + } + + /// + /// Materialize the binding . + /// Properties left at on this builder are + /// copied verbatim from TextStyle.Default; properties the + /// caller set replace the corresponding slot. + /// + public AndroidX.Compose.UI.Text.TextStyle Build() + { + var d = TextStyleCompanion.Default; + return d.Copy( + color: Color is { } c ? (long)c : d.Color, + fontSize: FontSize is { } fs ? Sp.Pack(fs) : d.FontSize, + fontWeight: FontWeight is null ? d.FontWeight : Cast(FontWeight), + fontStyle: FontStyle is null ? d.FontStyle : Cast(FontStyle), + fontSynthesis: d.FontSynthesis, + fontFamily: FontFamily is null ? d.FontFamily : Cast(FontFamily), + fontFeatureSettings: d.FontFeatureSettings, + letterSpacing: LetterSpacing is { } ls ? Sp.Pack(ls) : d.LetterSpacing, + baselineShift: d.BaselineShift, + textGeometricTransform: d.TextGeometricTransform, + localeList: d.LocaleList, + background: d.Background, + textDecoration: TextDecoration is null ? d.TextDecoration : Cast(TextDecoration), + shadow: d.Shadow, + drawStyle: d.DrawStyle, + textAlign: UnboxTextAlign(TextAlign, d.GetTextAlign()), + textDirection: d.GetTextDirection(), + lineHeight: LineHeight is { } lh ? Sp.Pack(lh) : d.LineHeight, + textIndent: d.TextIndent, + platformStyle: d.PlatformStyle, + lineHeightStyle: d.LineHeightStyle, + lineBreak: LineBreak ?? d.GetLineBreak(), + hyphens: d.GetHyphens(), + textMotion: d.TextMotion); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextStyleCompanion.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextStyleCompanion.cs new file mode 100644 index 000000000..1db2f2114 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TextStyleCompanion.cs @@ -0,0 +1,47 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Cached accessor for androidx.compose.ui.text.TextStyle.Companion — +/// the singleton that exposes Default (and via that +/// Default.toSpanStyle()). The C# binding doesn't expose a static +/// accessor for the Companion field, so we read it through JNI once and +/// cache the wrapper for both and +/// to share. +/// +static class TextStyleCompanion +{ + static AndroidX.Compose.UI.Text.TextStyle.Companion? s_companion; + static AndroidX.Compose.UI.Text.TextStyle? s_default; + static AndroidX.Compose.UI.Text.SpanStyle? s_defaultSpan; + + public static AndroidX.Compose.UI.Text.TextStyle.Companion Get() + { + if (s_companion is not null) return s_companion; + IntPtr local = IntPtr.Zero; + try + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/ui/text/TextStyle"); + IntPtr fid = JNIEnv.GetStaticFieldID(cls, "Companion", + "Landroidx/compose/ui/text/TextStyle$Companion;"); + local = JNIEnv.GetStaticObjectField(cls, fid); + return s_companion = Java.Lang.Object.GetObject( + local, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + // GetObject(.., TransferLocalRef) consumes `local` on success; + // the explicit DeleteLocalRef only runs if the wrapper threw + // before taking ownership. + if (local != IntPtr.Zero && s_companion is null) + JNIEnv.DeleteLocalRef(local); + } + } + + public static AndroidX.Compose.UI.Text.TextStyle Default => + s_default ??= Get().Default; + + public static AndroidX.Compose.UI.Text.SpanStyle DefaultSpan => + s_defaultSpan ??= Default.ToSpanStyle(); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ThemeBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ThemeBridges.cs new file mode 100644 index 000000000..c3e523d3d --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ThemeBridges.cs @@ -0,0 +1,140 @@ +using Android.Runtime; +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +// Hand-written JNI bridges for Kotlin synthetic `$default` overloads +// that yield a Material 3 ColorScheme. Used by +// MaterialTheme.LightColorScheme(...) / MaterialTheme.DarkColorScheme(...) +// so callers can take the Material 3 baseline palette and optionally +// override individual slots, without having to supply all 48 Color +// values themselves. +// +// `ColorSchemeKt.lightColorScheme(J×48)` and `darkColorScheme(J×48)` +// ARE bound directly — `Color` is `@JvmInline value class Color(val value: ULong)` +// which the binder represents as plain `long`, so the JVM names aren't +// stripped. What ISN'T bound is the synthetic `$default` sibling +// (Kotlin emits it for any function whose parameters have default +// values), so a managed caller currently has to pass all 48 colors +// explicitly. These bridges invoke the synthetic with a dual-int +// `$default` mask: bit N == 1 tells Kotlin "use the per-slot default +// from the M3 tonal palette tokens"; bit N == 0 tells it "use the +// caller-supplied long at slot N". +// +// Why raw JNI instead of `[ComposeBridge]`: the generator's +// non-`@Composable` shape supports a single `int $default` slot. +// `lightColorScheme$default` has 48 colors, requiring TWO `$default` +// ints (32 bits each), which the generator doesn't model. +internal static partial class ComposeBridges +{ + // 48 J slots (one per Color param) + II (two $default mask ints, + // since 48 > 32 bits) + L (synthetic-overload Object marker). + const string ColorSchemeDefaultSig = + "(JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ" + + "IILjava/lang/Object;)" + + "Landroidx/compose/material3/ColorScheme;"; + + // Number of Color slots in lightColorScheme / darkColorScheme. + internal const int ColorSchemeSlotCount = 48; + + static IntPtr s_lightColorSchemeDefault_class; + static IntPtr s_lightColorSchemeDefault_method; + static IntPtr s_darkColorSchemeDefault_class; + static IntPtr s_darkColorSchemeDefault_method; + + /// + /// Build a Material 3 light using every + /// per-slot default from the M3 tonal palette tokens. + /// + internal static ColorScheme DefaultLightColorScheme() => InvokeColorSchemeDefault( + ref s_lightColorSchemeDefault_class, + ref s_lightColorSchemeDefault_method, + "lightColorScheme-_VG5OTI$default", + colors: null, + mask0: -1, + mask1: -1); + + /// + /// Build a Material 3 dark using every + /// per-slot default from the M3 tonal palette tokens. + /// + internal static ColorScheme DefaultDarkColorScheme() => InvokeColorSchemeDefault( + ref s_darkColorSchemeDefault_class, + ref s_darkColorSchemeDefault_method, + "darkColorScheme-_VG5OTI$default", + colors: null, + mask0: -1, + mask1: -1); + + /// + /// Build a Material 3 light from a + /// 48-element array of packed + /// longs and a dual-int / + /// selecting which slots use the + /// caller-supplied value (bit clear) vs the Kotlin per-slot + /// default (bit set). + /// + internal static ColorScheme CustomLightColorScheme(long[] colors, int mask0, int mask1) => + InvokeColorSchemeDefault( + ref s_lightColorSchemeDefault_class, + ref s_lightColorSchemeDefault_method, + "lightColorScheme-_VG5OTI$default", + colors: colors, + mask0: mask0, + mask1: mask1); + + /// + /// Dark-mode counterpart of . + /// + internal static ColorScheme CustomDarkColorScheme(long[] colors, int mask0, int mask1) => + InvokeColorSchemeDefault( + ref s_darkColorSchemeDefault_class, + ref s_darkColorSchemeDefault_method, + "darkColorScheme-_VG5OTI$default", + colors: colors, + mask0: mask0, + mask1: mask1); + + static unsafe ColorScheme InvokeColorSchemeDefault( + ref IntPtr classCache, + ref IntPtr methodCache, + string jvmName, + long[]? colors, + int mask0, + int mask1) + { + if (methodCache == IntPtr.Zero) + { + classCache = JNIEnv.FindClass("androidx/compose/material3/ColorSchemeKt"); + methodCache = JNIEnv.GetStaticMethodID(classCache, jvmName, ColorSchemeDefaultSig); + } + + // 48 colors + 2 mask ints + 1 marker = 51 JValue slots. + JValue* args = stackalloc JValue[51]; + if (colors is null) + { + for (int i = 0; i < ColorSchemeSlotCount; i++) + args[i] = new JValue(0L); + } + else + { + if (colors.Length != ColorSchemeSlotCount) + throw new ArgumentException( + $"colors array must have exactly {ColorSchemeSlotCount} elements; got {colors.Length}.", + nameof(colors)); + for (int i = 0; i < ColorSchemeSlotCount; i++) + args[i] = new JValue(colors[i]); + } + // mask0 covers slots 0-31, mask1 covers slots 32-47. Bit N == 1 + // tells Kotlin "use the per-slot default from the M3 tonal + // palette tokens"; bit N == 0 tells it "use the caller-supplied + // long at slot N". Bits past position 47 (the last real + // parameter) are never tested by the generated $default body. + args[48] = new JValue(mask0); + args[49] = new JValue(mask1); + args[50] = new JValue(IntPtr.Zero); + + IntPtr handle = JNIEnv.CallStaticObjectMethod(classCache, methodCache, args); + return Java.Lang.Object.GetObject(handle, JniHandleOwnership.TransferLocalRef)!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TileMode.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TileMode.cs new file mode 100644 index 000000000..3a1918ba2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TileMode.cs @@ -0,0 +1,39 @@ +namespace AndroidX.Compose; + +/// +/// C# mirror of androidx.compose.ui.graphics.TileMode — controls +/// what a gradient does outside its declared start/end region. +/// +/// +/// In Kotlin source TileMode is a @JvmInline value class +/// wrapping ; across JNI it travels as a bare +/// , which is what the underlying Brush companion +/// factories expect. The integer values here match Kotlin's +/// TileMode.Clamp.value / Repeated.value / etc. +/// +public enum TileMode +{ + /// + /// Clamp to the boundary color — pixels outside the gradient region + /// keep the endpoint color. This is Compose's default. + /// + Clamp = 0, + + /// + /// Repeat the gradient pattern indefinitely outside the declared + /// region. + /// + Repeated = 1, + + /// + /// Repeat the gradient pattern with every other repetition mirrored, + /// producing a seamless tile. + /// + Mirror = 2, + + /// + /// Render anything outside the gradient region as transparent — the + /// gradient draws only inside its declared bounds. + /// + Decal = 3, +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimeInput.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimeInput.cs new file mode 100644 index 000000000..f404e2072 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimeInput.cs @@ -0,0 +1,19 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 TimeInput — the keyboard-entry counterpart to +/// . Share a single +/// across both facades and the user can switch between the clock and +/// keyboard layouts without losing the entered value; the generator +/// emits a shared-state preamble (StateHolder.SharedState) that +/// reuses the same rememberTimePickerState peer for both +/// facades so the cached hour/minute survive the toggle. +/// +/// +/// As with , leaving the state ctor +/// argument null auto-creates a fresh +/// wrapper. Reading / +/// from a button callback Just +/// Works without manual remember plumbing. +/// +public sealed partial class TimeInput; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePicker.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePicker.cs new file mode 100644 index 000000000..f9341b346 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePicker.cs @@ -0,0 +1,20 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 TimePicker. The generator wires the +/// rememberTimePickerState(initialHour, initialMinute, is24Hour, composer) +/// round-trip and auto-creates a wrapper +/// when the caller leaves the state ctor argument null, so reading +/// / +/// from a button callback Just Works without manual remember plumbing. +/// +/// +/// Opts in to StateHolder.SharedState so a caller-supplied +/// can be passed to a sibling +/// facade without triggering a duplicate +/// rememberTimePickerState call — both facades reuse the same +/// Kotlin peer and stay in sync as the user toggles between clock and +/// keyboard entry. +/// +public sealed partial class TimePicker; + diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePickerDialog.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePickerDialog.cs new file mode 100644 index 000000000..b16d80abc --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePickerDialog.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 TimePickerDialog. Both ConfirmButton and +/// DismissButton are required by Compose; Title and +/// ModeToggleButton are optional. Place a +/// in Body. +/// +public sealed partial class TimePickerDialog; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePickerState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePickerState.cs new file mode 100644 index 000000000..f3e963705 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TimePickerState.cs @@ -0,0 +1,65 @@ +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for / +/// . Mirrors Kotlin's +/// TimePickerState: / +/// expose the live values; reflects the format. +/// +/// +/// Typical usage — Remember a state instance with the desired +/// initial values, pass it to a , and read the +/// picked time from the dialog's ConfirmButton.OnClick: +/// +/// var pickerState = Remember(() => new TimePickerState(initialHour: 9, initialMinute: 30)); +/// +/// new TimePickerDialog(onDismissRequest: ...) +/// { +/// ConfirmButton = new Button(onClick: () => +/// { +/// var h = pickerState.Hour; +/// var m = pickerState.Minute; +/// }) +/// { new Text("OK") }, +/// Body = new TimePicker(pickerState), +/// } +/// +/// +public sealed class TimePickerState +{ + internal ITimePickerState? Jvm; + + internal int InitialHour { get; } + internal int InitialMinute { get; } + internal bool InitialIs24Hour { get; } + + public TimePickerState(int initialHour = 12, int initialMinute = 0, bool is24Hour = true) + { + InitialHour = initialHour; + InitialMinute = initialMinute; + InitialIs24Hour = is24Hour; + } + + /// Currently displayed hour (0–23). Falls back to the + /// constructor's initialHour until bound. + public int Hour + { + get => Jvm?.Hour ?? InitialHour; + set { if (Jvm is not null) Jvm.Hour = value; } + } + + /// Currently displayed minute (0–59). Falls back to the + /// constructor's initialMinute until bound. + public int Minute + { + get => Jvm?.Minute ?? InitialMinute; + set { if (Jvm is not null) Jvm.Minute = value; } + } + + /// Whether the picker is in 24-hour mode (vs. 12-hour with + /// AM/PM). Falls back to the constructor's is24Hour until + /// bound. + public bool Is24Hour => Jvm?.Is24hour() ?? InitialIs24Hour; +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Tooltip.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Tooltip.cs new file mode 100644 index 000000000..988a6e0a2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Tooltip.cs @@ -0,0 +1,51 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 TooltipBox with a plain tooltip popup. The popup +/// position provider comes from TooltipDefaults.rememberPlainTooltipPositionProvider; +/// TooltipState from rememberTooltipState. Both are +/// resolved via JNI inside . +/// +/// Use named-property syntax: is the popup body shown +/// on long-press / hover, is the always-visible +/// content the popup attaches to. +/// +public sealed class Tooltip : ComposableNode +{ + readonly bool _isPersistent; + public Tooltip(bool isPersistent = false) => _isPersistent = isPersistent; + + /// Required: the popup body shown on long-press / hover. + public ComposableNode? Tip { get; set; } + + /// Required: the always-visible anchor the tooltip attaches to. + public ComposableNode? Anchor { get; set; } + + public override void Render(IComposer composer) + { + if (Tip is null || Anchor is null) + throw new InvalidOperationException( + "Tooltip requires both Tip (popup body) and Anchor (visible content)."); + + var positionProvider = ComposeBridges.RememberPlainTooltipPositionProvider(composer); + var stateHandle = ComposeBridges.RememberTooltipState(_isPersistent, composer); + + var tooltip = ComposableLambdas.Wrap3(composer, c => Tip.Render(c)); + var anchor = ComposableLambdas.Wrap2(composer, c => Anchor.Render(c)); + + var modifier = BuildModifier(); + int defaults = (int)TooltipBoxDefault.All; + if (modifier is not null) defaults &= ~(int)TooltipBoxDefault.Modifier; + + ComposeBridges.TooltipBox( + positionProvider: positionProvider, + tooltip: tooltip, + state: stateHandle, + modifier: modifier, + content: anchor, + defaults: defaults, + composer: composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TopAppBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TopAppBar.cs new file mode 100644 index 000000000..17fd91b54 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TopAppBar.cs @@ -0,0 +1,29 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 TopAppBar. Title is required; +/// Subtitle, NavigationIcon, and Actions are +/// optional slots: +/// +/// new TopAppBar +/// { +/// Title = new Text("My App"), +/// Subtitle = new Text("Inbox"), +/// NavigationIcon = new IconButton(onClick: ...) { new Text("☰") }, +/// Actions = new Row { new IconButton(...) { new Text("⋮") } }, +/// } +/// +/// When Subtitle is set, the bar is rendered via the newer +/// two-line TopAppBar-cJHQLPU overload; otherwise it uses the +/// single-line TopAppBar-GHTll3U overload. The branching is +/// driven by the [ComposeFacade(BranchOn=..., AlternateBridge=...)] +/// attribute on the corresponding bridge in ComposeBridges.cs. +/// +/// Set ScrollBehavior to a value from +/// to make the bar elevate / collapse +/// in response to scrolling. The same behavior's +/// must +/// be wired to the scrolling container via +/// . +/// +public sealed partial class TopAppBar; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TopSearchBar.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TopSearchBar.cs new file mode 100644 index 000000000..6a2db77d2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TopSearchBar.cs @@ -0,0 +1,32 @@ +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 TopSearchBar (state-based) — variant of +/// that snaps to a scrolling top app bar +/// position. Same shape as : provide a +/// and an ; pair +/// with an or +/// popup that shares the state. +/// +public sealed class TopSearchBar : ComposableNode +{ + readonly SearchBarState _state; + + public TopSearchBar(SearchBarState state) => _state = state; + + /// Required: composable that renders the search input field. + public ComposableNode? InputField { get; set; } + + public override void Render(IComposer composer) + { + if (InputField is null) + throw new InvalidOperationException( + "TopSearchBar.InputField is required (the Kotlin parameter has no default)."); + + var stateHandle = SearchBar.ResolveStateHandle(_state, composer); + var inputField = ComposableLambdas.Wrap2(composer, c => InputField.Render(c)); + ComposeBridges.TopSearchBar(stateHandle, inputField, BuildModifier(), composer); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformGestureBlock.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformGestureBlock.cs new file mode 100644 index 000000000..5c1352cc7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformGestureBlock.cs @@ -0,0 +1,100 @@ +using Android.Runtime; +using Kotlin.Coroutines; +using Kotlin.Coroutines.Intrinsics; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW exposing a that Compose's +/// pointerInput(key, handler) invokes inside the gesture-detector +/// coroutine, then forwards to detectTransformGestures. Mirrors +/// the shape of but routes to the +/// pinch / zoom / rotate suspend bridge. +/// +/// +/// See for the tail-call / +/// callback-freshness rationale; the same constraints apply here. +/// The single instance is the +/// only callback (Compose's detectTransformGestures doesn't +/// surface separate start / end events — the runtime applies the +/// per-frame deltas as long as enough pointers are down). +/// +[Register("net/compose/TransformGestureBlock")] +internal sealed class TransformGestureBlock : Java.Lang.Object, IFunction2 +{ + static IntPtr s_suspendedHandle; + + readonly bool _panZoomLock; + readonly TransformGestureCallback _onGesture; + + public TransformGestureBlock(bool panZoomLock, TransformGestureCallback onGesture) + { + _panZoomLock = panZoomLock; + _onGesture = onGesture; + } + + public Java.Lang.Object? Invoke(Java.Lang.Object? scope, Java.Lang.Object? cont) + { + if (scope is null) + throw new InvalidOperationException( + "TransformGestureBlock.Invoke received a null PointerInputScope in slot 0"); + if (cont is null) + throw new InvalidOperationException( + "TransformGestureBlock.Invoke received a null Continuation in slot 1"); + + var scopeHandle = scope.Handle; + + IContinuation continuation; + try + { + continuation = cont.JavaCast(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + "TransformGestureBlock.Invoke could not project slot 1 (" + + (cont.Class?.Name ?? "") + + ") as kotlin.coroutines.Continuation", ex); + } + + try + { + var resultHandle = ComposeBridges.DetectTransformGestures( + scopeHandle, + _panZoomLock, + ((Java.Lang.Object)_onGesture).Handle, + continuation); + + if (resultHandle == IntPtr.Zero) + return null; + + EnsureSuspendedHandle(); + if (JNIEnv.IsSameObject(resultHandle, s_suspendedHandle)) + { + JNIEnv.DeleteLocalRef(resultHandle); + return IntrinsicsKt.COROUTINE_SUSPENDED; + } + + return Java.Lang.Object.GetObject( + resultHandle, JniHandleOwnership.TransferLocalRef); + } + finally + { + GC.KeepAlive(scope); + GC.KeepAlive(cont); + GC.KeepAlive(_onGesture); + } + } + + static void EnsureSuspendedHandle() + { + if (s_suspendedHandle != IntPtr.Zero) return; + var inst = IntrinsicsKt.COROUTINE_SUSPENDED; + var gref = JNIEnv.NewGlobalRef(inst.Handle); + if (Interlocked.CompareExchange( + ref s_suspendedHandle, gref, IntPtr.Zero) != IntPtr.Zero) + JNIEnv.DeleteGlobalRef(gref); + GC.KeepAlive(inst); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformGestureCallback.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformGestureCallback.cs new file mode 100644 index 000000000..89506e3cf --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformGestureCallback.cs @@ -0,0 +1,59 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW adapter implementing +/// Function4<Offset, Offset, Float, Float, Unit> for the +/// onGesture callback of detectTransformGestures. Kotlin +/// passes the gesture centroid in p0, the cumulative pan in +/// p1, the cumulative zoom multiplier in p2, and the +/// rotation in degrees in p3. +/// +/// +/// Both args (p0 centroid, p1 pan) +/// arrive as boxed androidx.compose.ui.geometry.Offset +/// wrappers around a packed long; we unbox via the shared +/// unbox-impl()J instance method. p2 / p3 are +/// boxed primitives. +/// +[Register("net/compose/TransformGestureCallback")] +internal sealed class TransformGestureCallback : Java.Lang.Object, IFunction4 +{ + static IntPtr s_unbox; + + readonly Action _body; + + public TransformGestureCallback(Action body) => _body = body; + + public Java.Lang.Object Invoke( + Java.Lang.Object? p0, Java.Lang.Object? p1, + Java.Lang.Object? p2, Java.Lang.Object? p3) + { + long centroidPacked = 0L; + long panPacked = 0L; + if (p0 is not null) + { + EnsureUnboxMethod(); + centroidPacked = JNIEnv.CallLongMethod(p0.Handle, s_unbox); + } + if (p1 is not null) + { + EnsureUnboxMethod(); + panPacked = JNIEnv.CallLongMethod(p1.Handle, s_unbox); + } + // Kotlin Float => Java.Lang.Float; FloatValue() unwraps. + var zoom = (p2 as Java.Lang.Float)?.FloatValue() ?? 1f; + var rotation = (p3 as Java.Lang.Float)?.FloatValue() ?? 0f; + _body(Offset.FromPacked(centroidPacked), Offset.FromPacked(panPacked), zoom, rotation); + return Kotlin.Unit.Instance!; + } + + static void EnsureUnboxMethod() + { + if (s_unbox != IntPtr.Zero) return; + var cls = JNIEnv.FindClass("androidx/compose/ui/geometry/Offset"); + s_unbox = JNIEnv.GetMethodID(cls, "unbox-impl", "()J"); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformOrigin.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformOrigin.cs new file mode 100644 index 000000000..0e57fff6b --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TransformOrigin.cs @@ -0,0 +1,41 @@ +namespace AndroidX.Compose; + +/// +/// Mirror of Kotlin's androidx.compose.ui.graphics.TransformOrigin — +/// the pivot point used by transform-aware modifiers such as +/// for scale and rotation. Compose +/// defines it as a @JvmInline value class TransformOrigin(val packedValue: Long) +/// that packs two s (fractions in [0, 1]) +/// into a single : x in the upper 32 bits, +/// y in the lower 32 bits. Because the binding generator +/// strips inline value-class types, the +/// API takes the raw packed ; +/// builds one from a (x, y) pair and +/// supplies the common (0.5, 0.5) default. +/// +public static class TransformOrigin +{ + /// + /// Packed equivalent to Compose's + /// TransformOrigin.Center — pivot (0.5, 0.5), i.e. the + /// geometric center of the composable. This is the default + /// pivot Compose uses when none is supplied. + /// + public static long Center { get; } = Pack(0.5f, 0.5f); + + /// + /// Pack a (, + /// ) pair into the + /// TransformOrigin bit layout expected by + /// . Both fractions are + /// typically in [0, 1]: 0 = left/top, + /// 0.5 = center, 1 = right/bottom. Values outside + /// that range are allowed but rarely useful. + /// + public static long Pack(float pivotFractionX, float pivotFractionY) + { + long x = unchecked((uint)BitConverter.SingleToInt32Bits(pivotFractionX)); + long y = unchecked((uint)BitConverter.SingleToInt32Bits(pivotFractionY)); + return (x << 32) | y; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Transitions.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Transitions.cs new file mode 100644 index 000000000..f621f3987 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/Transitions.cs @@ -0,0 +1,139 @@ +using AndroidX.Compose.Animation; +using AndroidX.Compose.Animation.Core; + +namespace AndroidX.Compose; + +/// +/// Factory methods that mirror Kotlin's top-level +/// EnterTransition / ExitTransition builders from +/// androidx.compose.animation — fadeIn(), +/// fadeOut(), scaleIn(), scaleOut(), and the +/// slideIn* / slideOut* family. Pass the returned values +/// to / +/// (and, in a future release, to +/// AnimatedContent) to override the default fade-plus-expand +/// transitions Compose chooses on your behalf. +/// +/// +/// +/// Every factory takes an optional +/// ( uses +/// spring(stiffness = Spring.StiffnessMediumLow), matching +/// Kotlin's default). Build a custom spec via +/// , +/// , etc. +/// +/// +/// The expandIn, expandHorizontally, expandVertically, +/// shrinkOut, shrinkHorizontally, and shrinkVertically +/// factories aren't surfaced yet — they need +/// singletons that aren't +/// exposed by the v1 wrappers. Use the bound +/// directly if you need them. +/// +/// +public static class Transitions +{ + static IFiniteAnimationSpec DefaultSpec() => + AnimationSpecKt.Spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow, null); + + /// + /// Fades the content in from (default + /// 0f = fully transparent) to its final opacity. Mirrors Kotlin + /// . + /// + /// The starting alpha, in [0f, 1f]. + /// Optional spec; uses + /// spring(stiffness = StiffnessMediumLow). + public static EnterTransition FadeIn(float initialAlpha = 0f, IFiniteAnimationSpec? animationSpec = null) => + EnterExitTransitionKt.FadeIn(animationSpec ?? DefaultSpec(), initialAlpha); + + /// + /// Fades the content out from its current opacity to + /// (default 0f). Mirrors Kotlin + /// . + /// + /// The ending alpha, in [0f, 1f]. + /// Optional spec; uses + /// spring(stiffness = StiffnessMediumLow). + public static ExitTransition FadeOut(float targetAlpha = 0f, IFiniteAnimationSpec? animationSpec = null) => + EnterExitTransitionKt.FadeOut(animationSpec ?? DefaultSpec(), targetAlpha); + + /// + /// Scales the content in from to + /// 1f, anchored at the layout center. Mirrors Kotlin + /// . + /// + /// The starting scale factor. + /// Optional spec; uses + /// spring(stiffness = StiffnessMediumLow). + public static EnterTransition ScaleIn(float initialScale = 0f, IFiniteAnimationSpec? animationSpec = null) => + EnterExitTransitionKt.ScaleIn(animationSpec ?? DefaultSpec(), initialScale, TransformOrigin.Center); + + /// + /// Scales the content out from 1f to + /// , anchored at the layout center. + /// Mirrors Kotlin + /// . + /// + /// The ending scale factor. + /// Optional spec; uses + /// spring(stiffness = StiffnessMediumLow). + public static ExitTransition ScaleOut(float targetScale = 0f, IFiniteAnimationSpec? animationSpec = null) => + EnterExitTransitionKt.ScaleOut(animationSpec ?? DefaultSpec(), targetScale, TransformOrigin.Center); + + /// + /// Slides the content in horizontally from + /// applied to its measured width — the lambda receives the container's + /// width in pixels and returns the starting X offset. Kotlin's default + /// of { -it / 2 } (half a width to the left) is matched by + /// passing width => -width / 2. Mirrors Kotlin + /// slideInHorizontally(animationSpec, initialOffsetX). + /// + /// Maps the container's width (px) to the + /// starting horizontal offset (px). Defaults to half a width to the + /// left. + /// Optional spec; uses + /// spring(stiffness = StiffnessMediumLow, visibilityThreshold = IntOffset.VisibilityThreshold) + /// — the binding doesn't expose the offset-typed default, so the same + /// float-typed spring is substituted. + public static EnterTransition SlideInHorizontally(Func? initialOffsetX = null, IFiniteAnimationSpec? animationSpec = null) => + EnterExitTransitionKt.SlideInHorizontally(animationSpec ?? DefaultSpec(), new IntCallback(initialOffsetX ?? (w => -w / 2))); + + /// + /// Slides the content in vertically from + /// applied to its measured height. Mirrors Kotlin + /// slideInVertically(animationSpec, initialOffsetY). + /// + /// Maps the container's height (px) to the + /// starting vertical offset (px). Defaults to half a height upward. + /// Optional spec; see + /// . + public static EnterTransition SlideInVertically(Func? initialOffsetY = null, IFiniteAnimationSpec? animationSpec = null) => + EnterExitTransitionKt.SlideInVertically(animationSpec ?? DefaultSpec(), new IntCallback(initialOffsetY ?? (h => -h / 2))); + + /// + /// Slides the content out horizontally to + /// applied to its measured width. Mirrors Kotlin + /// slideOutHorizontally(animationSpec, targetOffsetX). + /// + /// Maps the container's width (px) to the + /// ending horizontal offset (px). Defaults to half a width to the + /// left. + /// Optional spec; see + /// . + public static ExitTransition SlideOutHorizontally(Func? targetOffsetX = null, IFiniteAnimationSpec? animationSpec = null) => + EnterExitTransitionKt.SlideOutHorizontally(animationSpec ?? DefaultSpec(), new IntCallback(targetOffsetX ?? (w => -w / 2))); + + /// + /// Slides the content out vertically to + /// applied to its measured height. Mirrors Kotlin + /// slideOutVertically(animationSpec, targetOffsetY). + /// + /// Maps the container's height (px) to the + /// ending vertical offset (px). Defaults to half a height upward. + /// Optional spec; see + /// . + public static ExitTransition SlideOutVertically(Func? targetOffsetY = null, IFiniteAnimationSpec? animationSpec = null) => + EnterExitTransitionKt.SlideOutVertically(animationSpec ?? DefaultSpec(), new IntCallback(targetOffsetY ?? (h => -h / 2))); +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TriStateCheckbox.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TriStateCheckbox.cs new file mode 100644 index 000000000..c985223b3 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TriStateCheckbox.cs @@ -0,0 +1,15 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 TriStateCheckbox — a checkbox with an +/// indeterminate state in addition to checked/unchecked. The +/// ToggleableState values are On, Off, and +/// Indeterminate: +/// +/// var state = Remember.Of(ToggleableState.Indeterminate); +/// new TriStateCheckbox( +/// state: state.Value, +/// onClick: () => state.Value = ToggleableState.On) +/// +/// +public sealed partial class TriStateCheckbox; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TypographyBridges.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TypographyBridges.cs new file mode 100644 index 000000000..6fe28f639 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/TypographyBridges.cs @@ -0,0 +1,124 @@ +using Android.Runtime; +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +// Hand-written JNI bridge for the Material 3 `Typography` synthetic +// default constructor. Used by MaterialTheme.BuildTypography(...) so +// callers can override individual text-style slots while leaving the +// rest at the M3 baseline tokens. +// +// Same story as ShapesBridges: only the parameterless ctor is bound. +// The full primary constructor takes 15 TextStyle params plus +// Kotlin's synthetic `$default` int + DefaultConstructorMarker — +// hand-rolled JNI is the simplest path until the binder fix lands. +internal static partial class ComposeBridges +{ + const string TypographyDefaultCtorSig = + "(Landroidx/compose/ui/text/TextStyle;" + // displayLarge + "Landroidx/compose/ui/text/TextStyle;" + // displayMedium + "Landroidx/compose/ui/text/TextStyle;" + // displaySmall + "Landroidx/compose/ui/text/TextStyle;" + // headlineLarge + "Landroidx/compose/ui/text/TextStyle;" + // headlineMedium + "Landroidx/compose/ui/text/TextStyle;" + // headlineSmall + "Landroidx/compose/ui/text/TextStyle;" + // titleLarge + "Landroidx/compose/ui/text/TextStyle;" + // titleMedium + "Landroidx/compose/ui/text/TextStyle;" + // titleSmall + "Landroidx/compose/ui/text/TextStyle;" + // bodyLarge + "Landroidx/compose/ui/text/TextStyle;" + // bodyMedium + "Landroidx/compose/ui/text/TextStyle;" + // bodySmall + "Landroidx/compose/ui/text/TextStyle;" + // labelLarge + "Landroidx/compose/ui/text/TextStyle;" + // labelMedium + "Landroidx/compose/ui/text/TextStyle;" + // labelSmall + "ILkotlin/jvm/internal/DefaultConstructorMarker;)V"; + + static IntPtr s_typographyCtor_class; + static IntPtr s_typographyCtor_method; + + /// + /// Build a Material 3 with per-slot + /// overrides. Each + /// is either a wrapper to use for that slot, or null to fall + /// back to the M3 baseline default for that slot via Kotlin's + /// synthetic $default mechanism. + /// + /// + /// 15-bit mask: bit N set means "leave slot N at the Kotlin + /// default" (the corresponding wrapper is ignored). Bit 0 is + /// displayLarge, bit 14 is labelSmall. + /// + internal static unsafe Typography BuildTypography( + AndroidX.Compose.UI.Text.TextStyle? displayLarge, + AndroidX.Compose.UI.Text.TextStyle? displayMedium, + AndroidX.Compose.UI.Text.TextStyle? displaySmall, + AndroidX.Compose.UI.Text.TextStyle? headlineLarge, + AndroidX.Compose.UI.Text.TextStyle? headlineMedium, + AndroidX.Compose.UI.Text.TextStyle? headlineSmall, + AndroidX.Compose.UI.Text.TextStyle? titleLarge, + AndroidX.Compose.UI.Text.TextStyle? titleMedium, + AndroidX.Compose.UI.Text.TextStyle? titleSmall, + AndroidX.Compose.UI.Text.TextStyle? bodyLarge, + AndroidX.Compose.UI.Text.TextStyle? bodyMedium, + AndroidX.Compose.UI.Text.TextStyle? bodySmall, + AndroidX.Compose.UI.Text.TextStyle? labelLarge, + AndroidX.Compose.UI.Text.TextStyle? labelMedium, + AndroidX.Compose.UI.Text.TextStyle? labelSmall, + int defaults) + { + if (s_typographyCtor_method == IntPtr.Zero) + { + s_typographyCtor_class = JNIEnv.FindClass("androidx/compose/material3/Typography"); + s_typographyCtor_method = JNIEnv.GetMethodID(s_typographyCtor_class, "", TypographyDefaultCtorSig); + } + + try + { + JValue* args = stackalloc JValue[17]; + args[0] = new JValue(Handle(displayLarge)); + args[1] = new JValue(Handle(displayMedium)); + args[2] = new JValue(Handle(displaySmall)); + args[3] = new JValue(Handle(headlineLarge)); + args[4] = new JValue(Handle(headlineMedium)); + args[5] = new JValue(Handle(headlineSmall)); + args[6] = new JValue(Handle(titleLarge)); + args[7] = new JValue(Handle(titleMedium)); + args[8] = new JValue(Handle(titleSmall)); + args[9] = new JValue(Handle(bodyLarge)); + args[10] = new JValue(Handle(bodyMedium)); + args[11] = new JValue(Handle(bodySmall)); + args[12] = new JValue(Handle(labelLarge)); + args[13] = new JValue(Handle(labelMedium)); + args[14] = new JValue(Handle(labelSmall)); + args[15] = new JValue(defaults); + args[16] = new JValue(IntPtr.Zero); + + IntPtr handle = JNIEnv.NewObject(s_typographyCtor_class, s_typographyCtor_method, args); + return Java.Lang.Object.GetObject(handle, JniHandleOwnership.TransferLocalRef)!; + } + finally + { + // Keep every wrapper alive across the JNI call so its + // backing global ref isn't released between the Handle() + // reads above and JNIEnv.NewObject. The bridge owns this + // responsibility — facade callers shouldn't have to. + GC.KeepAlive(displayLarge); + GC.KeepAlive(displayMedium); + GC.KeepAlive(displaySmall); + GC.KeepAlive(headlineLarge); + GC.KeepAlive(headlineMedium); + GC.KeepAlive(headlineSmall); + GC.KeepAlive(titleLarge); + GC.KeepAlive(titleMedium); + GC.KeepAlive(titleSmall); + GC.KeepAlive(bodyLarge); + GC.KeepAlive(bodyMedium); + GC.KeepAlive(bodySmall); + GC.KeepAlive(labelLarge); + GC.KeepAlive(labelMedium); + GC.KeepAlive(labelSmall); + } + + static IntPtr Handle(AndroidX.Compose.UI.Text.TextStyle? ts) => + ts is null ? IntPtr.Zero : ((Java.Lang.Object)ts).Handle; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/UnitCallback.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/UnitCallback.cs new file mode 100644 index 000000000..c6cdf1099 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/UnitCallback.cs @@ -0,0 +1,34 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW adapter implementing Function0<Unit> for gesture +/// callbacks that take no arguments — Compose's +/// onDragEnd / onDragCancel on +/// detectDragGestures. +/// +/// +/// The wrapped is invoked synchronously on the +/// Compose UI thread; the return value is the canonical +/// singleton so Kotlin sees a +/// well-formed Unit result. The body is captured at +/// construction time — see for the same +/// callback-freshness caveat (the wrapping pointerInput +/// modifier only restarts on key change, not on handler instance +/// change). +/// +[Register("net/compose/UnitCallback")] +internal sealed class UnitCallback : Java.Lang.Object, IFunction0 +{ + readonly Action _body; + + public UnitCallback(Action body) => _body = body; + + public Java.Lang.Object Invoke() + { + _body(); + return Kotlin.Unit.Instance!; + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/VerticalDivider.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/VerticalDivider.cs new file mode 100644 index 000000000..337de5f7f --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/VerticalDivider.cs @@ -0,0 +1,35 @@ +using AndroidX.Compose.Material3; +using AndroidX.Compose.Runtime; + +namespace AndroidX.Compose; + +/// +/// Material 3 VerticalDivider — a thin vertical line used to +/// separate sibling content blocks inside a . +/// +public sealed class VerticalDivider : ComposableNode +{ + /// Optional explicit thickness in Dp. Leave null to use the Material default. + public float? ThicknessDp { get; set; } + + /// Optional explicit color. Leave null to use the Material default. + public Color? Color { get; set; } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + + int defaults = (int)VerticalDividerDefault.All; + if (modifier is not null) defaults &= ~(int)VerticalDividerDefault.Modifier; + if (ThicknessDp.HasValue) defaults &= ~(int)VerticalDividerDefault.Thickness; + if (Color.HasValue) defaults &= ~(int)VerticalDividerDefault.Color; + + DividerKt.VerticalDivider( + modifier: modifier, + thickness: ThicknessDp ?? 0f, + color: Color is { } c ? c : 0L, + _composer: composer, + p4: 0, + _changed: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/VerticalPager.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/VerticalPager.cs new file mode 100644 index 000000000..aade68fc7 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/VerticalPager.cs @@ -0,0 +1,105 @@ +using AndroidX.Compose.Foundation.Pager; +using AndroidX.Compose.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// Foundation VerticalPager — vertical mirror of +/// . Swipes through pages along the +/// vertical axis and snaps to page boundaries. +/// +/// +/// new VerticalPager<Photo>( +/// items: photos, +/// itemContent: p => new Image(p.Url)) +/// { +/// Modifier = Modifier.FillMaxSize(), +/// } +/// +/// +public sealed class VerticalPager : ComposableNode +{ + readonly IReadOnlyList _items; + readonly Func _itemContent; + IFunction0? _pageCountFn; + + /// + /// Construct a vertical pager that snaps through + /// , rendering each via + /// . + /// + public VerticalPager(IReadOnlyList items, Func itemContent) + { + ArgumentNullException.ThrowIfNull(items); + _items = items; + ArgumentNullException.ThrowIfNull(itemContent); + _itemContent = itemContent; + } + + /// + /// Optional caller-supplied state holder. Leave null to let the + /// facade allocate one via rememberPagerState internally. + /// + public PagerState? State { get; set; } + + /// + /// Optional fixed content padding applied inside the pager (lets + /// adjacent pages peek without shrinking the page bounds via + /// ). + /// + public PaddingValues? ContentPadding { get; set; } + + public override void Render(IComposer composer) + { + // See HorizontalPager.Render — same eager-vs-remember path. + AndroidX.Compose.Foundation.Pager.PagerState jvmState; + if (State is not null) + { + jvmState = State.Jvm; + } + else + { + _pageCountFn ??= new ComposableLambda0Int(() => _items.Count); + jvmState = PagerStateKt.RememberPagerState( + p0: 0, + initialPageOffsetFraction: 0f, + pageCount: _pageCountFn, + _composer: composer, + initialPage: 0, + _changed: 0); + } + + var modifier = BuildModifier(); + var content = ComposableLambdas.Wrap4(composer, (_, indexBoxed, comp) => + { + var i = ((Java.Lang.Integer)indexBoxed!).IntValue(); + _itemContent(_items[i]).Render(comp); + }); + + int defaults = (int)VerticalPagerDefault.All; + if (modifier is not null) defaults &= ~(int)VerticalPagerDefault.Modifier; + if (ContentPadding is not null) defaults &= ~(int)VerticalPagerDefault.ContentPadding; + + PagerKt.VerticalPager( + state: jvmState, + modifier: modifier, + contentPadding: ContentPadding?.Jvm, + pageSize: null, + p4: 0, // beyondViewportPageCount + pageSpacing: 0f, + horizontalAlignment: null, + flingBehavior: null, + userScrollEnabled: true, + reverseLayout: false, + key: null, + pageNestedScrollConnection: null, + snapPosition: null, + overscrollEffect: null, + pageContent: content, + _composer: composer, + beyondViewportPageCount: 0, // $changed slot 1 + _changed: 0, // $changed slot 2 + _changed1: defaults); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ViewModel.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ViewModel.cs new file mode 100644 index 000000000..cba56bad2 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/ViewModel.cs @@ -0,0 +1,184 @@ + +namespace AndroidX.Compose; + +/// +/// Base class for AndroidX.Compose view models. Derives from +/// so the host's +/// owns the +/// instance — the C# equivalent of subclassing +/// androidx.lifecycle.ViewModel in Kotlin. +/// +/// +/// +/// Allocate one instance per call site via +/// . +/// The factory routes through +/// on the +/// nearest +/// (the host at +/// the root, or the current +/// inside a +/// ), so the view model survives recomposition +/// and configuration change — and is cleared exactly when +/// the owner clears (the activity is finished, or the destination +/// is popped off the back stack). +/// +/// +/// OnCleared template: the framework +/// is +/// sealed override'd here so cancellation and CTS disposal +/// always happen, even if a subclass forgets to call +/// base.OnCleared(). Subclasses override +/// for their own cleanup; it runs +/// after the token has been cancelled and +/// before the backing is +/// disposed. +/// +/// +/// Threading: the view model is constructed on +/// whatever thread invoked the composition that first observed it +/// — typically the Android main thread. Subclasses should treat +/// instance fields as main-thread state and post mutations from +/// background work back through , whose +/// continuations honor the captured +/// when awaiting. +/// +/// +/// Manual disposal is unsupported. The class +/// inherits from its +/// Java peer base, but that disposes the JNI reference — it does +/// not remove the entry from the +/// . Treat the +/// store owner as the sole authority over view model lifetime +/// and never call Dispose directly. +/// +/// +public abstract class ViewModel : AndroidX.Lifecycle.ViewModel +{ + readonly CancellationTokenSource _cts = new(); + int _cleared; + + /// + /// A that fires when the + /// host clears this view model — i.e. the framework + /// + /// callback. The C# equivalent of Kotlin's + /// viewModelScope.coroutineContext[Job]: pass it to + /// any long-running task / HTTP call / channel read that + /// should stop when the view model goes away. + /// + public CancellationToken Scope => _cts.Token; + + /// + /// Returns true once the framework has cleared this + /// view model. Useful for guarding callbacks that race the + /// teardown. + /// + public bool IsCleared => Volatile.Read(ref _cleared) != 0; + + /// + /// Launches tied to . + /// The C# equivalent of viewModelScope.launch { … } — + /// any exception other than + /// is logged via + /// under the AndroidX.Compose tag rather than propagating as + /// an unobserved task exception. The returned + /// is the same task that surface-area callers can await + /// (e.g. to chain a snackbar dispatch after a refresh + /// completes). + /// + /// + /// Async body. Receives directly so + /// downstream await Task.Delay(…, ct) / + /// repository.GetAsync(ct) calls cancel cleanly. + /// + /// + /// + /// The body is invoked synchronously on the calling thread up + /// to the first await — that's the same behaviour + /// Kotlin's launch exhibits when the dispatcher is the + /// current one. To force a yield before the body starts, begin + /// with await Task.Yield();. + /// + /// + /// When the view model is already cleared at the time of the + /// call, this returns without + /// invoking . + /// + /// + public Task LaunchAsync(Func body) + { + ArgumentNullException.ThrowIfNull(body); + if (IsCleared) + return Task.CompletedTask; + + // Capture the token snapshot BEFORE invoking the body. Once + // the framework calls OnCleared we cancel and dispose the + // CTS, so any task that resumes after that point can no + // longer read `Scope` (it would throw ObjectDisposedException). + // A captured token stays valid for IsCancellationRequested + // reads even after the source is disposed. + var token = Scope; + return Wrap(body, token); + + static async Task Wrap(Func b, CancellationToken token) + { + try + { + await b(token).ConfigureAwait(true); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Normal teardown — swallow. + } + catch (Exception ex) + { + Android.Util.Log.Error("AndroidX.Compose", + $"ViewModel LaunchAsync body faulted: {ex}"); + } + } + } + + /// + /// Override hook for view-model cleanup. Invoked exactly once, + /// after has been cancelled and before the + /// backing is disposed. + /// Use it to release non-task resources (file handles, sockets, + /// native peers, subscriptions, etc.). + /// + /// + /// Throwing from this method logs the exception and continues + /// teardown. The framework guarantees this method fires at most + /// once per instance, so subclasses don't need their own + /// idempotency guards. + /// + protected virtual void OnClearedCore() { } + + /// + /// Sealed override of + /// — runs + /// the framework cancellation, then dispatches to + /// , then disposes the backing + /// . Sealed so subclass + /// teardown can't accidentally skip the cancellation step by + /// forgetting base.OnCleared(). + /// + protected sealed override void OnCleared() + { + if (Interlocked.Exchange(ref _cleared, 1) != 0) + return; + + try { _cts.Cancel(); } + catch (ObjectDisposedException) { /* already disposed */ } + + try { OnClearedCore(); } + catch (Exception ex) + { + Android.Util.Log.Error("AndroidX.Compose", + $"ViewModel ({GetType().Name}) OnClearedCore threw: {ex}"); + } + + _cts.Dispose(); + base.OnCleared(); + } +} diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRail.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRail.cs new file mode 100644 index 000000000..91f0c8988 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRail.cs @@ -0,0 +1,23 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 WideNavigationRail — adaptive vertical navigation +/// rail that shows item labels when expanded. Children are +/// s. Stripped from the binding +/// because the underlying composable is annotated +/// @ExperimentalMaterial3ExpressiveApi; reached via +/// . +/// +/// new WideNavigationRail +/// { +/// new WideNavigationRailItem(selected: tab == 0, onClick: () => tab.Value = 0) +/// { +/// Icon = new Text("🏠"), +/// Label = new Text("Home"), +/// }, +/// } +/// +/// WideNavigationRailItem is a top-level static (not a scope +/// extension), so children render directly without a published scope. +/// +public sealed partial class WideNavigationRail; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRailItem.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRailItem.cs new file mode 100644 index 000000000..435bd6257 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRailItem.cs @@ -0,0 +1,9 @@ +namespace AndroidX.Compose; + +/// +/// Material 3 WideNavigationRailItem. Used inside +/// . is required; +/// is optional (only shown when the rail is +/// expanded). +/// +public sealed partial class WideNavigationRailItem; diff --git a/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRailState.cs b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRailState.cs new file mode 100644 index 000000000..b045b0625 --- /dev/null +++ b/src/Comet/src/vendor/Microsoft.AndroidX.Compose/WideNavigationRailState.cs @@ -0,0 +1,55 @@ +using AndroidX.Compose.Material3; + +namespace AndroidX.Compose; + +/// +/// Caller-supplied state holder for . +/// Wraps Kotlin's WideNavigationRailState (created via +/// rememberWideNavigationRailState) so a facade can carry an +/// initial across recompositions. +/// +/// +/// Construct inside Remember so the same instance survives +/// recomposition: +/// +/// var rail = Remember(() => new WideNavigationRailState(WideNavigationRailValue.Expanded)); +/// new ModalWideNavigationRail(state: rail) { … }; +/// +/// +public sealed class WideNavigationRailState +{ + internal IWideNavigationRailState? Jvm; + + /// + /// Initial the rail + /// remembers on first composition. Defaults to + /// so the rail + /// pops open as soon as it mounts (matches the modal-overlay + /// pattern documented on ). + /// + public WideNavigationRailValue InitialValue { get; } + + /// + /// Construct a holder with the given + /// (or when omitted). + /// + public WideNavigationRailState(WideNavigationRailValue? initialValue = null) + { + InitialValue = initialValue ?? WideNavigationRailValue.Expanded!; + } + + /// + /// The rail's current visual state. Falls back to + /// until the holder is bound to a live + /// peer (i.e. the first render has happened). + /// + public WideNavigationRailValue CurrentValue => + Jvm?.CurrentValue ?? InitialValue; + + /// + /// The rail's target state during animation, or the resting state + /// otherwise. Falls back to until bound. + /// + public WideNavigationRailValue TargetValue => + Jvm?.TargetValue ?? InitialValue; +} diff --git a/src/Comet/src/vendor/SPIKE.Compose-interop.md b/src/Comet/src/vendor/SPIKE.Compose-interop.md new file mode 100644 index 000000000..7a1bb62bb --- /dev/null +++ b/src/Comet/src/vendor/SPIKE.Compose-interop.md @@ -0,0 +1,147 @@ +# Comet.Compose.Facade — interop spike notes + +Phase 1, risk #1: *Can C# host a Jetpack Compose tree and drive it from managed +code on .NET 11?* This facade is Comet's **own** thin C# layer over the +`Xamarin.AndroidX.Compose.*` JNI bindings (we build our own; Jonathan Peppers' +`Microsoft.AndroidX.Compose` is reference only). + +## Findings so far (2026-06-12) + +| Question | Result | +|---|---| +| Do the `Xamarin.AndroidX.Compose.*` bindings **restore** on .NET 11 preview 5? | ✅ Yes | +| Do they **build** under the .NET 11 Android workload (minSdk 24)? | ✅ Yes, clean | +| Is `ComposeView` (the host Android view) reachable from C#? | ✅ `AndroidX.Compose.UI.Platform.ComposeView` | +| Are the snapshot-state primitives reachable? | ✅ `AndroidX.Compose.Runtime.IMutableState`, `SnapshotStateKt.MutableStateOf(value, policy)` | +| The Kotlin `$default` parameter gap (optional args lost in binding)? | ✅ Confirmed & solvable — pass the arg explicitly (e.g. `StructuralEqualityPolicy()`); a facade source generator will restore ergonomic call sites | + +Version set that aligns on .NET 11 preview 5: +- `Xamarin.AndroidX.Compose.Runtime` 1.11.2.1, `.Runtime.Saveable` 1.11.2 +- `Xamarin.AndroidX.Compose.UI[.Graphics/.Text/.Unit]` 1.11.2 +- `Xamarin.AndroidX.Compose.Foundation[.Layout]` 1.11.2 +- `Xamarin.AndroidX.Compose.Material3` 1.4.0.2 +- `Xamarin.AndroidX.Activity[.Compose]` 1.13.0 (pin to resolve the transitive Activity graph) + +## Composition entry point — confirmed signature + +From `Xamarin.AndroidX.Compose.UI.Android` 1.11.2 (ikdasm): + +``` +ComposeView.SetContent(Kotlin.Jvm.Functions.IFunction2 content) + // JNI: setContent.(Lkotlin/jvm/functions/Function2;)V +``` + +So C# drives Compose by implementing a `Kotlin.Jvm.Functions.IFunction2` +(the composable lambda `(Composer, Integer) -> Unit`) and passing it to +`SetContent`. The lambda's `Invoke(composer, changed)` re-enters our emit code, +threading the `composer` into each composable call. + +## Mechanism — fully reverse-engineered (risk retired) + +The composables are **not** callable binding methods (the .NET-for-Android binder +drops `@Composable` functions — they have no `$default` sibling overload and a +mangled JVM name from Kotlin value-class params). They must be invoked by **raw +JNI**. The complete, proven pattern (from Peppers' `ComposeBridgeGenerator`): + +**1. Invoke a composable** — cached `FindClass` + `GetStaticMethodID` + `CallStaticVoidMethod`: +``` +s_Button_class = FindClass("androidx/compose/material3/ButtonKt") // cached global ref +mid = GetStaticMethodID(s_Button_class, "Button", signature) // JVM sig incl. value classes +int defaults = (int)ButtonDefault.All; // $default bitmask: bit set = use Kotlin default +if (modifier is not null) defaults &= ~(int)ButtonDefault.Modifier; // clear bit for each arg we pass +// marshal args + composer.Handle + $changed(0) + defaults into JValue* +CallStaticVoidMethod(s_Button_class, mid, args); +GC.KeepAlive(onClick); GC.KeepAlive(content); GC.KeepAlive(composer); // across the JNI call +``` +- The JVM **signature** ends `…Landroidx/compose/runtime/Composer;II)V` (or `;III)V` + when params > ~10 → `$changed` splits across multiple ints). Material3 `Text`'s + name is mangled `Text--4IGK_g` and packs `Color`/`TextUnit` value classes as `J` (long). +- `$default` enum per composable (`[ComposeDefaults("ButtonDefault","!onClick","modifier",…,"!content")]`): + `!`-prefixed = required (no default bit). Provide an arg → clear its bit. + +**2. SetContent entry**: +``` +view.SetContent(ComposableLambdaKt.ComposableLambdaInstance(key:-1, tracked:…, block: )) +// block.Invoke(composer, changed) -> content(composer).Render(composer) +``` + +**3. Kotlin function params** (onClick, content, the SetContent block) are +`[Register("net/compose/…")]`'d `Java.Lang.Object` ACWs implementing the bound +`Kotlin.Jvm.Functions.IFunction0/2/3`, so Compose's bytecode-typed lambda slots +accept them. `Invoke()` returns `Kotlin.Unit.Instance`. + +**4. The node model maps 1:1 onto Comet's protocol.** Peppers' `ComposableNode.Render(IComposer)` +is the moral equivalent of `ICometBackendNode`: a passive AST whose `Render` does the +JNI invocation, threading the composer to children via containers. Comet's +`ComposeXxxNode` will hold its `MutableState`s and read them in `Render` so a +`setValue` recomposes the narrowest scope (the steady-state-JNI-is-`setValue` goal). + +## ⚠️ Strategic finding — build-vs-reuse of the bridge layer + +Building Comet's **own** facade (per the plan) means reproducing **all of the +above** for ~65 composables: a multi-file Roslyn generator +(`ComposeBridgeGenerator` + `ComposeFacadeGenerator` + `ComposeDefaultsGenerator` ++ `ComposeCompanionGenerator`) emitting ~4,000 lines of raw-JNI bridges from +`[ComposeBridge]`/`[ComposeDefaults]` metadata, including value-class ABI +mangling and `$changed`/`$default` bit math. That is the bulk of Peppers' +~8,000-line library — and it is **commodity Compose-ABI plumbing with zero Comet +differentiation**. Comet's value-add is the reactive MVU core, the +`ICometBackendNode` protocol, Yoga layout, and typed storage — none of which this +plumbing touches. + +**Recommendation to revisit with David:** the "build our own facade, Peppers as +reference only" decision was made before the bridge layer's true shape was known. +Options, fastest-to-most-control: +1. **Vendor/fork Peppers' `*.SourceGenerators` + bridges as the JNI layer**, build + Comet's `ICometBackendNode` Compose backend on top (own the node model, reuse the + ABI plumbing). Keeps full control of everything that differentiates Comet. +2. **Reference his NuGet** as a dependency (least control, fastest). +3. **Build our own generator** from this blueprint (max control, ~weeks of ABI work, + no differentiation gained). + +Open environment question for any option: Peppers targets `net10.0-android`; +confirm his composition renders under **.NET 11 preview 5** on the Pixel 5 +(binding rebuild may be required). + +## Decision & status (2026-06-12) + +**Decided: vendor/fork Peppers' facade** as the bridge layer (this folder). +✅ Vendored `Microsoft.AndroidX.Compose` (facade + JNI-bridge generator, 309 files) +**builds clean under .NET 11 preview 5** — net10→net11 bump + inline version pins +(StdLib 2.4.0 for Core 1.19.0). The whole C#→Compose plumbing is now in-tree. + +## Next: the retained↔composition backend bridge (Comet's value-add) + +Comet's diff drives **imperative retained mutations** (`ICometBackendNode`: +ApplyProperty/InsertChild/…); the vendored facade is **declarative +rebuild-per-composition** (`ComposableNode.Render(IComposer)`). Bridge them in +`src/Comet/src/Comet/Platform/Compose/` (android TFM), referencing the vendored +facade: + +- `ComposeNode : ICometBackendNode` base. Holds children in a vendored + `MutableStateList` so structural diffs (InsertChild/RemoveChildAt/MoveChild) + recompose the container. Implements `Render(IComposer)` (abstract). +- Per-control nodes (`ComposeTextNode`, `ComposeButtonNode`, `ComposeColumnNode`…): + each property is a vendored `IMutableState` from + `SnapshotStateKt.MutableStateOf(value, StructuralEqualityPolicy())`. + - `ApplyProperty(id, value)` writes the matching state's `.Value` — the single + steady-state JNI call (`setValue`). + - `Render(composer)` builds the vendored composable (`new AndroidX.Compose.Text( + (string)_text.Value)`) reading the states, so Compose tracks the read and + recomposes only that scope on the next `setValue`. +- `ComposeBackendRoot`: owns the `ComposeView`, `SetContent(c => rootNode)`, set as + activity content. `CreateBackendNode` overrides on Comet's control partials + (`Text.Compose.g.cs` …) return the matching `ComposeNode` — the only reference, so + unused controls + nodes trim away. + +Then: wire `Comet.csproj` → conditional ProjectReference to the vendored facade for +`net11.0-android`; render the mini Todo on the Pixel 5 (Phase 1 gate); A/B vs the +`RESULTS.md` baseline. + +Escape hatch (still open) if the retained↔composition bridge proves impractical: a +tiny Kotlin AAR holding only the composition skeleton (state holders + a node-kind +`when`), with C# owning all logic. The `ICometBackendNode` protocol is unchanged. + +## Files +- `Comet.Compose.Facade.csproj` — package set + TFM (net11.0-android, minSdk 24) +- `InteropProbe.cs` — compiles the reachability checks above (de-risk signal) diff --git a/src/Comet/src/vendor/VENDORED.md b/src/Comet/src/vendor/VENDORED.md new file mode 100644 index 000000000..af2f1c970 --- /dev/null +++ b/src/Comet/src/vendor/VENDORED.md @@ -0,0 +1,31 @@ +# Vendored: Microsoft.AndroidX.Compose + +These two projects are vendored (forked) from **jonathanpeppers/Microsoft.AndroidX.Compose**, +MIT licensed (see `LICENSE.Microsoft.AndroidX.Compose`). + +- Source: https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose +- Commit: `29017064e3f3775d808d0b301ca994ac9eea7e9e` +- Vendored: 2026-06-12 + +## Why vendored + +Comet-Next renders Android via Jetpack Compose. The C#→Compose **bridge layer** +(raw-JNI invocation of `@Composable` functions, the `$changed`/`$default` bitmask +ABI, value-class mangling, and the Roslyn generator that emits it) is ~33,000 LOC +of commodity Compose-ABI plumbing with zero Comet-specific differentiation. Per +David's decision (2026-06-12), we vendor this proven layer rather than re-derive +it, and build Comet's value-add — the reactive MVU core, the `ICometBackendNode` +protocol, Yoga layout, and typed storage — on top. + +`Microsoft.AndroidX.Compose` provides the `AndroidX.Compose.*` C# facade +(`ComposableNode`, `Text`, `Column`, `Button`, `MutableState`, `SetContent`, …). +`Microsoft.AndroidX.Compose.SourceGenerators` emits the raw-JNI bridge bodies. + +## Local modifications + +- Facade `TargetFramework` bumped `net10.0-android` → `net11.0-android`. +- Compose package versions pinned in-csproj (Comet's repo has central package + management disabled), mirroring the upstream `Directory.Build.targets` pins. +- Dropped the unused `Microsoft.AndroidX.Compose.Maui` MAUI-backend coupling. + +Keep this attribution and the MIT `LICENSE.*` file when updating. diff --git a/src/Comet/templates/LEGACY-TEMPLATE.md b/src/Comet/templates/LEGACY-TEMPLATE.md new file mode 100644 index 000000000..40573fef9 --- /dev/null +++ b/src/Comet/templates/LEGACY-TEMPLATE.md @@ -0,0 +1,18 @@ +# Comet templates + +## Use the node-backend template + +**`single-project-node/`** (shortName **`cometnode`**) is the current template — a +node-backend single-project app (Jetpack Compose on Android, SwiftUI on iOS, no +MAUI in the render path). See `single-project-node/README.md`. + +## `single-project/` is parked (legacy) + +The old `single-project` template scaffolds the **legacy MAUI-hosted Comet app** +model (`public class App : CometApp` + `builder.UseCometApp()`). Phase 5 +(2026-07-01) **deleted that render path** — `CometApp`, `UseCometApp`, the MAUI +ViewHandlers, and the native platform views are gone (see +`../sample/LEGACY-SAMPLES.md`). So `dotnet new` from the legacy template produces a +project that does **not** compile against current Comet; check out tag +**`comet-pre-phase5-delete`** to use it. Do not publish the legacy pack as-is — +use `single-project-node/` instead. diff --git a/src/Comet/templates/single-project-node/.template.config/template.json b/src/Comet/templates/single-project-node/.template.config/template.json new file mode 100644 index 000000000..5232329a0 --- /dev/null +++ b/src/Comet/templates/single-project-node/.template.config/template.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Microsoft", + "classifications": [ "Comet", "Android", "iOS", "Compose", "SwiftUI" ], + "identity": "Microsoft.Maui.Comet.NodeApp", + "groupIdentity": "Microsoft.Maui.Comet.Node", + "name": "Comet Node-Backend App", + "description": "A single-project Comet app rendered natively by the node backend (Jetpack Compose on Android, SwiftUI on iOS) — no MAUI in the render path.", + "shortName": "cometnode", + "tags": { + "language": "C#", + "type": "project" + }, + "sourceName": "CometNodeApp1", + "preferNameDirectory": true, + "guids": [], + "sources": [ + { + "modifiers": [ + { "exclude": [ "README.md" ] } + ] + } + ], + "symbols": { + "applicationId": { + "type": "parameter", + "description": "Overrides the app id ($(ApplicationId) on Android, CFBundleIdentifier on iOS).", + "datatype": "string", + "replaces": "com.companyname.cometnodeapp1" + }, + "cometPackageVersion": { + "type": "parameter", + "description": "Version of the Comet package to reference (restored from the local feed in nuget.config).", + "datatype": "string", + "defaultValue": "0.5.1-local", + "replaces": "COMET_PACKAGE_VERSION" + } + }, + "defaultName": "CometNodeApp1" + } diff --git a/src/Comet/templates/single-project-node/App.cs b/src/Comet/templates/single-project-node/App.cs new file mode 100644 index 000000000..588798680 --- /dev/null +++ b/src/Comet/templates/single-project-node/App.cs @@ -0,0 +1,35 @@ +using Comet; +using Comet.Reactive; +using Microsoft.Maui; +using Microsoft.Maui.Graphics; + +namespace CometNodeApp1; + +/// +/// The shared Comet view tree. It is rendered NATIVELY by Comet's node backend — +/// Jetpack Compose on Android, SwiftUI on iOS — with no MAUI handlers in the render +/// path. The same tree drives both platforms. +/// +public static class App +{ + static readonly Signal Count = new(0); + + /// Dp of top padding to clear the platform's status bar / notch. + /// The node backend does not apply safe-area insets automatically yet, so each platform + /// head passes its own (Android status bar ~24, iOS notch / Dynamic Island ~50). + public static View Build(double topInset = 0) => new VStack(spacing: 16f) + { + new Text("Hello from Comet 👋").FontSize(28).Color(Colors.White), + new Text(() => $"Count: {Count.Value}").FontSize(20).Color(Colors.White), + new Button("Increment", () => Count.Value++), + } + .Background(Color.FromArgb("#6750A4")) + .Padding(new Thickness(24, topInset + 24, 24, 24)); + + /// A backend needs an (its DI container). + /// This starter app has no registered services. + public sealed class Services : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } +} diff --git a/src/Comet/templates/single-project-node/CometNodeApp1.csproj b/src/Comet/templates/single-project-node/CometNodeApp1.csproj new file mode 100644 index 000000000..4dcad77d1 --- /dev/null +++ b/src/Comet/templates/single-project-node/CometNodeApp1.csproj @@ -0,0 +1,53 @@ + + + + + net11.0-android;net11.0-ios + Exe + enable + + disable + CometNodeApp1 + + com.companyname.cometnodeapp1 + CometNodeApp1 + 1 + 1.0 + + 17.0 + 24 + + + + + + + + + + + + + + + + + + + + + Platforms\Android\AndroidManifest.xml + + + diff --git a/src/Comet/templates/single-project-node/Info.plist b/src/Comet/templates/single-project-node/Info.plist new file mode 100644 index 000000000..50ad59ba7 --- /dev/null +++ b/src/Comet/templates/single-project-node/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDisplayName + CometNodeApp1 + CFBundleIdentifier + com.companyname.cometnodeapp1 + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + UILaunchStoryboardName + LaunchScreen + MinimumOSVersion + 17.0 + + diff --git a/src/Comet/templates/single-project-node/LaunchScreen.storyboard b/src/Comet/templates/single-project-node/LaunchScreen.storyboard new file mode 100644 index 000000000..e27e4e8e8 --- /dev/null +++ b/src/Comet/templates/single-project-node/LaunchScreen.storyboard @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Comet/templates/single-project-node/Platforms/Android/AndroidManifest.xml b/src/Comet/templates/single-project-node/Platforms/Android/AndroidManifest.xml new file mode 100644 index 000000000..5cdd366f8 --- /dev/null +++ b/src/Comet/templates/single-project-node/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Comet/templates/single-project-node/Platforms/Android/MainActivity.cs b/src/Comet/templates/single-project-node/Platforms/Android/MainActivity.cs new file mode 100644 index 000000000..c80c9632e --- /dev/null +++ b/src/Comet/templates/single-project-node/Platforms/Android/MainActivity.cs @@ -0,0 +1,34 @@ +using Android.App; +using Android.OS; +using Comet; +using Comet.Platform.Compose; + +namespace CometNodeApp1; + +[Activity(Label = "CometNodeApp1", MainLauncher = true)] +public class MainActivity : AndroidX.Activity.ComponentActivity +{ + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // Comet's fluent env writes marshal through ThreadHelper; run them on the UI thread. + ThreadHelper.SetFireOnMainThread(a => RunOnUiThread(a)); + + var backend = new ComposeBackendRoot(new App.Services()) + { + UseYogaLayout = true, + // Material 3 components (Button, ripples) read their colors from a MaterialTheme + // ancestor — wrap the composed content in one (default scheme here). + WrapContent = content => + { + var theme = new AndroidX.Compose.MaterialTheme(); + theme.Add(content); + return theme; + }, + }; + + // topInset clears the status bar (the node backend doesn't apply safe-area insets yet). + SetContentView(backend.CreateView(this, App.Build(topInset: 24))); + } +} diff --git a/src/Comet/templates/single-project-node/Platforms/iOS/AppDelegate.cs b/src/Comet/templates/single-project-node/Platforms/iOS/AppDelegate.cs new file mode 100644 index 000000000..fc0936098 --- /dev/null +++ b/src/Comet/templates/single-project-node/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,28 @@ +using Comet; +using Comet.Platform.SwiftUI; +using Foundation; +using UIKit; + +namespace CometNodeApp1; + +[Register("AppDelegate")] +public class AppDelegate : UIApplicationDelegate +{ + public override UIWindow? Window { get; set; } + + public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions) + { + // Comet's fluent env writes marshal through ThreadHelper; we're on the main thread. + ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + Window = new UIWindow(UIScreen.MainScreen.Bounds); + + // topInset clears the notch / Dynamic Island (the node backend doesn't apply + // safe-area insets yet). + var backend = new SwiftUIBackendRoot(new App.Services()) { UseYogaLayout = true }; + Window.RootViewController = backend.CreateController(App.Build(topInset: 50)); + + Window.MakeKeyAndVisible(); + return true; + } +} diff --git a/src/Comet/templates/single-project-node/Platforms/iOS/Main.cs b/src/Comet/templates/single-project-node/Platforms/iOS/Main.cs new file mode 100644 index 000000000..5a09b3542 --- /dev/null +++ b/src/Comet/templates/single-project-node/Platforms/iOS/Main.cs @@ -0,0 +1,8 @@ +using UIKit; + +namespace CometNodeApp1; + +public class Application +{ + static void Main(string[] args) => UIApplication.Main(args, null, typeof(AppDelegate)); +} diff --git a/src/Comet/templates/single-project-node/README.md b/src/Comet/templates/single-project-node/README.md new file mode 100644 index 000000000..abeead786 --- /dev/null +++ b/src/Comet/templates/single-project-node/README.md @@ -0,0 +1,69 @@ +# Comet node-backend app template + +A `dotnet new` template for a single-project Comet app rendered natively by the +**node backend** — Jetpack Compose on Android, SwiftUI on iOS — with **no MAUI in +the render path**. One shared view tree (`App.cs`) is hosted by a thin native head +per platform (`Platforms/Android/MainActivity.cs`, `Platforms/iOS/AppDelegate.cs`). + +This is the node-backend successor to the legacy `single-project/` template (which +scaffolded the deleted `UseCometApp` MAUI-host model — see `LEGACY-TEMPLATE.md`). + +## Reference model — the Comet package (local feed) + +Comet's node backend is published to a **local NuGet feed** (not yet on nuget.org). +The generated app references the `Comet` package, which pulls in everything else +transitively: + +- `Comet.Layout.Yoga` (the C# flexbox engine) +- `Microsoft.AndroidX.Compose` (the Jetpack Compose facade) on Android, plus its + ~35 Xamarin.AndroidX.Compose.* dependencies +- `Comet.SwiftUI.Binding` + the `CometSwiftUIShim.xcframework` on iOS, and a + `build/Comet.targets` that adds the `SmartLink=False` `NativeReference` the iOS + static registrar needs (so `_OBJC_CLASS_$_CometSwiftUIHost` resolves at link time) + +The generated `nuget.config` points at the local feed. Edit it if your +`Comet.*.nupkg` files live elsewhere. Produce/refresh the packages with: + +``` +# Comet.Layout.Yoga, the Compose facade, the SwiftUI binding +dotnet pack src/Comet/src/Comet.Layout.Yoga/Comet.Layout.Yoga.csproj -c Release -p:Version=0.5.1-local -p:IsPackable=true -o ~/work/LocalNugets +dotnet pack src/Comet/src/vendor/Microsoft.AndroidX.Compose/Microsoft.AndroidX.Compose.csproj -c Release -p:Version=0.5.1-local -p:IsPackable=true -o ~/work/LocalNugets +dotnet pack src/Comet/src/Comet.SwiftUI.Binding/Comet.SwiftUI.Binding.csproj -c Release -p:Version=0.5.1-local -p:IsPackable=true -o ~/work/LocalNugets +# Comet itself (depends on the three above; ships the iOS shim + build targets) +dotnet pack src/Comet/src/Comet/Comet.csproj -c Release -p:Version=0.5.1-local -o ~/work/LocalNugets +``` + +The SwiftUI shim xcframework must be built once (and after any shim change) before +packing Comet: + +``` +./src/Comet/src/Comet.SwiftUI.Shim/build-xcframework.sh +``` + +## Use it + +``` +# from repo root +dotnet new install src/Comet/templates/single-project-node + +dotnet new cometnode -n MyApp +cd MyApp + +# Android +dotnet build MyApp.csproj -f net11.0-android -t:Run + +# iOS (simulator) +dotnet build MyApp.csproj -f net11.0-ios -c Debug -p:RuntimeIdentifier=iossimulator-arm64 +``` + +Options: `--applicationId ` (app id / bundle id), +`--cometPackageVersion ` (which Comet package version to reference). + +## Developing against Comet source instead of the package + +For live-source iteration inside the maui-labs repo, replace the +`` with a `ProjectReference` to +`src/Comet/src/Comet/Comet.csproj`, and add a `` to +`src/Comet/src/Comet.SwiftUI.Shim/CometSwiftUIShim.xcframework` +(`Kind=Framework`, `SmartLink=False`) for the iOS head — that is the setup the +`CometComposeProbe` / `CometSwiftUIProbe` samples use. diff --git a/src/Comet/templates/single-project-node/nuget.config b/src/Comet/templates/single-project-node/nuget.config new file mode 100644 index 000000000..5751c925d --- /dev/null +++ b/src/Comet/templates/single-project-node/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/src/Comet/templates/single-project/CometApp1/App.cs b/src/Comet/templates/single-project/CometApp1/App.cs index 9df03ebe6..db547139a 100644 --- a/src/Comet/templates/single-project/CometApp1/App.cs +++ b/src/Comet/templates/single-project/CometApp1/App.cs @@ -1,3 +1,6 @@ +// LEGACY (parked): this scaffolds the pre-Phase-5 MAUI-hosted Comet model +// (App : CometApp + UseCometApp), which no longer exists in current Comet and +// will NOT compile. See ../../LEGACY-TEMPLATE.md. Use tag comet-pre-phase5-delete. using Microsoft.Maui.Hosting; namespace CometApp1; diff --git a/src/Comet/tests/Comet.Benchmarks/BackendMutationBenchmarks.cs b/src/Comet/tests/Comet.Benchmarks/BackendMutationBenchmarks.cs new file mode 100644 index 000000000..7f78b4a25 --- /dev/null +++ b/src/Comet/tests/Comet.Benchmarks/BackendMutationBenchmarks.cs @@ -0,0 +1,119 @@ +using System; +using BenchmarkDotNet.Attributes; +using Comet.Backend; +using Comet.Reactive; +using Microsoft.Maui.Graphics; + +namespace Comet.Benchmarks; + +/// +/// Node-backend patch-stream benchmarks (the plan's Phase-0/4 verification item): +/// measures the steady-state cost and allocations of the diff→backend contract — +/// materialization, reactive property patches, and reload-driven node transfer — +/// against a recording no-op node, so the numbers are pure Comet-side overhead +/// (no Compose/SwiftUI interop in the loop). +/// +[MemoryDiagnoser] +[ShortRunJob] +public class BackendMutationBenchmarks +{ + /// Minimal ICometBackendNode: counts patches, allocates nothing per call. + sealed class NullBackendNode : ICometBackendNode + { + public int ApplyCount; + public void ApplyProperty(PropertyId id, in PropertyValue value) => ApplyCount++; + public void InsertChild(int index, ICometBackendNode child) { } + public void RemoveChildAt(int index) { } + public void MoveChild(int fromIndex, int toIndex) { } + public Size Measure(double widthConstraint, double heightConstraint) => Size.Zero; + public void Arrange(Rect frame) { } + public void SetEventSink(ICometEventSink sink) { } + public void Dispose() { } + } + + sealed class NullServiceProvider : IServiceProvider + { + public object GetService(Type serviceType) => null; + } + + class LabelHostView : View + { + public string Label = "before"; + + [Body] + View body() => new VStack + { + new Text(Label), + new Text("static one"), + new Text("static two"), + new Button("Tap", () => { }), + }; + } + + BackendContext _context; + Signal _counter; + View _boundRoot; + LabelHostView _reloadHost; + int _label; + + [GlobalSetup] + public void Setup() + { + BenchmarkUI.Init(); + ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + _context = new BackendContext(new NullServiceProvider()); + + // Reactive-patch scenario: one bound Text among static siblings. + _counter = new Signal(0); + _boundRoot = new VStack + { + new Text(() => $"Count: {_counter.Value}"), + new Text("static one"), + new Text("static two"), + }; + CometBackendBridge.Materialize(_boundRoot, _ => new NullBackendNode(), _context); + + // Reload scenario: a [Body] host whose rebuild diffs onto retained nodes. + _reloadHost = new LabelHostView(); + CometBackendBridge.Materialize(_reloadHost, _ => new NullBackendNode(), _context); + } + + // ---------------------------------------------------------------- + // Steady state: a Signal change flushing one property patch to the + // retained node (the per-frame hot loop of the reactive UI). + // ---------------------------------------------------------------- + [Benchmark] + public void SignalChange_PatchesNode() + { + _counter.Value++; + ReactiveScheduler.FlushSync(); + } + + // ---------------------------------------------------------------- + // Materialize a small (5-node) tree: view→node bridge cost incl. + // set-only property emission. + // ---------------------------------------------------------------- + [Benchmark] + public object Materialize_SmallTree() + { + var root = new VStack + { + new Text("one"), + new Text("two"), + new Button("Tap", () => { }), + new HStack { new Text("nested") }, + }; + return CometBackendBridge.Materialize(root, _ => new NullBackendNode(), _context); + } + + // ---------------------------------------------------------------- + // Reload: rebuild the [Body] tree, diff against the old one, and + // transfer the retained nodes (the hot-reload / Reload() path). + // ---------------------------------------------------------------- + [Benchmark] + public void Reload_DiffsOntoRetainedNodes() + { + _reloadHost.Label = (++_label & 1) == 0 ? "even" : "odd"; + _reloadHost.Reload(); + } +} diff --git a/src/Comet/tests/Comet.Benchmarks/BenchmarkUI.cs b/src/Comet/tests/Comet.Benchmarks/BenchmarkUI.cs index 8109c15d6..f4b159c6d 100644 --- a/src/Comet/tests/Comet.Benchmarks/BenchmarkUI.cs +++ b/src/Comet/tests/Comet.Benchmarks/BenchmarkUI.cs @@ -49,8 +49,7 @@ public static void Init() MauiHotReloadHelper.IsEnabled = false; // Disable for benchmarks - var style = new Styles.Style(); - style.Apply(); + // Legacy Styles.Style was removed; ThemeManager lazily provides the default theme. } public static void InitializeHandlers(View view) diff --git a/src/Comet/tests/Comet.Benchmarks/DiffAlgorithmBenchmarks.cs b/src/Comet/tests/Comet.Benchmarks/DiffAlgorithmBenchmarks.cs index 5ece3d205..aa4d57c4a 100644 --- a/src/Comet/tests/Comet.Benchmarks/DiffAlgorithmBenchmarks.cs +++ b/src/Comet/tests/Comet.Benchmarks/DiffAlgorithmBenchmarks.cs @@ -1,3 +1,4 @@ +using Comet.Reactive; using System; using System.Collections.Generic; using BenchmarkDotNet.Attributes; @@ -121,11 +122,11 @@ public void TriggerRebuild() public class GrowingListView : Comet.View { - readonly State _count; + readonly Signal _count; public GrowingListView(int initial) { - _count = new State(initial); + _count = new Signal(initial); Body = () => { var children = new Comet.View[_count.Value]; @@ -140,12 +141,12 @@ public GrowingListView(int initial) public class ShrinkingListView : Comet.View { - readonly State _count; + readonly Signal _count; int _removeIndex; public ShrinkingListView(int initial) { - _count = new State(initial); + _count = new Signal(initial); _removeIndex = initial / 2; Body = () => { @@ -164,7 +165,7 @@ public ShrinkingListView(int initial) public class ConditionalView : Comet.View { - readonly State _showSubtree = new State(false); + readonly Signal _showSubtree = new Signal(false); readonly int _subtreeSize; public ConditionalView(int subtreeSize) diff --git a/src/Comet/tests/Comet.Benchmarks/EdgeCaseBenchmarks.cs b/src/Comet/tests/Comet.Benchmarks/EdgeCaseBenchmarks.cs index 9fb7c5e3f..580b88dae 100644 --- a/src/Comet/tests/Comet.Benchmarks/EdgeCaseBenchmarks.cs +++ b/src/Comet/tests/Comet.Benchmarks/EdgeCaseBenchmarks.cs @@ -1,3 +1,4 @@ +using Comet.Reactive; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -51,7 +52,7 @@ public object MvuWideTree() // --- Many independent state fields --- - [Benchmark(Description = "MVU: View with N independent State fields")] + [Benchmark(Description = "MVU: View with N independent Signal fields")] public void MvuManyStates() { var view = new ManyStatesCometView(Size); @@ -163,13 +164,13 @@ public WideCometView(int count) public class ManyStatesCometView : Comet.View { - readonly State[] _states; + readonly Signal[] _states; public ManyStatesCometView(int count) { - _states = new State[count]; + _states = new Signal[count]; for (int i = 0; i < count; i++) - _states[i] = new State($"Initial {i}"); + _states[i] = new Signal($"Initial {i}"); Body = () => { @@ -189,7 +190,7 @@ public void SetState(int index, string value) public class ListChurnCometView : Comet.View { - readonly State _generation = new State(0); + readonly Signal _generation = new Signal(0); readonly int _size; readonly List _items; @@ -222,7 +223,7 @@ public void ChurnOne(int newIndex) public class DeepConditionalView : Comet.View { - readonly State _show = new State(false); + readonly Signal _show = new Signal(false); readonly int _depth; public DeepConditionalView(int depth) @@ -254,8 +255,8 @@ Comet.View BuildDeep(int level) public class HiddenStateCometView : Comet.View { - readonly State _showMain = new State(true); - public readonly State _hiddenCounter = new State(0); + readonly Signal _showMain = new Signal(true); + public readonly Signal _hiddenCounter = new Signal(0); public HiddenStateCometView(int size) { @@ -276,7 +277,7 @@ public HiddenStateCometView(int size) public class TypeChangingView : Comet.View { - readonly State _viewType = new State(0); + readonly Signal _viewType = new Signal(0); public TypeChangingView(int unused) { @@ -297,7 +298,7 @@ public TypeChangingView(int unused) _ => new VStack { new Text("Type C"), - new Comet.Slider(new Func(() => 0.5), 0.0, 1.0) + new Comet.Slider(() => 0.5, () => 0.0, () => 1.0) } }; }; diff --git a/src/Comet/tests/Comet.Benchmarks/Handlers/GenericViewHandler.cs b/src/Comet/tests/Comet.Benchmarks/Handlers/GenericViewHandler.cs index a1c4b479f..57643cc23 100644 --- a/src/Comet/tests/Comet.Benchmarks/Handlers/GenericViewHandler.cs +++ b/src/Comet/tests/Comet.Benchmarks/Handlers/GenericViewHandler.cs @@ -47,8 +47,11 @@ public Rect Frame public void UpdateValue(string property) { var val = CurrentView?.GetPropValue(property); - if (val is Binding b) - ChangedProperties[property] = b.Value; + // PropertySubscription replaced the old Binding type; unwrap via reflection + // since there is no non-generic value accessor. + var currentValueProp = val?.GetType().GetProperty("CurrentValue"); + if (currentValueProp != null) + ChangedProperties[property] = currentValueProp.GetValue(val); else ChangedProperties[property] = val; } diff --git a/src/Comet/tests/Comet.Benchmarks/MemoryBenchmarks.cs b/src/Comet/tests/Comet.Benchmarks/MemoryBenchmarks.cs index 9aa3d2cb9..fef401bd2 100644 --- a/src/Comet/tests/Comet.Benchmarks/MemoryBenchmarks.cs +++ b/src/Comet/tests/Comet.Benchmarks/MemoryBenchmarks.cs @@ -1,3 +1,4 @@ +using Comet.Reactive; using System; using System.ComponentModel; using System.Runtime.CompilerServices; @@ -131,7 +132,7 @@ void OnPropertyChanged([CallerMemberName] string? n = null) public class MemCounterCometView : Comet.View { - public readonly State _text = new State(""); + public readonly Signal _text = new Signal(""); public MemCounterCometView() { Body = () => new Text(() => _text.Value); @@ -141,7 +142,7 @@ public MemCounterCometView() public class LargeTreeCometView : Comet.View { readonly int _size; - public readonly State _trigger = new State(0); + public readonly Signal _trigger = new Signal(0); public LargeTreeCometView(int size) { @@ -194,8 +195,8 @@ void OnPropertyChanged([CallerMemberName] string? n = null) public class CascadeCometView : Comet.View { - public readonly State _firstName = new State(""); - public readonly State _lastName = new State(""); + public readonly Signal _firstName = new Signal(""); + public readonly Signal _lastName = new Signal(""); public CascadeCometView() { diff --git a/src/Comet/tests/Comet.Benchmarks/RapidUpdateBenchmarks.cs b/src/Comet/tests/Comet.Benchmarks/RapidUpdateBenchmarks.cs index 480ac5091..e9c2d5455 100644 --- a/src/Comet/tests/Comet.Benchmarks/RapidUpdateBenchmarks.cs +++ b/src/Comet/tests/Comet.Benchmarks/RapidUpdateBenchmarks.cs @@ -1,3 +1,4 @@ +using Comet.Reactive; using System; using System.ComponentModel; using System.Runtime.CompilerServices; @@ -85,12 +86,11 @@ public void MvuMultiPropAnimationBatched() { for (int i = 0; i < Iterations; i++) { - StateManager.BeginBatch(); _animView._x.Value = (float)(i * 0.1); _animView._y.Value = (float)(i * 0.2); _animView._opacity.Value = (float)((i % 100) / 100.0); _animView._scale.Value = (float)(1.0 + (i % 50) * 0.01); - StateManager.EndBatch(); + ReactiveScheduler.FlushSync(); } } @@ -115,7 +115,7 @@ public void MvuStringUpdates() public class RapidCounterCometView : Comet.View { - public readonly State _value = new State(0); + public readonly Signal _value = new Signal(0); public RapidCounterCometView() { Body = () => new Text(() => $"Value: {_value.Value}"); @@ -124,10 +124,10 @@ public RapidCounterCometView() public class AnimationCometView : Comet.View { - public readonly State _x = new State(0); - public readonly State _y = new State(0); - public readonly State _opacity = new State(1); - public readonly State _scale = new State(1); + public readonly Signal _x = new Signal(0); + public readonly Signal _y = new Signal(0); + public readonly Signal _opacity = new Signal(1); + public readonly Signal _scale = new Signal(1); public AnimationCometView() { @@ -137,7 +137,7 @@ public AnimationCometView() public class StringHeavyCometView : Comet.View { - public readonly State _content = new State(""); + public readonly Signal _content = new Signal(""); public StringHeavyCometView() { Body = () => new Text(() => _content.Value); diff --git a/src/Comet/tests/Comet.Benchmarks/RealWorldBenchmarks.cs b/src/Comet/tests/Comet.Benchmarks/RealWorldBenchmarks.cs index bae80d922..72e580fba 100644 --- a/src/Comet/tests/Comet.Benchmarks/RealWorldBenchmarks.cs +++ b/src/Comet/tests/Comet.Benchmarks/RealWorldBenchmarks.cs @@ -1,3 +1,4 @@ +using Comet.Reactive; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -152,16 +153,16 @@ void OnPropertyChanged([CallerMemberName] string? n = null) public class TodoCometView : Comet.View { - readonly State[] _done; + readonly Signal[] _done; readonly string[] _titles; public TodoCometView(int count) { - _done = new State[count]; + _done = new Signal[count]; _titles = new string[count]; for (int i = 0; i < count; i++) { - _done[i] = new State(i % 3 == 0); + _done[i] = new Signal(i % 3 == 0); _titles[i] = $"Task {i}"; } @@ -214,17 +215,17 @@ public FormVM(int count) public class FormCometView : Comet.View { - readonly State[] _values; - readonly State[] _errors; + readonly Signal[] _values; + readonly Signal[] _errors; public FormCometView(int count) { - _values = new State[count]; - _errors = new State[count]; + _values = new Signal[count]; + _errors = new Signal[count]; for (int i = 0; i < count; i++) { - _values[i] = new State(""); - _errors[i] = new State(""); + _values[i] = new Signal(""); + _errors[i] = new Signal(""); } Body = () => @@ -233,7 +234,7 @@ public FormCometView(int count) for (int i = 0; i < _values.Length; i++) { var idx = i; - children.Add(new TextField((Func)(() => _values[idx].Value), $"Field {idx}")); + children.Add(new TextField(() => _values[idx].Value, () => $"Field {idx}")); if (!string.IsNullOrEmpty(_errors[idx].Value)) children.Add(new Text(() => _errors[idx].Value)); } diff --git a/src/Comet/tests/Comet.Benchmarks/StateChangeBenchmarks.cs b/src/Comet/tests/Comet.Benchmarks/StateChangeBenchmarks.cs index 230947934..ccee87585 100644 --- a/src/Comet/tests/Comet.Benchmarks/StateChangeBenchmarks.cs +++ b/src/Comet/tests/Comet.Benchmarks/StateChangeBenchmarks.cs @@ -1,3 +1,4 @@ +using Comet.Reactive; using System; using System.ComponentModel; using System.Runtime.CompilerServices; @@ -130,7 +131,7 @@ public void MvuSelectiveUpdate() public class SingleStateCometView : Comet.View { - public readonly State _counter = new State(0); + public readonly Signal _counter = new Signal(0); public SingleStateCometView() { @@ -140,13 +141,13 @@ public SingleStateCometView() public class MultiStateCometView : Comet.View { - readonly State[] _states; + readonly Signal[] _states; public MultiStateCometView(int count) { - _states = new State[count]; + _states = new Signal[count]; for (int i = 0; i < count; i++) - _states[i] = new State($"Value {i}"); + _states[i] = new Signal($"Value {i}"); Body = () => { diff --git a/src/Comet/tests/Comet.Benchmarks/ViewConstructionBenchmarks.cs b/src/Comet/tests/Comet.Benchmarks/ViewConstructionBenchmarks.cs index a0aae7986..5fad5f036 100644 --- a/src/Comet/tests/Comet.Benchmarks/ViewConstructionBenchmarks.cs +++ b/src/Comet/tests/Comet.Benchmarks/ViewConstructionBenchmarks.cs @@ -177,7 +177,7 @@ Comet.View BuildBody() 1 => new TextField("", $"Entry {i}"), 2 => new Comet.Button($"Button {i}", null), 3 => new Toggle(false), - 4 => new Comet.Slider(new System.Func(() => 50.0), 0.0, 100.0), + 4 => new Comet.Slider(() => 50.0, () => 0.0, () => 100.0), _ => new Text("?") }; } diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendBodyMetricsTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendBodyMetricsTests.cs new file mode 100644 index 000000000..6de366645 --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendBodyMetricsTests.cs @@ -0,0 +1,90 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Comet.Reactive; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Locks BODY-level window-metrics tracking — the adaptive NavigationSuite pattern reads + /// WidthClass inside a [Body] and swaps chrome STRUCTURE (VStack+bottom bar vs HStack+rail). + /// Binding-level tracking was locked in BackendWindowMetricsTests; this locks the body + /// re-build path (View.GetRenderViewReactive dependencies → MarkViewDirty → Reload → Diff). + /// + public class BackendBodyMetricsTests + { + static BackendBodyMetricsTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + static FakeBackendNode Bridge(View root) + => (FakeBackendNode)CometBackendBridge.Materialize( + root, v => new FakeBackendNode(v.GetType().Name), Ctx); + + sealed class AdaptiveRoot : View + { + readonly CometWindowMetrics _metrics; + public AdaptiveRoot(CometWindowMetrics metrics) => _metrics = metrics; + + [Body] + View body() => _metrics.WidthClass == WindowWidthClass.Compact + ? new VStack { new Text("content"), new Text("bottombar") } + : new HStack { new Text("rail"), new Text("content") }; + } + + [Fact(Skip = "KNOWN GAP (documented reproduction): the node backend does not propagate a " + + "body-level container-TYPE swap — DiffUpdate returns the new view on type mismatch " + + "(DatabindingExtensions.cs) and no node patches flow, so the old node tree keeps " + + "rendering. View-level swap works (first half of this test); the node assert fails. " + + "Adaptive chrome therefore ships as own-content nodes (NavigationSuite/ListDetail, " + + "the SelectorPanel/Drawer idiom) instead of body-level composition. Un-skip when " + + "generic subtree replacement lands.")] + public void BodyReadingWidthClass_SwapsStructure_OnBreakpointCross() + { + var metrics = new CometWindowMetrics(); + metrics.Update(new Microsoft.Maui.Graphics.Size(400, 800)); // Compact + + var root = new AdaptiveRoot(metrics); + var node = Bridge(root); + Assert.Equal("VStack", node.Kind == "AdaptiveRoot" ? node.Children[0].Kind : node.Kind); + + metrics.Update(new Microsoft.Maui.Graphics.Size(700, 952)); // Medium — rail chrome + ReactiveScheduler.FlushSync(); + + // The built structure must now be the HStack variant. Walk from the materialized + // root: either the root node re-pointed or its child was replaced. + var built = root.BuiltView; + Assert.NotNull(built); + Assert.IsType(built); + + // And the BACKEND tree must reflect it — the backend root keeps rendering the node + // it materialized, so the swap has to arrive as patches on that same node tree + // (this is exactly what the device exercises via ComposeView.SetContent). + Assert.Contains("rail", AllTexts(node)); + Assert.DoesNotContain("bottombar", AllTexts(node)); + } + + static System.Collections.Generic.List AllTexts(FakeBackendNode node) + { + var texts = new System.Collections.Generic.List(); + Collect(node, texts); + return texts; + + static void Collect(FakeBackendNode n, System.Collections.Generic.List acc) + { + var v = n.Get(PropertyIds.Text_Value); + if (v.AsString is { Length: > 0 } s) + acc.Add(s); + foreach (var c in n.Children) + Collect(c, acc); + } + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendBridgeTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendBridgeTests.cs new file mode 100644 index 000000000..21b198e53 --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendBridgeTests.cs @@ -0,0 +1,127 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Proves the View→backend-node materialization end-to-end, host-side: a real Comet + /// view tree becomes an ICometBackendNode tree with the right structure and only the + /// properties the views actually set. Node creation uses a recording fake; property + /// emission exercises the production ApplyAllSetProperties path. + /// + public class BackendBridgeTests + { + static BackendBridgeTests() + { + // Fluent setters post env writes through ThreadHelper; in a host test there is + // no platform main thread, so run inline. No MAUI handlers are registered — + // materialization must work without them. + ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + } + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + static FakeBackendNode Bridge(View root) + => (FakeBackendNode)CometBackendBridge.Materialize( + root, v => new FakeBackendNode(v.GetType().Name), Ctx); + + [Fact] + public void Materialize_BuildsContainerWithChildren() + { + var root = new VStack + { + new Text("Hello"), + new Button("Tap", () => { }), + }; + + var node = Bridge(root); + + Assert.Equal("VStack", node.Kind); + Assert.Equal(2, node.Children.Count); + Assert.Equal("Text", node.Children[0].Kind); + Assert.Equal("Button", node.Children[1].Kind); + } + + [Fact] + public void Materialize_EmitsTextValue() + { + var node = Bridge(new VStack { new Text("Hello") }); + var textNode = node.Children[0]; + Assert.Equal("Hello", textNode.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void Materialize_EmitsButtonText() + { + var node = Bridge(new VStack { new Button("Tap", () => { }) }); + Assert.Equal("Tap", node.Children[0].Get(PropertyIds.Button_Text).AsString); + } + + [Fact] + public void Materialize_DoesNotEmitDefaultCommonProperties() + { + // A plain Text with no opacity/transform set must not push those defaults. + var node = Bridge(new VStack { new Text("x") }); + var textNode = node.Children[0]; + + Assert.Equal(PropertyValueKind.None, textNode.Get(PropertyIds.Opacity).Kind); + Assert.Equal(PropertyValueKind.None, textNode.Get(PropertyIds.ScaleX).Kind); + Assert.Equal(PropertyValueKind.None, textNode.Get(PropertyIds.Rotation).Kind); + Assert.Equal(PropertyValueKind.None, textNode.Get(PropertyIds.IsVisible).Kind); + } + + [Fact] + public void Materialize_EmitsSetCommonPropertiesOnly() + { + var node = Bridge(new VStack { new Text("x").Opacity(0.5) }); + var textNode = node.Children[0]; + + Assert.Equal(0.5, textNode.Get(PropertyIds.Opacity).AsDouble, 10); + // Scale was never set → still absent. + Assert.Equal(PropertyValueKind.None, textNode.Get(PropertyIds.ScaleX).Kind); + } + + [Fact] + public void Materialize_EmitsTextColorWhenSet() + { + var node = Bridge(new VStack { new Text("x").Color(Colors.Red) }); + var textNode = node.Children[0]; + Assert.Equal(Colors.Red, textNode.Get(PropertyIds.Text_Color).AsColor); + } + + [Fact] + public void Materialize_EmitsBackgroundPaddingAndSpacing() + { + var node = Bridge(new VStack { new Text("x") }.Background(Colors.Blue).Padding(16)); + + Assert.Equal(Colors.Blue, node.Get(PropertyIds.BackgroundColor).AsColor); + Assert.IsType(node.Get(PropertyIds.Padding).AsObject); + // VStack carries a default spacing, emitted for the Compose Column arrangement. + Assert.NotEqual(PropertyValueKind.None, node.Get(PropertyIds.Stack_Spacing).Kind); + } + + [Fact] + public void Materialize_ResolvesComponentBodyToConcreteTree() + { + var node = Bridge(new BodyComponent()); + // BodyComponent renders to a VStack { Text } — the component itself is transparent. + Assert.Equal("VStack", node.Kind); + Assert.Single(node.Children); + Assert.Equal("Text", node.Children[0].Kind); + Assert.Equal("from-body", node.Children[0].Get(PropertyIds.Text_Value).AsString); + } + + sealed class BodyComponent : View + { + public BodyComponent() => Body = () => new VStack { new Text("from-body") }; + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendGestureTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendGestureTests.cs new file mode 100644 index 000000000..47375fc5e --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendGestureTests.cs @@ -0,0 +1,51 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Locks tap-gesture wiring: a view with a tap gesture emits the HasTapGesture flag + /// (so the backend applies a clickable modifier) and a tap routed back through the + /// sink invokes the Comet gesture handler. + /// + public class BackendGestureTests + { + static BackendGestureTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + [Fact] + public void TapGesture_EmitsFlagAndRoutesToHandler() + { + var taps = 0; + var row = new HStack { new Text("Row") }.OnTap(_ => taps++); + + var node = (FakeBackendNode)CometBackendBridge.Materialize( + row, v => new FakeBackendNode(v.GetType().Name), Ctx); + + Assert.True(node.Get(PropertyIds.HasTapGesture).AsBool); + + node.Sink!.OnGesture(GestureKind.Tap, new GestureData(GestureState.Ended, default)); + node.Sink!.OnGesture(GestureKind.Tap, new GestureData(GestureState.Ended, default)); + + Assert.Equal(2, taps); + } + + [Fact] + public void NoTapGesture_DoesNotEmitFlag() + { + var node = (FakeBackendNode)CometBackendBridge.Materialize( + new HStack { new Text("plain") }, v => new FakeBackendNode(v.GetType().Name), Ctx); + + Assert.Equal(PropertyValueKind.None, node.Get(PropertyIds.HasTapGesture).Kind); + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendHostedCompositionChurnTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendHostedCompositionChurnTests.cs new file mode 100644 index 000000000..4205ea46f --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendHostedCompositionChurnTests.cs @@ -0,0 +1,582 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Comet; +using Comet.Backend; +using Comet.Reactive; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Host-side reproduction harness for the iOS hosted-composition twins + /// (SwiftUIHostedCompositionNode and subclasses): fake own-content nodes that copy + /// the twins' Refresh/Relayout/Arrange semantics — rebuild wrapper views around PERSISTENT + /// slot views, re-materialize the subtree, re-layout — so the Reply compact detail-swap + /// crash (stack overflow / runaway during Layout(Detail) after + /// IsDetailOpen=true, with suite/switcher refresh churn) can be reproduced and + /// fixed off-device. A re-entrancy guard turns unbounded recursion into a readable + /// assertion failure instead of a process kill. + /// + public class BackendHostedCompositionChurnTests + { + static BackendHostedCompositionChurnTests() => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + static ICometBackendNode Factory(View v) => v switch + { + NavigationSuite suite => new FakeSuiteNode(suite, Factory, Ctx), + ContentSwitcher switcher => new FakeSwitcherNode(switcher, Factory, Ctx), + ListDetail listDetail => new FakeListDetailNode(listDetail, Factory, Ctx), + IListView list => new FakeListNode(list, Factory, Ctx), + _ => new FakeBackendNode(v.GetType().Name), + }; + + /// Mirror of SwiftUIListNode: owns its rows, materializes each row's template + /// view on List_Version patches, lays rows out to the arranged width. + sealed class FakeListNode : FakeBackendNode, IBackendManagesOwnContent + { + readonly IListView _list; + readonly CometNodeFactory _factory; + readonly BackendContext _context; + readonly List _rowViews = new(); + double _width; + + public FakeListNode(IListView list, CometNodeFactory factory, BackendContext context) + : base("list") + { + _list = list; + _factory = factory; + _context = context; + ListInstances.Add(this); + } + + public override void ApplyProperty(PropertyId id, in PropertyValue value) + { + base.ApplyProperty(id, value); + if (id == PropertyIds.List_Version) + Rebuild(); + } + + public static readonly List ListInstances = new(); + + readonly List _rowNodes = new(); + public int RowCount => _rowViews.Count; + public Microsoft.Maui.Graphics.Color? RowBackground(int i) + => _rowNodes[i].Get(PropertyIds.BackgroundColor).AsColor; + + void Rebuild() + { + using var hold = ReactiveScheduler.HoldFlushes(); + if (_list is View listView) + Comet.DevTools.CometDevRegistry.UnregisterSubtree(listView, includeRoot: false); + _rowViews.Clear(); + _rowNodes.Clear(); + int count = _list.Sections() > 0 ? _list.Rows(0) : 0; + for (int i = 0; i < count; i++) + { + var view = _list.ViewFor(0, i); + _rowNodes.Add((FakeBackendNode)CometBackendBridge.Materialize(view, _factory, _context)); + _rowViews.Add(view); + LayoutRow(view); + } + } + + void LayoutRow(View row) + { + if (_width > 0) + CometBackendLayoutEngine.LayoutContent(row, _width); + } + + public override void Arrange(Rect frame) + { + base.Arrange(frame); + if (frame.Width > 0 && Math.Abs(frame.Width - _width) > 0.5) + { + _width = frame.Width; + foreach (var row in _rowViews) + LayoutRow(row); + } + } + } + + // ---------------------------------------------------------------- fake twins + + /// Mirror of SwiftUIHostedCompositionNode minus the shim: one hosted subtree, + /// swapped on state patches, re-laid-out after every reactive flush. + abstract class FakeHostedNode : FakeBackendNode, IBackendManagesOwnContent + { + protected readonly BackendContext Context; + protected readonly CometNodeFactory NodeFactory; + View? _shown; + Size _frame; + + public int RefreshCount { get; private set; } + public static int Depth; + public static int MaxDepth; + public static int TotalRefreshes; + public static readonly List Instances = new(); + + protected FakeHostedNode(CometNodeFactory factory, BackendContext context) + : base("hosted") + { + NodeFactory = factory; + Context = context; + Instances.Add(this); + ReactiveScheduler.AfterFlush += Relayout; // matches the iOS twin (unhook in Dispose) + } + + protected abstract View BuildContent(); + + List? _generation; + + public void Refresh() + { + RefreshCount++; + TotalRefreshes++; + // Matches the iOS twin: the swap is atomic w.r.t. reactive flushes, and the + // previous node generation is disposed (releases static hooks). + using var hold = ReactiveScheduler.HoldFlushes(); + Enter(); + try + { + if (_shown is { } prev) + { + Comet.DevTools.CometDevRegistry.UnregisterSubtree(prev, includeRoot: true); + _shown = null; + } + if (_generation is { } stale) + { + _generation = null; + foreach (var n in stale) + n.Dispose(); + } + var view = BuildContent(); + var generation = new List(); + using (CometBackendBridge.CollectNodes(generation)) + CometBackendBridge.Materialize(view, NodeFactory, Context); + _generation = generation; + _shown = view; + Relayout(); + } + finally { Exit(); } + } + + protected void Relayout() + { + if (_shown is not { } v) + return; + Enter(); + try + { + var size = _frame.Width > 0 ? _frame : new Size(402, 874); + CometBackendLayoutEngine.Layout(v, size); + } + finally { Exit(); } + } + + public static string? DeepStack; + + static void Enter() + { + Depth++; + if (Depth > MaxDepth) MaxDepth = Depth; + if (Depth == 20 && DeepStack is null) + DeepStack = Environment.StackTrace; + if (Depth > 64) + throw new InvalidOperationException( + "hosted-composition runaway: Refresh/Relayout re-entrancy exceeded 64"); + } + static void Exit() => Depth--; + + public override void ApplyProperty(PropertyId id, in PropertyValue value) => OnPatch(id, value); + protected abstract void OnPatch(PropertyId id, in PropertyValue value); + + public override Size Measure(double widthConstraint, double heightConstraint) + { + double w = double.IsFinite(widthConstraint) && widthConstraint > 0 ? widthConstraint : 402; + double h = double.IsFinite(heightConstraint) && heightConstraint > 0 ? heightConstraint : 874; + return new Size(w, h); + } + + public override void Arrange(Rect frame) + { + _frame = new Size(frame.Width, frame.Height); + if (_shown is null) + Refresh(); + else + Relayout(); + } + + public override void Dispose() + { + base.Dispose(); + ReactiveScheduler.AfterFlush -= Relayout; + if (_generation is { } nodes) + { + _generation = null; + foreach (var n in nodes) + n.Dispose(); + } + } + } + + sealed class FakeSuiteNode : FakeHostedNode + { + readonly NavigationSuite _suite; + int _selected; + bool _applied; + + public FakeSuiteNode(NavigationSuite suite, CometNodeFactory f, BackendContext c) : base(f, c) + => _suite = suite; + + protected override View BuildContent() + { + // Compact bottom-bar variant, matching SwiftUINavigationSuiteNode: fresh wrapper + // stacks each build; the PERSISTENT Content and item IconViews are re-added and + // re-modified (FlexGrow etc.) every time. + var row = new HStack(spacing: 0f); + for (int i = 0; i < _suite.Items.Count; i++) + { + int index = i; + row.Add(new VStack(spacing: 0f) { _suite.Items[i].IconView } + .FlexGrow(1).FlexBasis(0) + .OnTap(_ => _suite.SelectItem(index))); + } + return new VStack(spacing: 0f) + { + new HStack().Frame(height: 59f).FlexShrink(0), + _suite.Content.FlexGrow(1).FlexBasis(0), + new VStack(spacing: 0f) { row.Frame(height: 64f), new HStack().Frame(height: 34f) } + .FlexShrink(0), + }; + } + + protected override void OnPatch(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.Nav_SelectedIndex) + return; + if (_applied && value.AsInt == _selected) + return; + _applied = true; + _selected = value.AsInt; + Refresh(); + } + } + + sealed class FakeSwitcherNode : FakeHostedNode + { + readonly ContentSwitcher _switcher; + int _index; + bool _applied; + + public FakeSwitcherNode(ContentSwitcher switcher, CometNodeFactory f, BackendContext c) : base(f, c) + => _switcher = switcher; + + protected override View BuildContent() + => _index >= 0 && _index < _switcher.Views.Count ? _switcher.Views[_index] : new VStack(); + + protected override void OnPatch(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.ContentSwitcher_Index) + return; + if (_applied && value.AsInt == _index) + return; + _applied = true; + _index = value.AsInt; + Refresh(); + } + } + + sealed class FakeListDetailNode : FakeHostedNode + { + readonly ListDetail _listDetail; + bool _open; + bool _applied; + + public FakeListDetailNode(ListDetail listDetail, CometNodeFactory f, BackendContext c) : base(f, c) + => _listDetail = listDetail; + + protected override View BuildContent() + => _open ? _listDetail.Detail : _listDetail.List; // compact swap + + protected override void OnPatch(PropertyId id, in PropertyValue value) + { + if (id != PropertyIds.ListDetail_IsDetailOpen) + return; + if (_applied && value.AsBool == _open) + return; + _applied = true; + _open = value.AsBool; + Refresh(); + } + } + + // ---------------------------------------------------------------- the repro + + /// The ReplyProbeRoot analog: a [Body] view whose body builds a FRESH suite — + /// fresh ContentSwitcher, fresh ListDetail, fresh panes — around the same persistent + /// static-style signals, exactly like ReplyScreens.Inbox(). A body rebuild therefore + /// re-hooks the signals from new control instances. + sealed class ProbeRoot : View + { + readonly MiniReply _app; + public ProbeRoot(MiniReply app) => _app = app; + + [Body] + View body() => _app.BuildSuite(); + } + + /// A Reply-shaped mini app: ListView panes, reactive Text closures reading the + /// opened-email signal, and the ReloadData hook — the pieces the real ReplyScreens uses. + sealed class MiniReply + { + public readonly Signal DetailOpen = new(false); + public readonly Signal OpenedEmailId = new(0); + public readonly Signal Selected = new(0); + ListView? _inboxList; + + public MiniReply() + // Static-ctor analog from ReplyScreens: opened highlight moves via ReloadData. + => OpenedEmailId.PropertyChanged += (_, __) => _inboxList?.ReloadData(); + + public NavigationSuite BuildSuite() + { + var inbox = new VStack(spacing: 0f) + { + new ListDetail(DetailOpen, InboxList(), EmailDetail()).FlexGrow(1).FlexBasis(0), + }; + var articles = new VStack { new Text("Screen under construction") }; + var switcher = new ContentSwitcher(Selected, new View[] { inbox, articles }); + return new NavigationSuite(Selected, + new[] + { + new NavigationItem(new Text("inbox")), + new NavigationItem(new Text("article")), + }, + switcher); + } + + View InboxList() + { + var list = new ListView(() => new List { 1, 2, 3 }) + { + ViewFor = i => Row(i), + }; + _inboxList = list; + return new VStack(spacing: 0f) { list.FlexGrow(1).FlexBasis(0) } + .Padding(new Microsoft.Maui.Thickness(16, 4, 16, 4)); + } + + public static readonly List RowLog = new(); + + View Row(int i) + { + RowLog.Add($"{i}@{OpenedEmailId.Value}"); + return new VStack(spacing: 2f) + { + new Text($"Email {i}"), + new Text("Preview text"), + } + .Background(i == OpenedEmailId.Value ? Colors.Red : Colors.Gray) + .OnTap(_ => + { + // The real EmailListItem tap: highlight signal first, then the pane swap. + OpenedEmailId.Value = i; + DetailOpen.Value = true; + }); + } + + View EmailDetail() + { + var stack = new VStack(spacing: 0f) + { + new HStack(spacing: 0f) + { + new Text("back").OnTap(_ => DetailOpen.Value = false).FlexShrink(0), + new VStack(spacing: 2f) + { + new Text(() => $"Subject of {OpenedEmailId.Value}"), + new Text(() => $"{OpenedEmailId.Value} Messages"), + }.FlexGrow(1).FlexBasis(0), + }, + }; + // The items closure reads the signal, like the real `() => Opened().Threads`; + // the row template carries a modifier (an environment WRITE during lazy row + // materialization) like the real ThreadItem's .FontSize — the trigger of the + // on-device stack overflow. + var threads = new ListView(() => new List { OpenedEmailId.Value, 2 }) + { + ViewFor = t => new Text(() => $"thread {t} of {OpenedEmailId.Value}").FontSize(12), + }; + stack.Add(threads.FlexGrow(1).FlexBasis(0)); + return stack; + } + } + + [Fact] + public void CompactDetailSwap_AfterHostedChurn_StaysBounded() + { + FakeHostedNode.Depth = 0; + FakeHostedNode.MaxDepth = 0; + FakeHostedNode.TotalRefreshes = 0; + FakeHostedNode.Instances.Clear(); + + var app = new MiniReply(); + var probe = new ProbeRoot(app); + CometBackendBridge.Materialize(probe, Factory, Ctx); + + // SwiftUIBackendRoot analog: layout on mount + after every reactive flush, + // re-resolving BuiltView each pass (a body rebuild swaps it). + void RunLayout() + { + var layoutRoot = probe.BuiltView ?? probe; + CometBackendLayoutEngine.Layout(layoutRoot, new Size(402, 874)); + } + ReactiveScheduler.AfterFlush += RunLayout; + try + { + RunLayout(); + + // Device-observed churn: the suite node refreshes again when the window + // metrics land (size + safe area publish separately, then again on rotation + // etc.). Each suite refresh re-materializes the persistent switcher/ListDetail + // controls into NEW node generations; the old generations stay + // AfterFlush-subscribed (never disposed) — the state on device. + var suiteNode = (FakeSuiteNode)FakeHostedNode.Instances[0]; + for (int i = 0; i < 3; i++) + suiteNode.Refresh(); + + // The tap (real EmailListItem ordering): highlight first — fires ReloadData + // and dirties whatever tracked the signal — then the pane swap. On-device + // this stack-overflows in the environment walk during Layout(Detail). + app.OpenedEmailId.Value = 2; + app.DetailOpen.Value = true; + + // Back, then open another email; repeat to let per-rebuild handler stacking + // (each fresh ListDetail hooks the same persistent signal) compound. + for (int i = 3; i < 8; i++) + { + app.DetailOpen.Value = false; + app.OpenedEmailId.Value = i; + app.DetailOpen.Value = true; + } + + var byType = new Dictionary(); + foreach (var n in FakeHostedNode.Instances) + { + var key = n.GetType().Name; + var cur = byType.TryGetValue(key, out var c) ? c : (0, 0); + byType[key] = (cur.Item1 + 1, cur.Item2 + n.RefreshCount); + } + var diag = $"maxDepth={FakeHostedNode.MaxDepth} totalRefreshes={FakeHostedNode.TotalRefreshes} " + + string.Join(" ", System.Linq.Enumerable.Select(byType, kv => $"{kv.Key}: n={kv.Value.nodes} r={kv.Value.refreshes}")); + + Assert.True(FakeHostedNode.MaxDepth <= 16, + $"Refresh/Relayout re-entrancy too deep: {diag}\n{FakeHostedNode.DeepStack}"); + // Bound the churn itself: 3 suite refreshes + 13 detail flips currently cost + // 9 hosted-node generations / 24 refreshes (linear in interactions). Headroom + // so incidental changes don't flake; a runaway blows straight past this. + Assert.True(FakeHostedNode.Instances.Count <= 20 && FakeHostedNode.TotalRefreshes <= 60, + $"hosted-node churn runaway: {diag}"); + + // Stale generations must be DISPOSED (static hooks released): only the live + // tree's hosted nodes — one suite, one switcher, one ListDetail — may remain. + var alive = FakeHostedNode.Instances.FindAll(n => !n.Disposed).Count; + Assert.True(alive <= 3, $"stale hosted-node generations leaked: {alive} alive — {diag}"); + } + finally + { + ReactiveScheduler.AfterFlush -= RunLayout; + } + } + + /// The opened-row highlight (Reply): after the opened-email signal changes and + /// the static hook reloads the list, rebuilt rows must reflect the NEW signal value in + /// their background — the ReloadData path may not serve stale row state. + [Fact] + public void ReloadData_RebuildsRowsAgainstCurrentSignalValue() + { + FakeHostedNode.Depth = 0; + FakeHostedNode.MaxDepth = 0; + FakeHostedNode.TotalRefreshes = 0; + FakeHostedNode.Instances.Clear(); + FakeListNode.ListInstances.Clear(); + + var app = new MiniReply(); + var probe = new ProbeRoot(app); + CometBackendBridge.Materialize(probe, Factory, Ctx); + CometBackendLayoutEngine.Layout(probe.BuiltView ?? probe, new Size(402, 874)); + + // The tap: open email 2 (highlight signal first, then the pane swap). + app.OpenedEmailId.Value = 2; + app.DetailOpen.Value = true; + // Back to the list (compact restores the list pane). + app.DetailOpen.Value = false; + + // Find the live inbox list node and read the rebuilt rows' backgrounds. + var lists = FakeListNode.ListInstances.FindAll(l => !l.Disposed); + Assert.NotEmpty(lists); + + // Row 2 must now carry the highlight color, row 1 must not. + var inbox = lists.FindLast(l => l.RowCount == 3); + Assert.NotNull(inbox); + var diag = string.Join(" | ", FakeListNode.ListInstances.ConvertAll(l => + $"disposed={l.Disposed} rows={l.RowCount} bgs=[{string.Join(",", System.Linq.Enumerable.Select(System.Linq.Enumerable.Range(0, l.RowCount), i => l.RowBackground(i)?.ToHex()))}]")); + Assert.True(Colors.Gray.Equals(inbox!.RowBackground(0)) && Colors.Red.Equals(inbox.RowBackground(1)), + $"stale row state after ReloadData: {diag}\nrowLog: {string.Join(" ", MiniReply.RowLog)}"); + } + + /// A hosted swap triggered OUTSIDE a flush (a tap handler) must stay a single + /// generation: its env writes during Materialize may not run an inline flush whose + /// AfterFlush layout re-enters the mid-build node (was: search pane double-built and + /// its node state lost). + [Fact] + public void RefreshOutsideFlush_IsAtomic_SingleGeneration() + { + FakeHostedNode.Depth = 0; + FakeHostedNode.MaxDepth = 0; + FakeHostedNode.TotalRefreshes = 0; + FakeHostedNode.Instances.Clear(); + + var app = new MiniReply(); + var probe = new ProbeRoot(app); + CometBackendBridge.Materialize(probe, Factory, Ctx); + + void RunLayout() + { + var layoutRoot = probe.BuiltView ?? probe; + CometBackendLayoutEngine.Layout(layoutRoot, new Size(402, 874)); + } + ReactiveScheduler.AfterFlush += RunLayout; + try + { + RunLayout(); + var ldBefore = FakeHostedNode.Instances.FindAll(n => n is FakeListDetailNode).Count; + + // The tap-handler path: flip the signal from OUTSIDE any flush. The LD node's + // Refresh materializes the detail pane, whose ListView rows write the + // environment (.FontSize) — before the HoldFlushes fix this ran an inline + // flush that re-entered the mid-build node and spawned a second generation. + app.OpenedEmailId.Value = 2; + app.DetailOpen.Value = true; + + var ld = FakeHostedNode.Instances.FindAll(n => n is FakeListDetailNode); + Assert.Equal(ldBefore, ld.Count); // no new node generations from one tap + Assert.True(FakeHostedNode.MaxDepth <= 4, + $"nested re-entry during an outside-flush Refresh: depth {FakeHostedNode.MaxDepth}"); + } + finally + { + ReactiveScheduler.AfterFlush -= RunLayout; + } + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendHotReloadTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendHotReloadTests.cs new file mode 100644 index 000000000..185757aff --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendHotReloadTests.cs @@ -0,0 +1,200 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Comet.HotReload; +using Comet.Reactive; +using Microsoft.Maui.HotReload; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Locks the hot-reload / Reload() path on the node backend: a reload transfers the + /// retained from the old view tree to the rebuilt one + /// (no re-materialization), re-emits changed properties as patches, rebinds events to + /// the new views, and preserves component state across a hot-reload type replacement. + /// The legacy-path equivalents live in HotReloadTests/ComponentHotReloadTests. + /// + public class BackendHotReloadTests + { + static BackendHotReloadTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new NullServiceProvider()); + + sealed class NullServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + + static FakeBackendNode Bridge(View root) + => (FakeBackendNode)CometBackendBridge.Materialize( + root, v => new FakeBackendNode(v.GetType().Name), Ctx); + + class CounterState + { + public int Count { get; set; } + } + + class CounterComponent : Component + { + public override View Render() => new Text($"Count: {State.Count}"); + public void SetCount(int value) => SetState(state => state.Count = value); + } + + class CounterReplacementComponent : Component + { + public override View Render() => new Text($"Updated: {State.Count}"); + } + + class LabelHostView : View + { + public string Label = "before"; + + [Body] + View body() => new VStack { new Text(Label) }; + } + + class ClickHostView : View + { + public int Clicks; + public string ButtonTitle = "Tap"; + + [Body] + View body() => new VStack { new Button(ButtonTitle, () => Clicks++) }; + } + + class CounterHostView : View + { + public readonly CounterComponent Counter = new(); + + [Body] + View body() => new VStack { Counter }; + } + + class RootV1 : View + { + [Body] + View body() => new VStack { new Text("v1") }; + } + + class RootV2 : View + { + [Body] + View body() => new VStack { new Text("v2") }; + } + + [Fact] + public void Reload_PatchesExistingNode_WithoutRematerializing() + { + MauiHotReloadHelper.IsEnabled = true; + var host = new LabelHostView(); + var stackNode = Bridge(host); + var textNode = stackNode.Children[0]; + Assert.Equal("before", textNode.Get(PropertyIds.Text_Value).AsString); + + host.Label = "after"; + host.Reload(); + + // The SAME retained node instances got the patch — no re-materialization. + Assert.Same(textNode, stackNode.Children[0]); + Assert.Equal("after", textNode.Get(PropertyIds.Text_Value).AsString); + Assert.False(textNode.Disposed); + } + + [Fact] + public void Reload_RebindsEventsToTheNewViews() + { + MauiHotReloadHelper.IsEnabled = true; + var host = new ClickHostView(); + var stackNode = Bridge(host); + var buttonNode = stackNode.Children[0]; + + buttonNode.Sink!.OnEvent(EventIds.Clicked); + Assert.Equal(1, host.Clicks); + + host.ButtonTitle = "Tap v2"; + host.Reload(); + + Assert.Equal("Tap v2", buttonNode.Get(PropertyIds.Button_Text).AsString); + buttonNode.Sink!.OnEvent(EventIds.Clicked); + Assert.Equal(2, host.Clicks); + } + + [Fact] + public void HotReload_ReplacesRootViewType_ViaTriggerReload() + { + // The device shape: the app root itself is the replaced type (the + // CometComposeProbe HotReloadDemo scenario). + MauiHotReloadHelper.IsEnabled = true; + var root = new RootV1(); + var stackNode = Bridge(root); + var textNode = stackNode.Children[0]; + Assert.Equal("v1", textNode.Get(PropertyIds.Text_Value).AsString); + + CometHotReloadHelper.RegisterReplacedView(typeof(RootV1).FullName!, typeof(RootV2)); + MauiHotReloadHelper.TriggerReload(); + + Assert.Same(textNode, stackNode.Children[0]); + Assert.Equal("v2", textNode.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void HotReload_ReplacesComponent_PreservingStateAndPatchingNode() + { + // MauiHotReloadHelper.TriggerReload only reloads ROOT views (Parent == null), + // so the app shape matters: a [Body] host re-renders and its diff reconciles + // the nested component — the same contract the legacy handler path had. + MauiHotReloadHelper.IsEnabled = true; + var host = new CounterHostView(); + var stackNode = Bridge(host); + var textNode = stackNode.Children[0]; + Assert.Equal("Count: 0", textNode.Get(PropertyIds.Text_Value).AsString); + + host.Counter.SetCount(7); + ReactiveScheduler.FlushSync(); + Assert.Equal("Count: 7", textNode.Get(PropertyIds.Text_Value).AsString); + + CometHotReloadHelper.RegisterReplacedView( + typeof(CounterComponent).FullName!, typeof(CounterReplacementComponent)); + MauiHotReloadHelper.TriggerReload(); + + // Replacement code runs, state carried over, and the patch landed on the + // SAME retained node (the transfer path, not a rebuild). + Assert.Same(textNode, stackNode.Children[0]); + Assert.Equal("Updated: 7", textNode.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void OwnerTransfer_FlagsHotReloadOnlyForActualHotReload() + { + // Regression for the OnOwnerViewChanged mis-trigger: an ordinary re-render + // (SetState/Reload) must transfer the node with isHotReload=FALSE so own-content + // nodes (nav/list) preserve their retained state; only a real hot reload passes + // TRUE and re-materializes. Here the transferred child node records the flag. + MauiHotReloadHelper.IsEnabled = true; + var host = new LabelHostView(); + var stackNode = Bridge(host); + var textNode = stackNode.Children[0]; + + // Ordinary re-render → transfer flagged as NOT hot reload. + host.Label = "again"; + host.Reload(); + Assert.True(textNode.OwnerChangedCount >= 1); + Assert.Equal(false, textNode.LastOwnerChangeWasHotReload); + + // Hot-reload replacement of the root type → transfer flagged as hot reload. + CometHotReloadHelper.RegisterReplacedView(typeof(LabelHostView).FullName!, typeof(LabelHostViewV2)); + MauiHotReloadHelper.TriggerReload(); + Assert.Equal(true, textNode.LastOwnerChangeWasHotReload); + } + + class LabelHostViewV2 : View + { + public string Label = "v2"; + + [Body] + View body() => new VStack { new Text(Label) }; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendInputTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendInputTests.cs new file mode 100644 index 000000000..8fba40dcf --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendInputTests.cs @@ -0,0 +1,145 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Comet.Reactive; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Locks the two-way input path for the Compose backend (TextField, Toggle): the + /// control emits its value to the node, and a user edit routed back through the event + /// sink writes through to the bound Signal and re-renders dependents. + /// + public class BackendInputTests + { + static BackendInputTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + static FakeBackendNode Bridge(View root) + => (FakeBackendNode)CometBackendBridge.Materialize( + root, v => new FakeBackendNode(v.GetType().Name), Ctx); + + [Fact] + public void TextField_EmitsTextAndPlaceholder() + { + var node = Bridge(new VStack { new TextField("hi", "type here") }); + var field = node.Children[0]; + Assert.Equal("hi", field.Get(PropertyIds.TextField_Text).AsString); + Assert.Equal("type here", field.Get(PropertyIds.TextField_Placeholder).AsString); + } + + [Fact] + public void TextField_Edit_WritesBackToSignal() + { + var name = new Signal(""); + var node = Bridge(new VStack { new TextField(name) }); + var field = node.Children[0]; + + Assert.NotNull(field.Sink); + field.Sink!.OnEvent(EventIds.TextChanged, "Dave"); + + Assert.Equal("Dave", name.Value); + } + + [Fact] + public void TextField_Edit_RerendersDependentText() + { + var name = new Signal(""); + var root = new VStack + { + new TextField(name), + new Text(() => $"Hi {name.Value}"), + }; + var node = Bridge(root); + var field = node.Children[0]; + var greeting = node.Children[1]; + + field.Sink!.OnEvent(EventIds.TextChanged, "Dave"); + ReactiveScheduler.FlushSync(); + + Assert.Equal("Hi Dave", greeting.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void Toggle_EmitsIsOnAndWritesBack() + { + var on = new Signal(false); + var node = Bridge(new VStack { new Toggle(on) }); + var toggle = node.Children[0]; + + Assert.False(toggle.Get(PropertyIds.Toggle_IsOn).AsBool); + + toggle.Sink!.OnEvent(EventIds.Toggled, true); + Assert.True(on.Value); + } + + [Fact] + public void Toggle_OwnVisualReflectsValueAfterWriteBack() + { + // The controlled-component round-trip: after the user flips the switch and the + // value writes back to the signal, the toggle's OWN node must reflect the new + // value (so the Compose Switch knob moves), not just dependent views. + var on = new Signal(false); + var node = Bridge(new VStack { new Toggle(on) }); + var toggle = node.Children[0]; + + toggle.Sink!.OnEvent(EventIds.Toggled, true); + ReactiveScheduler.FlushSync(); + + Assert.True(toggle.Get(PropertyIds.Toggle_IsOn).AsBool); + } + + [Fact] + public void Toggle_DrivesDependentVisibilityText() + { + var on = new Signal(false); + var root = new VStack + { + new Toggle(on), + new Text(() => on.Value ? "ON" : "OFF"), + }; + var node = Bridge(root); + var toggle = node.Children[0]; + var label = node.Children[1]; + Assert.Equal("OFF", label.Get(PropertyIds.Text_Value).AsString); + + toggle.Sink!.OnEvent(EventIds.Toggled, true); + ReactiveScheduler.FlushSync(); + + Assert.Equal("ON", label.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void Slider_EmitsValueAndWritesBack() + { + var pos = new Signal(0.0); + var node = Bridge(new VStack { new Slider(pos) }); + var slider = node.Children[0]; + Assert.Equal(0.0, slider.Get(PropertyIds.Slider_Value).AsDouble, 5); + + slider.Sink!.OnEvent(EventIds.ValueChanged, 0.7); + Assert.Equal(0.7, pos.Value, 5); + } + + [Fact] + public void Slider_OwnVisualReflectsValueAfterDrag() + { + var pos = new Signal(0.0); + var node = Bridge(new VStack { new Slider(pos) }); + var slider = node.Children[0]; + + slider.Sink!.OnEvent(EventIds.ValueChanged, 0.7); + ReactiveScheduler.FlushSync(); + + Assert.Equal(0.7, slider.Get(PropertyIds.Slider_Value).AsDouble, 5); + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendJetcasterControlTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendJetcasterControlTests.cs new file mode 100644 index 000000000..05e789dbc --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendJetcasterControlTests.cs @@ -0,0 +1,92 @@ +#nullable enable +using System.Linq; +using Comet; +using Comet.Backend; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Contract tests for the M4 (Jetcaster) control surface: the FilterChip control's + /// typed-patch emit, and the ListView grid/carousel flavor properties the Compose + /// node branches on (LazyVerticalGrid / M3 carousels vs plain LazyColumn/LazyRow). + /// + public class BackendJetcasterControlTests + { + static BackendJetcasterControlTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + [Fact] + public void FilterChip_EmitsSelectedState() + { + bool clicked = false; + var chip = new FilterChip(selected: true, onClick: () => clicked = true, + label: new Text("Society & Culture")); + + var node = (FakeBackendNode)CometBackendBridge.Materialize( + chip, v => new FakeBackendNode("FilterChip"), Ctx); + + Assert.True(node.Get(PropertyIds.Toggle_IsOn).AsBool); + chip.OnClick(); + Assert.True(clicked); + } + + [Fact] + public void FilterChip_ExposesSlotChildren() + { + var label = new Text("Arts"); + var leading = new Icon("check"); + var chip = new FilterChip(false, () => { }, label, leading); + + var children = ((IContainerView)chip).GetChildren(); + Assert.Equal(new View[] { label, leading }, children); + Assert.Same(chip, label.Parent); + Assert.Same(chip, leading.Parent); + } + + [Fact] + public void IconButton_ExposesSlotAndClick() + { + bool clicked = false; + var icon = new Icon("playlist_add"); + var button = new IconButton(() => clicked = true, icon); + + Assert.Equal(new View[] { icon }, ((IContainerView)button).GetChildren()); + Assert.Same(button, icon.Parent); + button.OnClick(); + Assert.True(clicked); + } + + [Fact] + public void ListView_GridAndCarouselFlavorsFlowThroughTheInterface() + { + var list = new ListView(() => Enumerable.Range(0, 4).ToList()) + { + ViewFor = i => new Text($"{i}"), + GridAdaptiveMinWidth = 362, + }; + IListView lv = list; + Assert.Equal(362, lv.GridAdaptiveMinWidth); + Assert.Equal(ListCarousel.None, lv.Carousel); + + var carousel = new ListView(() => Enumerable.Range(0, 4).ToList()) + { + ViewFor = i => new Text($"{i}"), + Horizontal = true, + Carousel = ListCarousel.MultiBrowse, + CarouselItemWidth = 220, + }; + IListView cv = carousel; + Assert.True(cv.Horizontal); + Assert.Equal(ListCarousel.MultiBrowse, cv.Carousel); + Assert.Equal(220, cv.CarouselItemWidth); + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendLayoutTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendLayoutTests.cs new file mode 100644 index 000000000..b2689ed36 --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendLayoutTests.cs @@ -0,0 +1,304 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Proves the C# Yoga engine drives the backend node protocol: a materialized Comet tree + /// is laid out by , with leaf intrinsic sizes coming + /// from each node's Measure and the computed frames pushed via Arrange. This + /// is the host-side proof that Yoga — not the native UI kit — positions the tree. + /// + public class BackendLayoutTests + { + static BackendLayoutTests() => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + static FakeBackendNode Bridge(View root) + => (FakeBackendNode)CometBackendBridge.Materialize( + root, v => new FakeBackendNode(v.GetType().Name), Ctx); + + static FakeBackendNode Node(View v) => (FakeBackendNode)v.Node!; + + [Fact] + public void VStack_StacksChildrenAlongYWithSpacing() + { + var a = new Text("a"); + var b = new Text("b"); + var root = new VStack(spacing: 10f) { a, b }; + Bridge(root); + + Node(a).MeasureResult = new Size(100, 20); + Node(b).MeasureResult = new Size(100, 30); + + CometBackendLayoutEngine.Layout(root, new Size(200, 400)); + + var fa = Node(a).ArrangedFrame!.Value; + var fb = Node(b).ArrangedFrame!.Value; + + Assert.Equal(0, fa.Y, 3); + Assert.Equal(20, fa.Height, 3); + // Second child is offset by the first child's height + the 10pt gap. + Assert.Equal(30, fb.Y, 3); + Assert.Equal(30, fb.Height, 3); + } + + [Fact] + public void HStack_StacksChildrenAlongX() + { + var a = new Text("a"); + var b = new Text("b"); + var root = new HStack(spacing: 0f) { a, b }; + Bridge(root); + + Node(a).MeasureResult = new Size(40, 20); + Node(b).MeasureResult = new Size(60, 20); + + CometBackendLayoutEngine.Layout(root, new Size(400, 200)); + + var fa = Node(a).ArrangedFrame!.Value; + var fb = Node(b).ArrangedFrame!.Value; + + Assert.Equal(0, fa.X, 3); + Assert.Equal(40, fa.Width, 3); + // Second child sits to the right of the first. + Assert.Equal(40, fb.X, 3); + Assert.Equal(60, fb.Width, 3); + Assert.Equal(0, fb.Y, 3); + } + + [Fact] + public void NestedStacks_ComposeOffsets() + { + var leaf = new Text("x"); + var inner = new HStack(spacing: 0f) { leaf }; + var outer = new VStack(spacing: 0f) { new Text("header"), inner }; + Bridge(outer); + + Node(((IContainerView)outer).GetChildren()[0]).MeasureResult = new Size(100, 50); // header + Node(leaf).MeasureResult = new Size(30, 30); + + CometBackendLayoutEngine.Layout(outer, new Size(300, 300)); + + // The inner HStack is the second row of the outer VStack → offset down by the + // header's height; the leaf is arranged relative to its HStack parent. + var innerFrame = Node(inner).ArrangedFrame!.Value; + Assert.Equal(50, innerFrame.Y, 3); + + var leafFrame = Node(leaf).ArrangedFrame!.Value; + Assert.Equal(0, leafFrame.X, 3); + Assert.Equal(30, leafFrame.Width, 3); + } + + [Fact] + public void StretchedText_IsMeasuredWithContainerWidth_AndWraps() + { + // A "text" leaf whose height grows as its width shrinks (simulating wrapping): + // natural single line is 600 wide x 20 tall; constrained narrower, it wraps taller. + var text = new Text("long"); + var root = new VStack(spacing: 0f) { text }; + Bridge(root); + + Node(text).MeasureFunc = (w, h) => + { + const double natural = 600, line = 20; + if (double.IsInfinity(w) || w >= natural) return new Size(natural, line); + int lines = (int)System.Math.Ceiling(natural / w); + return new Size(w, lines * line); + }; + + CometBackendLayoutEngine.Layout(root, new Size(200, 1000)); + + // The leaf must be measured with the (stretched) container width, not infinity, + // so it wraps: 600/200 = 3 lines * 20 = 60 tall. + Assert.Equal(200, Node(text).LastMeasureWidth, 3); + Assert.Equal(60, Node(text).ArrangedFrame!.Value.Height, 3); + } + + [Fact] + public void PaddedStack_MeasuresChildWithContentWidth_NotFullWidth() + { + // Mirrors the probe: a padded VStack; the text child must be measured with the + // content width (available - padding), so it wraps inside the padding. + var text = new Text("long"); + var root = new VStack(spacing: 0f) { text }.Padding(24); + Bridge(root); + + Node(text).MeasureFunc = (w, h) => + { + const double natural = 600, line = 20; + if (double.IsInfinity(w) || w >= natural) return new Size(natural, line); + int lines = (int)System.Math.Ceiling(natural / w); + return new Size(w, lines * line); + }; + + CometBackendLayoutEngine.Layout(root, new Size(400, 1000)); + + // 400 wide - 48 padding = 352 content width. + Assert.Equal(352, Node(text).LastMeasureWidth, 3); + // 600/352 = 2 lines * 20 = 40 tall; and the child sits at x=24 (left padding). + Assert.Equal(40, Node(text).ArrangedFrame!.Value.Height, 3); + Assert.Equal(24, Node(text).ArrangedFrame!.Value.X, 3); + } + + [Fact] + public void FlexGrow_ExpandsToFillMainAxis_PushingSiblings() + { + // HStack: A (40) | filler (grows) | C (60), in 400 wide → filler = 300, C at x=340. + var a = new Text("a"); + var filler = new Text("f").FlexGrow(1); + var c = new Text("c"); + var root = new HStack(spacing: 0f) { a, filler, c }; + Bridge(root); + Node(a).MeasureResult = new Size(40, 20); + Node(filler).MeasureResult = new Size(10, 20); + Node(c).MeasureResult = new Size(60, 20); + + CometBackendLayoutEngine.Layout(root, new Size(400, 100)); + + Assert.Equal(300, Node(filler).ArrangedFrame!.Value.Width, 2); + Assert.Equal(340, Node(c).ArrangedFrame!.Value.X, 2); + } + + [Fact] + public void BaselineAlignedRow_LinesUpChildBaselines() + { + // HStack with two .AlignBaseline() texts of different heights/baselines: the engine + // must shift each so their baselines (top + baseline offset) coincide on one line. + // Bigger text has the larger baseline, so it sets the shared line and the smaller rides up. + var big = new Text("big").AlignBaseline(); + var small = new Text("small").AlignBaseline(); + var root = new HStack(spacing: 0f) { big, small }; + Bridge(root); + + Node(big).MeasureResult = new Size(40, 30); + Node(big).BaselineResult = 24; // baseline 24px from its top + Node(small).MeasureResult = new Size(40, 20); + Node(small).BaselineResult = 16; // baseline 16px from its top + + CometBackendLayoutEngine.Layout(root, new Size(400, 100)); + + var fBig = Node(big).ArrangedFrame!.Value; + var fSmall = Node(small).ArrangedFrame!.Value; + + // Shared baseline = max(24, 16) = 24: big sits at y=0, small drops to y=8. + Assert.Equal(0, fBig.Y, 2); + Assert.Equal(8, fSmall.Y, 2); + // The invariant: top + baseline is equal across the row (one common baseline). + Assert.Equal(fBig.Y + 24, fSmall.Y + 16, 2); + } + + [Fact] + public void CenterAlignedChild_IsCenteredOnCrossAxis_NotStretched() + { + // A VStack child with center horizontal alignment keeps its intrinsic width and + // centers, instead of stretching to fill (the container default). + var a = new Text("a").HorizontalLayoutAlignment(Microsoft.Maui.Primitives.LayoutAlignment.Center); + var root = new VStack(spacing: 0f) { a }; + Bridge(root); + Node(a).MeasureResult = new Size(100, 20); + + CometBackendLayoutEngine.Layout(root, new Size(300, 100)); + + var f = Node(a).ArrangedFrame!.Value; + Assert.Equal(100, f.Width, 2); // intrinsic, not stretched to 300 + Assert.Equal(100, f.X, 2); // centered: (300-100)/2 + } + + [Fact] + public void ExplicitFrameWidth_IsHonored() + { + var a = new Text("a").Frame(width: 80); + var root = new VStack(spacing: 0f) { a }; + Bridge(root); + Node(a).MeasureResult = new Size(999, 20); + + CometBackendLayoutEngine.Layout(root, new Size(300, 100)); + + Assert.Equal(80, Node(a).ArrangedFrame!.Value.Width, 2); + } + + [Fact] + public void LayoutContent_PinsWidth_AndWrapsHeightToContent() + { + // The list-row / scroll-content model: width is pinned to the host, height grows to fit + // the content (here two stacked rows) rather than filling a viewport. + var top = new Text("top"); + var bottom = new Text("bottom"); + var row = new VStack(spacing: 8f) { top, bottom }; + Bridge(row); + + Node(top).MeasureResult = new Size(100, 20); + Node(bottom).MeasureResult = new Size(100, 30); + + var size = CometBackendLayoutEngine.LayoutContent(row, 250); + + // Width pinned to the host; height = 20 + 8 gap + 30 = 58 (wrapped, not the screen). + Assert.Equal(250, size.Width, 3); + Assert.Equal(58, size.Height, 3); + Assert.Equal(58, Node(row).ArrangedFrame!.Value.Height, 3); + // Children stack within the pinned width. + Assert.Equal(0, Node(top).ArrangedFrame!.Value.Y, 3); + Assert.Equal(28, Node(bottom).ArrangedFrame!.Value.Y, 3); + } + + [Fact] + public void LayoutContent_StretchesChildToPinnedWidth_SoTextWraps() + { + // A row whose body text wraps: pinned to 200 wide, a 600-wide natural line wraps to + // 3 lines (60 tall), and the row height reflects it — the virtualized-row height path. + var text = new Text("long"); + var row = new VStack(spacing: 0f) { text }; + Bridge(row); + + Node(text).MeasureFunc = (w, h) => + { + const double natural = 600, line = 20; + if (double.IsInfinity(w) || w >= natural) return new Size(natural, line); + int lines = (int)System.Math.Ceiling(natural / w); + return new Size(w, lines * line); + }; + + var size = CometBackendLayoutEngine.LayoutContent(row, 200); + + Assert.Equal(200, Node(text).LastMeasureWidth, 3); + Assert.Equal(60, size.Height, 3); + } + + [Fact] + public void LeafPadding_GrowsTheArrangedBox() + { + // A LEAF's .Padding must reach Yoga: the box = measured content + padding (the + // node insets its own content at render — ComposeNode.PadsOwnContent). Regression + // guard for the gap where leaf padding was silently dropped (Reply title flush at + // x=0 despite Padding(16)). + var padded = new Text("padded").Padding(new Microsoft.Maui.Thickness(16, 8, 16, 4)); + var plain = new Text("plain"); + var root = new VStack(spacing: 0f) { padded, plain }; + Bridge(root); + + Node(padded).MeasureResult = new Size(100, 20); + Node(plain).MeasureResult = new Size(100, 20); + + CometBackendLayoutEngine.Layout(root, new Size(400, 400)); + + var fp = Node(padded).ArrangedFrame!.Value; + Assert.Equal(20 + 8 + 4, fp.Height, 3); // content + vertical padding + // Stretch gives both full width; the padded one is NOT narrower than the plain one + // (padding grows inward space, the box still fills). + Assert.Equal(Node(plain).ArrangedFrame!.Value.Width, fp.Width, 3); + + // The next sibling is pushed down by the padded height. + Assert.Equal(32, Node(plain).ArrangedFrame!.Value.Y, 3); + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendListTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendListTests.cs new file mode 100644 index 000000000..707e29591 --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendListTests.cs @@ -0,0 +1,60 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Comet; +using Comet.Backend; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Covers the data surface a Compose LazyColumn pulls from when rendering a + /// Comet ListView: bridging produces a list node and signals composition, and + /// the per-row template resolves to the expected views. (The virtualized rendering + /// itself lives in the Android-only ComposeListNode.) + /// + public class BackendListTests + { + static BackendListTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + [Fact] + public void ListView_BridgesAndSignalsComposition() + { + var list = new ListView(() => Enumerable.Range(1, 5).ToList()) + { + ViewFor = i => new Text($"Row {i}"), + }; + + var node = (FakeBackendNode)CometBackendBridge.Materialize( + list, v => new FakeBackendNode("List"), Ctx); + + // The list node was told to (re)compose, and lazily-pulled rows are NOT + // materialized as eager children. + Assert.NotEqual(PropertyValueKind.None, node.Get(PropertyIds.List_Version).Kind); + Assert.Empty(node.Children); + } + + [Fact] + public void ListView_RowTemplateResolvesPerIndex() + { + var list = new ListView(() => Enumerable.Range(10, 3).ToList()) + { + ViewFor = i => new Text($"Item {i}"), + }; + + var lv = (IListView)list; + Assert.True(lv.Sections() >= 1); + Assert.Equal(3, lv.Rows(0)); + Assert.IsType(lv.ViewFor(0, 0)); + Assert.IsType(lv.ViewFor(0, 2)); + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendNavChromeTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendNavChromeTests.cs new file mode 100644 index 000000000..7fa65a9cd --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendNavChromeTests.cs @@ -0,0 +1,236 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Comet.Reactive; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Locks the nav chrome contract (Reply's adaptive NavigationSuite programs against it): + /// NavigationBar/NavigationRail push the selected index to their node and patch it on + /// signal change; SelectItem is the single selection entry point (signal write + OnSelect); + /// items expose their icon/label views as bridgeable children. + /// + public class BackendNavChromeTests + { + static BackendNavChromeTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + static FakeBackendNode Bridge(View root) + => (FakeBackendNode)CometBackendBridge.Materialize( + root, v => new FakeBackendNode(v.GetType().Name), Ctx); + + static NavigationItem Item(string label, System.Action? onSelect = null) + => new(new Icon("circle"), new Text(label), onSelect); + + [Fact] + public void NavigationBar_PushesSelectedIndex_AndPatchesOnSignalChange() + { + var selected = new Signal(1); + var bar = new NavigationBar(selected, new[] { Item("a"), Item("b"), Item("c") }); + + var node = Bridge(bar); + Assert.Equal(1, node.Get(PropertyIds.Nav_SelectedIndex).AsInt); + + selected.Value = 2; + Assert.Equal(2, node.Get(PropertyIds.Nav_SelectedIndex).AsInt); + } + + [Fact] + public void NavigationBar_SelectItem_WritesSignal_AndInvokesOnSelect() + { + int invoked = -1; + var selected = new Signal(0); + var bar = new NavigationBar(selected, new[] + { + Item("a", () => invoked = 0), + Item("b", () => invoked = 1), + }); + var node = Bridge(bar); + + bar.SelectItem(1); + Assert.Equal(1, selected.Value); + Assert.Equal(1, invoked); + Assert.Equal(1, node.Get(PropertyIds.Nav_SelectedIndex).AsInt); + + bar.SelectItem(5); // out of range: signal still moves, no crash, no callback + Assert.Equal(5, selected.Value); + Assert.Equal(1, invoked); + } + + [Fact] + public void NavigationBar_SelectedIndex_DrivesReactiveChrome() + { + // The adaptive-suite pattern: sibling content switches on the same signal the + // bar writes — one tap re-renders exactly the views that read it. + var selected = new Signal(0); + var bar = new NavigationBar(selected, new[] { Item("inbox"), Item("articles") }); + var root = new VStack + { + new Text(() => selected.Value == 0 ? "inbox" : "articles"), + bar, + }; + + var node = Bridge(root); + var text = node.Children[0]; + Assert.Equal("inbox", text.Get(PropertyIds.Text_Value).AsString); + + bar.SelectItem(1); + ReactiveScheduler.FlushSync(); + Assert.Equal("articles", text.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void NavigationBar_BridgesItemIconAndLabelAsChildren() + { + var bar = new NavigationBar(new Signal(0), new[] { Item("a"), Item("b") }); + var node = Bridge(bar); + + // Host bridge (no own-content node) walks the full structure: + // bar → 2 items → icon + label each. + Assert.Equal(2, node.Children.Count); + Assert.All(node.Children, item => Assert.Equal(2, item.Children.Count)); + } + + [Fact] + public void NavigationItem_WithoutLabel_ExposesOnlyIcon() + { + var item = new NavigationItem(new Icon("circle")); + Assert.Single(item.GetChildren()); + } + + [Fact] + public void NavigationRail_SameContract_WithHeaderFirst() + { + int invoked = -1; + var selected = new Signal(0); + var header = new VStack { new Icon("menu") }; + var rail = new NavigationRail(selected, + new[] { Item("a"), Item("b", () => invoked = 1) }, header); + + var node = Bridge(rail); + Assert.Equal(0, node.Get(PropertyIds.Nav_SelectedIndex).AsInt); + Assert.Equal(3, node.Children.Count); // header + 2 items + Assert.Equal("VStack", node.Children[0].Kind); // header bridges first + + rail.SelectItem(1); + Assert.Equal(1, selected.Value); + Assert.Equal(1, invoked); + Assert.Equal(1, node.Get(PropertyIds.Nav_SelectedIndex).AsInt); + } + + [Theory] + [InlineData(400, 900, NavigationSuiteVariant.BottomBar)] // phone portrait + [InlineData(599.9, 900, NavigationSuiteVariant.BottomBar)] + [InlineData(800, 400, NavigationSuiteVariant.BottomBar)] // short window: bar even when wide + [InlineData(600, 900, NavigationSuiteVariant.Rail)] + [InlineData(700, 952, NavigationSuiteVariant.Rail)] // gold medium capture + [InlineData(960, 952, NavigationSuiteVariant.Rail)] // gold: 840-1199 = rail + two-pane + [InlineData(1199.9, 952, NavigationSuiteVariant.Rail)] + [InlineData(1200, 952, NavigationSuiteVariant.PermanentDrawer)] + [InlineData(1260, 952, NavigationSuiteVariant.PermanentDrawer)] + public void NavigationSuite_VariantFor_FollowsGoldBreakpoints( + double w, double h, NavigationSuiteVariant expected) + => Assert.Equal(expected, NavigationSuite.VariantFor(w, h)); + + [Fact] + public void NavigationSuite_SameSelectionContract_AndOwnContentBridging() + { + int invoked = -1; + var selected = new Signal(2); + var suite = new NavigationSuite(selected, + new[] { Item("a"), Item("b"), Item("c", () => invoked = 2) }, + content: new VStack { new Text("screen") }, + railHeader: new Icon("menu")); + + var node = Bridge(suite); + // Own-content gating keys off the NODE type (platform nodes implement + // IBackendManagesOwnContent; FakeBackendNode doesn't), so host-side the children + // bridge normally: content + railHeader + 3 items. + Assert.Equal(5, node.Children.Count); + Assert.Equal(2, node.Get(PropertyIds.Nav_SelectedIndex).AsInt); + + suite.SelectItem(2); + Assert.Equal(2, invoked); + selected.Value = 0; + Assert.Equal(0, node.Get(PropertyIds.Nav_SelectedIndex).AsInt); + } + + [Fact] + public void NavigationSuite_ContentSwapsByReadingSelectedIndex() + { + // The screen-swap pattern the suite prescribes: content BINDINGS read the signal + // (structure stays put — the body-swap node gap is exactly why). + var selected = new Signal(0); + var titles = new[] { "Inbox", "Articles" }; + var content = new VStack { new Text(() => titles[selected.Value]) }; + var suite = new NavigationSuite(selected, new[] { Item("inbox"), Item("articles") }, content); + Bridge(suite); + + // The suite's node owns content materialization on-device; host-side, bridge the + // content directly to observe the binding patch flow. + var contentNode = Bridge(content); + Assert.Equal("Inbox", contentNode.Children[0].Get(PropertyIds.Text_Value).AsString); + + suite.SelectItem(1); + ReactiveScheduler.FlushSync(); + Assert.Equal("Articles", contentNode.Children[0].Get(PropertyIds.Text_Value).AsString); + } + + [Theory] + [InlineData(427, false)] // phone + [InlineData(700, false)] // gold medium: rail + SINGLE pane + [InlineData(839.9, false)] + [InlineData(840, true)] // WindowWidthSizeClass.Expanded + [InlineData(960, true)] // gold: rail + two-pane + [InlineData(1260, true)] // gold: permanent drawer + two-pane + public void ListDetail_TwoPaneFor_FollowsGoldThreshold(double w, bool expected) + => Assert.Equal(expected, ListDetail.TwoPaneFor(w)); + + [Fact] + public void ListDetail_PushesAndPatchesIsDetailOpen_AndBackCloseWritesSignalBack() + { + var open = new Signal(false); + var listDetail = new ListDetail(open, + list: new VStack { new Text("list") }, + detail: new VStack { new Text("detail") }); + + var node = Bridge(listDetail); + Assert.False(node.Get(PropertyIds.ListDetail_IsDetailOpen).AsBool); + + open.Value = true; // app opens the detail (list row tap) + Assert.True(node.Get(PropertyIds.ListDetail_IsDetailOpen).AsBool); + + // The compact detail's BackHandler fires → node raises DetailClosed → the control + // writes the signal back (the Drawer dismiss round-trip). + node.Sink!.OnEvent(EventIds.DetailClosed); + Assert.False(open.Value); + Assert.False(node.Get(PropertyIds.ListDetail_IsDetailOpen).AsBool); + } + + [Fact] + public void ContentSwitcher_PushesAndPatchesIndex() + { + var index = new Signal(1); + var switcher = new ContentSwitcher(index, new View[] + { + new VStack { new Text("inbox") }, + new VStack { new Text("articles") }, + }); + + var node = Bridge(switcher); + Assert.Equal(1, node.Get(PropertyIds.ContentSwitcher_Index).AsInt); + + index.Value = 0; + Assert.Equal(0, node.Get(PropertyIds.ContentSwitcher_Index).AsInt); + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendProtocolTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendProtocolTests.cs new file mode 100644 index 000000000..790a21a7e --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendProtocolTests.cs @@ -0,0 +1,160 @@ +#nullable enable +using Comet.Backend; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Locks the backend protocol contract: PropertyValue's no-box union semantics and + /// the FakeBackendNode patch-stream recorder (child insert/remove/move, property + /// apply, arrange, sink wiring). Platform-agnostic — runs host-side. + /// + public class BackendProtocolTests + { + // --- PropertyValue --- + + [Fact] + public void PropertyValue_RoundTripsPrimitivesWithoutBoxing() + { + Assert.Equal(true, PropertyValue.From(true).AsBool); + Assert.Equal(42, PropertyValue.From(42).AsInt); + Assert.Equal(9_000_000_000L, PropertyValue.From(9_000_000_000L).AsLong); + Assert.Equal(1.5f, PropertyValue.From(1.5f).AsSingle); + Assert.Equal(3.14159, PropertyValue.From(3.14159).AsDouble, 10); + } + + [Fact] + public void PropertyValue_RoundTripsReferenceTypes() + { + Assert.Equal("hello", PropertyValue.From("hello").AsString); + var red = Colors.Red; + Assert.Same(red, PropertyValue.From(red).AsColor); + var boxed = new object(); + Assert.Same(boxed, PropertyValue.FromObject(boxed).AsObject); + } + + [Fact] + public void PropertyValue_CarriesKindDiscriminator() + { + Assert.Equal(PropertyValueKind.Double, PropertyValue.From(1.0).Kind); + Assert.Equal(PropertyValueKind.String, PropertyValue.From("x").Kind); + Assert.Equal(PropertyValueKind.None, PropertyValue.None.Kind); + } + + [Fact] + public void PropertyValue_EqualityIsValueBased() + { + Assert.Equal(PropertyValue.From(5), PropertyValue.From(5)); + Assert.NotEqual(PropertyValue.From(5), PropertyValue.From(6)); + // Same numeric bits, different kind must not be equal. + Assert.NotEqual(PropertyValue.From(1), PropertyValue.From(1L)); + Assert.Equal(PropertyValue.From("a"), PropertyValue.From("a")); + Assert.NotEqual(PropertyValue.From("a"), PropertyValue.From("b")); + } + + // --- PropertyId / EventId --- + + [Fact] + public void PropertyIds_AreUniqueAndStable() + { + Assert.NotEqual(PropertyIds.Opacity, PropertyIds.IsVisible); + Assert.Equal(PropertyIds.Text_Value, new PropertyId(64)); + Assert.True(PropertyIds.Opacity == new PropertyId(1)); + Assert.True(PropertyIds.Opacity != PropertyIds.BackgroundColor); + } + + // --- FakeBackendNode patch stream --- + + [Fact] + public void ApplyProperty_RecordsLatestValue() + { + var node = new FakeBackendNode("text"); + node.ApplyProperty(PropertyIds.Text_Value, PropertyValue.From("hi")); + node.ApplyProperty(PropertyIds.Opacity, PropertyValue.From(0.5)); + node.ApplyProperty(PropertyIds.Text_Value, PropertyValue.From("bye")); + + Assert.Equal("bye", node.Get(PropertyIds.Text_Value).AsString); + Assert.Equal(0.5, node.Get(PropertyIds.Opacity).AsDouble, 10); + Assert.Equal(3, node.ApplyCount); + } + + [Fact] + public void InsertRemoveMove_MaintainChildOrder() + { + var parent = new FakeBackendNode("stack"); + var a = new FakeBackendNode("a"); + var b = new FakeBackendNode("b"); + var c = new FakeBackendNode("c"); + + parent.InsertChild(0, a); + parent.InsertChild(1, c); + parent.InsertChild(1, b); // a, b, c + Assert.Equal(new[] { "a", "b", "c" }, ChildKinds(parent)); + + parent.MoveChild(0, 2); // b, c, a + Assert.Equal(new[] { "b", "c", "a" }, ChildKinds(parent)); + + parent.RemoveChildAt(1); // b, a + Assert.Equal(new[] { "b", "a" }, ChildKinds(parent)); + } + + [Fact] + public void Log_PreservesMutationOrder() + { + var parent = new FakeBackendNode("stack"); + var child = new FakeBackendNode("child"); + parent.InsertChild(0, child); + parent.ApplyProperty(PropertyIds.Opacity, PropertyValue.From(1.0)); + parent.Arrange(new Rect(0, 0, 100, 50)); + parent.RemoveChildAt(0); + + Assert.Equal( + new[] { "insert@0 child", "apply 1=Double(1)", "arrange {X=0 Y=0 Width=100 Height=50}", "remove@0 child" }, + parent.Log); + } + + [Fact] + public void SetEventSink_WiresAndClears() + { + var node = new FakeBackendNode(); + var sink = new RecordingSink(); + node.SetEventSink(sink); + Assert.Same(sink, node.Sink); + node.SetEventSink(null); + Assert.Null(node.Sink); + } + + [Fact] + public void EventSink_DeliversTypedPayloads() + { + var sink = new RecordingSink(); + sink.OnEvent(EventIds.Clicked); + sink.OnEvent(EventIds.TextChanged, "typed"); + sink.OnGesture(GestureKind.Tap, new GestureData(GestureState.Ended, new Point(3, 4))); + + Assert.Equal(EventIds.Clicked, sink.Events[0].id); + Assert.Equal("typed", sink.Events[1].payload); + Assert.Equal(GestureKind.Tap, sink.Gestures[0].kind); + Assert.Equal(new Point(3, 4), sink.Gestures[0].data.Position); + } + + static string[] ChildKinds(FakeBackendNode n) + { + var result = new string[n.Children.Count]; + for (int i = 0; i < n.Children.Count; i++) + result[i] = n.Children[i].Kind; + return result; + } + + sealed class RecordingSink : ICometEventSink + { + public readonly System.Collections.Generic.List<(EventId id, object? payload)> Events = new(); + public readonly System.Collections.Generic.List<(GestureKind kind, GestureData data)> Gestures = new(); + + public void OnEvent(EventId id) => Events.Add((id, null)); + public void OnEvent(EventId id, T payload) => Events.Add((id, payload)); + public void OnGesture(GestureKind kind, in GestureData data) => Gestures.Add((kind, data)); + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendReactiveTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendReactiveTests.cs new file mode 100644 index 000000000..bfe715802 --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendReactiveTests.cs @@ -0,0 +1,109 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Comet.Reactive; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Locks the reactive-update and event-routing paths that drive the Compose backend + /// (verified on-device, asserted here host-side via FakeBackendNode): a Signal change + /// re-emits the bound property to the node, and a node event routes back to the + /// control's handler. + /// + public class BackendReactiveTests + { + static BackendReactiveTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + static FakeBackendNode Bridge(View root) + => (FakeBackendNode)CometBackendBridge.Materialize( + root, v => new FakeBackendNode(v.GetType().Name), Ctx); + + [Fact] + public void SignalChange_ReEmitsBoundPropertyToNode() + { + var count = new Signal(0); + var root = new VStack { new Text(() => $"Count: {count.Value}") }; + + var node = Bridge(root); + var textNode = node.Children[0]; + Assert.Equal("Count: 0", textNode.Get(PropertyIds.Text_Value).AsString); + + count.Value = 5; + ReactiveScheduler.FlushSync(); + + Assert.Equal("Count: 5", textNode.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void DerivedValue_TracksMultipleSignals() + { + var a = new Signal(1); + var b = new Signal(2); + var root = new VStack { new Text(() => $"Sum: {a.Value + b.Value}") }; + + var node = Bridge(root); + var sumNode = node.Children[0]; + Assert.Equal("Sum: 3", sumNode.Get(PropertyIds.Text_Value).AsString); + + a.Value = 10; + ReactiveScheduler.FlushSync(); + Assert.Equal("Sum: 12", sumNode.Get(PropertyIds.Text_Value).AsString); + + b.Value = 100; + ReactiveScheduler.FlushSync(); + Assert.Equal("Sum: 110", sumNode.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void NodeEvent_RoutesToButtonClickedHandler() + { + var clicks = 0; + var root = new VStack { new Button("Tap", () => clicks++) }; + + var node = Bridge(root); + var buttonNode = node.Children[0]; + + Assert.NotNull(buttonNode.Sink); + buttonNode.Sink!.OnEvent(EventIds.Clicked); + buttonNode.Sink!.OnEvent(EventIds.Clicked); + + Assert.Equal(2, clicks); + } + + [Fact] + public void ButtonClick_DrivesSignalThatRecomposesText() + { + // The full on-device loop, host-side: click -> Clicked -> Signal++ -> flush -> + // bound Text re-emits to its node. + var count = new Signal(0); + var root = new VStack + { + new Text(() => $"Count: {count.Value}"), + new Button("Increment", () => count.Value++), + }; + + var node = Bridge(root); + var textNode = node.Children[0]; + var buttonNode = node.Children[1]; + + buttonNode.Sink!.OnEvent(EventIds.Clicked); + ReactiveScheduler.FlushSync(); + Assert.Equal("Count: 1", textNode.Get(PropertyIds.Text_Value).AsString); + + buttonNode.Sink!.OnEvent(EventIds.Clicked); + buttonNode.Sink!.OnEvent(EventIds.Clicked); + ReactiveScheduler.FlushSync(); + Assert.Equal("Count: 3", textNode.Get(PropertyIds.Text_Value).AsString); + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/BackendWindowMetricsTests.cs b/src/Comet/tests/Comet.Tests/Backend/BackendWindowMetricsTests.cs new file mode 100644 index 000000000..926901174 --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/BackendWindowMetricsTests.cs @@ -0,0 +1,144 @@ +#nullable enable +using Comet; +using Comet.Backend; +using Comet.Reactive; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Comet.Tests.Backend +{ + /// + /// Locks the per-root reactive window contract adaptive samples (Reply's list-detail / + /// navigation-suite chrome) program against: a root-size update re-renders exactly the + /// views that read the metrics, size classes follow the M3 breakpoints, and resolution + /// prefers an environment-installed instance over the shared default. + /// + public class BackendWindowMetricsTests + { + static BackendWindowMetricsTests() + => ThreadHelper.SetFireOnMainThread(a => a?.Invoke()); + + static readonly BackendContext Ctx = new(new EmptyServiceProvider()); + + static FakeBackendNode Bridge(View root) + => (FakeBackendNode)CometBackendBridge.Materialize( + root, v => new FakeBackendNode(v.GetType().Name), Ctx); + + [Theory] + [InlineData(0, WindowWidthClass.Compact)] + [InlineData(411, WindowWidthClass.Compact)] + [InlineData(599.9, WindowWidthClass.Compact)] + [InlineData(600, WindowWidthClass.Medium)] + [InlineData(839.9, WindowWidthClass.Medium)] + [InlineData(840, WindowWidthClass.Expanded)] + [InlineData(1280, WindowWidthClass.Expanded)] + public void WidthClass_FollowsM3Breakpoints(double dp, WindowWidthClass expected) + => Assert.Equal(expected, CometWindowMetrics.ClassifyWidth(dp)); + + [Theory] + [InlineData(0, WindowHeightClass.Compact)] + [InlineData(479.9, WindowHeightClass.Compact)] + [InlineData(480, WindowHeightClass.Medium)] + [InlineData(899.9, WindowHeightClass.Medium)] + [InlineData(900, WindowHeightClass.Expanded)] + public void HeightClass_FollowsM3Breakpoints(double dp, WindowHeightClass expected) + => Assert.Equal(expected, CometWindowMetrics.ClassifyHeight(dp)); + + [Fact] + public void ResizeUpdate_ReRendersViewThatReadsWidthClass() + { + // The adaptive-chrome pattern: a bound Text (any reactive binding) switches on + // the width class; crossing a breakpoint re-emits, a same-class resize does not. + var metrics = new CometWindowMetrics(); + var root = new VStack + { + new Text(() => metrics.WidthClass == WindowWidthClass.Expanded + ? "two-pane" : "single-pane"), + }; + root.WindowMetrics(metrics); + + var node = Bridge(root); + var textNode = node.Children[0]; + Assert.Equal("single-pane", textNode.Get(PropertyIds.Text_Value).AsString); + + metrics.Update(new Size(1024, 800)); // phone → tablet width + ReactiveScheduler.FlushSync(); + Assert.Equal("two-pane", textNode.Get(PropertyIds.Text_Value).AsString); + + metrics.Update(new Size(900, 800)); // still Expanded — value re-derives equal + ReactiveScheduler.FlushSync(); + Assert.Equal("two-pane", textNode.Get(PropertyIds.Text_Value).AsString); + + metrics.Update(new Size(400, 800)); // back under the breakpoint + ReactiveScheduler.FlushSync(); + Assert.Equal("single-pane", textNode.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void EqualSizeUpdate_DoesNotNotify() + { + // Per-frame layout callbacks forward sizes unconditionally; the Signal's equality + // gate must absorb them. + var metrics = new CometWindowMetrics(); + metrics.Update(new Size(400, 800)); + + int notifications = 0; + metrics.SizeDp.PropertyChanged += (_, __) => notifications++; + + metrics.Update(new Size(400, 800)); + metrics.Update(new Size(400, 800)); + Assert.Equal(0, notifications); + + metrics.Update(new Size(401, 800)); + Assert.Equal(1, notifications); + } + + [Fact] + public void SafeAreaUpdate_IsReactive_AndEqualityGated() + { + var metrics = new CometWindowMetrics(); + metrics.UpdateSafeArea(new Microsoft.Maui.Thickness(0, 24, 0, 24)); + + var root = new VStack + { + new Text(() => $"top:{metrics.SafeAreaDp.Value.Top:0}"), + }; + root.WindowMetrics(metrics); + var node = Bridge(root); + var textNode = node.Children[0]; + Assert.Equal("top:24", textNode.Get(PropertyIds.Text_Value).AsString); + + int notifications = 0; + metrics.SafeAreaDp.PropertyChanged += (_, __) => notifications++; + metrics.UpdateSafeArea(new Microsoft.Maui.Thickness(0, 24, 0, 24)); // equal → gated + Assert.Equal(0, notifications); + + metrics.UpdateSafeArea(new Microsoft.Maui.Thickness(0, 35, 0, 24)); // cutout grew + ReactiveScheduler.FlushSync(); + Assert.Equal(1, notifications); + Assert.Equal("top:35", textNode.Get(PropertyIds.Text_Value).AsString); + } + + [Fact] + public void GetWindowMetrics_PrefersEnvironmentInstance_FallsBackToShared() + { + var perRoot = new CometWindowMetrics(); + var root = new VStack(); + var child = new Text("x"); + root.Add(child); + root.WindowMetrics(perRoot); + Bridge(root); + + Assert.Same(perRoot, root.GetWindowMetrics()); + Assert.Same(perRoot, child.GetWindowMetrics()); + + var orphan = new Text("y"); + Assert.Same(CometWindowMetrics.Shared, orphan.GetWindowMetrics()); + } + + sealed class EmptyServiceProvider : System.IServiceProvider + { + public object? GetService(System.Type serviceType) => null; + } + } +} diff --git a/src/Comet/tests/Comet.Tests/Backend/FakeBackendNode.cs b/src/Comet/tests/Comet.Tests/Backend/FakeBackendNode.cs new file mode 100644 index 000000000..139203348 --- /dev/null +++ b/src/Comet/tests/Comet.Tests/Backend/FakeBackendNode.cs @@ -0,0 +1,116 @@ +#nullable enable +using System.Collections.Generic; +using Comet.Backend; +using Microsoft.Maui.Graphics; + +namespace Comet.Tests.Backend +{ + /// + /// A host-side that records the patch stream it + /// receives, so the diff→backend contract can be asserted without any platform. + /// This replaces the role the legacy MAUI mapper tests played. + /// + public class FakeBackendNode : ICometBackendNode + { + public string Kind { get; } + public FakeBackendNode(string kind = "node") => Kind = kind; + + /// Last applied value per property id (the node's current state). + public readonly Dictionary Properties = new(); + + /// Ordered child list, mirroring InsertChild/RemoveChildAt/MoveChild. + public readonly List Children = new(); + + /// Full ordered log of every mutation, for ordering/dedup assertions. + public readonly List Log = new(); + + /// Records calls: how many, and whether the last + /// one was flagged as a hot reload (vs an ordinary re-render transfer). + public int OwnerChangedCount { get; private set; } + public bool? LastOwnerChangeWasHotReload { get; private set; } + + public void OnOwnerViewChanged(View newView, bool isHotReload) + { + OwnerChangedCount++; + LastOwnerChangeWasHotReload = isHotReload; + } + + public ICometEventSink? Sink { get; private set; } + public Rect? ArrangedFrame { get; private set; } + public Size MeasureResult { get; set; } = Size.Zero; + + /// Optional width-aware measure (e.g. to simulate text wrapping); when set it + /// overrides . Receives the (width, height) constraints. + public System.Func? MeasureFunc { get; set; } + + /// Simulated first-baseline offset (Dp) reported by ; + /// null means "no text baseline" (the engine falls back to the node height). + public double? BaselineResult { get; set; } + + /// The width constraint of the most recent call. + public double LastMeasureWidth { get; private set; } = double.NaN; + public bool Disposed { get; private set; } + + /// Count of ApplyProperty calls (including no-op re-applies, if any reach us). + public int ApplyCount { get; private set; } + + public virtual void ApplyProperty(PropertyId id, in PropertyValue value) + { + ApplyCount++; + Properties[id.Value] = value; + Log.Add($"apply {id.Value}={value}"); + } + + public void InsertChild(int index, ICometBackendNode child) + { + var fake = (FakeBackendNode)child; + Children.Insert(index, fake); + Log.Add($"insert@{index} {fake.Kind}"); + } + + public void RemoveChildAt(int index) + { + var removed = Children[index]; + Children.RemoveAt(index); + Log.Add($"remove@{index} {removed.Kind}"); + } + + public void MoveChild(int fromIndex, int toIndex) + { + var node = Children[fromIndex]; + Children.RemoveAt(fromIndex); + Children.Insert(toIndex, node); + Log.Add($"move {fromIndex}->{toIndex} {node.Kind}"); + } + + public virtual Size Measure(double widthConstraint, double heightConstraint) + { + LastMeasureWidth = widthConstraint; + return MeasureFunc?.Invoke(widthConstraint, heightConstraint) ?? MeasureResult; + } + + public double? MeasureBaseline(double width, double height) => BaselineResult; + + public virtual void Arrange(Rect frame) + { + ArrangedFrame = frame; + Log.Add($"arrange {frame}"); + } + + public void SetEventSink(ICometEventSink? sink) + { + Sink = sink; + Log.Add(sink is null ? "sink=null" : "sink=set"); + } + + public virtual void Dispose() + { + Disposed = true; + Log.Add("dispose"); + } + + /// Convenience: the current value of a property id, or None if unset. + public PropertyValue Get(PropertyId id) + => Properties.TryGetValue(id.Value, out var v) ? v : PropertyValue.None; + } +} diff --git a/src/Comet/tests/Comet.Tests/DevTools/CometDevAgentTests.cs b/src/Comet/tests/Comet.Tests/DevTools/CometDevAgentTests.cs new file mode 100644 index 000000000..e07a1120d --- /dev/null +++ b/src/Comet/tests/Comet.Tests/DevTools/CometDevAgentTests.cs @@ -0,0 +1,65 @@ +using Comet.DevTools; +using Xunit; + +namespace Comet.Tests +{ + public class CometDevAgentTests + { + [Theory] + [InlineData("\"300\"")] + [InlineData("null")] + [InlineData("true")] + [InlineData("1.5")] + [InlineData("0")] + [InlineData("10001")] + public void DragAction_InvalidDuration_DoesNotInvokeDrag(string duration) + { + var calls = 0; + CometDevRegistry.DragInjector = (_, _, _, _, _) => + { + calls++; + return true; + }; + + try + { + var result = CometDevAgent.DragAction( + $"{{\"x1\":1,\"y1\":2,\"x2\":3,\"y2\":4,\"durationMs\":{duration}}}"); + + Assert.Contains("\"success\":false", result); + Assert.Equal(0, calls); + } + finally + { + CometDevRegistry.DragInjector = null; + } + } + + [Theory] + [InlineData("", 300)] + [InlineData(",\"durationMs\":1", 1)] + [InlineData(",\"durationMs\":10000", 10000)] + public void DragAction_ValidDuration_InvokesDrag(string durationProperty, int expectedDuration) + { + var actualDuration = 0; + CometDevRegistry.DragInjector = (_, _, _, _, duration) => + { + actualDuration = duration; + return true; + }; + + try + { + var result = CometDevAgent.DragAction( + $"{{\"x1\":1,\"y1\":2,\"x2\":3,\"y2\":4{durationProperty}}}"); + + Assert.Contains("\"success\":true", result); + Assert.Equal(expectedDuration, actualDuration); + } + finally + { + CometDevRegistry.DragInjector = null; + } + } + } +} diff --git a/src/Comet/tests/Comet.Tests/InteropTests/NativeHostInteropTests.cs b/src/Comet/tests/Comet.Tests/InteropTests/NativeHostInteropTests.cs index dc1717ce2..fe9ab0236 100644 --- a/src/Comet/tests/Comet.Tests/InteropTests/NativeHostInteropTests.cs +++ b/src/Comet/tests/Comet.Tests/InteropTests/NativeHostInteropTests.cs @@ -218,16 +218,9 @@ public void NativeHost_CanParticipateInMixedInteropLayouts() Assert.Equal(20, mauiSize.Height); } - [Fact] - public void NativeHost_ExposesNativeViewAccessAfterHandlerInitialization() - { - var native = new NativeObject(); - var host = new NativeHost(native); - host.ViewHandler = new NativeHostTrackingHandler(native); - - Assert.True(host.TryGetNativeView(out var resolved)); - Assert.Same(native, resolved); - } + // NativeHost_ExposesNativeViewAccessAfterHandlerInitialization was deleted with + // the legacy MAUI ViewHandler render path (Phase 5): it exercised + // Comet.Handlers.INativeHostHandler, which no longer exists. class InteropPage : View { @@ -335,69 +328,5 @@ class NativeObject { } - class NativeHostTrackingHandler : IViewHandler, Comet.Handlers.INativeHostHandler - { - public NativeHostTrackingHandler(object nativeView) - { - NativeView = nativeView; - } - - public IView CurrentView { get; private set; } - public object NativeView { get; } - public IMauiContext MauiContext => null; - public object ContainerView => null; - public bool HasContainer { get; set; } - public Rect Frame { get; private set; } - public Dictionary ChangedProperties { get; } = new Dictionary(); - - IView IViewHandler.VirtualView => CurrentView; - IElement IElementHandler.VirtualView => CurrentView; - object IElementHandler.PlatformView => NativeView; - - public void SetVirtualView(IView view) - { - CurrentView = view; - } - - void IElementHandler.SetVirtualView(IElement view) => SetVirtualView((IView)view); - - public void UpdateValue(string property) - { - } - - public void Invoke(string command, object args = null) - { - } - - public void DisconnectHandler() - { - CurrentView = null; - } - - public void Dispose() - { - } - - void IElementHandler.SetMauiContext(IMauiContext mauiContext) - { - } - - Size IViewHandler.GetDesiredSize(double widthConstraint, double heightConstraint) => new Size(120, 35); - - void IViewHandler.PlatformArrange(Rect frame) - { - Frame = frame; - } - - object Comet.Handlers.INativeHostHandler.GetNativeView() => NativeView; - - Size Comet.Handlers.INativeHostHandler.MeasureNativeView(Size availableSize) => new Size(120, 35); - - void Comet.Handlers.INativeHostHandler.SyncNativeView() - { - if (CurrentView is NativeHost host) - host.ApplyUpdated(NativeView, null); - } - } } } diff --git a/src/Comet/tests/Comet.Tests/NativeHostTests.cs b/src/Comet/tests/Comet.Tests/NativeHostTests.cs index 37270ca1b..29015ca45 100644 --- a/src/Comet/tests/Comet.Tests/NativeHostTests.cs +++ b/src/Comet/tests/Comet.Tests/NativeHostTests.cs @@ -51,48 +51,9 @@ public void ReleaseNativeView_ClearsOwnedFactoryInstance() Assert.NotSame(first, second); } - [Fact] - public void Sync_AppliesUpdates_WhenHandlerRequestsSynchronization() - { - var native = new NativeObject(); - var host = new NativeHost(native) - .Sync("Ready", (view, text) => ((NativeObject)view).Text = text); - - var handler = new NativeHostTrackingHandler(native); - host.ViewHandler = handler; - - Assert.Equal(1, handler.SyncCount); - Assert.Equal("Ready", native.Text); - } - - [Fact] - public void TryGetNativeView_ReturnsHandlerHostedInstance() - { - var native = new NativeObject(); - var host = new NativeHost(native); - host.ViewHandler = new NativeHostTrackingHandler(native); - - Assert.True(host.TryGetNativeView(out var resolved)); - Assert.Same(native, resolved); - } - - [Fact] - public void MeasureUsing_TakesPrecedenceOverHandlerMeasurement() - { - var native = new NativeObject(); - var host = new NativeHost(native) - .MeasureUsing(_ => new Size(80, 25)); - - host.ViewHandler = new NativeHostTrackingHandler(native) - { - MeasuredSize = new Size(120, 60), - }; - - var measured = host.GetDesiredSize(new Size(200, 200)); - - Assert.Equal(80, measured.Width); - Assert.Equal(25, measured.Height); - } + // The handler-driven Sync/TryGetNativeView/MeasureUsing-precedence tests were + // deleted with the legacy MAUI ViewHandler render path (Phase 5): they exercised + // Comet.Handlers.INativeHostHandler, which no longer exists. [Fact] public void OnConnectAndDisconnect_RunLifecycleCallbacks() @@ -113,72 +74,5 @@ class NativeObject public string Text { get; set; } } - class NativeHostTrackingHandler : IViewHandler, Comet.Handlers.INativeHostHandler - { - public NativeHostTrackingHandler(object nativeView) - { - NativeView = nativeView; - } - - public IView CurrentView { get; private set; } - public object NativeView { get; } - public int SyncCount { get; private set; } - public Size MeasuredSize { get; set; } = new Size(120, 35); - public IMauiContext MauiContext => null; - public object ContainerView => null; - public bool HasContainer { get; set; } - public Rect Frame { get; private set; } - public Dictionary ChangedProperties { get; } = new Dictionary(); - - IView IViewHandler.VirtualView => CurrentView; - IElement IElementHandler.VirtualView => CurrentView; - object IElementHandler.PlatformView => NativeView; - - public void SetVirtualView(IView view) - { - CurrentView = view; - } - - void IElementHandler.SetVirtualView(IElement view) => SetVirtualView((IView)view); - - public void UpdateValue(string property) - { - } - - public void Invoke(string command, object args = null) - { - } - - public void DisconnectHandler() - { - CurrentView = null; - } - - public void Dispose() - { - } - - void IElementHandler.SetMauiContext(IMauiContext mauiContext) - { - } - - Size IViewHandler.GetDesiredSize(double widthConstraint, double heightConstraint) => MeasuredSize; - - void IViewHandler.PlatformArrange(Rect frame) - { - Frame = frame; - } - - object Comet.Handlers.INativeHostHandler.GetNativeView() => NativeView; - - Size Comet.Handlers.INativeHostHandler.MeasureNativeView(Size availableSize) => MeasuredSize; - - void Comet.Handlers.INativeHostHandler.SyncNativeView() - { - SyncCount++; - if (CurrentView is NativeHost host) - host.ApplyUpdated(NativeView, null); - } - } } } diff --git a/src/Comet/tools/bench/size.sh b/src/Comet/tools/bench/size.sh new file mode 100755 index 000000000..5e0b7d913 --- /dev/null +++ b/src/Comet/tools/bench/size.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Publishes an app in Release and reports package size (Android APK today; +# extend with iOS .ipa once the SwiftUI backend lands). +# Usage: tools/bench/size.sh [extra msbuild args...] +set -euo pipefail + +CSPROJ="${1:?usage: size.sh [extra msbuild args...]}" +shift || true + +dotnet publish "$CSPROJ" -f net11.0-android -c Release "$@" > /tmp/comet-size-publish.log 2>&1 || { + tail -20 /tmp/comet-size-publish.log + exit 1 +} + +DIR=$(dirname "$CSPROJ") +APK=$(find "$DIR/bin/Release/net11.0-android/publish" -name "*-Signed.apk" | head -1) +BYTES=$(stat -f %z "$APK" 2>/dev/null || stat -c %s "$APK") +echo "apk: $APK" +echo "size: $BYTES bytes ($(echo "scale=1; $BYTES/1048576" | bc) MiB)" + +# Per-dex/per-lib breakdown if apkanalyzer is available. +APKANALYZER="$HOME/Library/Android/sdk/cmdline-tools/latest/bin/apkanalyzer" +if [ -x "$APKANALYZER" ]; then + echo "--- top-level breakdown ---" + "$APKANALYZER" files list --apk "$APK" 2>/dev/null | head -40 || true +fi diff --git a/src/Comet/tools/bench/startup.sh b/src/Comet/tools/bench/startup.sh new file mode 100755 index 000000000..f6468711f --- /dev/null +++ b/src/Comet/tools/bench/startup.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Measures Android cold-start time for a Comet app via `am start -W`. +# Usage: tools/bench/startup.sh [runs] +# Prints each run's TotalTime and the median. +set -euo pipefail + +APK="${1:?usage: startup.sh [runs]}" +PKG="${2:?usage: startup.sh [runs]}" +RUNS="${3:-10}" + +adb install -r "$APK" > /dev/null + +# Resolve the launcher activity from the APK's package. +ACTIVITY=$(adb shell cmd package resolve-activity --brief "$PKG" | tail -1 | tr -d '\r') + +times=() +for i in $(seq 1 "$RUNS"); do + adb shell am force-stop "$PKG" + # Drop app from page cache as much as the emulator allows between runs. + sleep 2 + out=$(adb shell am start -W -n "$ACTIVITY" | tr -d '\r') + t=$(echo "$out" | awk -F': ' '/TotalTime/ {print $2}') + times+=("$t") + echo "run $i: ${t}ms" +done + +adb shell am force-stop "$PKG" + +median=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {print (NR%2==1) ? a[(NR+1)/2] : int((a[NR/2]+a[NR/2+1])/2)}') +echo "median (${RUNS} runs): ${median}ms" diff --git a/src/Comet/tools/clean-android.sh b/src/Comet/tools/clean-android.sh new file mode 100755 index 000000000..f2a0e35b2 --- /dev/null +++ b/src/Comet/tools/clean-android.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# One-keystroke clean rebuild + deploy for the Comet Android dev loop. +# +# Normally NOT needed: the "no Java peer" crash after facade edits was fixed at +# the root (see Directory.Build.targets `_CometFixFastDevApkInputs` and +# docs/research/upstream-issue-fastdev-typemap-staleness.md). Keep this as the +# fallback if an SDK update regresses the incremental path. +# +# Usage: tools/clean-android.sh [app-project-dir] (default: sample/CometComposeProbe) +set -euo pipefail + +COMET_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP="${1:-sample/CometComposeProbe}" + +cd "$COMET_ROOT" +rm -rf \ + src/vendor/Microsoft.AndroidX.Compose/obj src/vendor/Microsoft.AndroidX.Compose/bin \ + src/Comet/obj src/Comet/bin \ + "$APP/obj" "$APP/bin" + +exec dotnet build "$APP" -f net11.0-android -t:Run -p:AndroidPackageFormat=apk diff --git a/src/Comet/tools/smoke/.gitignore b/src/Comet/tools/smoke/.gitignore new file mode 100644 index 000000000..89f9ac04a --- /dev/null +++ b/src/Comet/tools/smoke/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/src/Comet/tools/smoke/jetchat.android.sh b/src/Comet/tools/smoke/jetchat.android.sh new file mode 100755 index 000000000..ee047a00f --- /dev/null +++ b/src/Comet/tools/smoke/jetchat.android.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Jetchat smoke: launch → walk the conversation screen → exercise scroll (real drag), +# jump-to-bottom, composer fill/clear → assert via Comet dev-agent element queries → +# screenshot each step. See tools/smoke/lib.sh for the helpers and conventions. +# +# Usage: ANDROID_SERIAL=emulator-5554 tools/smoke/jetchat.android.sh +source "$(dirname "$0")/lib.sh" + +PKG=com.comet.composeprobe +smoke_begin jetchat.android + +android_launch "$PKG" +agent_wait +sleep 2 # first composition + seed-scroll-to-newest + +# --- screen: conversation --------------------------------------------------- +android_shot 01-conversation +assert_element "channel title bar text" "type=Text&text=%23composers" +assert_element "member count" "type=Text&text=42%20members" +assert_element "composer text field" "type=TextField" +assert_element "message list" "type=ListView" + +# --- interaction: scroll away (REAL drag through the input pipeline) -------- +# The jump-to-bottom pill is a Comet.Fab that is ALWAYS composed; visibility is its +# reactive Opacity bound to the list's ScrolledAway signal (JetchatConversation.cs). +FAB_ID=$(element_id "type=Fab") +assert_element "jump-to-bottom FAB present" "type=Fab" +assert_prop "jump-to-bottom hidden at newest" "$FAB_ID" opacity 0 +drag 540 600 540 1800 400 # finger down = reveal older messages +sleep 1 +android_shot 02-scrolled-away +assert_prop "jump-to-bottom appears after scrolling away (ScrolledAway signal)" "$FAB_ID" opacity 1 + +# --- interaction: jump back to newest ---------------------------------------- +check "tap jump-to-bottom" tap "$FAB_ID" +sleep 2 +android_shot 03-jumped-back +assert_prop "jump-to-bottom hides again at newest" "$FAB_ID" opacity 0 + +# --- interaction: composer fill / clear -------------------------------------- +TF_ID=$(element_id "type=TextField") +check "fill composer" fill "$TF_ID" "smoke test message" +sleep 1 +android_shot 04-composer-filled +assert_element "composer holds the typed text" "type=TextField&text=smoke%20test%20message" +check "clear composer" clear_el "$TF_ID" +sleep 1 +assert_element "composer cleared" "type=TextField" + +smoke_end diff --git a/src/Comet/tools/smoke/jetchat.ios.sh b/src/Comet/tools/smoke/jetchat.ios.sh new file mode 100755 index 000000000..59c9d1c35 --- /dev/null +++ b/src/Comet/tools/smoke/jetchat.ios.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Jetchat iOS smoke: same walk as jetchat.android.sh on the CometSwiftUIProbe +# simulator build. Scroll-away is driven via the agent's scroll action (contentOffset +# on the backing UIScrollView — fires the shim's onScroll exactly like a finger); +# there is no iOS drag injector yet (Android's MotionEvent synthesis has no public +# UIKit equivalent — revisit at the first iOS sample that needs a true gesture). +# +# Usage: tools/smoke/jetchat.ios.sh (expects a booted simulator with the app installed) +source "$(dirname "$0")/lib.sh" + +BUNDLE=com.comet.swiftuiprobe +smoke_begin jetchat.ios + +ios_launch "$BUNDLE" +sleep 3 # first render + seed-scroll-to-newest +ios_agent_discover +agent_wait + +# --- screen: conversation --------------------------------------------------- +ios_shot 01-conversation +assert_element "channel title bar text" "type=Text&text=%23composers" +assert_element "member count" "type=Text&text=42%20members" +assert_element "composer text field" "type=TextField" +assert_element "message list" "type=ListView" + +# --- interaction: scroll away (agent scroll → shim onScroll → ScrolledAway) -- +FAB_ID=$(element_id "type=Fab") +assert_element "jump-to-bottom FAB present" "type=Fab" +assert_prop "jump-to-bottom hidden at newest" "$FAB_ID" opacity 0 +check "scroll up toward older messages" scroll_by -1200 +sleep 1 +ios_shot 02-scrolled-away +assert_prop "jump-to-bottom appears after scrolling away (ScrolledAway signal)" "$FAB_ID" opacity 1 + +# --- interaction: jump back to newest ---------------------------------------- +check "tap jump-to-bottom" tap "$FAB_ID" +sleep 2 +ios_shot 03-jumped-back +assert_prop "jump-to-bottom hides again at newest" "$FAB_ID" opacity 0 + +# --- interaction: composer fill / clear -------------------------------------- +TF_ID=$(element_id "type=TextField") +check "fill composer" fill "$TF_ID" "smoke test message" +sleep 1 +ios_shot 04-composer-filled +assert_element "composer holds the typed text" "type=TextField&text=smoke%20test%20message" +check "clear composer" clear_el "$TF_ID" +sleep 1 +assert_element "composer cleared" "type=TextField" + +smoke_end diff --git a/src/Comet/tools/smoke/jetnews.android.sh b/src/Comet/tools/smoke/jetnews.android.sh new file mode 100755 index 000000000..74403cd13 --- /dev/null +++ b/src/Comet/tools/smoke/jetnews.android.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# JetNews (M2) Android smoke: launch → Home asserts → Article round-trip → +# drawer → Interests (tab switch + topic toggle) → back to Home. Chrome that +# lives inside own-content nodes (drawer sheet pills, PrimaryTabRow tabs) is +# driven with coordinate taps (input swipe X Y X Y 120 — the device-tap method). +set -euo pipefail +cd "$(dirname "$0")" +source ./lib.sh + +PKG=com.comet.composeprobe +smoke_begin jetnews.android + +# Launch straight into the JetNews screen (intent extra picks the sample). +adb_ shell am force-stop "$PKG" +ACTIVITY=$(adb_ shell cmd package resolve-activity --brief "$PKG" | tail -1 | tr -d '\r') +adb_ shell am start -W -n "$ACTIVITY" --es screen jetnews > /dev/null +adb_ forward "tcp:${AGENT_HOST_PORT}" tcp:9223 > /dev/null +agent_wait +sleep 2 + +# ── Home (gold compact-01) ── +assert_element "wordmark" "type=Text&text=jetnews" +assert_element "top-stories header" "type=Text&text=Top stories for you" +assert_element "hero title" "type=Text&text=Redesigning the Android Studio Logo" +assert_element "popular section" "type=Text&text=Popular on Jetnews" +android_shot 01-home + +# ── Article round-trip (gold compact-07): tap the hero card ── +adb_ shell input swipe 640 776 640 776 120 +sleep 1.5 +assert_element "published-in bar" "type=Text&text=Published in:" +assert_element "publication name" "type=Text&text=Android Developers" +assert_element "article author" "type=Text&text=Android Studio Team" +android_shot 02-article +# Article body scroll (headers/bullets deep in the post). +drag 640 2200 640 700 300 +sleep 1 +android_shot 03-article-scrolled +# Back arrow (top-left) returns Home. +adb_ shell input swipe 71 229 71 229 120 +sleep 1.5 +assert_element "back on home" "type=Text&text=Top stories for you" + +# ── Drawer (gold compact-03): menu opens the REAL ModalNavigationDrawer ── +adb_ shell input swipe 71 229 71 229 120 +sleep 2 +assert_element "drawer home item" "type=Text&text=Home" +assert_element "drawer interests item" "type=Text&text=Interests" +android_shot 04-drawer + +# ── Interests (gold compact-04): drawer item → tabs + topics ── +adb_ shell input swipe 286 642 286 642 120 +sleep 2.5 +# (Tab labels render inside the real PrimaryTabRow facade — not registry-visible; +# tab function is proven by the People switch below.) +assert_element "interests title" "type=Text&text=Interests" +assert_element "topics section" "type=Text&text=Android" +assert_element "topic row" "type=Text&text=Jetpack Compose" +android_shot 05-interests + +# Tab switch: People (real PrimaryTabRow tab, coordinate tap). +adb_ shell input swipe 639 419 639 419 120 +sleep 1.5 +assert_element "people row" "type=Text&text=Kobalt Toral" +android_shot 06-people +# And back to Topics. +adb_ shell input swipe 213 419 213 419 120 +sleep 1.5 +assert_element "topics restored" "type=Text&text=Jetpack Compose" + +# ── Back to Home via drawer ── +adb_ shell input swipe 71 229 71 229 120 +sleep 2 +adb_ shell input swipe 286 462 286 462 120 # Home pill +sleep 2 +assert_element "home via drawer" "type=Text&text=Top stories for you" +android_shot 07-home-again + +# ── Expanded chrome (gold expanded-1260dp-01): rail + list-detail ── +trap android_resize_reset EXIT +android_resize 3780 2856 +sleep 4 +# (The REAL OutlinedTextField renders its placeholder facade-internally — assert the field.) +assert_element "expanded search field" "type=TextField" +assert_element "select-a-post placeholder" "type=Text&text=Select a post" +android_shot 08-expanded +# Open a post into the detail pane (hero title tap). +adb_ shell input swipe 794 800 794 800 120 +sleep 2 +assert_element "expanded article" "type=Text&text=Published in:" +android_shot 09-expanded-detail +android_resize_reset +sleep 4 +assert_element "compact restored" "type=Text&text=Top stories for you" + +smoke_end diff --git a/src/Comet/tools/smoke/jetnews.ios.sh b/src/Comet/tools/smoke/jetnews.ios.sh new file mode 100755 index 000000000..e07e9f6fe --- /dev/null +++ b/src/Comet/tools/smoke/jetnews.ios.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# JetNews iOS smoke (the M2 iOS gate): Home → Article round-trip → drawer → +# Interests (TabBar twin switch + topic toggle) → Home. The iOS chrome is +# composed from Comet views, so drawer items and tab labels ARE registered +# elements (unlike Android's facade-internal tab labels). +source "$(dirname "$0")/lib.sh" + +BUNDLE=com.comet.swiftuiprobe +smoke_begin jetnews.ios + +xcrun simctl terminate "$IOS_SIM" "$BUNDLE" 2>/dev/null || true +SIMCTL_CHILD_COMET_SCREEN=jetnews xcrun simctl launch "$IOS_SIM" "$BUNDLE" > /dev/null +sleep 4 +ios_agent_discover +agent_wait + +# --- Home (gold compact-01) --------------------------------------------------- +ios_shot 01-home +assert_element "wordmark vector" "type=Icon&text=jetnews_wordmark" +assert_element "top-stories header" "type=Text&text=Top stories for you" +assert_element "hero title" "type=Text&text=Redesigning the Android Studio Logo" + +# --- Article round-trip (hero tap → detail; back arrow → home) ----------------- +HERO=$(element_id "type=Text&text=Redesigning the Android Studio Logo") +check "tap hero" tap "$HERO" +sleep 2 +ios_shot 02-article +assert_element "published-in bar" "type=Text&text=Published in:" +assert_element "publication name" "type=Text&text=Android Developers" +assert_element "article author" "type=Text&text=Android Studio Team" +BACK=$(element_id "type=Icon&text=arrow_back") +check "tap article back" tap "$BACK" +sleep 2 +assert_element "back on home" "type=Text&text=Top stories for you" + +# --- Drawer (logo tap → sheet) -------------------------------------------------- +LOGO=$(element_id "type=Icon&text=jetnews_logo") +check "open drawer" tap "$LOGO" +sleep 2 +ios_shot 03-drawer +assert_element "drawer home item" "type=Text&text=Home" +assert_element "drawer interests item" "type=Text&text=Interests" + +# --- Interests: TabBar twin + toggle ------------------------------------------- +INTERESTS=$(element_id "type=Text&text=Interests") +check "open interests" tap "$INTERESTS" +sleep 2 +ios_shot 04-interests +assert_element "topics tab" "type=Text&text=Topics" +assert_element "topics section" "type=Text&text=Android" +assert_element "topic row" "type=Text&text=Jetpack Compose" + +PEOPLE=$(element_id "type=Text&text=People") +check "switch to People" tap "$PEOPLE" +sleep 2 +assert_element "people row" "type=Text&text=Kobalt Toral" +PERSON=$(element_id "type=Text&text=Kobalt Toral") +check "toggle person" tap "$PERSON" +sleep 1 +ios_shot 05-people-toggled +TOPICS=$(element_id "type=Text&text=Topics") +check "back to Topics" tap "$TOPICS" +sleep 2 +assert_element "topics restored" "type=Text&text=Jetpack Compose" + +# --- Home via drawer ------------------------------------------------------------ +LOGO=$(element_id "type=Icon&text=jetnews_logo") +check "reopen drawer" tap "$LOGO" +sleep 2 +HOME=$(element_id "type=Text&text=Home") +check "home via drawer" tap "$HOME" +sleep 2 +assert_element "home restored" "type=Text&text=Top stories for you" +ios_shot 06-home-again + +smoke_end diff --git a/src/Comet/tools/smoke/jetsnack.android.sh b/src/Comet/tools/smoke/jetsnack.android.sh new file mode 100755 index 000000000..e4f3ffb94 --- /dev/null +++ b/src/Comet/tools/smoke/jetsnack.android.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Jetsnack (M3) Android smoke: home feed → filters sheet → snack detail → +# search grid → cart (remove + totals) → profile → home. Chrome is the gold's +# own hand-composed design system, so most controls are registered Comet views; +# coordinate taps drive the few spots where element text repeats across rows. +set -euo pipefail +cd "$(dirname "$0")" +source ./lib.sh + +PKG=com.comet.composeprobe +smoke_begin jetsnack.android + +adb_ shell am force-stop "$PKG" +ACTIVITY=$(adb_ shell cmd package resolve-activity --brief "$PKG" | tail -1 | tr -d '\r') +adb_ shell am start -W -n "$ACTIVITY" --es screen jetsnack > /dev/null +adb_ forward "tcp:${AGENT_HOST_PORT}" tcp:9223 > /dev/null +agent_wait +sleep 2 + +# ── Home (gold compact-01) ── +assert_element "destination bar" "type=Text&text=Delivery to 1600 Amphitheater Way" +assert_element "highlight section" "type=Text&text=Android's picks" +assert_element "popular section" "type=Text&text=Popular on Jetsnack" +assert_element "filter chip" "type=Text&text=Gluten-free" +android_shot 01-home + +# ── Filters sheet (gold compact-03): filter circle → sheet → close ── +adb_ shell input swipe 105 410 105 410 120 +sleep 2 +assert_element "filters title" "type=Text&text=Filters" +assert_element "sort default row" "type=Text&text=Android's favorite (default)" +assert_element "price chip" "type=Text&text=\$\$\$\$" +android_shot 02-filters +adb_ shell input swipe 250 795 250 795 120 # X close +sleep 2 + +# ── Snack detail (gold compact-04): cupcake card → detail → back ── +adb_ shell input swipe 258 976 258 976 120 +sleep 2.5 +assert_element "detail price" "type=Text&text=\$2.99" +assert_element "details header" "type=Text&text=Details" +assert_element "see more" "type=Text&text=SEE MORE" +assert_element "add to cart" "type=Text&text=ADD TO CART" +android_shot 03-detail +adb_ shell input swipe 101 240 101 240 120 # up circle +sleep 2 +assert_element "back on feed" "type=Text&text=Android's picks" + +# ── Search (gold compact-06) ── +adb_ shell input swipe 570 2700 570 2700 120 +sleep 2 +assert_element "categories grid" "type=Text&text=Categories" +assert_element "lifestyles grid" "type=Text&text=Lifestyles" +assert_element "category card" "type=Text&text=Fruit snacks" +android_shot 04-search + +# ── Cart (gold compact-07): totals + row remove ── +adb_ shell input swipe 800 2700 800 2700 120 +sleep 2 +# The selected pill's label only exists while selected — proves the bar follows the tab. +assert_element "cart pill selected" "type=Text&text=MY CART" +assert_element "order header" "type=Text&text=Order (6 items)" +assert_element "cart row" "type=Text&text=Ice Cream Sandwich" +assert_element "summary total" "type=Text&text=\$58.13" +assert_element "checkout" "type=Text&text=Checkout" +android_shot 05-cart +# Remove the KitKat row (X at its top-right) → totals recompute. +adb_ shell input swipe 1185 1330 1185 1330 120 +sleep 2 +assert_element "recomputed header" "type=Text&text=Order (5 items)" +assert_element "recomputed total" "type=Text&text=\$52.64" +android_shot 06-cart-removed + +# ── Profile (gold compact-08 is the WIP screen) + home restore ── +adb_ shell input swipe 1100 2700 1100 2700 120 +sleep 2 +assert_element "profile wip" "type=Text&text=This is currently work in progress" +adb_ shell input swipe 220 2700 220 2700 120 +sleep 2 +assert_element "home restored" "type=Text&text=Android's picks" +android_shot 07-home-again + +smoke_end diff --git a/src/Comet/tools/smoke/jetsnack.ios.sh b/src/Comet/tools/smoke/jetsnack.ios.sh new file mode 100755 index 000000000..79ba46b9c --- /dev/null +++ b/src/Comet/tools/smoke/jetsnack.ios.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Jetsnack iOS smoke: feed → filters sheet → snack detail → search → cart math → +# profile → home. The chrome is Comet-composed on both platforms, so items are +# registered elements. +source "$(dirname "$0")/lib.sh" + +BUNDLE=com.comet.swiftuiprobe +smoke_begin jetsnack.ios + +xcrun simctl terminate "$IOS_SIM" "$BUNDLE" 2>/dev/null || true +SIMCTL_CHILD_COMET_SCREEN=jetsnack xcrun simctl launch "$IOS_SIM" "$BUNDLE" > /dev/null +sleep 4 +ios_agent_discover +agent_wait + +# --- Feed ---------------------------------------------------------------------- +ios_shot 01-home +assert_element "destination bar" "type=Text&text=Delivery to 1600 Amphitheater Way" +assert_element "highlight section" "type=Text&text=Android's picks" +assert_element "filter chip" "type=Text&text=Gluten-free" + +# --- Filters sheet -------------------------------------------------------------- +FILTER=$(element_id "type=Icon&text=filter_list") +check "open filters" tap "$FILTER" +sleep 2 +assert_element "filters title" "type=Text&text=Filters" +assert_element "sort default" "type=Text&text=Android's favorite (default)" +ios_shot 02-filters +CLOSE=$(element_id "type=Icon&text=close") +check "close filters" tap "$CLOSE" +sleep 2 + +# --- Snack detail ---------------------------------------------------------------- +CARD=$(element_id "type=Text&text=Cupcake") +check "open detail" tap "$CARD" +sleep 2 +assert_element "detail price" "type=Text&text=\$2.99" +assert_element "details header" "type=Text&text=Details" +assert_element "add to cart" "type=Text&text=ADD TO CART" +ios_shot 03-detail +BACK=$(element_id "type=Icon&text=arrow_back") +check "detail up" tap "$BACK" +sleep 2 +assert_element "back on feed" "type=Text&text=Android's picks" + +# --- Search ---------------------------------------------------------------------- +SEARCH=$(element_id "type=Icon&text=search") +check "search tab" tap "$SEARCH" +sleep 2 +assert_element "categories" "type=Text&text=Categories" +assert_element "lifestyles" "type=Text&text=Lifestyles" +ios_shot 04-search + +# --- Cart ------------------------------------------------------------------------ +CART=$(element_id "type=Icon&text=shopping_cart") +check "cart tab" tap "$CART" +sleep 2 +assert_element "order header" "type=Text&text=Order (6 items)" +assert_element "summary total" "type=Text&text=\$58.13" +assert_element "checkout" "type=Text&text=Checkout" +ios_shot 05-cart + +# --- Profile + home restore -------------------------------------------------------- +PROFILE=$(element_id "type=Icon&text=account_circle") +check "profile tab" tap "$PROFILE" +sleep 2 +assert_element "profile wip" "type=Text&text=This is currently work in progress" +HOME=$(element_id "type=Icon&text=home") +check "home tab" tap "$HOME" +sleep 2 +assert_element "home restored" "type=Text&text=Android's picks" +ios_shot 06-home-again + +smoke_end diff --git a/src/Comet/tools/smoke/lib.sh b/src/Comet/tools/smoke/lib.sh new file mode 100755 index 000000000..e545ceb58 --- /dev/null +++ b/src/Comet/tools/smoke/lib.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Shared helpers for the per-sample DevFlow smoke scripts (tools/smoke/..sh). +# Convention: each script launches the app, walks every screen, exercises each interaction, +# asserts via the Comet dev agent's tree/element queries, and screenshots each step into +# tools/smoke/out/./ — see docs/sample-workflow-checklist.md step 6. + +set -euo pipefail + +# --- Android --------------------------------------------------------------- + +# The Comet dev agent listens on 9223 IN the app; the host side of the forward defaults +# to 19223 because 9223 is commonly squatted on dev Macs (FoundrySt… does on David's). +: "${ANDROID_SERIAL:=emulator-5554}" +: "${AGENT_HOST_PORT:=19223}" +# Which simulator simctl targets — override with a UDID when two sims are booted +# ("booted" is ambiguous then and terminate/launch/screenshot all fail). +: "${IOS_SIM:=booted}" +# 127.0.0.1, NOT localhost: `adb forward` binds IPv4 only, and a Mac-side MAUI +# DevFlow process can squat the same port on IPv6 [::1] — curl to `localhost` +# prefers IPv6 and silently talks to the WRONG server ("agent did not come up"). +AGENT="http://127.0.0.1:${AGENT_HOST_PORT}" + +adb_() { adb -s "$ANDROID_SERIAL" "$@"; } + +android_launch() { # android_launch + local pkg="$1" + adb_ shell am force-stop "$pkg" + local activity + activity=$(adb_ shell cmd package resolve-activity --brief "$pkg" | tail -1 | tr -d '\r') + adb_ shell am start -W -n "$activity" > /dev/null + adb_ forward "tcp:${AGENT_HOST_PORT}" tcp:9223 > /dev/null +} + +agent_wait() { # poll the agent until it answers (app startup) + # 60s: the FIRST launch after an emulator boot carries full JIT warmup and + # can hold the agent's accept loop past 20s (backlog fills, then clears). + for _ in $(seq 1 120); do + if curl -s -m 2 "$AGENT/api/v1/agent/status" | grep -q '"running":true'; then + return 0 + fi + sleep 0.5 + done + echo "FAIL: dev agent did not come up on $AGENT" >&2 + return 1 +} + +# 30/60s: a loaded emulator can hold a tree query past 10s while the app is +# healthy — set -e turns one slow curl into a dead smoke run otherwise. +agent_get() { curl -s -m 30 "$AGENT$1"; } +agent_post() { curl -s -m 60 -X POST "$AGENT$1" -d "$2"; } + +# elements → the raw JSON array for ?type=&text=&automationId= +# (spaces in text= values are URL-encoded here so callers can write them naturally) +elements() { agent_get "/api/v1/ui/elements?${1// /%20}"; } + +# element_id → first matching element id (empty when none) +element_id() { elements "$1" | sed -n 's/.*"id":"\([0-9]*\)".*/\1/p' | head -1; } + +tap() { agent_post /api/v1/ui/actions/tap "{\"elementId\":\"$1\"}" | grep -q '"success":true'; } +longpress() { agent_post /api/v1/ui/actions/longpress "{\"elementId\":\"$1\"}" | grep -q '"success":true'; } +clear_el() { agent_post /api/v1/ui/actions/clear "{\"elementId\":\"$1\"}" | grep -q '"success":true'; } +fill() { agent_post /api/v1/ui/actions/fill "{\"elementId\":\"$1\",\"text\":$(printf '%s' "$2" | jq -Rs .)}" | grep -q '"success":true'; } +focus() { agent_post /api/v1/ui/actions/focus "{\"elementId\":\"$1\"}" | grep -q '"success":true'; } +back() { agent_post /api/v1/ui/actions/back '{}' | grep -q '"success":true'; } + +# drag [durationMs] — REAL input-pipeline drag, physical px +drag() { + agent_post /api/v1/ui/actions/drag \ + "{\"x1\":$1,\"y1\":$2,\"x2\":$3,\"y2\":$4,\"durationMs\":${5:-300}}" | grep -q '"success":true' +} + +# scroll_by — semantic scroll of the frontmost scrollable (dy<0 scrolls toward older/top) +scroll_by() { + agent_post /api/v1/ui/actions/scroll "{\"dy\":$1}" | grep -q '"success":true' +} + +android_shot() { # android_shot — screenshot into $OUT + adb_ exec-out screencap -p > "$OUT/$1.png" + echo " shot: $1" +} + +# android_resize — override the emulator display size (px). Drives the +# LayoutChange → CometWindowMetrics path, so adaptive size-class UI reflows — +# the smoke-script "resize verb" for Reply-style adaptive asserts. +# ALWAYS pair with android_resize_reset (trap it) or the emulator stays resized. +android_resize() { adb_ shell wm size "$1x$2"; sleep 2; } +android_resize_reset() { adb_ shell wm size reset; sleep 2; } + +# --- iOS (simulator) -------------------------------------------------------- + +# The sim shares the host loopback, so the agent is reached directly — but the default +# port 9223 is often held by a Mac-side process (a MAUI DevFlow agent squats it on +# David's machine), so CometDevAgent scans forward. Discover the COMET agent by probing. +ios_agent_discover() { + # 127.0.0.1 here too: the Comet agent binds IPv4-only, so an IPv6 [::1] + # squatter on the SAME port would win a `localhost` curl, fail the framework + # grep, and make discovery skip the real agent (the Android fix's twin). + for p in $(seq 9223 9232); do + if curl -s -m 1 "http://127.0.0.1:$p/api/v1/agent/status" | grep -q '"framework":"comet"'; then + AGENT_HOST_PORT=$p + AGENT="http://127.0.0.1:$p" + echo " agent: $AGENT" + return 0 + fi + done + echo "FAIL: no Comet dev agent found on localhost:9223-9232" >&2 + return 1 +} + +ios_launch() { # ios_launch + xcrun simctl terminate "$IOS_SIM" "$1" 2>/dev/null || true + xcrun simctl launch "$IOS_SIM" "$1" > /dev/null +} + +ios_shot() { # ios_shot + xcrun simctl io "$IOS_SIM" screenshot "$OUT/$1.png" > /dev/null 2>&1 + echo " shot: $1" +} + +# prop_of — a node's friendly prop (text/opacity/isOn/…) from the simple /tree route +prop_of() { + agent_get /tree | python3 -c " +import json,sys +for n in json.load(sys.stdin)['nodes']: + if n['id'] == $1: + print(n.get('props',{}).get('$2','')) + break" +} + +# --- assertions ------------------------------------------------------------ + +PASS=0 +FAIL=0 + +check() { # check + local desc="$1"; shift + if "$@"; then + PASS=$((PASS+1)); echo " ok: $desc" + else + FAIL=$((FAIL+1)); echo " FAIL: $desc" >&2 + fi +} + +assert_element() { # assert_element + local desc="$1" q="$2" + local id; id=$(element_id "$q") + if [ -n "$id" ]; then + PASS=$((PASS+1)); echo " ok: $desc (id=$id)" + else + FAIL=$((FAIL+1)); echo " FAIL: $desc — no element for '$q'" >&2 + fi +} + +assert_no_element() { # assert_no_element + local desc="$1" q="$2" + local id; id=$(element_id "$q") + if [ -z "$id" ]; then + PASS=$((PASS+1)); echo " ok: $desc" + else + FAIL=$((FAIL+1)); echo " FAIL: $desc — unexpected element id=$id for '$q'" >&2 + fi +} + +assert_prop() { # assert_prop + local desc="$1" id="$2" prop="$3" want="$4" + local got; got=$(prop_of "$id" "$prop") + if [ "$got" = "$want" ]; then + PASS=$((PASS+1)); echo " ok: $desc ($prop=$got)" + else + FAIL=$((FAIL+1)); echo " FAIL: $desc — $prop='$got', expected '$want'" >&2 + fi +} + +smoke_begin() { # smoke_begin . + OUT="$(cd "$(dirname "${BASH_SOURCE[1]}")" && pwd)/out/$1" + mkdir -p "$OUT" + echo "== smoke: $1 (out: $OUT) ==" +} + +smoke_end() { + echo "== $PASS passed, $FAIL failed ==" + [ "$FAIL" -eq 0 ] +} diff --git a/src/Comet/tools/smoke/reply.android.sh b/src/Comet/tools/smoke/reply.android.sh new file mode 100755 index 000000000..10b0be77b --- /dev/null +++ b/src/Comet/tools/smoke/reply.android.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Reply (M1) Android smoke: launch → inbox asserts → detail round-trip → adaptive +# chrome walk (rail @700dp, permanent drawer @1260dp) → route switch. Coordinate taps +# use the device-tap method (input swipe X Y X Y 120) for chrome widgets that live +# inside own-content nodes (nav items aren't individually registered Comet views). +set -euo pipefail +cd "$(dirname "$0")" +source ./lib.sh + +PKG=com.comet.composeprobe +smoke_begin reply.android + +# Launch straight into the Reply screen (intent extra picks the sample). +adb_ shell am force-stop "$PKG" +ACTIVITY=$(adb_ shell cmd package resolve-activity --brief "$PKG" | tail -1 | tr -d '\r') +adb_ shell am start -W -n "$ACTIVITY" --es screen reply > /dev/null +adb_ forward "tcp:${AGENT_HOST_PORT}" tcp:9223 > /dev/null +agent_wait +sleep 2 + +# ── Inbox (gold compact-01) ── +assert_element "search bar placeholder" "type=Text&text=Search emails" +assert_element "first email subject" "type=Text&text=Package shipped!" +assert_element "second email sender" "type=Text&text=Ali" +assert_element "compose FAB label" "type=Text&text=Compose" +android_shot 01-inbox + +# ── Detail round-trip (gold compact-02) ── +adb_ shell input swipe 640 640 640 640 120 # tap first email card +sleep 1.5 +assert_element "detail app bar message count" "type=Text&text=7 Messages" +assert_element "thread subject" "type=Text&text=Your update on Google Play Store is live!" +android_shot 02-detail +# SYSTEM back (KEYCODE_BACK → BackHandler → DetailClosed — the gold's close path). +# The agent's `back` action is PopNavigation, which no-ops here: Reply has no +# NavigationView (agent gap tracked in the backlog). +adb_ shell input keyevent KEYCODE_BACK +sleep 1.5 +assert_element "back on inbox" "type=Text&text=Search emails" + +# ── FAB collapse on scroll (gold compact-05): drag content up, label still composed +# (ExtendedFAB keeps both slots), then return to top. ── +drag 640 2000 640 800 300 +sleep 1.5 +android_shot 03-scrolled +drag 640 800 640 2000 300 +sleep 1 + +# ── Route switch: Articles via the bottom bar (coordinate tap) ── +adb_ shell input swipe 476 2703 476 2703 120 +sleep 1.5 +assert_element "coming-soon title" "type=Text&text=Screen under construction" +android_shot 06-coming-soon +adb_ shell input swipe 150 2703 150 2703 120 # back to Inbox +sleep 1.5 +assert_element "inbox restored" "type=Text&text=Package shipped!" + +# ── Adaptive chrome (gold medium-700dp / expanded-1260dp) ── +trap android_resize_reset EXIT +android_resize 2100 2856 +sleep 2 +assert_element "rail keeps inbox visible" "type=Text&text=Package shipped!" +android_shot 04-rail +android_resize 3780 2856 +sleep 2 +assert_element "permanent drawer wordmark" "type=Text&text=REPLY" +assert_element "drawer item label" "type=Text&text=Direct Messages" +assert_element "two-pane detail visible" "type=Text&text=7 Messages" +android_shot 05-drawer-twopane +android_resize_reset +trap - EXIT +sleep 3 +assert_element "inbox after resize walk" "type=Text&text=Package shipped!" + +# --- selection mode (long-press toggles; check avatar appears/disappears) ------ +ROW2=$(element_id "type=Text&text=Brunch this weekend?") +check "long-press row into selection" longpress "$ROW2" +sleep 2 +assert_element "selected check avatar" "type=Icon&text=check" +ROW2=$(element_id "type=Text&text=Brunch this weekend?") +check "long-press row out of selection" longpress "$ROW2" +sleep 2 +assert_no_element "selection cleared" "type=Icon&text=check" + +smoke_end diff --git a/src/Comet/tools/smoke/reply.ios.sh b/src/Comet/tools/smoke/reply.ios.sh new file mode 100755 index 000000000..5ff2cd2f2 --- /dev/null +++ b/src/Comet/tools/smoke/reply.ios.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Reply iOS smoke (the M1 iOS gate): structure/values/interaction parity on the +# CometSwiftUIProbe simulator build — inbox list, detail round-trip via the app bar +# back button, route switch through the suite's bottom bar (element taps: the iOS +# chrome is composed from Comet views, so items ARE registered elements). +# +# Usage: tools/smoke/reply.ios.sh (expects a booted simulator with the app installed; +# the app reads COMET_SCREEN=reply via simctl's SIMCTL_CHILD_ env prefix) +source "$(dirname "$0")/lib.sh" + +BUNDLE=com.comet.swiftuiprobe +smoke_begin reply.ios + +xcrun simctl terminate "$IOS_SIM" "$BUNDLE" 2>/dev/null || true +SIMCTL_CHILD_COMET_SCREEN=reply xcrun simctl launch "$IOS_SIM" "$BUNDLE" > /dev/null +sleep 4 +ios_agent_discover +agent_wait + +# --- inbox ------------------------------------------------------------------- +ios_shot 01-inbox +assert_element "search placeholder" "type=Text&text=Search emails" +assert_element "first email subject" "type=Text&text=Package shipped!" +assert_element "second email sender" "type=Text&text=Ali" +assert_element "compose FAB label" "type=Text&text=Compose" + +# --- detail round-trip (row tap → detail; app-bar back → list) ---------------- +ROW=$(element_id "type=Text&text=Package shipped!") +check "tap first email row" tap "$ROW" +sleep 2 +ios_shot 02-detail +assert_element "detail message count" "type=Text&text=7 Messages" +assert_element "thread subject" "type=Text&text=Your update on Google Play Store is live!" +BACK=$(element_id "type=Icon&text=arrow_back") +check "tap app-bar back" tap "$BACK" +sleep 2 +assert_element "back on inbox" "type=Text&text=Search emails" + +# --- route switch through the suite's bottom bar ------------------------------ +ARTICLES=$(element_id "type=Icon&text=article") +check "tap Articles item" tap "$ARTICLES" +sleep 2 +ios_shot 03-coming-soon +assert_element "coming-soon title" "type=Text&text=Screen under construction" +INBOX=$(element_id "type=Icon&text=inbox") +check "tap Inbox item" tap "$INBOX" +sleep 2 +assert_element "inbox restored" "type=Text&text=Package shipped!" + +# --- search: pill → expanded pane → typed query filters → close collapses ------ +PILL=$(element_id "type=Text&text=Search emails") +check "tap search pill" tap "$PILL" +sleep 2 +ios_shot 04-search-expanded +FIELD=$(element_id "type=TextField") +check "fill search query" fill "$FIELD" "Brunch" +sleep 2 +ios_shot 05-search-results +assert_element "search result subject" "type=Text&text=Brunch this weekend?" +CLOSE=$(element_id "type=Text&text=Close") +check "close search" tap "$CLOSE" +sleep 2 +assert_element "search pill restored" "type=Text&text=Search emails" + +# --- selection mode (long-press toggles; check avatar appears/disappears) ------ +ROW2=$(element_id "type=Text&text=Brunch this weekend?") +check "long-press row into selection" longpress "$ROW2" +sleep 2 +assert_element "selected check avatar" "type=Icon&text=check" +ROW2=$(element_id "type=Text&text=Brunch this weekend?") +check "long-press row out of selection" longpress "$ROW2" +sleep 2 +assert_no_element "selection cleared" "type=Icon&text=check" + +smoke_end