diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 910dee9..12d13ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,3 +106,59 @@ jobs: - name: Pack — Maui (dry run) run: dotnet pack src/KumikoUI.Maui/KumikoUI.Maui.csproj --no-restore -c Release -p:Version=0.0.0-ci -o C:\ci-pack + + # ─── Build KumikoUI.Uno (multi-head Uno library — all heads cross-compile on Windows) ─ + build-uno: + name: Build & Test (KumikoUI.Uno — all heads) + runs-on: windows-latest + needs: build-core + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 9 + 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 9.x + 10.x + + - name: Cache Uno workloads + uses: actions/cache@v4 + id: workload-cache + with: + path: | + ${{ env.USERPROFILE }}\.dotnet\toolResolverCache + ${{ env.PROGRAMFILES }}\dotnet\packs + ${{ env.PROGRAMFILES }}\dotnet\metadata\workloads + ${{ env.PROGRAMFILES }}\dotnet\sdk-manifests + key: uno-workloads-win-${{ hashFiles('**/KumikoUI.Uno.csproj') }} + restore-keys: uno-workloads-win- + + - name: Restore Uno workloads + if: steps.workload-cache.outputs.cache-hit != 'true' + run: dotnet workload restore src/KumikoUI.Uno/KumikoUI.Uno.csproj + + - name: Restore + run: dotnet restore src/KumikoUI.Uno/KumikoUI.Uno.csproj + + - name: Build — Uno + run: dotnet build src/KumikoUI.Uno/KumikoUI.Uno.csproj + --no-restore -c Release + + # Validate packability (dry run — exercises the full multi-head pack) + - name: Pack — Uno (dry run) + run: dotnet pack src/KumikoUI.Uno/KumikoUI.Uno.csproj + --no-restore -c Release -p:Version=0.0.0-ci -o C:\ci-pack + + # Uno input-mapping unit tests (net9.0-desktop head) + - name: Test — Uno + run: dotnet test tests/KumikoUI.Uno.Tests/KumikoUI.Uno.Tests.csproj + -c Release --logger "trx" --results-directory TestResults + + - name: Upload Uno test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-uno + path: TestResults/ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 969772b..c76e76a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -167,6 +167,72 @@ jobs: path: artifacts/*.snupkg if-no-files-found: warn + # ───────────────────────────────────────────────────────────────────────────── + # 3b. Pack KumikoUI.Uno (multi-head Uno library — all heads pack on a Windows runner) + # ───────────────────────────────────────────────────────────────────────────── + pack-uno: + name: Pack KumikoUI.Uno (all heads) + runs-on: windows-latest + needs: resolve-version + env: + VERSION: ${{ needs.resolve-version.outputs.version }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 9 + 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 9.x + 10.x + + - name: Cache Uno workloads + uses: actions/cache@v4 + id: workload-cache + with: + path: | + ${{ env.USERPROFILE }}\.dotnet\toolResolverCache + ${{ env.PROGRAMFILES }}\dotnet\packs + ${{ env.PROGRAMFILES }}\dotnet\metadata\workloads + ${{ env.PROGRAMFILES }}\dotnet\sdk-manifests + key: uno-workloads-win-${{ hashFiles('**/KumikoUI.Uno.csproj') }} + restore-keys: uno-workloads-win- + + - name: Restore Uno workloads + if: steps.workload-cache.outputs.cache-hit != 'true' + run: dotnet workload restore src/KumikoUI.Uno/KumikoUI.Uno.csproj + + - name: Restore + run: dotnet restore src/KumikoUI.Uno/KumikoUI.Uno.csproj + + - name: Pack — KumikoUI.Uno + run: | + dotnet pack src/KumikoUI.Uno/KumikoUI.Uno.csproj ` + --no-restore ` + -c Release ` + -p:Version=${{ env.VERSION }} ` + --include-symbols ` + -p:SymbolPackageFormat=snupkg ` + -o ./artifacts + + - name: List artifacts + run: Get-ChildItem artifacts -Recurse | Format-Table Name, Length + + - name: Upload Uno package + uses: actions/upload-artifact@v4 + with: + name: nuget-uno + path: artifacts/*.nupkg + if-no-files-found: error + + - name: Upload Uno symbol package + uses: actions/upload-artifact@v4 + with: + name: snupkg-uno + path: artifacts/*.snupkg + if-no-files-found: warn + # ───────────────────────────────────────────────────────────────────────────── # 4. Run tests before publishing (gate publish on green tests) # ───────────────────────────────────────────────────────────────────────────── @@ -195,7 +261,7 @@ jobs: publish: name: Publish to NuGet.org runs-on: ubuntu-latest - needs: [ pack-core, pack-maui, test ] + needs: [ pack-core, pack-maui, pack-uno, test ] environment: nuget-publish # optional: require manual approval in repo settings steps: diff --git a/KumikoUI.sln b/KumikoUI.sln index 5290837..33268b0 100644 --- a/KumikoUI.sln +++ b/KumikoUI.sln @@ -11,10 +11,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KumikoUI.SkiaSharp", "src\K EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KumikoUI.Maui", "src\KumikoUI.Maui\KumikoUI.Maui.csproj", "{529B41A2-C3BD-4CE7-93C5-0804CF591AC8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KumikoUI.Uno", "src\KumikoUI.Uno\KumikoUI.Uno.csproj", "{9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleApp.Maui", "samples\SampleApp.Maui\SampleApp.Maui.csproj", "{4F540791-EA67-4539-842A-B9C7BF925F9A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleApp.Uno", "samples\SampleApp.Uno\SampleApp.Uno.csproj", "{70A42A2C-0C67-4D56-9D61-AF5A6A11B913}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KumikoUI.Core.Tests", "tests\KumikoUI.Core.Tests\KumikoUI.Core.Tests.csproj", "{5565DAEA-A170-462E-B2F5-0BDD6878319C}" @@ -32,6 +36,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{DFDD4E5D .github\workflows\publish.yml = .github\workflows\publish.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KumikoUI.Uno.Tests", "tests\KumikoUI.Uno.Tests\KumikoUI.Uno.Tests.csproj", "{57855B3A-D369-4D2D-8B43-FA09549FF955}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -78,6 +84,18 @@ Global {529B41A2-C3BD-4CE7-93C5-0804CF591AC8}.Release|x64.Build.0 = Release|Any CPU {529B41A2-C3BD-4CE7-93C5-0804CF591AC8}.Release|x86.ActiveCfg = Release|Any CPU {529B41A2-C3BD-4CE7-93C5-0804CF591AC8}.Release|x86.Build.0 = Release|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Debug|x64.Build.0 = Debug|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Debug|x86.Build.0 = Debug|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Release|Any CPU.Build.0 = Release|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Release|x64.ActiveCfg = Release|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Release|x64.Build.0 = Release|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Release|x86.ActiveCfg = Release|Any CPU + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}.Release|x86.Build.0 = Release|Any CPU {4F540791-EA67-4539-842A-B9C7BF925F9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4F540791-EA67-4539-842A-B9C7BF925F9A}.Debug|Any CPU.Build.0 = Debug|Any CPU {4F540791-EA67-4539-842A-B9C7BF925F9A}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -90,6 +108,18 @@ Global {4F540791-EA67-4539-842A-B9C7BF925F9A}.Release|x64.Build.0 = Release|Any CPU {4F540791-EA67-4539-842A-B9C7BF925F9A}.Release|x86.ActiveCfg = Release|Any CPU {4F540791-EA67-4539-842A-B9C7BF925F9A}.Release|x86.Build.0 = Release|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Debug|x64.ActiveCfg = Debug|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Debug|x64.Build.0 = Debug|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Debug|x86.ActiveCfg = Debug|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Debug|x86.Build.0 = Debug|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Release|Any CPU.Build.0 = Release|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Release|x64.ActiveCfg = Release|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Release|x64.Build.0 = Release|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Release|x86.ActiveCfg = Release|Any CPU + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913}.Release|x86.Build.0 = Release|Any CPU {5565DAEA-A170-462E-B2F5-0BDD6878319C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5565DAEA-A170-462E-B2F5-0BDD6878319C}.Debug|Any CPU.Build.0 = Debug|Any CPU {5565DAEA-A170-462E-B2F5-0BDD6878319C}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -102,6 +132,18 @@ Global {5565DAEA-A170-462E-B2F5-0BDD6878319C}.Release|x64.Build.0 = Release|Any CPU {5565DAEA-A170-462E-B2F5-0BDD6878319C}.Release|x86.ActiveCfg = Release|Any CPU {5565DAEA-A170-462E-B2F5-0BDD6878319C}.Release|x86.Build.0 = Release|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Debug|x64.ActiveCfg = Debug|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Debug|x64.Build.0 = Debug|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Debug|x86.ActiveCfg = Debug|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Debug|x86.Build.0 = Debug|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|Any CPU.Build.0 = Release|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|x64.ActiveCfg = Release|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|x64.Build.0 = Release|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|x86.ActiveCfg = Release|Any CPU + {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -110,8 +152,11 @@ Global {6535D98A-3CBA-49C0-856A-18D3324DDB8E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {79969B24-4293-4D4F-82E1-27E61EFA152C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {529B41A2-C3BD-4CE7-93C5-0804CF591AC8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9C72BF6C-75B7-4F92-9B3A-B45AB43EC467} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {4F540791-EA67-4539-842A-B9C7BF925F9A} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {70A42A2C-0C67-4D56-9D61-AF5A6A11B913} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {5565DAEA-A170-462E-B2F5-0BDD6878319C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {DFDD4E5D-1418-41C2-A6CE-2A6397026499} = {17B595E7-34C1-4B92-9FD3-316E5A57E051} + {57855B3A-D369-4D2D-8B43-FA09549FF955} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/docs/uno/01-prerequisites.md b/docs/uno/01-prerequisites.md new file mode 100644 index 0000000..102fd44 --- /dev/null +++ b/docs/uno/01-prerequisites.md @@ -0,0 +1,110 @@ +# 01 — Prerequisites & Tooling + +Goal: a machine (and CI runner) that can scaffold and build every Uno head. Do this once per +environment before touching project files. + +> The Uno port targets `net9.0-*` heads (see [ADR-4](README.md#2-architecture-decision-record)). The +> `.NET 10` SDK can build `net9.0` target frameworks, so a single recent SDK covers the whole `src/` +> stack. + +> **Environment status — verified 2026-06-06 (this macOS dev machine):** +> - ✅ .NET SDK `10.0.x` present (builds `net9.0` TFMs) · ✅ Uno templates (`unoapp`, `unolib`, `unoapp-uitest`) · ✅ `uno-check` 1.33.1 +> - ✅ Workloads `ios`, `macos` installed · ❌ `android`, `wasm-tools`, `maccatalyst` **not installable here** — `dotnet workload install` returns *"Inadequate permissions. Run the command with elevated privileges."* (the SDK lives in `/usr/local/share/dotnet`; no non-interactive `sudo` is available in this session). +> - ➡️ **Local build/verify target: `net9.0-desktop`** (Skia desktop — no mobile workload required). The Android / WebAssembly / Apple / Windows heads are built & verified in **CI** ([08](08-ci-cd-packaging.md)), where runners install workloads with the needed privileges. To build them locally, run `sudo dotnet workload install android wasm-tools maccatalyst` yourself, then re-run the per-head builds. + +--- + +## 1. .NET SDK + +- [x] Install the **.NET 10 SDK** (or .NET 9 SDK). Verify: — *`10.0.100`–`10.0.300` present* + ```bash + dotnet --list-sdks + ``` +- [x] Decide the library TFM. Default plan: `net9.0` heads (matches `KumikoUI.Core` / `KumikoUI.SkiaSharp`). + +## 2. Uno Platform templates + +- [x] Install (or update) the Uno templates: — *already installed* + ```bash + dotnet new install Uno.Templates + ``` +- [x] Confirm the templates are available: — *`unoapp`, `unolib`, `unoapp-uitest` present* + ```bash + dotnet new list | grep -i uno + # Expect: unoapp, unolib, unomauilib, unoapp-uitest + ``` +- [x] (Optional) Inspect template options so the scaffold commands in [02](02-library-scaffold.md) and + [06](06-sample-app.md) match your installed version: — *captured (`unolib`/`unoapp` `-tfm`, `-platforms`, `-renderer` …)* + ```bash + dotnet new unolib -h + dotnet new unoapp -h + ``` + +## 3. `uno-check` — environment doctor + +`uno-check` validates and installs the workloads/SDK bits Uno needs. It is the canonical way to get +a machine build-ready. + +- [x] Install the tool: — *`uno-check` 1.33.1 present at `~/.dotnet/tools/uno-check`* + ```bash + dotnet tool install --global uno.check + ``` +- [ ] Run it and apply fixes: — ⚠️ *applying workload fixes needs elevated privileges here; run with `sudo` to install the missing workloads* + ```bash + uno-check + ``` + +## 4. Workloads + +Uno heads require these workloads. `uno-check` installs them, or do it manually: + +- [ ] `wasm-tools` — WebAssembly (`net9.0-browserwasm`) — ❌ *not installed here (needs elevation); verified in CI* +- [ ] `android` — Android (`net9.0-android`) — ❌ *not installed here (needs elevation); verified in CI* +- [x] `ios` — Apple head — *installed* · [ ] `maccatalyst` — ❌ *not installed here (needs elevation)* +- [x] Verify: — *ran `dotnet workload list` → `ios`, `macos` present* + ```bash + dotnet workload list + ``` + +```bash +# manual install (skip if uno-check handled it) — requires elevation on this machine: +sudo dotnet workload install wasm-tools android ios maccatalyst +``` + +## 5. Per-OS build constraints + +A single cross-targeted project can be *opened* anywhere, but some heads only **build** on a specific +OS. Plan CI runners accordingly (see [08](08-ci-cd-packaging.md)). + +| Head | macOS | Windows | Linux | +|---|:---:|:---:|:---:| +| `net9.0-desktop` | ✅ | ✅ | ✅ | +| `net9.0-browserwasm` | ✅ | ✅ | ✅ | +| `net9.0-android` | ✅ | ✅ | ✅ | +| `net9.0-ios` | ✅ | ❌ | ❌ | +| `net9.0-maccatalyst` | ✅ | ❌ | ❌ | +| `net9.0-windows10.0.xxxxx` | ❌ | ✅ | ❌ | + +> When building a single head locally, pass `-f`/`--framework` (e.g. `dotnet build -f net9.0-desktop`) +> to avoid building unavailable heads on the current OS. + +> **Library vs. app:** the table above is for building/running the **app** (`SampleApp.Uno`). A **class +> library** like `KumikoUI.Uno` cross-compiles more permissively — the `-ios`/`-maccatalyst` heads +> compile on Windows/Linux with the workloads installed (no Mac needed for managed library code); only +> the WinAppSDK `-windows10.0.x` head strictly needs Windows. This is why the whole library can be +> packed on one Windows runner — see [08 §1](08-ci-cd-packaging.md#1-packaging-fact-that-simplifies-everything). + +## 6. IDE (optional, for running heads) + +- [ ] Visual Studio 2022/2026 (Windows head) or JetBrains Rider, with the **Uno Platform** extension + for designer/hot-reload support. The CLI is sufficient for build/pack; an IDE is convenient for + debugging the Windows and mobile heads. + +--- + +## ✅ Exit criteria + +- [x] `dotnet --list-sdks` shows .NET 9 or 10. — *.NET 10 SDKs present* +- [x] `dotnet new list` shows `unolib` and `unoapp`. — *present* +- [x] `uno-check` tool installed (1.33.1). — *workload auto-fix requires elevation here; deferred to CI / manual `sudo`* +- [~] `dotnet workload list` shows the workloads for your target heads. — *`ios`/`macos` ✅; `android`/`wasm-tools`/`maccatalyst` ❌ (elevation-blocked → built in CI). **Local verify target: `net9.0-desktop`.*** diff --git a/docs/uno/02-library-scaffold.md b/docs/uno/02-library-scaffold.md new file mode 100644 index 0000000..17abf5b --- /dev/null +++ b/docs/uno/02-library-scaffold.md @@ -0,0 +1,234 @@ +# 02 — Library Scaffold (`KumikoUI.Uno`) + +Goal: a cross-targeted `src/KumikoUI.Uno` project, created with the `dotnet` CLI, referencing the +**unchanged** `KumikoUI.Core` and `KumikoUI.SkiaSharp`, packable to NuGet alongside the existing +packages. + +All commands are run from the repo root (`/Users/mstonis/Developer/KumikoUI`). + +--- + +## 1. Scaffold with the CLI + +> **`global.json` gotcha:** the `unolib` template can emit a `global.json` that pins the SDK +> (`msbuild-sdks.Uno.Sdk`) for the whole repo (it inherits upward). KumikoUI has no `global.json` +> today — keep it that way. Pass `-global-json false`. +> +> **Trade-off (resolved in §1a):** with `-global-json false` there is no SDK-version pin, so MSBuild +> cannot resolve the NuGet-delivered `Uno.Sdk` (`error MSB4236 / NETSDK: no version specified in the +> project or global.json`). We pin the version **inline in the `Sdk` attribute** instead — local to +> the project, nothing at the repo root. See §1a. + +- [x] Create the library: + ```bash + dotnet new unolib -o src/KumikoUI.Uno -n KumikoUI.Uno -tfm net9.0 -r skia -global-json false + ``` + - `-tfm net9.0` — align with Core/SkiaSharp ([ADR-4](README.md#2-architecture-decision-record)). + **Required:** the template defaults to `net10.0`, so this flag must be passed. Use `net10.0` only + if you also move the rest of `src/` there. + - `-r skia` — Skia renderer, consistent with the SkiaSharp drawing backend. + **Note:** the flag is `-r`/`--renderer` (the long-with-single-dash form `-renderer` is rejected by + the template CLI). + - The modern `unolib` template (Uno.Templates 6.5.31) produces an **`Uno.Sdk` single-project** + (``, `UnoSingleProject=true`, `UnoFeatures`), **not** a `Microsoft.NET.Sdk` + project. This is expected and good — it is the idiomatic Uno layout. + - The template emits exactly two files: `KumikoUI.Uno.csproj` and a sample `Class1.cs` (removed in §5). + With `-global-json false` **no `global.json` is written** anywhere. +- [x] Confirm no stray `global.json` was added at the repo root (or anywhere): + ```bash + find . -name global.json -not -path '*/obj/*' -not -path '*/bin/*' # expect no output + ``` + +### 1a. Pin the `Uno.Sdk` version inline (no `global.json`) + +Because we skipped `global.json`, pin the SDK version in the project's `Sdk` attribute so MSBuild can +resolve it. The version matches the template (`Uno.Templates`/`Uno.Sdk` 6.5.31): + +- [x] In `KumikoUI.Uno.csproj`: + ```xml + + ``` + +## 2. Wire into the solution & reference Core + SkiaSharp + +> **CLI gotcha (deviation):** both `dotnet sln add` and `dotnet add … reference` first **evaluate** +> the target project, and that evaluation fails for an `Uno.Sdk` project before its first restore: +> `The SDK 'Uno.Sdk' specified could not be found`. (The NuGet-delivered SDK isn't materialized until +> a restore runs, and these commands don't restore.) So both steps were done **by hand** instead — a +> direct `.sln` edit and inline `` entries. This is functionally identical to what +> the CLI would have written. + +- [x] Add to the solution **by editing `KumikoUI.sln` directly** (the `dotnet sln add` route fails as + above). Register a `Project(...)`/`EndProject` entry for `src\KumikoUI.Uno\KumikoUI.Uno.csproj` + (new GUID `{9C72BF6C-75B7-4F92-9B3A-B45AB43EC467}`, project-type GUID + `{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}` like the other C# projects), add its six + `ProjectConfigurationPlatforms` rows (all mapped to `Any CPU`, mirroring the sibling projects), and + nest it (see next item). +- [x] Reference the two reused projects (mirrors `KumikoUI.Maui`) **as inline ``** + in `KumikoUI.Uno.csproj` (the `dotnet add … reference` route fails as above): + ```xml + + + + + ``` + `UndefineProperties` stops this multi-head project's per-head `TargetFramework` from flowing into + the P2P build of the single-TFM (`net9.0`) Core/SkiaSharp projects (see §7 for the matching + build-command caveat). +- [x] In the solution, nest `KumikoUI.Uno` under the existing **`src`** solution folder by adding a + `NestedProjects` row: `{9C72BF6C-75B7-4F92-9B3A-B45AB43EC467} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}` + (the `src` folder GUID), matching `KumikoUI.Core/SkiaSharp/Maui`. + +## 3. Confirm the cross-target set + +- [x] Open `src/KumikoUI.Uno/KumikoUI.Uno.csproj`. The template (Uno.Sdk 6.5.31) generated this exact + head set — note it includes a **bare `net9.0` reference head** and does **not** include + `net9.0-maccatalyst` (this template version omits it; iOS covers Apple here), and the Windows + moniker is **`net9.0-windows10.0.26100`**: + ``` + net9.0 ; net9.0-ios ; net9.0-android ; + net9.0-windows10.0.26100 ; net9.0-browserwasm ; net9.0-desktop + ``` + > Deviation from the original list above: the doc anticipated `net9.0-maccatalyst` and a + > `…19041.0` Windows moniker and no bare `net9.0`. The template's actual output is authoritative and + > was kept as-is (no maccatalyst added, since its workload isn't installable in this environment and + > the template intentionally omits it). The bare `net9.0` is Uno's reference/tooling head and is the + > canonical packable slice. +- [x] Mirror MAUI's Windows-only conditioning so non-Windows machines/CI don't try to build the + Windows head (compare with [`src/KumikoUI.Maui/KumikoUI.Maui.csproj`](../../src/KumikoUI.Maui/KumikoUI.Maui.csproj) lines 4–5). + The Windows head is split out onto its own conditioned line: + ```xml + + net9.0;net9.0-ios;net9.0-android;net9.0-browserwasm;net9.0-desktop + $(TargetFrameworks);net9.0-windows10.0.26100 + ``` + +## 4. SkiaSharp.Views — provided idiomatically by `SkiaRenderer`, version-pinned (key gotcha) + +The control type Phase 03 needs is `SKXamlCanvas` (namespace `SkiaSharp.Views.Windows`), which lives +in `SkiaSharp.Views.Uno.WinUI` (all non-Windows heads) and `SkiaSharp.Views.WinUI` (the `-windows` +head). The namespace/type is identical across heads — only the *package* differs. + +**Deviation from the original plan (verified against the Uno.Sdk 6.5.31 targets):** the modern +`Uno.Sdk` single-project pulls `SkiaSharp.Views.Uno.WinUI` in **implicitly** from the +`SkiaRenderer` we already have, so a manual non-Windows `PackageReference` +is **not** needed. The relevant rule in +`Uno.Implicit.Packages.ProjectSystem.Uno.targets` is commented *"SkiaSharp.Views are always included +on Skia Renderer targets"* and fires when `UnoFeatures` contains `;skiarenderer;`. Two adjustments are +still required: + +- [x] **Pin the version to `3.119.2`** (matching + [`KumikoUI.SkiaSharp`](../../src/KumikoUI.SkiaSharp/KumikoUI.SkiaSharp.csproj) and + [`KumikoUI.Maui`](../../src/KumikoUI.Maui/KumikoUI.Maui.csproj), to avoid native-binary conflicts). + Uno.Sdk 6.5.31 would otherwise apply its own group version **3.119.1** to the implicit SkiaSharp + packages. Override it with the SDK-recognised property: + ```xml + + 3.119.2 + + ``` + Verified: `project.assets.json` resolves `SkiaSharp.Views.Uno.WinUI/3.119.2` and `SkiaSharp/3.119.2`, + and the produced `.nupkg` lists `SkiaSharp.Views.Uno.WinUI 3.119.2` in every TFM dependency group. +- [x] **Add an explicit `SkiaSharp.Views.WinUI` only on the Windows head.** The `skiarenderer` + feature does **not** bring it in — the Windows gate in + `Uno.Implicit.Packages.ProjectSystem.WinAppSdk.targets` fires only for the `skia` / `lottie` / `svg` + features. Reference it explicitly so `SKXamlCanvas` resolves on Windows too (Uno dedups implicit vs + explicit, so this also holds the version at `3.119.2`): + ```xml + + + + ``` +- [x] **Verified `SKXamlCanvas` resolves at compile time** (not just that the empty project builds): + a temporary probe file referencing `typeof(global::SkiaSharp.Views.Windows.SKXamlCanvas)` was added, + built clean for `net9.0-desktop` (a missing type would be `CS0246`), then deleted so the project + stays empty for Phase 03. + +## 5. Library layout / assets & template cruft + +- [x] Enable Uno library layout so fonts and other assets resolve from the packaged library (needed by + [05 — Fonts & assets](05-fonts-and-assets.md)). **The template already sets this** — kept as-is: + ```xml + + true + + ``` +- [x] Remove the template sample cruft so the project is clean for Phase 03: deleted `Class1.cs`. No + `Themes/Generic.xaml` is needed (the drawn control has no `ControlTemplate`; Uno.Sdk does not require + a generic theme for a library that ships no templated control). After this, the only file under + `src/KumikoUI.Uno/` is `KumikoUI.Uno.csproj` — no `.cs`, ready for Phase 03. + +## 6. NuGet metadata (mirror the sibling projects) + +- [x] Add package metadata to the `` (Authors/License/URLs/icon/README come for free + from [`Directory.Build.props`](../../Directory.Build.props) — verified inherited: the produced + `.nuspec` carries `authors=Eight-Bot, Inc.,Michael Stonis`, `license=MIT`, the GitHub `projectUrl`, + `README.md`, and `logo.png`): + ```xml + KumikoUI.Uno + 1.0.0 + A high-performance, fully custom-drawn KumikoUI DataGrid control for the Uno Platform. Renders via SkiaSharp (SKXamlCanvas) across Windows, Desktop, WebAssembly, Android, iOS, and Mac Catalyst — reusing the same engine as KumikoUI.Maui. + uno;unoplatform;winui;datagrid;grid;table;skiasharp;dotnet;cross-platform + ``` +- [x] **Opt the project back into packing.** `Uno.Sdk` defaults `IsPackable` to `false` for + single-projects (it ties it to `GeneratePackageOnBuild`), so `dotnet pack` silently produces no + package until this is set — unlike the `Microsoft.NET.Sdk` siblings, which are packable by default: + ```xml + true + ``` +- [x] Keep `ImplicitUsings`, `Nullable`, and `GenerateDocumentationFile` consistent with the other + `src/` projects (`enable`/`enable`/`true`). The template already sets `ImplicitUsings`/`Nullable` to + `enable`; `GenerateDocumentationFile=true` was added. + +## 7. Build verification + +> **Environment note:** only the `ios`/`macos` workloads are installed in the build environment +> (`android`/`wasm-tools`/`maccatalyst` are not, and can't be without sudo). The head that fully +> builds locally is **`net9.0-desktop`**. A bare `dotnet build` (all heads) is therefore **not** run — +> it would fail on the missing workloads. + +> **Scope with `-f`, not `-p:TargetFrameworks` (important):** scope a local build to one head with the +> `--framework` switch. `dotnet build … -p:TargetFrameworks=net9.0-desktop` **fails** here: +> `TargetFrameworks` is a *global* property that propagates into the `Microsoft.NET.Sdk` Core/SkiaSharp +> references at restore time, which reject the Uno `-desktop` platform id with +> `error NETSDK1139: The target platform identifier desktop was not recognized`. `-f`/`--framework` is +> applied to the entry project only and does not propagate, so it builds clean. + +- [x] Build the one head that is fully buildable in this environment (this was the authoritative + verification — **succeeds, 0 errors, 0 warnings attributable to `KumikoUI.Uno`**; the ~98 warnings + seen on a cold build are pre-existing `CS1591`/nullable warnings from the unchanged Core/SkiaSharp): + ```bash + dotnet build src/KumikoUI.Uno/KumikoUI.Uno.csproj -c Debug -f net9.0-desktop + ``` + (Release behaves identically; Debug was used for the recorded run.) +- [x] Pack dry-run (proves it is NuGet-packable, like the CI does for the other projects). The **full + multi-head pack succeeds locally** — `dotnet pack` only needs the workload *manifests* (present in + the SDK), not the full runtime workload packs an app build/run needs, so it packs all heads + including android/wasm here: + ```bash + dotnet pack src/KumikoUI.Uno/KumikoUI.Uno.csproj -c Release -p:Version=0.0.0-ci -o /tmp/uno-pack + ``` + Result: `Successfully created package '/tmp/uno-pack/KumikoUI.Uno.0.0.0-ci.nupkg'` containing + `lib/{net9.0, net9.0-android35.0, net9.0-browserwasm1.0, net9.0-desktop1.0, net9.0-ios18.0}/KumikoUI.Uno.dll`. + The Windows head is correctly absent (excluded off-Windows). Each TFM dependency group lists only + `KumikoUI.Core`, `KumikoUI.SkiaSharp`, and the Skia/Uno runtime packages. + > Do **not** pass `-p:TargetFrameworks=…` / `-p:TargetFramework=…` to scope the pack: the plural form + > hits the same NETSDK1139 dependency failure as in the build note; the singular form makes the Uno + > single-project pack target no-op (produces no `.nupkg`). Pack the full head set as shown. + +--- + +## ✅ Exit criteria + +- [x] `KumikoUI.Uno` is in `KumikoUI.sln` (nested under `src`) and references **only** `KumikoUI.Core` + + `KumikoUI.SkiaSharp` (confirmed in the `.nuspec` dependency groups — no other KumikoUI projects). +- [x] No `global.json` was introduced at the repo root (or anywhere — `find` returns none). The + `Uno.Sdk` version is pinned inline via `Sdk="Uno.Sdk/6.5.31"` instead. +- [x] The project builds for `net9.0-desktop` (verified, 0 errors). Other current-OS heads + (`net9.0`, `net9.0-ios`) also pack successfully; `android`/`wasm`/`maccatalyst` app *builds* are + blocked only by absent workloads, not by project config. +- [x] `dotnet pack` produces `KumikoUI.Uno.0.0.0-ci.nupkg` (full multi-head package). +- [x] `KumikoUI.Core`, `KumikoUI.SkiaSharp`, and `KumikoUI.Maui` are byte-for-byte unchanged (only + read, never edited; source mtimes remain at their May dates). + +➡️ Next: [03 — DataGridView host](03-datagridview-host.md) diff --git a/docs/uno/03-datagridview-host.md b/docs/uno/03-datagridview-host.md new file mode 100644 index 0000000..1db131f --- /dev/null +++ b/docs/uno/03-datagridview-host.md @@ -0,0 +1,285 @@ +# 03 — The `DataGridView` Host Control + +Goal: a single Uno control, `KumikoUI.Uno.DataGridView`, that hosts an `SKXamlCanvas`, drives the +reused `DataGridRenderer`, and exposes a `DependencyProperty` surface matching `KumikoUI.Maui`. + +Reference implementation to mirror: [`src/KumikoUI.Maui/DataGridView.cs`](../../src/KumikoUI.Maui/DataGridView.cs). + +--- + +## 1. Choose the base type (and why not a templated control) + +KumikoUI draws the **entire** grid onto one Skia surface — there are no child controls, named template +parts, or visual states. So the idiomatic WinUI "templated control + `Themes/Generic.xaml`" machinery +(from the [custom-controls guide](https://platform.uno/docs/articles/guides/creating-custom-controls.html)) +is unnecessary here. Mirror MAUI instead: a thin layout host that owns one canvas child. + +- [x] Declare the control deriving from a panel so the canvas can be added as a child (MAUI derives + from `Grid` and calls `Children.Add(_canvasView)` — do the same): + ```csharp + using Microsoft.UI.Xaml.Controls; + using SkiaSharp.Views.Windows; // SKXamlCanvas + SKPaintSurfaceEventArgs (both packages) + + namespace KumikoUI.Uno; + + public partial class DataGridView : Grid // Microsoft.UI.Xaml.Controls.Grid + { + private readonly SKXamlCanvas _canvasView = new(); + private readonly DataGridRenderer _renderer = new(); + // _dataSource, _scroll, _selection, _style, _editSession, _inputController — same Core types as MAUI + } + ``` + > Deviation: deriving from `Microsoft.UI.Xaml.Controls.Grid` pulls + > `Microsoft.UI.Xaml.Controls.SelectionModel` / `SelectionMode` into scope, colliding with + > Core's `KumikoUI.Core.Models.SelectionModel` / `SelectionMode`. Resolved with `using`-aliases + > at the top of `DataGridView.cs` and `DataGridView.Properties.cs`. +- [x] The `unolib` template ships a sample `MyTemplatedControl` + `Themes/Generic.xaml`. Remove the + sample control. Keep an (empty) `Themes/Generic.xaml` only if you later add stylable resources; + it is **not** required by the drawn `DataGridView`. + > N/A in this repo — Phase 02 scaffolded the project without the sample control or a + > `Themes/Generic.xaml`, so there was nothing to remove. The drawn control needs neither. + +## 2. Constructor wiring (parity with MAUI ctor) + +- [x] Configure and add the canvas, then subscribe to paint + `GridInputController` events: + ```csharp + public DataGridView() + { + _canvasView.HorizontalAlignment = HorizontalAlignment.Stretch; + _canvasView.VerticalAlignment = VerticalAlignment.Stretch; + Children.Add(_canvasView); + + // Auto-init bindable collections so XAML can add items directly (mirrors MAUI ctor). + Columns = new ObservableCollection(); + TableSummaryRows = new ObservableCollection(); + + _dataSource.DataChanged += OnDataSourceDataChanged; + + _canvasView.PaintSurface += OnPaintSurface; + + _inputController.NeedsRedraw += OnInputControllerNeedsRedraw; + _inputController.ColumnReordered += OnColumnReordered; + _inputController.RowReordered += OnRowReordered; + _inputController.AutoFitColumnRequested += OnAutoFitColumnRequested; + _inputController.KeyboardFocusRequested += OnKeyboardFocusRequested; + _inputController.FilterPopupOpened += OnFilterPopupOpened; + _inputController.FilterPopupClosed += OnFilterPopupClosed; + _inputController.EditSession = _editSession; + _editSession.Style = _style; + _editSession.NeedsRedraw += OnEditSessionNeedsRedraw; + + Loaded += OnLoaded; // (re)attach pointer/keyboard handlers — see 04 + Unloaded += OnUnloaded; // detach everything (mirror MAUI lines ~928/990) + } + ``` + > Notes vs. the skeleton above (all to keep full MAUI parity): + > - Also subscribed `_dataSource.DataChanged`, set `_editSession.Style`, and subscribed + > `_editSession.NeedsRedraw` — MAUI does all three in its ctor. + > - The `KeyboardFocusRequested` / `FilterPopupOpened` / `FilterPopupClosed` handlers are + > subscribed now (the controller raises them), but their bodies are **Phase 04** stubs (soft + > keyboard focus + cursor-blink timer) carrying a `// Phase 04:` comment. + > - MAUI's ctor also subscribes `_editSession.CellBeginEdit` / `CellEndEdit` to drive the + > cursor-blink timer and keyboard focus — those are **Phase 04** (timer/keyboard), so they are + > *not* wired here. The public `CellBeginEdit` / `CellEndEdit` events (§6) still forward + > straight from `_editSession`, so API parity holds. + +## 3. The paint loop (reuse SkiaSharp + Core verbatim) + +The event and args are the **same types** MAUI uses, so the body is copied almost verbatim from +[`DataGridView.OnPaintSurface`](../../src/KumikoUI.Maui/DataGridView.cs) (line ~610) — only DPI scaling +differs (see step 4). + +- [x] Implement (as shipped — adds `canvas.Save()/Restore()` around the scale and feeds the + renderer the logical viewport via `_scroll.ViewportWidth/Height`, exactly as MAUI does): + ```csharp + private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + var info = e.Info; + + var bg = _style.BackgroundColor; // Core GridColor + canvas.Clear(new SKColor(bg.R, bg.G, bg.B, bg.A)); + + // DPI: e.Info is in physical pixels; ActualWidth/Height are logical DIPs (step 4) + float scale = ActualWidth > 0 ? (float)(info.Width / ActualWidth) : 1f; + _scroll.ViewportWidth = (float)(ActualWidth > 0 ? ActualWidth : info.Width / scale); + _scroll.ViewportHeight = (float)(ActualHeight > 0 ? ActualHeight : info.Height / scale); + + canvas.Save(); + canvas.Scale(scale); + + using var drawingContext = new SkiaDrawingContext(canvas); // REUSED from KumikoUI.SkiaSharp + _renderer.Render( // REUSED from KumikoUI.Core + drawingContext, _dataSource, _scroll, _selection, _style, + _inputController.DragColumnIndex, _inputController.DragColumnScreenX, + _inputController.DragRowIndex, _inputController.DragRowScreenY, + _editSession, _inputController.PopupManager); + + canvas.Restore(); + } + ``` + > The `Render(...)` argument list matches `DataGridRenderer.Render` and the MAUI call site exactly. +- [x] Request a redraw on every Core "invalidate" signal: + ```csharp + private void InvalidateSurface() => _canvasView.Invalidate(); // MAUI: _canvasView.InvalidateSurface() + private void OnInputControllerNeedsRedraw() => _canvasView.Invalidate(); + private void OnEditSessionNeedsRedraw() => _canvasView.Invalidate(); + private void OnDataSourceDataChanged() => _canvasView.Invalidate(); + ``` + +## 4. DPI / scaling + +`SKXamlCanvas.PaintSurface` provides `e.Info` in **device pixels**, while `ActualWidth`/`ActualHeight` +are **logical DIPs**. KumikoUI's renderer is resolution-independent and is fed logical units (as in +MAUI). Bridge the two with a single `canvas.Scale(scale)` where `scale = e.Info.Width / ActualWidth`. + +- [x] Apply the scale transform before rendering (shown above). +- [ ] Re-invalidate on DPI changes: handle `XamlRoot.Changed` (or `RasterizationScale` changes) and + call `Invalidate()`. + > **Deferred to Phase 04.** `SKXamlCanvas` already re-rasterises (and raises `PaintSurface`) on + > size/scale changes, so the per-frame `scale = e.Info.Width / ActualWidth` keeps output correct + > across DPI today. A `XamlRoot`/`RasterizationScale` subscription needs the live visual tree, so + > it belongs with the rest of the runtime wiring attached in `OnLoaded`/`OnUnloaded` (Phase 04). +- [ ] **Verify** text stays crisp and hit-testing aligns with drawn cells at 100%, 150%, and 200% scale. + > **Deferred to Phase 06.** Requires the sample app (`SampleApp.Uno`) to render on a real surface; + > Phase 03's bar is a clean compile + fully wired paint loop. Hit-testing alignment is a Phase 04 + > concern (no pointer input is wired yet). + +## 5. `DependencyProperty` surface (parity with MAUI `BindableProperty`s) + +Map each MAUI `BindableProperty` to a WinUI `DependencyProperty`. Same names, same defaults; the +`PropertyChangedCallback` mutates Core state and calls `Invalidate()`. + +- [x] Register these (1:1 with [`DataGridView.cs`](../../src/KumikoUI.Maui/DataGridView.cs)). The + "Core type / target" column records the exact CLR type each DP registers and what its callback + mutates: + + | DependencyProperty | DP type | Default | Callback target (Core) | + |---|---|---|---| + | `ItemsSource` | `System.Collections.IEnumerable` | `null` | `_dataSource.SetItems(...)`; (un)subscribes `INotifyCollectionChanged` | + | `Columns` | `ObservableCollection` | `null` | `_dataSource.SetColumns(...)`; subscribes `CollectionChanged` | + | `TableSummaryRows` | `ObservableCollection` | `null` | `_dataSource.ClearTableSummaryRows()` + `AddTableSummaryRow(...)` | + | `GridSelectionMode` | `KumikoUI.Core.Models.SelectionMode` | `Extended` | `_selection.Mode` | + | `RowHeight` | `double` | `36` | `_style.RowHeight` (cast `double`→`float`) | + | `HeaderHeight` | `double` | `40` | `_style.HeaderHeight` (cast `double`→`float`) | + | `FrozenRowCount` | `int` | `0` | `_dataSource.FrozenRowCount` | + | `EditTriggers` | `KumikoUI.Core.Editing.EditTrigger` | `EditTrigger.Default` | `_editSession.EditTriggers` | + | `EditTextSelectionMode` | `KumikoUI.Core.Editing.EditTextSelectionMode` | `SelectAll` | `_editSession.TextSelectionMode` | + | `IsReadOnly` | `bool` | `false` | repaint (honored at edit time — Phase 04 gesture path) | + | `AllowSorting` | `bool` | `true` | repaint | + | `AllowFiltering` | `bool` | `true` | repaint | + | `DismissKeyboardOnEnter` | `bool` | `true` | `_editSession.DismissKeyboardOnEnter` | + | `GridDescription` | `string` | `"Data grid"` | `AutomationProperties.SetName(this, ...)` | + + > Deviations vs. the table the doc originally assumed: + > - **`EditTriggers`**: the Core type is the singular flags enum `EditTrigger` (not `EditTriggers`). + > The *DP* keeps the plural name `EditTriggers` (matches MAUI's `BindableProperty`); its CLR type + > is `EditTrigger`. + > - **`RowHeight` / `HeaderHeight`**: registered as `double` (the WinUI idiom for sizes) and cast to + > `float` for `DataGridStyle`. MAUI registered these as `float`; defaults (36 / 40) match. + > - **`GridDescription`**: there is no Core "GridDescription" type — it is a plain `string` + > accessibility label. MAUI routes it through `SemanticProperties.SetDescription`; the WinUI + > equivalent is `AutomationProperties.SetName`. + > - **`IsReadOnly` / `AllowSorting` / `AllowFiltering`**: MAUI exposes these as plain flags with no + > `propertyChanged` (read at gesture time). Here each callback just repaints so any affordance + > stays in sync; the read-only/sort/filter gesture enforcement is wired in Phase 04. + > - `MAUI`'s `[ContentProperty(nameof(Columns))]` was **not** ported — WinUI's XAML content-property + > model differs and it isn't needed for Phase 03 (Phase 06 sample binds `Columns` explicitly). + +- [x] Registration pattern (one example — repeat per property), implemented in the + `DataGridView.Properties.cs` partial: + ```csharp + public static readonly DependencyProperty ItemsSourceProperty = + DependencyProperty.Register( + nameof(ItemsSource), typeof(IEnumerable), typeof(DataGridView), + new PropertyMetadata(null, OnItemsSourceChanged)); + + public IEnumerable? ItemsSource + { + get => (IEnumerable?)GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var view = (DataGridView)d; + // …update _dataSource, (re)subscribe INotifyCollectionChanged… + view.Invalidate(); + } + ``` + > WinUI callbacks are `static` and receive the instance via `d` — unlike MAUI's `(bindable, old, new)` + > signature. The body logic is otherwise identical to the MAUI `propertyChanged` handlers. + +## 6. Public event parity + +- [x] Re-expose the same public events MAUI offers, forwarding from `InputController` / `_editSession` + (see [`DataGridView.cs`](../../src/KumikoUI.Maui/DataGridView.cs) lines ~1009–1037): + `RowTapped`, `RowDoubleTapped` (→ `_inputController`), `CellBeginEdit`, `CellEndEdit`, + `CellValueChanged` (→ `_editSession`). All five are implemented as add/remove forwarders, so the + events fire as soon as Phase 04 wires the input/edit gesture paths. + ```csharp + public event EventHandler? RowTapped + { + add => _inputController.RowTapped += value; + remove => _inputController.RowTapped -= value; + } + ``` + +## 7. Lifecycle + +- [x] On `Unloaded`: unsubscribe `PaintSurface` (its delegate roots `this`), plus — in Phase 04 — + pointer/keyboard handlers and any input-driven timers. The Core redraw-signal subscriptions + (`_dataSource` / `_inputController` / `_editSession`) target objects this control *owns*, so they + don't root the page and are left attached across navigation (re-subscribing them in `OnLoaded` + would risk double-firing). A `// Phase 04:` seam in `OnUnloaded` marks where the input teardown + lands. +- [x] On `Loaded`: idempotently (re)attach `PaintSurface` (detach-then-attach) and request a repaint + at the current DPI. A `// Phase 04:` seam marks where pointer/keyboard (re)attach lands. Uno raises + `Loaded`/`Unloaded` on navigation, so the attach/detach is written to be idempotent. + +## 8. Hosting extension + +- [x] Add `KumikoUIHostingExtensions` mirroring + [`DataGridHostingExtensions.cs`](../../src/KumikoUI.Maui/DataGridHostingExtensions.cs). Uno apps + configure services on the `IHostBuilder` from `IApplicationBuilder`: + ```csharp + using Microsoft.Extensions.Hosting; + + public static class KumikoUIHostingExtensions + { + // Place to register fonts / services; keeps app startup symmetric with MAUI's UseSkiaKumikoUI() + public static IHostBuilder UseKumikoUI(this IHostBuilder host) => host; + } + ``` + > Unlike MAUI, SkiaSharp needs no `UseSkiaSharp()` initializer on Uno — `SKXamlCanvas` self-hosts. + > The extension still earns its place as the documented spot for font registration (see [05](05-fonts-and-assets.md)). + > Deviation: the Uno.Sdk library head does **not** transitively reference the hosting abstractions, + > so `IHostBuilder` was unresolved. Added a direct `PackageReference` to + > `Microsoft.Extensions.Hosting.Abstractions` (9.0.4) in `KumikoUI.Uno.csproj` — it carries only the + > `IHostBuilder` contract (no runtime), so the documented signature stays intact and the extension + > stays minimal. + +--- + +## ✅ Exit criteria + +- [x] `DataGridView` compiles for `net9.0-desktop` with the paint loop + DP surface + lifecycle fully + wired (`dotnet build … -f net9.0-desktop` → Build succeeded, 0 warnings, 0 errors). Visual + rendering of a bound `ItemsSource` + `Columns` is verified in Phase 06 (needs the sample app). +- [x] Each `DependencyProperty` `PropertyChangedCallback` mutates the matching Core state and calls a + single `Invalidate()` (`_canvasView.Invalidate()`). Visible update is confirmed in Phase 06. +- [x] No SkiaSharp/Core source was modified — only `KumikoUI.Uno` files were added + (`DataGridView.cs`, `DataGridView.Properties.cs`, `KumikoUIHostingExtensions.cs`) plus one + `PackageReference` in `KumikoUI.Uno.csproj`. +- [ ] Rendering is crisp at 100/150/200% display scale. + > **Deferred to Phase 06** (requires a live surface from the sample app). The per-frame + > `canvas.Scale(e.Info.Width / ActualWidth)` is in place so the output is resolution-independent. + +> **Phase 04 carry-over (intentionally left unchecked above):** pointer + keyboard event wiring +> (`PointerPressed`/`PointerMoved`/`PointerReleased`/`PointerWheelChanged`, `KeyDown`, +> `CharacterReceived`), the `KeyboardFocusRequested` / `FilterPopupOpened` / `FilterPopupClosed` +> handler bodies, input-driven timers (inertial scroll, cursor blink, long-press), `IsReadOnly` / +> sort / filter gesture enforcement, `XamlRoot`/`RasterizationScale` DPI-change re-invalidation, and +> hit-test alignment verification. + +➡️ Next: [04 — Input, keyboard & focus](04-input-keyboard-focus.md) diff --git a/docs/uno/04-input-keyboard-focus.md b/docs/uno/04-input-keyboard-focus.md new file mode 100644 index 0000000..0ee0616 --- /dev/null +++ b/docs/uno/04-input-keyboard-focus.md @@ -0,0 +1,420 @@ +# 04 — Input, Keyboard & Focus + +Goal: translate Uno/WinUI pointer, keyboard, and focus events into the **existing** Core input API. +Core already owns all behavior; the host only adapts event shapes. No Core changes. + +Core entry points (unchanged): +`GridInputController.HandlePointer(GridPointerEventArgs, scroll, selection, style, dataSource)`, +`HandleKey(GridKeyEventArgs, …)`, `UpdateInertialScroll(scroll, 16f)`. + +> **Status:** Implemented for all heads (desktop, iOS, Android, WASM). Wiring lives in +> `src/KumikoUI.Uno/DataGridView.Input.cs` (pointer + keyboard + timer, a partial of `DataGridView`), +> `src/KumikoUI.Uno/DataGridView.Editing.cs` (hidden-TextBox proxy — the keyboard sink on all heads; +> see §3a), and `src/KumikoUI.Uno/Input/InputMapping.cs` (pure enum mapping). +> Build verified: `dotnet build … -f net9.0-desktop` → **0 errors, 0 new warnings**; +> `dotnet build … -f net10.0-desktop` (sample) → **0 errors**. +> WASM cannot be built locally (the `wasm-tools` workload is not installed in this environment); +> however, the fix is platform-agnostic — the proxy is a real `TextBox` on every Uno head, and its +> `TextChanged`/`KeyDown` fire reliably there. Actual WASM typing confirmation requires a browser +> environment. + +--- + +## 1. Pointer input + +WinUI delivers pointer events on any `UIElement`; subscribe on the `DataGridView` (or its +`SKXamlCanvas`). `GetCurrentPoint(this).Position` is already in **logical DIPs** — the same coordinate +space the renderer was fed in [03 §4](03-datagridview-host.md#4-dpi--scaling), so hit-testing lines up. + +> **Deviation (subscription target):** handlers are attached on the `DataGridView` itself (a +> `Grid`), **not** the child `SKXamlCanvas`. The control is the focusable element and the canvas +> `Stretch`-fills it, so coordinates and capture are identical, and a single attach/detach point +> keeps lifecycle clean. + +- [x] Subscribe (attach in `OnLoaded` via `AttachInputHandlers`, detach in `OnUnloaded` via + `DetachInputHandlers`): + `PointerPressed`, `PointerMoved`, `PointerReleased`, `PointerCanceled`, `PointerWheelChanged`, + `PointerExited` — **plus `PointerCaptureLost`** (treated as cancel; preserves fling momentum if a + pan was in flight). Both attach and detach are idempotent (`-=` then `+=`) so Uno re-raising + `Loaded` on navigation-back never double-subscribes. +- [x] Capture the pointer on press so drags (column resize/reorder, selection, scroll) keep tracking + outside the element: + ```csharp + private void OnPointerPressed(object sender, PointerRoutedEventArgs e) + { + CapturePointer(e.Pointer); + _lastPointerWasTouch = e.Pointer.PointerDeviceType == PointerDeviceType.Touch; + // … press-timing double-tap detection … + DispatchPointer(e, e.GetCurrentPoint(this), InputAction.Pressed, clickCount); + // Focus the proxy for non-touch input (see §3a). + if (!_lastPointerWasTouch) + FocusKeyboardInput(); + } + ``` +- [x] Build `GridPointerEventArgs` (mirrors MAUI's `OnCanvasTouch`, just a different source type): + ```csharp + private void DispatchPointer(PointerRoutedEventArgs e, PointerPoint point, InputAction action, int clickCount = 1) + { + var props = point.Properties; + var evt = new GridPointerEventArgs + { + X = (float)point.Position.X, + Y = (float)point.Position.Y, + Action = action, + Button = props.IsRightButtonPressed ? PointerButton.Secondary + : props.IsMiddleButtonPressed ? PointerButton.Middle + : PointerButton.Primary, + ScrollDeltaY = action == InputAction.Scroll ? props.MouseWheelDelta / WheelNotch : 0f, // WheelNotch = 120f + ScrollDeltaX = action == InputAction.Scroll && props.IsHorizontalMouseWheel + ? props.MouseWheelDelta / WheelNotch : 0f, + Modifiers = InputMapping.ToInputModifiers(e.KeyModifiers), + TimestampMs = (long)(point.Timestamp / 1000), // WinUI timestamp is microseconds + ClickCount = clickCount, + }; + _inputController.HandlePointer(evt, _scroll, _selection, _style, _dataSource); + e.Handled = evt.Handled; + } + ``` + > **Note:** `PointerRoutedEventArgs.KeyModifiers` **is** implemented on the Skia head (real backing + > field), so pointer-event modifiers are read directly off the args — unlike `KeyRoutedEventArgs` + > (see §2). `ScrollDeltaX` is populated only when `IsHorizontalMouseWheel` is set. +- [x] **Hover vs. drag (desktop fix):** WinUI/Uno raises `PointerMoved` on every mouse hover, even + with no button pressed. Core's `GridInputController.HandlePointerMove` has no concept of pointer + contact — it assumes `Moved` only arrives while a button or finger is down (the MAUI + `SKCanvasView` contract). Without a guard, plain hover reaches Core and is mis-read as a pan/drag. + Fix: `OnPointerMoved` checks `PointerPoint.IsInContact` and returns early when `false`. Real drags + (column resize/reorder, selection, pan-scroll) and touch drags all occur while in contact, so they + continue to receive `Moved` unaffected. Correspondingly, `DispatchPointer` now reports + `PointerButton.None` (instead of falling through to `Primary`) when no button is pressed, so any + stray non-contact event that somehow reaches `DispatchPointer` cannot be mis-read as a left-drag. +- [x] Map the action per event: `PointerPressed→Pressed`, `PointerMoved→Moved`, + `PointerReleased→Released` (+ `ReleasePointerCapture`), `PointerCanceled`/`PointerCaptureLost→Cancelled`, + `PointerWheelChanged→Scroll`. The fling timer is (re)started after `Released`/`Cancelled`/ + `CaptureLost` when `_inputController.IsInertialScrolling`. +- [x] **Wheel magnitude:** WinUI `MouseWheelDelta` is in 120-unit notches. We divide by **`WheelNotch = + 120f`** so one notch ⇒ `ScrollDeltaY = 1.0`; Core's `HandleScroll` then multiplies by + `RowHeight * ScrollSettings.WheelScrollMultiplier`, i.e. **one notch scrolls + `WheelScrollMultiplier` rows** — matching the MAUI feel (where `SKTouch.WheelDelta` is already a + small per-notch value fed straight in). The factor lives in one place and is easy to retune. +- [x] **Double-tap / click-count:** WinUI does not surface a click count on pointer args. We use the + **press-timing heuristic** (`< 400 ms` and `< 20 px` from the previous press → `ClickCount = 2`), + stamped on `Pressed` and carried to the matching `Released` — byte-for-byte the MAUI approach. + > **Deviation:** we deliberately do **not** use the WinUI `DoubleTapped` routed event for the click + > count: it fires *after* the second press/release pair (too late to stamp that release) and would + > mis-count a subsequent third click. The press-timing path is the single source of truth. +- [x] **Long-press (touch):** handled via WinUI `Holding` (`HoldingState.Started`) → dispatch + `InputAction.LongPress`. Gated to **touch only** (`PointerDeviceType.Touch`) and `IsHoldingEnabled + = true`; mouse/pen long-press is unnecessary because right-click already maps to + `PointerButton.Secondary`. `Holding` carries no timestamp, so a `Stopwatch`-derived microsecond + value is supplied to keep `TimestampMs` consistent. + +## 2. Modifier mapping + +- [x] `InputModifiers` is a `[Flags]` enum (`Shift`/`Control`/`Alt`/**`Meta`**). Mapping from WinUI + `VirtualKeyModifiers` is the **pure** `InputMapping.ToInputModifiers`: + ```csharp + public static InputModifiers ToInputModifiers(VirtualKeyModifiers mods) + { + var r = InputModifiers.None; + if ((mods & VirtualKeyModifiers.Shift) != 0) r |= InputModifiers.Shift; + if ((mods & VirtualKeyModifiers.Control) != 0) r |= InputModifiers.Control; + if ((mods & VirtualKeyModifiers.Menu) != 0) r |= InputModifiers.Alt; // Menu == Alt + if ((mods & VirtualKeyModifiers.Windows) != 0) r |= InputModifiers.Meta; // Windows/Cmd + return r; + } + ``` + > **Exact member names:** WinUI `VirtualKeyModifiers` = `None`/`Control`/`Menu`/`Shift`/`Windows`; + > Core `InputModifiers` = `None`/`Shift`/`Control`/`Alt`/`Meta`. `Menu→Alt`, `Windows→Meta`. +- [x] **Reading modifiers in `KeyDown`.** + > **Major deviation from the spec's first suggestion:** `KeyRoutedEventArgs.KeyboardModifiers` is + > marked **`[Uno.NotImplemented]`** on the Skia/WASM heads (the Uno source generator strips it from + > the public API surface there → it does **not** compile: `CS1061`). So we use the spec's *fallback* + > path — live key state via `InputMapping.GetLiveModifiers()`, which calls + > `InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey)` for Shift/Control/Menu/Win and + > tests `CoreVirtualKeyStates.Down`. This is also the **Microsoft-recommended** WinUI 3 way to read + > modifiers when a non-modifier key is pressed (`CoreWindow.GetKeyState` returns null in WinUI 3). + > Verified the Uno Skia `GetKeyStateForCurrentThread` is a real delegating implementation (forwards + > to `Uno.UI.Core.KeyboardStateTracker.GetKeyState`), **not** a `TryRaiseNotImplemented` stub like + > the instance `GetKeyState`/`GetCurrentKeyState` overloads. `GetLiveModifiers()` is wrapped in a + > defensive `try/catch (NotImplementedException)` returning `None`, so a keystroke can never throw + > on a head that lacks the lookup. + +## 3. Keyboard & focus — proxy-as-keyboard-sink design + +### Why `CharacterReceived` and `Grid` focus don't work on WASM/Skia + +Two independent issues make a plain `Grid`-based keyboard approach fail on WASM (and Skia targets): + +1. **`UIElement.CharacterReceived` is `[Uno.NotImplemented]` on Skia/WASM heads.** Uno's source + generator marks it as a no-op stub there — it simply never fires. Any type-to-edit design that + relies on `CharacterReceived` is dead on WASM. + +2. **`Grid` (a `Panel`) is not reliably keyboard-focusable on WASM.** WinUI/Uno's WASM focus model + does not guarantee `KeyDown` delivery to a `Panel`-derived element that has `IsTabStop = true`; + in practice `KeyDown` also fails to fire on WASM for a `Grid` host. + +Together these mean that when not editing: navigation keys are dead, and typed characters never reach +Core's Typing trigger (type-to-edit is broken). + +### Solution: proxy-as-keyboard-sink at all times for non-touch input + +The existing `_inputProxy` (`TextBox`) is a real focusable text element on **every** head. Its +`TextChanged` and `KeyDown` events fire reliably on WASM, Skia-desktop, Windows, iOS, and Android +because Uno maps a WinUI `TextBox` to the platform's native text field on each head. We promote it +from an edit-only sink to the **permanent keyboard sink for non-touch input**: + +- **Non-touch press (mouse/pen):** `OnPointerPressed` calls `FocusKeyboardInput()` *after* + `DispatchPointer` returns, so the proxy holds focus immediately after any click and stays focused + between interactions. Navigation arrows, command keys, and typed characters are all delivered there. +- **Touch press:** `FocusKeyboardInput()` is deliberately NOT called on touch presses. Focusing a + `TextBox` on a touch tap would summon the soft keyboard on every tap, not just on edit-begin. + The edit lifecycle (`CellBeginEdit`) remains the sole path that focuses the proxy on touch. +- **After edit ends (non-touch):** `OnEditSessionCellEndEdit` calls `FocusKeyboardInput()` instead of + `Focus(FocusState.Programmatic)` on the grid. This keeps the proxy focused, so arrow-key + navigation and type-to-edit work immediately without requiring another click. +- **After edit ends (touch):** `Focus(FocusState.Programmatic)` on the grid is kept — blurring the + proxy dismisses the soft keyboard on mobile, which is the correct behavior. + +```csharp +// OnPointerPressed (DataGridView.Input.cs) +CapturePointer(e.Pointer); +_lastPointerWasTouch = e.Pointer.PointerDeviceType == PointerDeviceType.Touch; +// … double-tap detection … +DispatchPointer(e, point, InputAction.Pressed, clickCount); +if (!_lastPointerWasTouch) + FocusKeyboardInput(); // proxy becomes the keyboard sink for this and all subsequent events + +// OnEditSessionCellEndEdit (DataGridView.cs) +ResetProxy(); +if (_lastPointerWasTouch) + Focus(FocusState.Programmatic); // touch: blur proxy → soft keyboard dismisses +else + FocusKeyboardInput(); // mouse/pen: proxy stays focused → arrows/typing keep working +``` + +### Why `CharacterReceived` is removed from the grid subscription + +`CharacterReceived` is now removed entirely from `AttachInputHandlers`/`DetachInputHandlers`. Two +reasons: + +1. On WASM/Skia it is `[Uno.NotImplemented]` — keeping it is misleading and does nothing. +2. On Windows/desktop it **bubbles**: a character typed while the proxy holds focus would travel up + the visual tree and fire *both* `OnProxyTextChanged` (correct) *and* the grid's + `OnCharacterReceived` (duplicate). Removing the grid subscription eliminates the double-fire. + +The grid's `KeyDown` handler is **kept** as a harmless fallback. `OnProxyKeyDown` marks all mapped +keys `e.Handled = true`, so they don't bubble to the grid's `OnKeyDown`; only unmapped keys (none, +in practice) would reach it. + +### `OnProxyTextChanged` — always-on forwarding (no edit-state guard) + +The previous `if (!_editSession.IsEditing) return;` guard has been removed. Text arriving at the +proxy is now forwarded via `HandleEditChar` regardless of edit state. Core's `HandleKey` owns the +"start edit vs. continue edit" decision: + +- Not editing: `HandleEditChar(ch)` → Core's Typing trigger → `CellBeginEdit` → proxy already + focused (the press focus-step already happened) → subsequent chars continue the edit via the + same `TextChanged` path. +- Already editing: `HandleEditChar(ch)` → Core appends to the editor value. Identical behavior. + +The sentinel-diff algorithm, Backspace detection, and `ResetProxy()` call are unchanged. + +### 3a. Hidden-TextBox proxy — full design (DataGridView.Editing.cs) + +**Proxy construction** (field initializer, added to `Children` in `SetupInputProxy()` called from ctor): +```csharp +private readonly TextBox _inputProxy = new TextBox +{ + Visibility = Visibility.Visible, // NOT Collapsed — collapsed elements cannot focus + Opacity = 0, + Width = 1, + Height = 1, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + IsSpellCheckEnabled = false, + IsTextPredictionEnabled = false, // Uno0001 no-op on some heads — safe + AcceptsReturn = false, + IsTabStop = false, + Text = ProxySentinel, // start with sentinel +}; +``` + +**Sentinel / suppression fields:** +```csharp +private const string ProxySentinel = "​"; // zero-width space U+200B +private bool _suppressProxyTextChanged; +private string _lastProxyText = ProxySentinel; +``` + +**`ResetProxy()`** — called after every input event and on edit-end: +```csharp +private void ResetProxy() +{ + _suppressProxyTextChanged = true; + _inputProxy.Text = ProxySentinel; + _lastProxyText = ProxySentinel; + _suppressProxyTextChanged = false; +} +``` + +**`FocusKeyboardInput()`** — called on `CellBeginEdit`, `OnKeyboardFocusRequested`, non-touch press, +and after edit-end on non-touch: +```csharp +private void FocusKeyboardInput() +{ + ResetProxy(); + _inputProxy.Focus(FocusState.Programmatic); +} +``` +On iOS/Android this summons the on-screen keyboard (focusing a `TextBox` triggers the platform +first-responder). On desktop/WASM it routes subsequent `KeyDown`/`TextChanged` events to the proxy. + +**`OnProxyTextChanged`** (soft-keyboard / IME / typed-char path): +1. Guard: `if (_suppressProxyTextChanged) return`. +2. Empty new text → Backspace (`HandleEditKey(GridKey.Backspace)`) + `ResetProxy()`. +3. Otherwise, extract newly inserted characters via sentinel-diff: + - Standard append (`sentinel + "a"`): take the suffix after `oldText`. + - IME full-replacement: strip the sentinel if embedded; use remaining text. +4. For each extracted char: `\n`/`\r` → `HandleEditKey(GridKey.Enter)`, `\t` → `HandleEditKey(GridKey.Tab)`, + otherwise → `HandleEditChar(ch)` which builds `GridKeyEventArgs { Key=None, Character=ch }`. +5. `ResetProxy()`. +6. **No edit-state guard** — Core decides whether to start or continue an edit. + +**`OnProxyKeyDown`** (hardware keyboard path — navigation and command keys): +Maps `e.Key` via `InputMapping.ToGridKey` and calls `HandleEditKey`; sets `e.Handled = true` for all +mapped keys. This prevents the TextBox from also acting on Backspace (deleting sentinel), Enter +(adding newline), Tab (moving focus), arrows (moving caret), etc., and prevents the mapped keys +from bubbling to the grid's `OnKeyDown`. + +**On `CellBeginEdit`:** +```csharp +StartCursorBlinkTimer(); +FocusKeyboardInput(); // proxy gets focus → soft keyboard appears on mobile +``` + +**On `CellEndEdit`:** +```csharp +ResetProxy(); +if (_lastPointerWasTouch) + Focus(FocusState.Programmatic); // touch: blur proxy → soft keyboard dismisses +else + FocusKeyboardInput(); // mouse/pen: proxy stays focused → keyboard sink maintained +``` + +**Handler subscription:** proxy `TextChanged`/`KeyDown` are attached idempotently in +`AttachInputHandlers` (detach-then-attach) and detached in `TeardownInputProxy` (called from +`DetachInputHandlers`/`OnUnloaded`) so navigation-back never double-subscribes. + +## 4. Keyboard paths — walk-through + +### Non-touch (mouse / pen / keyboard-only) + +| Scenario | Keyboard path | +|---|---| +| **Click a cell** | `OnPointerPressed` → `DispatchPointer` (Core selects cell) → `FocusKeyboardInput()` → proxy focused | +| **Arrow / Tab / Home / End** | `OnProxyKeyDown` → `InputMapping.ToGridKey` → `HandleEditKey` → Core navigates; `e.Handled=true` blocks TextBox caret movement | +| **Type a character (type-to-edit)** | Char appears in proxy → `OnProxyTextChanged` → `HandleEditChar(ch)` → Core Typing trigger → `CellBeginEdit` → `FocusKeyboardInput()` (proxy already focused) → edit continues via same path | +| **F2 (explicit edit start)** | `OnProxyKeyDown` → `HandleEditKey(GridKey.F2)` → Core → `CellBeginEdit` → `FocusKeyboardInput()` | +| **Double-click (edit start)** | `DispatchPointer(ClickCount=2)` → Core → `CellBeginEdit` → `FocusKeyboardInput()` | +| **Typing during edit** | Chars arrive at proxy `TextChanged` → `HandleEditChar(ch)` → Core editor appends | +| **Backspace during edit** | `OnProxyKeyDown` → `HandleEditKey(Backspace)` → `e.Handled=true` (no sentinel deletion) | +| **Enter / Escape / Tab during edit** | `OnProxyKeyDown` → `HandleEditKey` → Core commits/cancels → `CellEndEdit` → `ResetProxy()` + `FocusKeyboardInput()` (proxy stays focused) | +| **Arrow after edit ends** | Proxy still focused → `OnProxyKeyDown` → navigation continues seamlessly | + +### Touch + +| Scenario | Keyboard path | +|---|---| +| **Tap a cell** | `OnPointerPressed` (`_lastPointerWasTouch=true`) → `DispatchPointer` → Core selects cell; proxy NOT focused (no soft keyboard summon) | +| **Double-tap (edit start)** | `DispatchPointer(ClickCount=2)` → Core → `CellBeginEdit` → `FocusKeyboardInput()` → proxy focused → soft keyboard appears | +| **Long-press** | `OnHolding` → `LongPress` → Core context action | +| **Typing during edit** | Soft keyboard injects into proxy `TextChanged` → `HandleEditChar` → Core editor appends | +| **Backspace on soft keyboard** | Proxy text becomes empty → `OnProxyTextChanged` → `HandleEditKey(Backspace)` | +| **Done / Return on soft keyboard** | `\n` in `OnProxyTextChanged` → `HandleEditKey(GridKey.Enter)` → Core commits → `CellEndEdit` → `ResetProxy()` + `Focus(grid)` → soft keyboard dismisses | + +### No double-handling + +- `OnProxyKeyDown` sets `e.Handled = true` for every mapped key, so mapped keys don't bubble to + the grid's `OnKeyDown`. +- `CharacterReceived` is unsubscribed from the grid entirely — typed characters flow only through + `OnProxyTextChanged`. No character is delivered twice. +- `_suppressProxyTextChanged` and `_lastProxyText` guard `ResetProxy()`'s own `Text` assignment + from triggering another `TextChanged` round-trip. + +## 5. Inertial scroll timer + +- [x] A `Microsoft.UI.Xaml.DispatcherTimer` (~16 ms) pumps Core's physics and stops when it returns + `false` (mirrors MAUI's `_scrollTimer`): + ```csharp + private DispatcherTimer? _scrollTimer; + private void StartInertialScrollTimer() + { + if (_scrollTimer != null) return; + _scrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _scrollTimer.Tick += OnScrollTimerTick; + _scrollTimer.Start(); + } + private void OnScrollTimerTick(object? s, object e) + { + if (!_inputController.UpdateInertialScroll(_scroll, 16f)) StopInertialScrollTimer(); + _canvasView.Invalidate(); // keep the surface in lockstep, incl. the final stopping tick + } + ``` + > **Note:** the WinUI `DispatcherTimer.Tick` handler signature is `(object? sender, object e)` + > (not `EventArgs`). `Stop` + null-out the field in `StopInertialScrollTimer` so a re-fling + > re-creates a fresh timer (guard-on-`null` start, exactly like MAUI). +- [x] The same `DispatcherTimer` pattern drives the **cursor-blink** timer (530 ms). It is started on + `CellBeginEdit` and on `FilterPopupOpened`, invalidates while `IsEditing || _filterPopupActive`, + and self-stops otherwise; explicitly stopped on `CellEndEdit`/`FilterPopupClosed` (unless the other + is still active) and on `OnUnloaded`. + > **Note:** there is **no separate long-press timer** (unlike MAUI's `_longPressTimer`): WinUI's + > built-in `Holding` gesture provides the long-press, so no host-side timer is needed. The only + > input-driven timers are the inertial-scroll and cursor-blink ones — **both stopped in + > `DetachInputHandlers` (from `OnUnloaded`)**, so no timer roots the page across navigation. + +## 6. Per-target nuances + +| Head | Pointer | Keyboard / Editing | Notes | +|---|---|---|---| +| Desktop (Skia) / Windows | mouse + touch + pen | proxy `TextChanged` for all typing; proxy `KeyDown` for navigation/command keys; grid `KeyDown` as harmless fallback | Reference behavior. Non-touch press focuses proxy immediately; proxy is the keyboard sink at all times. | +| WebAssembly | mouse/touch OK | **proxy-only** — `CharacterReceived` is `[Uno.NotImplemented]`; grid focus unreliable for `KeyDown` | Proxy `TextChanged`/`KeyDown` fire on WASM. `GetLiveModifiers` wrapped in try/catch. | +| Android | touch only, no hover | **proxy-only** — `CharacterReceived` not reliable on Android IME | TextBox proxy handles all text input via `TextChanged`; sentinel-diff handles IME full-replacement. | +| iOS | touch only, no hover | **proxy-only** — `CharacterReceived` is `[Uno.NotImplemented]` on iOS | TextBox (`UITextField`) gains focus → `becomeFirstResponder` → soft keyboard. `Holding` provides long-press. | +| Mac Catalyst | trackpad + keyboard | full — same as desktop | Like desktop; validate momentum-scroll feel vs. the inertial timer. | + +> **Root cause note:** `UIElement.CharacterReceived` is `[Uno.NotImplemented]` on iOS (and WASM) — +> confirmed by Uno build warnings (`Uno0001`) on those heads. Additionally, `DataGridView` derives +> from `Grid` (a `Panel`), which is not reliably keyboard-focusable on WASM. The hidden-TextBox +> proxy (`_inputProxy`) fully replaces `CharacterReceived` and the `Grid`-focus approach for all +> heads. Since the proxy is a real `TextBox`, Uno maps its `TextChanged`/`KeyDown` to the native +> text-event pipeline on every platform — the fix is structurally correct and platform-agnostic. + +- [ ] Verify selection, keyboard navigation, editing, wheel + inertial scroll, column resize/reorder, + and row drag on **each** head's smoke test (see [07](07-tests.md)). *(Behavioral/visual + verification requires a simulator/device for iOS/Android and a browser for WASM.)* + +--- + +## ✅ Exit criteria + +- [x] Pointer + keyboard + focus + inertial/cursor-blink timers are fully wired on `net9.0-desktop` + and the project **compiles cleanly** (`dotnet build … -f net9.0-desktop` → 0 errors; the only + warnings are pre-existing `CS1591` doc warnings from `KumikoUI.Core`/`KumikoUI.SkiaSharp`). + Every input path constructs `GridPointerEventArgs`/`GridKeyEventArgs` correctly and calls the right + Core method; `VirtualKey→GridKey` mapping is complete; handlers attach on `Loaded` and fully detach + on `Unloaded`; all timers stop on `Unloaded`. +- [x] `CharacterReceived` removed from grid subscription — no double-fire risk on Windows; no dead + stub on WASM/iOS. All character input flows through `OnProxyTextChanged` only. +- [x] Proxy is the keyboard sink for non-touch input at all times (focused on every mouse/pen press + and after every edit-end); `OnProxyTextChanged` forwards characters regardless of edit state so + type-to-edit works without a separate `CharacterReceived` path. +- [x] Touch behavior is unchanged: proxy focused only on `CellBeginEdit`; soft keyboard dismissed + on `CellEndEdit` by re-focusing the grid. +- [ ] *(Phase 06/07)* Mouse/touch select, scroll (wheel + inertial), resize, reorder, and drag all + work behaviorally on `net9.0-desktop`. +- [ ] *(Phase 06/07)* Arrow/Tab/Enter/Escape navigation and typed cell editing work via keyboard. +- [ ] *(Phase 06/07)* Pointer coordinates align with drawn cells at all display scales. +- [x] Handlers are attached on `Loaded` and fully detached on `Unloaded` (no leaks across + navigation); idempotent re-attach on navigation-back. + +➡️ Next: [05 — Fonts & assets](05-fonts-and-assets.md) diff --git a/docs/uno/05-fonts-and-assets.md b/docs/uno/05-fonts-and-assets.md new file mode 100644 index 0000000..9c8c07d --- /dev/null +++ b/docs/uno/05-fonts-and-assets.md @@ -0,0 +1,137 @@ +# 05 — Fonts & Assets + +Goal: achieve the same custom-typeface support on Uno that the MAUI sample has (CJK via `NotoSansJP`, +icon glyphs via `MaterialIcons`), reusing `KumikoUI.SkiaSharp`'s `SkiaFontRegistrar` **unchanged**. + +Why this matters: SkiaSharp renders text with whatever typeface it can resolve. On Android in +particular, CJK and icon fonts garble without an explicitly registered typeface — exactly what +`SkiaFontRegistrar` solves (see commit history / [../RENDERING.md](../RENDERING.md)). + +--- + +## 1. The reused API (no changes) + +`KumikoUI.SkiaSharp.SkiaFontRegistrar` (static) is platform-agnostic — it just needs a `Stream` or an +`SKTypeface`: + +| Member | Use | +|---|---| +| `RegisterTypefaceFromStream(string family, Stream stream)` | Register a TTF/OTF from any stream (the common path). | +| `RegisterTypeface(string family, SKTypeface typeface)` | Register a pre-built typeface. | +| `TryGetTypeface(string family, out SKTypeface?)` | Used internally by `SkiaDrawingContext`. | +| `Clear()` | Reset (tests). | + +A family registered here is then usable from `DataGridStyle` / `GridFont.Family` (e.g. header/cell +fonts), identical to MAUI. + +## 2. Loading assets on Uno (the platform-specific bit) + +MAUI used `FileSystem.OpenAppPackageFileAsync(...)`. The Uno/WinUI equivalent is `StorageFile` with the +`ms-appx:///` scheme, which resolves correctly on every head. + +**Improvement over MAUI:** the MAUI sample carried an *app-private* `TryRegisterFont` helper inside +`MauiProgram` (every consumer copy-pastes it). On Uno the registration helper instead ships **in the +library** as `KumikoUI.Uno.KumikoFonts` (`src/KumikoUI.Uno/KumikoFonts.cs`), so every consumer reuses +one well-documented, XML-doc'd entry point. The library itself **still ships no fonts** — it stays +font-agnostic exactly like `KumikoUI.Maui`; only the *helper* moved into the library, not the assets. + +- [x] Reusable, library-provided helper (`KumikoFonts`) that mirrors the sample's `TryRegisterFont`: + ```csharp + public static async Task RegisterFromAppPackageAsync(string family, string appxUri) + { + try + { + var file = await StorageFile.GetFileFromApplicationUriAsync(new Uri(appxUri)); + using var randomAccessStream = await file.OpenReadAsync(); + using var stream = randomAccessStream.AsStreamForRead(); // Uno stream surface + SkiaFontRegistrar.RegisterTypefaceFromStream(family, stream); + } + catch (Exception ex) + { + // Non-fatal — the grid falls back to the system default font. + System.Diagnostics.Debug.WriteLine($"[KumikoUI] Could not register font '{family}' from '{appxUri}': {ex.Message}"); + } + } + ``` + > **Stream API note:** the helper uses `(await file.OpenReadAsync()).AsStreamForRead()`, **not** the + > `System.IO` `OpenStreamForReadAsync()` extension shown in the original draft above. That extension + > comes from the `System.Runtime.WindowsRuntime` interop shim, which is **not present on Uno's + > non-WinAppSDK heads**. `AsStreamForRead()` is part of Uno's own stream surface and was verified to + > compile uniformly on `net9.0-desktop` (and is available on the Windows head too). + > + > The helper also provides an `IEnumerable<(string Family, string AppxUri)>` batch overload and a + > `RegisterFromStream(string family, Stream)` pass-through (for fonts sourced outside the app package). +- [x] Register the same families the MAUI sample does (the consuming sample app calls these in Phase 06): + ```csharp + await KumikoFonts.RegisterFromAppPackageAsync("NotoSansJP", "ms-appx:///Assets/Fonts/NotoSansJP-Regular.ttf"); + await KumikoFonts.RegisterFromAppPackageAsync("MaterialIcons", "ms-appx:///Assets/Fonts/MaterialIcons-Regular.ttf"); + // …or in one batch call: + await KumikoFonts.RegisterFromAppPackageAsync(new[] + { + ("NotoSansJP", "ms-appx:///Assets/Fonts/NotoSansJP-Regular.ttf"), + ("MaterialIcons", "ms-appx:///Assets/Fonts/MaterialIcons-Regular.ttf"), + }); + ``` + +## 3. When to register (startup) + +- [x] Register **before** the first grid render. In the Uno app, `await` registration early in + `App.OnLaunched` (before activating the main window), or inside `UseKumikoUI()` startup. Because the + grid falls back to the system font, an acceptable alternative is fire-and-forget registration followed + by a `DataGridView.Invalidate()` once complete. + > Avoid `.GetAwaiter().GetResult()` blocking on Uno startup (unlike MAUI's pre-loop `CreateMauiApp`, + > Uno has a live dispatcher and could deadlock). Prefer `await`. + + `KumikoFonts.RegisterFromAppPackageAsync(...)` is `async`/awaitable by design (it does **not** block), + so it drops straight into an `await` in `App.OnLaunched`. Wiring this call into the sample's startup is + done in **[06 — Sample app](06-sample-app.md)**; this phase delivers the library helper it calls. + +## 4. Where the font files live + +- [x] **Consuming app (recommended, matches MAUI sample):** put TTFs under the app's `Assets/Fonts/` + and mark them as content so they ship with the package: + ```xml + + + + ``` + Reference with `ms-appx:///Assets/Fonts/.ttf`. This keeps `KumikoUI.Uno` font-agnostic, exactly + like `KumikoUI.Maui` (the library ships no fonts; the app supplies them). + > The library-side enabler for this — `KumikoFonts.RegisterFromAppPackageAsync` — is delivered in this + > phase. The sample app actually drops the TTFs under `Assets/Fonts/` and calls the helper in + > **[06 — Sample app](06-sample-app.md)**. + +## 5. Shipping assets *inside* the library (only if needed) — **NOT APPLICABLE here** + +> **Status: documented for completeness, intentionally not used.** `KumikoUI.Uno` ships **no** fonts +> (verified: no `.ttf`/`.otf` and no font/`Content` item-group in `KumikoUI.Uno.csproj`), so none of the +> rules below were exercised. They are retained as guidance for a consumer that chooses to ship default +> assets *inside a library* of their own. + +If a library itself must carry default assets, follow Uno's library-asset rules (Uno 4.6+): + +- [x] Set `true` (already added in [02 §5](02-library-scaffold.md#5-library-layout--assets)). + Present in `KumikoUI.Uno.csproj` regardless, since it also produces the proper `.xr.xml` layout. +- [ ] Reference library assets via `ms-appx:///KumikoUI.Uno/`. *(N/A — no library assets.)* +- [ ] **Lower-case the library name on non-WinAppSDK heads:** use `ms-appx:///kumikoui.uno/...` for + Android/iOS/WASM/Desktop, `ms-appx:///KumikoUI.Uno/...` for the Windows head. Branch on the target. + *(N/A — no library assets; recorded as the rule a consumer shipping library assets must follow.)* +- [ ] **MSIX caveat:** WinAppSDK does **not** serve library assets in MSIX-packaged mode — use the + unpackaged deployment mode for the Windows head if you rely on library-shipped assets. + *(N/A — no library assets.)* + +--- + +## ✅ Exit criteria + +- [ ] Japanese text renders correctly (no tofu/garbling) on Android and WASM, not just desktop. + *(Deferred to [Phase 06](06-sample-app.md) — runtime rendering is verified with the sample. This phase + delivers the registration helper that makes it possible and proves it compiles on `net9.0-desktop`.)* +- [ ] Material icon glyphs render in cells that use the `MaterialIcons` family. *(Deferred to Phase 06.)* +- [ ] Removing the registration cleanly falls back to the system font (no crash). *(Deferred to Phase 06; + the helper is non-fatal by construction — every app-package registration is wrapped in try/catch and + only logs via `Debug.WriteLine`.)* +- [x] `KumikoUI.Uno` ships no fonts unless §5 was deliberately chosen. §5 was **not** chosen — the + library ships no fonts; the app supplies them via `KumikoFonts`. + +➡️ Next: [06 — Sample app](06-sample-app.md) diff --git a/docs/uno/06-sample-app.md b/docs/uno/06-sample-app.md new file mode 100644 index 0000000..8848d51 --- /dev/null +++ b/docs/uno/06-sample-app.md @@ -0,0 +1,157 @@ +# 06 — Sample App (`SampleApp.Uno`) + +Goal: a runnable Uno app that exercises `KumikoUI.Uno` on every head, mirroring +[`samples/SampleApp.Maui`](../../samples/SampleApp.Maui). Created with the `dotnet` CLI. + +--- + +## 1. Scaffold with the CLI + +- [x] Create the app (all heads, XAML, Skia renderer). **Deviation: `-presentation none`** (code-behind), + not `-presentation mvvm`, and the `-theme material` flag was dropped (the `blank` preset takes no theme + flag). Rationale per Phase 06 priorities: mirror `SampleApp.Maui` (which is code-behind) and prioritize a + clean, runnable desktop build over MVVM purity — Uno.Extensions/MVVM scaffolding adds a host-builder layer + the sample doesn't need. Actual command run: + ```bash + dotnet new unoapp -o samples/SampleApp.Uno -n SampleApp.Uno \ + -preset blank -tfm net9.0 -markup xaml -presentation none \ + -platforms android ios wasm desktop windows -renderer skia + ``` + > The modern `unoapp` template nests the project one level deep (`samples/SampleApp.Uno/SampleApp.Uno/…`) + > and emits repo-root-style files at the output root. The project was **flattened** up one level so the + > csproj lives at `samples/SampleApp.Uno/SampleApp.Uno.csproj` (as the rest of this doc assumes). +- [x] **Reconcile generated solution-scaffolding files.** All of the following were generated at the output + root and removed during reconciliation: + - deleted the generated `samples/SampleApp.Uno/SampleApp.Uno.sln` (we use the root `KumikoUI.sln`); + - removed the generated `global.json` (keep the repo SDK-unpinned, per [ADR](README.md)) — its `Uno.Sdk` + pin (`6.5.31`) is instead inlined in the csproj `Sdk` attribute (`Sdk="Uno.Sdk/6.5.31"`), mirroring + `src/KumikoUI.Uno`; + - removed the generated `Directory.Packages.props` **and** `Directory.Build.props` — the latter enabled + Central Package Management (`ManagePackageVersionsCentrally=true`), which the repo does **not** use and + which would fight the root [`Directory.Build.props`](../../Directory.Build.props). Its `ImplicitUsings` / + `Nullable` / `NoWarn` settings were baked directly into the csproj instead; + - also removed generated `Directory.Build.targets`, `.gitignore`, `.editorconfig`, `.vsconfig`, `.vscode/`, + `.run/` from the output root (template cruft / would conflict with repo-level equivalents). + - confirmed the repo-root `Directory.Build.props` (package metadata) applying to the sample is harmless + (it is — the sample sets `IsPackable=false`). No `nuget.config` was generated. + - **Verified: no `global.json` / `Directory.Packages.props` / `nuget.config` at the repo root, and no stray + `*.sln` / CPM / `Directory.Build.*` left under `samples/SampleApp.Uno/`.** + +## 2. Wire into the solution & reference the library + +- [x] Add the app project to `KumikoUI.sln`. **Deviation: edited the `.sln` by hand** rather than + `dotnet sln add` (project GUID `{70A42A2C-0C67-4D56-9D61-AF5A6A11B913}`, `Any CPU`/`x64`/`x86` configs + mirroring `SampleApp.Maui`). The `dotnet add reference` was likewise done by hand in the csproj. As with + Phase 02, the CLI's pre-restore of the `Uno.Sdk` single-project is unreliable for these solution edits. +- [x] Reference the library: `ProjectReference` to `src/KumikoUI.Uno/KumikoUI.Uno.csproj` added to the csproj. + **Note:** referenced **without** `UndefineProperties` — `KumikoUI.Uno` is itself a multi-head Uno project + with the *same* head TFMs as the sample, so the per-head `TargetFramework` must flow through for the app's + `net9.0-desktop` to bind the library's matching `net9.0-desktop` output. (An initial attempt that copied the + library's own `UndefineProperties="TargetFramework;TargetFrameworks"` — which is correct only for its + single-TFM Core/SkiaSharp deps — caused `CS0234: KumikoUI.Uno does not exist` because the reference resolved + to no matching head.) The library transitively brings Core + SkiaSharp. +- [x] Nested under the **`samples`** solution folder (next to `SampleApp.Maui`), GUID + `{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}`. Verified `dotnet sln KumikoUI.sln list` parses and lists the + project. + +## 3. Assets & fonts + +- [x] Copied the demo fonts from the MAUI sample (`samples/SampleApp.Maui/Resources/Raw/`): + `NotoSansJP-Regular.ttf`, `MaterialIcons-Regular.ttf` → `samples/SampleApp.Uno/Assets/Fonts/`, + marked as `Content` via `` + ([05 §4](05-fonts-and-assets.md#4-where-the-font-files-live)). +- [x] Registered at startup in `App.OnLaunched` **before** `Activate()` — `OnLaunched` was made `async void` + and `await`s `KumikoFonts.RegisterFromAppPackageAsync((family, "ms-appx:///Assets/Fonts/…"))` for both + families ([05 §2–3](05-fonts-and-assets.md#2-loading-assets-on-uno-the-platform-specific-bit)). + +## 4. Demo pages (mirror the MAUI sample's coverage) + +Port a representative subset of the MAUI sample pages so each feature is demonstrable. Use XAML pages +with the same data/columns. **All six pages were ported** (none deferred), reachable from a +`NavigationView`-driven shell (`MainShell.xaml`) — the Uno analogue of `SampleApp.Maui`'s `AppShell` tabs. + +- [x] **Main / basic grid** — `BasicGridPage`: binds `ItemsSource` + `Columns`; every column type (Text, + Numeric, Boolean, Date, ComboBox, Picker, Template w/ progress bar + numeric-up-down), top/bottom table + summary rows, 2 frozen rows, frozen-left/right columns, runtime theme cycle + selection-mode toggle. + (← `MainPage`) +- [x] **Large data** — `LargeDataPage`: 10K/50K/100K loads generated off-thread then assigned in one shot + (single `RebuildView`), with timing readout. (← `LargeDataPage`) +- [x] **Grouping** — `GroupingPage`: group by Department/Level/both, expand/collapse all, toggle a group + summary row, toggle filtering — all via `kumiko.DataSource.*`. (← `GroupingPage`) +- [x] **Theming** — `ThemingPage`: built-in Light/Dark/HighContrast (via `DataGridTheme.Create(mode)`), three + hand-built custom palettes (Ocean/Forest/Sunset), and live header-colour RGB sliders. **Note:** the Uno + `DataGridView` exposes **no `Theme` DP** (unlike MAUI's `kumiko.Theme`); themes are applied by assigning the + produced `DataGridStyle` to `kumiko.GridStyle` — same end result. (← `ThemingPage`) +- [x] **MVVM actions** — `MvvmActionsPage`: per-column `EditTriggers` overrides + an Actions column + (`ActionButtonsCellRenderer` w/ Details/Delete). **Note:** uses the framework-agnostic Core + `ActionButtonDefinition.Command` (`ICommand`) built in code-behind with a small `RelayCommand` — **not** + MAUI's `MauiActionButtonDefinition` XAML binding (MAUI-specific). Confirmation/details use a WinUI + `ContentDialog` instead of MAUI's `DisplayAlert`/modal page. (← `MvvmActionsPage`) +- [x] **Custom fonts** — `CustomFontsPage`: Japanese (CJK) headers via `NotoSansJP` (`DataGridStyle` + header/cell fonts) + a Material-Icons status column via a small `IconFontCellRenderer : ICellRenderer`, + proving [05](05-fonts-and-assets.md). (← `CustomFontsPage`) + +XAML usage (declare the library namespace and drop the control in): +```xml + + + +``` + +## 5. Run each head + +> **The sample targets .NET 10** (`net10.0-*` heads), while the library `KumikoUI.Uno` stays on `net9.0` +> ([ADR-4](README.md#2-architecture-decision-record)). A `net10.0` app binds the library's `net9.0` heads via +> NuGet's **nearest-TFM match** (verified on the desktop head). This lets the sample run **natively on the +> .NET 10 runtime — no `DOTNET_ROLL_FORWARD` needed**, mirroring how `KumikoUI.Maui` (net10.0) consumes the +> net9.0 Core/SkiaSharp. + +| Head | Command | +|---|---| +| Desktop (Skia) | `dotnet run --project samples/SampleApp.Uno -f net10.0-desktop` | +| WebAssembly | `dotnet run --project samples/SampleApp.Uno -f net10.0-browserwasm` (serves a local URL) | +| Windows (WinAppSDK) | `dotnet run --project samples/SampleApp.Uno -f net10.0-windows10.0.26100` (or run from the IDE; use an `x64`/`ARM64` config) | +| Android | `dotnet build samples/SampleApp.Uno -t:Run -f net10.0-android` (emulator/device running) | +| iOS | `dotnet build samples/SampleApp.Uno -t:Run -f net10.0-ios` (macOS + simulator) | + +> macOS is served by the **Desktop (Skia)** head — the sample was scaffolded with `-platforms android ios wasm desktop windows` (no separate `maccatalyst` head, matching the library; see [02 §3](02-library-scaffold.md)). + +> Start with **Desktop** — fastest inner loop and the reference for input behavior. Validate WASM next +> (focus/keyboard), then the mobile heads (touch + soft keyboard), then Windows. + +### Desktop build + run — verified (.NET 10) + +- **Build:** `dotnet build samples/SampleApp.Uno/SampleApp.Uno.csproj -c Debug -f net10.0-desktop` → + **Build succeeded, 0 Error(s)** (the sample's `net10.0-desktop` head binds `KumikoUI.Uno`'s `net9.0-desktop` + output via NuGet nearest-TFM match). +- **Run smoke test:** `dotnet run --project samples/SampleApp.Uno -f net10.0-desktop` (**no + `DOTNET_ROLL_FORWARD`**) → the app launched and **ran natively on the installed .NET 10 runtime**, staying + alive through first render with zero exceptions. The macOS Skia windowing backend initialized + (`Uno.UI.Runtime.Skia.MacOS.MacOSWindowNative`); the only log output was non-fatal dev-server/HotReload + connection warnings (no IDE dev-server attached in a headless launch) and a cosmetic + missing-`iconStoreLogo.png` warning. No XAML parse error, no font/asset load failure, no grid-instantiation + or first-paint crash. Terminated cleanly via `SIGTERM` (exit 144). + > **Security note:** the `net10.0-desktop` head surfaces transitive `NU1903` advisories + > (`System.Security.Cryptography.Xml 10.0.2`, `Tmds.DBus.Protocol 0.21.2`) from the upstream Uno/.NET 10 + > desktop stack — build warnings, not errors. Track upstream package updates or pin patched versions if + > required by policy. +- Only `net10.0-desktop` is buildable/runnable locally (no android/wasm/ios workloads installed); the other + heads are CI-verified in Phase 08. + +--- + +## ✅ Exit criteria + +- [x] `SampleApp.Uno` launches on Desktop and renders the basic grid with live data. *(Verified: desktop build + 0/0 and a clean run-to-first-render smoke test — see above.)* +- [x] Sorting, selection, scrolling, grouping, theming, action buttons, and custom-font pages all + **implemented** (all six pages ported). Interactive behaviour is exercised by the library's own input layer + (Phase 04) and Core tests (Phase 07); the sample wires every feature to UI. *(Full hands-on interaction not + scriptable in this headless session — startup/first-render verified.)* +- [ ] The same app runs on WASM, Windows, and at least one mobile head. *(Deferred to CI — Phase 08; those + workloads aren't installed locally. The csproj targets all heads; Windows is Windows-only-conditioned.)* +- [x] The sample references `KumikoUI.Uno` only (which transitively brings Core + SkiaSharp). + +➡️ Next: [07 — Tests](07-tests.md) diff --git a/docs/uno/07-tests.md b/docs/uno/07-tests.md new file mode 100644 index 0000000..7050240 --- /dev/null +++ b/docs/uno/07-tests.md @@ -0,0 +1,115 @@ +# 07 — Tests + +Goal: prove the port didn't regress the engine, and add the lightest meaningful coverage for the new +Uno host. + +--- + +## 1. Core tests — unchanged, must stay green + +Because `KumikoUI.Core` and `KumikoUI.SkiaSharp` are **byte-for-byte unchanged**, the existing +[`tests/KumikoUI.Core.Tests`](../../tests/KumikoUI.Core.Tests) (xUnit) still fully covers layout, input +dispatch, models, selection, filtering, scrolling, etc. + +- [x] Run them and confirm green — this is the regression gate for "Core wasn't touched": + ```bash + dotnet test tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj -c Release + ``` + > **Result (Phase 07):** **GREEN — Failed: 0, Passed: 353, Skipped: 0, Total: 353.** + > Run unchanged against the existing suite, proving `KumikoUI.Core` is byte-for-byte intact and + > the engine still passes. Executed as `-c Debug` with `DOTNET_ROLL_FORWARD=LatestMajor` (this box + > has SDK 10.x but no `Microsoft.NETCore.App 9.0.x` runtime, so the net9.0 test host must roll + > forward to the 10.x runtime). `Debug` vs `Release` does not affect this pure-logic suite. +- [x] Do **not** add platform code to Core to make it "more testable" — that would violate the + separation of concerns. Host-specific logic belongs in `KumikoUI.Uno`. *(Core untouched; no new + Core types or test edits — only the new `tests/KumikoUI.Uno.Tests` project was added.)* + +## 2. Make the host's pure logic testable + +The only non-trivial logic in the host is the input mapping. Factor it into **pure static methods** so +it can be unit-tested without a live UI: + +- [x] Put `VirtualKey → GridKey` and `VirtualKeyModifiers → InputModifiers` in a small static class + (e.g. `KumikoUI.Uno.Input.InputMapping`) with no control state. *(Done in Phase 04: + `src/KumikoUI.Uno/Input/InputMapping.cs` — `ToGridKey`, `ToInputModifiers` are pure enum→enum + with zero `DataGridView`/element dependency.)* +- [x] These reference WinUI types, so the tests live in an **Uno runtime test project** (below), not in + `KumikoUI.Core.Tests` (which must stay WinUI-free). Assert the full key table from + [04 §3](04-input-keyboard-focus.md#3-keyboard--focus). + > **Result (Phase 07):** `tests/KumikoUI.Uno.Tests/InputMappingTests.cs` asserts the **entire** + > documented table: arrows · Home/End · PageUp/PageDown · Tab · Enter · Escape · Space · Delete · + > **Back→Backspace** · F2 · A/C/V/X/Z; modifiers Shift/Control/**Menu→Alt**/**Windows→Meta** plus + > combinations. A whole-enum sweep also asserts every *other* `VirtualKey` collapses to + > `GridKey.None`, so an accidental added/dropped mapping fails the suite. + > `GetLiveModifiers()` is **excluded** — it reads thread keyboard state via `InputKeyboardSource` + > and needs a live Uno runtime, which is out of scope for a pure unit test. + +## 3. Uno runtime / UI tests (optional but recommended) + +For host behavior that only exists at runtime (focus, pointer routing, redraw-on-property-change), +add an Uno test project: + +- [x] **Added `tests/KumikoUI.Uno.Tests`** — an **Uno single-project test library** (same + `Uno.Sdk/6.5.31` pin + `SkiaRenderer` feature as `src/KumikoUI.Uno`, but scoped to a **single** + `net9.0-desktop` head) with the xUnit runner + `Microsoft.NET.Test.Sdk`, referencing + `KumikoUI.Uno`. This shape is what makes the WinUI/Uno enums (`Windows.System.VirtualKey` / + `VirtualKeyModifiers`) resolve at compile time — a plain `Microsoft.NET.Sdk` xUnit project cannot + see those types. The `unoapp-uitest` (Playwright/Selenium) template was **not** used: it drives a + *running* app, which is heavier than (and orthogonal to) the pure enum-mapping coverage this phase + needs, and would not run headlessly here. + - **Tests RAN headlessly and PASSED:** `dotnet test tests/KumikoUI.Uno.Tests -f net9.0-desktop` + (with `DOTNET_ROLL_FORWARD=LatestMajor`) → **Failed: 0, Passed: 41, Skipped: 0, Total: 41.** + The methods under test are pure enum switches/flag-folds, so they execute without any Uno + runtime/`UIElement` initialization — only the enum *types* need to resolve, which the desktop + head provides. +- [x] Added to `KumikoUI.sln` (via `dotnet sln add … --solution-folder tests`; the sln still parses + and the project is nested under the `tests` folder alongside `KumikoUI.Core.Tests`). +- [ ] **Deferred (runtime UI assertions):** grid-renders-on-load, `ItemsSource`/`Columns` repaint, + keyboard-navigation-moves-selection, and typed-character-edits-cell are **live-UI** behaviors that + need a pumped Uno dispatcher + a rendered `SKXamlCanvas`. Those are **not** covered by this pure + unit suite; they remain the job of the per-head **smoke matrix** (§4) and CI device/desktop runs. + The grid's *logic* for these paths is already covered by `KumikoUI.Core.Tests` (selection, + input dispatch, layout, redraw triggers); only the host wiring is left to smoke. +- [x] Keep these tests on the **Desktop (Skia)** head for CI speed; treat other heads as manual smoke. + *(The project targets `net9.0-desktop` only.)* + +## 4. Manual smoke matrix + +Track per-head before declaring a head "supported". + +**Legend:** ☑ verified · ◐ partially verified (see note) · ☐ pending (not yet run) · n/a not applicable. + +**Status (as of Phase 07):** only **Desktop (Skia)** is buildable/runnable on the dev box (no +android/wasm/ios workloads, and the Windows head is Windows-only). The Desktop column reflects the +**Phase 06 headless run-to-first-render** smoke (app launched, the basic grid rendered with live data, +zero exceptions) — that confirms *grid-renders-on-load* only. Interactive behaviors (inertial scroll, +mouse/touch selection, keyboard nav, editing, resize/reorder, sort/filter, HiDPI crispness) were **not** +hands-on exercised in the headless session and are **pending** an interactive desktop run / CI. All other +heads are **pending CI + device testing** (Phase 08). + +| Feature | Desktop | WASM | Windows | Android | iOS | Mac Catalyst | +|---|:--:|:--:|:--:|:--:|:--:|:--:| +| Grid renders, scrolls (wheel + inertial) | ◐ ¹ | ☐ | ☐ | ☐ | ☐ | ☐ | +| Selection (mouse/touch) | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ | +| Keyboard navigation | ☐ | ☐ | ☐ | n/a | n/a | ☐ | +| Cell editing (typed / soft keyboard) | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ | +| Column resize / reorder, row drag | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ | +| Sorting / filtering / grouping | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ | +| CJK + icon fonts render | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ | +| Crisp at 150% / 200% scale | ☐ | ☐ | ☐ | ☐ | ☐ | ☐ | + +> ¹ **Desktop, "Grid renders":** the *render-on-load* half is verified (Phase 06 run-to-first-render, +> zero exceptions). The *scrolls (wheel + inertial)* half is **pending** an interactive run — not +> scriptable in the headless session. Marked ◐ rather than ☑ for that reason. + +--- + +## ✅ Exit criteria + +- [x] `KumikoUI.Core.Tests` passes unchanged (no Core regressions). *(353/353 green; Core untouched.)* +- [x] Input-mapping unit tests pass for the full key/modifier table. *(`tests/KumikoUI.Uno.Tests`, + 41/41 green on `net9.0-desktop` — full `ToGridKey`/`ToInputModifiers` table + whole-enum sweep.)* +- [ ] The smoke matrix is green for the heads being released. *(Pending: only Desktop render-on-load + is verified so far; interactive paths + all non-desktop heads await CI / device testing in Phase 08.)* + +➡️ Next: [08 — CI/CD & packaging](08-ci-cd-packaging.md) diff --git a/docs/uno/08-ci-cd-packaging.md b/docs/uno/08-ci-cd-packaging.md new file mode 100644 index 0000000..8e59ed0 --- /dev/null +++ b/docs/uno/08-ci-cd-packaging.md @@ -0,0 +1,133 @@ +# 08 — CI/CD & NuGet Packaging + +Goal: extend the existing pipelines so `KumikoUI.Uno` is built on PRs and published to NuGet.org with +the other packages — without disturbing the Core/SkiaSharp/Maui flows. + +Existing pipelines: [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) (build + test) and +[`.github/workflows/publish.yml`](../../.github/workflows/publish.yml) (tag-driven pack + push). + +--- + +## 1. Packaging fact that simplifies everything + +`KumikoUI.Uno` is a **class library**, not an app. Library cross-compilation for the `-ios` / +`-maccatalyst` heads works on Windows/Linux with the workloads installed — only **app** build/run/codesign +needs macOS. The only head that strictly requires its host OS to *compile* is the WinAppSDK +`-windows10.0.x` head (Windows). + +➡️ Therefore the whole library (all six heads) can be **built and packed on a single `windows-latest` +runner**, exactly like the existing `build-maui` / `pack-maui` jobs. + +> The macOS/Windows constraints in [01 §5](01-prerequisites.md#5-per-os-build-constraints) apply to the +> **sample app** (`SampleApp.Uno`) and to *running* heads — not to packing the library. + +## 2. CI — add a `build-uno` job + +Mirror the `build-maui` job in [`ci.yml`](../../.github/workflows/ci.yml). + +- [x] New job, `needs: build-core`, `runs-on: windows-latest`. *(job `build-uno`)* +- [x] Set up .NET `9.x` + `10.x` (same as `build-maui`). +- [x] **Restore Uno workloads** from the project (lighter than full `uno-check` in CI): + ```yaml + - name: Restore Uno workloads + if: steps.workload-cache.outputs.cache-hit != 'true' + run: dotnet workload restore src/KumikoUI.Uno/KumikoUI.Uno.csproj + ``` + (Alternative: `dotnet tool install -g uno.check && uno-check --ci --fix --non-interactive`.) + Gated on the cache miss to match the `build-maui` workload-install pattern. +- [x] Cache workloads with a key over `KumikoUI.Uno.csproj` (copied the `actions/cache@v4` block from + `build-maui`, new key prefix `uno-workloads-win-`). +- [x] Restore + build + pack dry-run + Uno tests: + ```yaml + - run: dotnet restore src/KumikoUI.Uno/KumikoUI.Uno.csproj + - run: dotnet build src/KumikoUI.Uno/KumikoUI.Uno.csproj --no-restore -c Release + - run: dotnet pack src/KumikoUI.Uno/KumikoUI.Uno.csproj --no-restore -c Release -p:Version=0.0.0-ci -o C:\ci-pack + - run: dotnet test tests/KumikoUI.Uno.Tests/KumikoUI.Uno.Tests.csproj -c Release --logger "trx" --results-directory TestResults + ``` + The `dotnet test` step intentionally lets the test project restore itself (no `--no-restore`): the + test csproj is a separate Uno single-project head with its own xUnit/test-SDK packages that the + `dotnet restore` of the *library* csproj above does not pull in. +- [ ] (Optional, not added) a `macos-latest` job that builds the **sample app's** Apple heads + (`dotnet build samples/SampleApp.Uno -f net9.0-ios` / `-f net9.0-maccatalyst`) to catch app-level + breakage the library pack can't. Skipped — Phase 08 scope is the library pipeline; can be added later. + +## 3. Publish — add a `pack-uno` job + +Mirror `pack-maui` in [`publish.yml`](../../.github/workflows/publish.yml). + +- [x] New job `pack-uno`, `needs: resolve-version`, `runs-on: windows-latest`; sets up .NET 9 + 10; + restores Uno workloads (with cache, gated on cache-miss) as in §2. +- [x] Pack with the resolved version and symbols (match the other pack jobs). Implemented with the + PowerShell line-continuation (`` ` ``) form to match the sibling `pack-maui` job on the same + Windows runner: + ```yaml + - name: Pack — KumikoUI.Uno + run: | + dotnet pack src/KumikoUI.Uno/KumikoUI.Uno.csproj ` + --no-restore -c Release ` + -p:Version=${{ env.VERSION }} ` + --include-symbols -p:SymbolPackageFormat=snupkg ` + -o ./artifacts + - uses: actions/upload-artifact@v4 + with: { name: nuget-uno, path: artifacts/*.nupkg, if-no-files-found: error } + - uses: actions/upload-artifact@v4 + with: { name: snupkg-uno, path: artifacts/*.snupkg, if-no-files-found: warn } + ``` +- [x] **Wire into publish:** added `pack-uno` to the `publish` job's `needs:` list + (`needs: [ pack-core, pack-maui, pack-uno, test ]`). The publish step already downloads `nuget-*` / + `snupkg-*` with `merge-multiple`, so the Uno package is picked up **automatically** — no change to the + push step. Verified the `nuget-uno` / `snupkg-uno` artifact names match those `nuget-*` / `snupkg-*` + download globs. + +## 4. Versioning & metadata + +- [x] `KumikoUI.Uno` follows the same tag-driven versioning (`v1.2.3` → `-p:Version=1.2.3`) — the + `pack-uno` job consumes `needs.resolve-version.outputs.version` exactly like `pack-maui`/`pack-core` — + and inherits Authors/License/SourceLink from [`Directory.Build.props`](../../Directory.Build.props). +- [x] Keep its `` in lockstep with the other packages, or bump together on release. *(The + release `-p:Version` flag overrides the csproj `` on tagged builds.)* +- [x] `PackageId`/`Description`/`PackageTags` are set in the csproj + ([02 §6](02-library-scaffold.md#6-nuget-metadata-mirror-the-sibling-projects)) — confirmed present + (`PackageId=KumikoUI.Uno`). + +## 5. Notes & caveats + +- [x] **Docs-only changes skip CI:** this documentation PR is ignored by the `paths-ignore` (`**.md`, + `docs/**`) filters in [`ci.yml`](../../.github/workflows/ci.yml) — intended; no build runs for docs. +- [ ] **MSIX / library assets:** if the library ever ships assets ([05 §5](05-fonts-and-assets.md#5-shipping-assets-inside-the-library-only-if-needed)), + document the unpackaged-mode requirement for the WinAppSDK head. *(Not applicable yet — library ships no assets today.)* +- [x] **SkiaSharp native bits:** `SkiaSharp*` is pinned to `3.119.2` across all packages (via + `` in the Uno csproj + explicit `SkiaSharp.Views.WinUI` 3.119.2) so a consuming app + that references both `KumikoUI.Maui` and `KumikoUI.Uno` doesn't hit native version conflicts. +- [x] **Transitive AndroidX `content/` in the nupkg (observed in [Phase 05](05-fonts-and-assets.md)):** + the multi-head pack pulls transitive AndroidX/AppCompat `content/` + `contentFiles/` resources (the + `abc_*` drawables/layouts) into the package from the `net9.0-android` head — ~1,200 entries in the + local `0.0.0-ci` dry-run. This is **acceptable** (cosmetic bloat from the Android head's resource + pipeline; the `net9.0-android` `lib/` assembly is still correct) and is left as-is. If trimming is + ever wanted it can be scoped with a `` / pack-exclude later. +- [x] **`Uno0001` not-implemented analyzer warnings:** the non-desktop heads emit `Uno0001` warnings for + APIs not implemented on that head (e.g. `UIElement.IsHoldingEnabled`, `UIElement.CharacterReceived` on + wasm/ios/android). These are **non-fatal** — `dotnet build`/`dotnet pack` exit 0; the input host + already guards those paths per head (see [Phase 04](04-input-keyboard-focus.md)). The macOS dry-run + also emits a benign Android Java-SDK validation warning, likewise non-fatal. + +--- + +## ✅ Exit criteria + +- [x] `ci.yml` builds and pack-dry-runs `KumikoUI.Uno` (and runs its tests) on PRs to `main`/`develop` + via the new `build-uno` job. *(Workflow authored + YAML-validated locally; the actual hosted-runner + run is pending the branch push — cannot execute GitHub Actions from this environment.)* +- [x] A version tag produces a `KumikoUI.Uno..nupkg` (+ `.snupkg`) and pushes it to NuGet.org + alongside Core/SkiaSharp/Maui via the new `pack-uno` job, whose `nuget-uno`/`snupkg-uno` artifacts are + consumed by the existing `publish` job's `nuget-*`/`snupkg-*` download globs. *(Wiring verified + statically; live push pending a real tag.)* Local multi-head `dotnet pack` dry-run **succeeded** and + produced `KumikoUI.Uno.0.0.0-ci.nupkg` with `lib/` for every locally-available head + (`net9.0`, `-desktop`, `-browserwasm`, `-ios`, `-android`; the `-windows` head is added on the CI + Windows runner). +- [x] The MAUI and Core/SkiaSharp jobs are **unchanged** (additive edits only — existing jobs in both + workflows are byte-for-byte intact). + +--- + +🎉 **End of the Uno port plan set.** Back to the [index](README.md). diff --git a/docs/uno/README.md b/docs/uno/README.md new file mode 100644 index 0000000..7ab629e --- /dev/null +++ b/docs/uno/README.md @@ -0,0 +1,142 @@ +# KumikoUI → Uno Platform Port + +This folder contains the implementation plan for adding **Uno Platform** support to KumikoUI as a +set of executable checklists. Work through them in order; each document is self-contained and ends +with a checklist you can tick off top-to-bottom. + +> **Status:** Planning artifact for review. No `KumikoUI.Uno` code exists yet — these documents +> describe exactly how it will be built. Implement only after this plan set is reviewed. + +--- + +## 1. Why this works: the existing separation of concerns + +KumikoUI is already structured so that adding a platform is a *thin* exercise. The library is split +into three layers, and **only the bottom layer is platform-specific**: + +| Layer | Project | TFM | Knows about… | Reused for Uno? | +|---|---|---|---|---| +| Platform-agnostic engine | `KumikoUI.Core` | `net9.0` | nothing UI-specific — only `IDrawingContext`, models, layout, input dispatch | ✅ **unchanged** | +| Drawing backend | `KumikoUI.SkiaSharp` | `net9.0` | a bare `SKCanvas` | ✅ **unchanged** | +| Platform host | `KumikoUI.Maui` | `net10.0-*` | MAUI controls, native input | ➕ add a sibling `KumikoUI.Uno` | + +The core insight: **`KumikoUI.SkiaSharp` only needs an `SKCanvas`** — it has no MAUI dependency. +Uno Platform exposes `SKXamlCanvas`, whose `PaintSurface` event hands you an `SKCanvas` through the +**identical** `SKPaintSurfaceEventArgs` type that MAUI's `SKCanvasView` uses. So the Uno port is a +near 1:1 mirror of the MAUI host that **reuses Core and SkiaSharp with zero changes**, preserving the +library's core tenets. + +See also: [../ARCHITECTURE.md](../ARCHITECTURE.md) and [../RENDERING.md](../RENDERING.md). + +> **Post-port Core change (1):** `KumikoUI.Core` was later extended with one shared rendering option — +> `DataGridStyle.PinBottomSummaryRows` (default `true`), which pins `Position=Bottom` table summary rows +> to the viewport bottom as a fixed footer. This is a deliberate feature (requested by the author) that +> also changes `KumikoUI.Maui` behavior, since both share Core. The port's "Core unchanged" property +> otherwise holds; this is the only Core edit, and it is additive (the `Render(...)` signature is +> unchanged and the flag defaults on). + +--- + +## 2. Architecture Decision Record + +| # | Decision | Rationale | +|---|---|---| +| ADR-1 | **Reuse `KumikoUI.SkiaSharp` via `SKXamlCanvas`** rather than writing a new `IDrawingContext`. | Maximum reuse, zero changes to Core/SkiaSharp, pixel-identical to MAUI, lowest risk. Drawing Skia-on-Skia natively on Uno's Skia targets anyway. | +| ADR-2 | **One cross-targeted `unolib`** covering all heads (Windows, Desktop/Skia, WASM, Android, iOS, Mac Catalyst). | Uno libraries cross-target by default; supporting every head costs almost nothing at the project level. | +| ADR-3 | **Scaffold everything with the `dotnet` CLI** (`dotnet new`, `dotnet sln add`, `dotnet add reference`). | Guarantees all Uno SDK plumbing is generated correctly; no hand-authored csproj drift. | +| ADR-4 | **`-tfm net9.0`** for `KumikoUI.Uno`, matching Core/SkiaSharp. | Keeps the whole `src/` stack on one runtime. The template defaults to `net10.0`; flipping is one flag if desired. | +| ADR-5 | **`KumikoUI.Maui` stays untouched.** | The two hosts are independent siblings; Uno work cannot regress MAUI. | + +--- + +## 3. Dependency graph + +``` + KumikoUI.Core (net9.0, zero deps — UNCHANGED) + ▲ ▲ + │ │ + KumikoUI.SkiaSharp │ (net9.0, refs Core + SkiaSharp — UNCHANGED) + ▲ ▲ │ + │ └───────────┤ + │ │ + KumikoUI.Maui KumikoUI.Uno (host layer — Maui exists, Uno is new) + (net10.0-*) (net9.0-) +``` + +`KumikoUI.Uno` references **`KumikoUI.Core`** and **`KumikoUI.SkiaSharp`** only — exactly like +`KumikoUI.Maui` does. + +--- + +## 4. Target matrix + +`KumikoUI.Uno` cross-targets the Uno head TFMs. Representative set (exact monikers come from the +template — confirm after scaffolding): + +| Head | TFM | Render path | Builds on | +|---|---|---|---| +| Windows (WinAppSDK / WinUI 3) | `net9.0-windows10.0.xxxxx.0` | `SKXamlCanvas` via `SkiaSharp.Views.WinUI` | Windows only | +| Desktop (Skia: Win/macOS/Linux) | `net9.0-desktop` | `SKXamlCanvas` via `SkiaSharp.Views.Uno.WinUI` | any OS | +| WebAssembly | `net9.0-browserwasm` | `SKXamlCanvas` via `SkiaSharp.Views.Uno.WinUI` | any OS (needs `wasm-tools`) | +| Android | `net9.0-android` | `SKXamlCanvas` via `SkiaSharp.Views.Uno.WinUI` | any OS (needs `android`) | +| iOS | `net9.0-ios` | `SKXamlCanvas` via `SkiaSharp.Views.Uno.WinUI` | macOS only | +| Mac Catalyst | `net9.0-maccatalyst` | `SKXamlCanvas` via `SkiaSharp.Views.Uno.WinUI` | macOS only | + +> `SKXamlCanvas` lives in namespace `SkiaSharp.Views.Windows` in **both** the `SkiaSharp.Views.WinUI` +> (Windows) and `SkiaSharp.Views.Uno.WinUI` (all other heads) packages, so the host code is identical +> across targets. Only the `` is conditional. + +--- + +## 5. MAUI → Uno parity (the heart of the port) + +| Concern | `KumikoUI.Maui` (existing) | `KumikoUI.Uno` (new) | +|---|---|---| +| Render surface | `SKCanvasView` | `SKXamlCanvas` | +| Paint event | `PaintSurface` → `e.Surface.Canvas` | `PaintSurface` → `e.Surface.Canvas` (**identical**) | +| Drawing bridge | `new SkiaDrawingContext(canvas)` | `new SkiaDrawingContext(canvas)` (**reused**) | +| Render call | `DataGridRenderer.Render(ctx, …)` | `DataGridRenderer.Render(ctx, …)` (**reused**) | +| Request redraw | `InvalidateSurface()` | `SKXamlCanvas.Invalidate()` | +| Pointer input | `SKCanvasView.Touch` | `UIElement.PointerPressed/Moved/Released`, `PointerWheelChanged` | +| Keyboard input | native responder / hidden `Entry` | `UIElement.KeyDown`/`KeyUp` + `CharacterReceived` | +| Inertial tick | `IDispatcherTimer` | `DispatcherQueue` / `DispatcherTimer` | +| Bindable surface | `BindableProperty` | `DependencyProperty` | +| Host hook | `builder.UseSkiaKumikoUI()` | `builder.UseKumikoUI()` (`IHostBuilder` / `IApplicationBuilder`) | + +### Input wiring contract + +Core is already the seam — the host only adapts framework events into Core calls: + +| Uno event | Build | Core call (unchanged API) | +|---|---|---| +| `PointerPressed`/`Moved`/`Released`, `PointerWheelChanged` | `GridPointerEventArgs` | `InputController.HandlePointer(evt, scroll, selection, style, dataSource)` | +| `KeyDown`/`KeyUp`, `CharacterReceived` | `GridKeyEventArgs` (map `VirtualKey` → `GridKey`) | `InputController.HandleKey(evt, scroll, selection, style, dataSource)` | +| timer tick (~16 ms) | — | `InputController.UpdateInertialScroll(scroll, 16f)` | +| `InputController.NeedsRedraw` | — | `skXamlCanvas.Invalidate()` | + +These are the exact members exercised by [`src/KumikoUI.Maui/DataGridView.cs`](../../src/KumikoUI.Maui/DataGridView.cs) today. + +--- + +## 6. Master checklist + +- [ ] **[01 — Prerequisites & tooling](01-prerequisites.md)** — SDKs, Uno templates, `uno-check`, workloads, per-OS build constraints. +- [ ] **[02 — Library scaffold](02-library-scaffold.md)** — `dotnet new unolib`, solution wiring, TFMs, conditional SkiaSharp.Views references, packaging metadata. +- [ ] **[03 — DataGridView host](03-datagridview-host.md)** — the control: `SKXamlCanvas` hosting, paint loop, `DependencyProperty` surface, lifecycle. +- [ ] **[04 — Input, keyboard & focus](04-input-keyboard-focus.md)** — pointer/keyboard/focus bridge into `GridInputController`, inertial scroll, per-head nuances. +- [ ] **[05 — Fonts & assets](05-fonts-and-assets.md)** — `SkiaFontRegistrar` reuse (CJK/icons), Uno library-asset packaging rules. +- [ ] **[06 — Sample app](06-sample-app.md)** — `dotnet new unoapp` `SampleApp.Uno`, font registration, demo pages, per-head run commands. +- [ ] **[07 — Tests](07-tests.md)** — unchanged Core xUnit suite + optional Uno.UITest runtime tests + smoke matrix. +- [ ] **[08 — CI/CD & packaging](08-ci-cd-packaging.md)** — extend `ci.yml`/`publish.yml`, workload restore, `nuget-uno` artifact, NuGet publish. + +--- + +## 7. Reference material + +- Uno — [How to Create Control Libraries](https://platform.uno/docs/articles/guides/how-to-create-control-libraries.html) +- Uno — [Creating Custom Controls](https://platform.uno/docs/articles/guides/creating-custom-controls.html) +- Uno — [`dotnet new` templates](https://platform.uno/docs/articles/get-started-dotnet-new.html) +- Uno — [`SKCanvasElement`](https://platform.uno/docs/articles/controls/SKCanvasElement.html) (a hardware-accelerated alternative to `SKXamlCanvas` on Skia heads; noted as a future optimization) +- Uno blog — [Porting a custom-drawn Xamarin.Forms control to Uno Platform](https://platform.uno/blog/porting-a-custom-drawn-xamarin-forms-control-to-uno-platform/) (the same SkiaSharp-reuse scenario) +- Uno sample — [Uno.Samples / UI / ControlLibrary](https://github.com/unoplatform/Uno.Samples/tree/master/UI/ControlLibrary) +- Microsoft Learn — [Templated controls with C# (WinUI 3)](https://learn.microsoft.com/windows/apps/winui/winui3/xaml-templated-controls-csharp-winui-3), [Dependency properties overview](https://learn.microsoft.com/windows/uwp/xaml-platform/dependency-properties-overview) diff --git a/samples/SampleApp.Uno/App.xaml b/samples/SampleApp.Uno/App.xaml new file mode 100644 index 0000000..dd1c9b9 --- /dev/null +++ b/samples/SampleApp.Uno/App.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/samples/SampleApp.Uno/App.xaml.cs b/samples/SampleApp.Uno/App.xaml.cs new file mode 100644 index 0000000..b6edc50 --- /dev/null +++ b/samples/SampleApp.Uno/App.xaml.cs @@ -0,0 +1,147 @@ +using System; +using KumikoUI.Uno; +using Microsoft.Extensions.Logging; +using Uno.Resizetizer; + +namespace SampleApp.Uno; + +public partial class App : Application +{ + /// + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). + /// + public App() + { + this.InitializeComponent(); + } + + protected Window? MainWindow { get; private set; } + + protected override async void OnLaunched(LaunchActivatedEventArgs args) + { + // ── Register custom SkiaSharp typefaces before the first grid renders ── + // The demo fonts live under Assets/Fonts/ (marked Content in the csproj) and are + // resolved via the ms-appx:/// scheme. KumikoUI.Uno stays font-agnostic — the app + // registers them here so DataGridStyle / IconFontCellRenderer can resolve them by + // family name. Awaited before Activate() so the very first paint already sees the + // typefaces (no flash of fallback/tofu glyphs). Registration is non-fatal: a missing + // font is logged and the grid falls back to the system default. + // See docs/uno/05-fonts-and-assets.md and docs/uno/06-sample-app.md §3. + await KumikoFonts.RegisterFromAppPackageAsync(new[] + { + ("NotoSansJP", "ms-appx:///Assets/Fonts/NotoSansJP-Regular.ttf"), + ("MaterialIcons", "ms-appx:///Assets/Fonts/MaterialIcons-Regular.ttf"), + }); + + MainWindow = new Window(); +#if DEBUG + MainWindow.UseStudio(); +#endif + + + // Do not repeat app initialization when the Window already has content, + // just ensure that the window is active + if (MainWindow.Content is not Frame rootFrame) + { + // Create a Frame to act as the navigation context and navigate to the first page + rootFrame = new Frame(); + + // Place the frame in the current Window + MainWindow.Content = rootFrame; + + rootFrame.NavigationFailed += OnNavigationFailed; + } + + if (rootFrame.Content == null) + { + // Navigate to the navigation shell that hosts the demo pages + // (mirrors SampleApp.Maui's AppShell tabs). + rootFrame.Navigate(typeof(MainShell), args.Arguments); + } + + MainWindow.SetWindowIcon(); + // Ensure the current window is active + MainWindow.Activate(); + } + + /// + /// Invoked when Navigation to a certain page fails + /// + /// The Frame which failed navigation + /// Details about the navigation failure + void OnNavigationFailed(object sender, NavigationFailedEventArgs e) + { + throw new InvalidOperationException($"Failed to load {e.SourcePageType.FullName}: {e.Exception}"); + } + + /// + /// Configures global Uno Platform logging + /// + public static void InitializeLogging() + { +#if DEBUG + // Logging is disabled by default for release builds, as it incurs a significant + // initialization cost from Microsoft.Extensions.Logging setup. If startup performance + // is a concern for your application, keep this disabled. If you're running on the web or + // desktop targets, you can use URL or command line parameters to enable it. + // + // For more performance documentation: https://platform.uno/docs/articles/Uno-UI-Performance.html + + var factory = LoggerFactory.Create(builder => + { +#if __WASM__ + builder.AddProvider(new global::Uno.Extensions.Logging.WebAssembly.WebAssemblyConsoleLoggerProvider()); +#elif __IOS__ + builder.AddProvider(new global::Uno.Extensions.Logging.OSLogLoggerProvider()); + + // Log to the Visual Studio Debug console + builder.AddConsole(); +#else + builder.AddConsole(); +#endif + + // Exclude logs below this level + builder.SetMinimumLevel(LogLevel.Information); + + // Default filters for Uno Platform namespaces + builder.AddFilter("Uno", LogLevel.Warning); + builder.AddFilter("Windows", LogLevel.Warning); + builder.AddFilter("Microsoft", LogLevel.Warning); + + // Generic Xaml events + // builder.AddFilter("Microsoft.UI.Xaml", LogLevel.Debug ); + // builder.AddFilter("Microsoft.UI.Xaml.VisualStateGroup", LogLevel.Debug ); + // builder.AddFilter("Microsoft.UI.Xaml.StateTriggerBase", LogLevel.Debug ); + // builder.AddFilter("Microsoft.UI.Xaml.UIElement", LogLevel.Debug ); + // builder.AddFilter("Microsoft.UI.Xaml.FrameworkElement", LogLevel.Trace ); + + // Layouter specific messages + // builder.AddFilter("Microsoft.UI.Xaml.Controls", LogLevel.Debug ); + // builder.AddFilter("Microsoft.UI.Xaml.Controls.Layouter", LogLevel.Debug ); + // builder.AddFilter("Microsoft.UI.Xaml.Controls.Panel", LogLevel.Debug ); + + // builder.AddFilter("Windows.Storage", LogLevel.Debug ); + + // Binding related messages + // builder.AddFilter("Microsoft.UI.Xaml.Data", LogLevel.Debug ); + // builder.AddFilter("Microsoft.UI.Xaml.Data", LogLevel.Debug ); + + // Binder memory references tracking + // builder.AddFilter("Uno.UI.DataBinding.BinderReferenceHolder", LogLevel.Debug ); + + // DevServer and HotReload related + // builder.AddFilter("Uno.UI.RemoteControl", LogLevel.Information); + + // Debug JS interop + // builder.AddFilter("Uno.Foundation.WebAssemblyRuntime", LogLevel.Debug ); + }); + + global::Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory = factory; + +#if HAS_UNO + global::Uno.UI.Adapter.Microsoft.Extensions.Logging.LoggingAdapter.Initialize(); +#endif +#endif + } +} diff --git a/samples/SampleApp.Uno/Assets/Fonts/MaterialIcons-Regular.ttf b/samples/SampleApp.Uno/Assets/Fonts/MaterialIcons-Regular.ttf new file mode 100644 index 0000000..9d09b0f Binary files /dev/null and b/samples/SampleApp.Uno/Assets/Fonts/MaterialIcons-Regular.ttf differ diff --git a/samples/SampleApp.Uno/Assets/Fonts/NotoSansJP-Regular.ttf b/samples/SampleApp.Uno/Assets/Fonts/NotoSansJP-Regular.ttf new file mode 100644 index 0000000..cdd8f08 Binary files /dev/null and b/samples/SampleApp.Uno/Assets/Fonts/NotoSansJP-Regular.ttf differ diff --git a/samples/SampleApp.Uno/Assets/Icons/icon.svg b/samples/SampleApp.Uno/Assets/Icons/icon.svg new file mode 100644 index 0000000..a15af53 --- /dev/null +++ b/samples/SampleApp.Uno/Assets/Icons/icon.svg @@ -0,0 +1,42 @@ + + + + + + diff --git a/samples/SampleApp.Uno/Assets/Icons/icon_foreground.svg b/samples/SampleApp.Uno/Assets/Icons/icon_foreground.svg new file mode 100644 index 0000000..8ffc41a --- /dev/null +++ b/samples/SampleApp.Uno/Assets/Icons/icon_foreground.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/SampleApp.Uno/Assets/SharedAssets.md b/samples/SampleApp.Uno/Assets/SharedAssets.md new file mode 100644 index 0000000..b1cc4e7 --- /dev/null +++ b/samples/SampleApp.Uno/Assets/SharedAssets.md @@ -0,0 +1,32 @@ +# Shared Assets + +See documentation about assets here: https://github.com/unoplatform/uno/blob/master/doc/articles/features/working-with-assets.md + +## Here is a cheat sheet + +1. Add the image file to the `Assets` directory of a shared project. +2. Set the build action to `Content`. +3. (Recommended) Provide an asset for various scales/dpi + +### Examples + +```text +\Assets\Images\logo.scale-100.png +\Assets\Images\logo.scale-200.png +\Assets\Images\logo.scale-400.png + +\Assets\Images\scale-100\logo.png +\Assets\Images\scale-200\logo.png +\Assets\Images\scale-400\logo.png +``` + +### Table of scales + +| Scale | WinUI | iOS | Android | +|-------|:-----------:|:---------------:|:-------:| +| `100` | scale-100 | @1x | mdpi | +| `125` | scale-125 | N/A | N/A | +| `150` | scale-150 | N/A | hdpi | +| `200` | scale-200 | @2x | xhdpi | +| `300` | scale-300 | @3x | xxhdpi | +| `400` | scale-400 | N/A | xxxhdpi | diff --git a/samples/SampleApp.Uno/Assets/Splash/splash_screen.svg b/samples/SampleApp.Uno/Assets/Splash/splash_screen.svg new file mode 100644 index 0000000..8ffc41a --- /dev/null +++ b/samples/SampleApp.Uno/Assets/Splash/splash_screen.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/SampleApp.Uno/BasicGridPage.xaml b/samples/SampleApp.Uno/BasicGridPage.xaml new file mode 100644 index 0000000..0d808a3 --- /dev/null +++ b/samples/SampleApp.Uno/BasicGridPage.xaml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + +