diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dad63ad..039ef55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,7 @@ jobs: with: global-json-file: global.json - # ShellIcons.Blazor multi-targets net8.0 + net9.0, so both runtimes must - # be available on the CI runner even though the SDK is pinned to net10. + # ShellIcons.Blazor targets net8.0 + net9.0, so the tests need both runtimes. - name: Install net8 + net9 runtimes uses: actions/setup-dotnet@v4 with: @@ -38,10 +37,7 @@ jobs: - name: Test run: dotnet test ShellIcons.slnx --configuration Release --no-build --verbosity normal - # Smoke pack — proves the packable project produces a valid .nupkg on - # every commit, so the release workflow can't be blindsided by a packaging - # bug. Packs the specific project (not the solution) to skip non-packable - # ones cleanly — packing the solution warns for every IsPackable=false project. + # The project, not the solution: packing the solution warns for every non-packable project. - name: Smoke pack run: dotnet pack src/ShellIcons.Blazor/ShellIcons.Blazor.csproj --configuration Release --no-build --output nupkgs env: @@ -49,3 +45,25 @@ jobs: - name: List produced packages run: ls -la nupkgs/ + + # Windows builds every MAUI target (Android, iOS, Mac Catalyst, Windows); ubuntu lacks the workloads. + maui: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Install MAUI workload + run: dotnet workload install maui + + - name: Build (all platform targets) + run: dotnet build ShellIcons.Maui.slnx --configuration Release + env: + ContinuousIntegrationBuild: true + + - name: Test + run: dotnet test tests/ShellIcons.Maui.Tests --configuration Release --no-build --verbosity normal diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e55bb6c..a15a49a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,23 +29,18 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 9.0.x - 10.0.x + global-json-file: global.json + # Keep in step with the ShellDocs.* package versions in ShellIcons.Docs.csproj. - name: Install ShellDocs.CLI - run: dotnet tool install -g ShellDocs.CLI --prerelease - - - name: Restore - run: dotnet restore ShellIcons.slnx + run: dotnet tool install -g ShellDocs.CLI --version 0.1.7-alpha + # Prerenders every content/ page to static HTML; CNAME ships from wwwroot/. - name: Build docs (static) working-directory: docs/ShellIcons.Docs - run: shelldocs build --output ../../publish --spa-fallback - - - name: Set custom domain (CNAME) - run: echo "shellicons.shellui.dev" > publish/CNAME + run: shelldocs build --output ../../publish --spa-fallback --site-url https://shellicons.shellui.dev + # Jekyll would drop the _framework/ and _content/ folders. - name: Disable Jekyll run: touch publish/.nojekyll diff --git a/.gitignore b/.gitignore index 9070dda..8ec03cf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ x86/ ## NuGet packages *.nupkg *.snupkg +nupkgs/ + +## Static docs build (shelldocs build --output publish) +publish/ ## IDE .vs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9541f92 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to the ShellIcons packages. `ShellIcons.Blazor` is published on NuGet; `ShellIcons.Maui` is in preview and not published yet. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **Blazor: `{Name}Icon` components** — ``, ``, … in the root `ShellIcons` namespace. A single `@using ShellIcons` now works next to UI kits whose component names match Lucide icons (`Badge`, `Table`, `Router`, …), which previously failed with RZ9985. This is the recommended form for markup. Lucide's `shell` icon is the one exception, since `ShellIcon` is the dispatcher; use `Icon.Shell()` (build info `SHELLICONS003`). +- **Blazor: `Icon.*` factory** — `Icon.ChevronRight()` returns a `RenderFragment`, for icons as values: component parameters and lists built in C#. Unused icons are still trimmed. +- **Blazor: clear error for `@onclick` on an icon.** On a component, Razor passes `@onclick="…"` as a plain string, which broke rendering in the browser with a cryptic error. It now throws an `InvalidOperationException` that explains the forms that work. +- **`ShellIcons.Maui` (preview, not yet on NuGet)** — the same catalog as native MAUI `Path` shapes: typed controls (``), an `IconName` dispatcher (``), and `IconCatalog` with tags, categories, alias-aware `TryParse` and `Search`. Builds for Android, iOS, Mac Catalyst and Windows. +- Build diagnostics for custom icons that can't be converted for MAUI: `SHELLICONS002` (unsupported SVG such as `` or `transform`), `SHELLICONS004` (name not usable as an enum member), `SHELLICONS005` (shape outside the viewBox, dropped). +- Docs site at [shellicons.shellui.dev](https://shellicons.shellui.dev) (deployed from `main`), with a searchable icon browser and a MAUI section. + +### Changed + +- `Icon.*` calls with no arguments reuse a cached fragment instead of allocating on every render. `ShellIcons.Blazor.dll` is about a third smaller than the first factory version, even with the new `{Name}Icon` components. +- Components in `ShellIcons.Icons` are no longer `sealed`. +- The build no longer references `Microsoft.SourceLink.GitHub`, whose `Microsoft.Build.Tasks.Git 8.0.0` dependency has a known vulnerability (NU1902). Source Link now comes from the .NET SDK; packages still link to the exact commit. + +### Fixed + +- The README's click-handler example for `additionalAttributes` used the key `"@onclick"`; the working key is `"onclick"`. +- MAUI: Lucide 0.475.0's `save-off.svg` contains a stray shape outside the viewBox. Browsers clip it; on MAUI it's dropped so it can't draw beside the icon. + +## [0.1.0-alpha] — 2026-08-22 + +First release of `ShellIcons.Blazor`: all 1,555 icons from Lucide 0.475.0 as typed components, the `` dispatcher, `IconCore` with `Size`, `StrokeWidth`, `AbsoluteStroke`, `Class` and `Title`, and a drop-in path for custom icons. diff --git a/README.md b/README.md index 0c8758c..95b31a4 100644 --- a/README.md +++ b/README.md @@ -7,27 +7,24 @@ Lucide-derived SVG icons for **Blazor**, **Avalonia**, and **.NET MAUI**. - Native rendering per target: inline `` in Blazor, native `Path` shapes in Avalonia/MAUI - Tree-shakeable typed components per icon -See [SHELLICONS.md](SHELLICONS.md) for the full design proposal. +Docs: [shellicons.shellui.dev](https://shellicons.shellui.dev) ## Status -**Docs live in dev, Phase 2 complete — full Lucide 0.475.0 catalog (1555 icons) auto-generated for Blazor.** - -- [x] Design doc -- [x] Solution scaffold -- [x] Blazor `IconCore` with Size/StrokeWidth/Class/Title/AbsoluteStroke props -- [x] Lucide catalog sync script (pinned via `LUCIDE_VERSION.txt`) -- [x] `catalog/custom/` drop-in path for repo-owned icons -- [x] Roslyn incremental source generator — reads both `catalog/lucide/` and `catalog/custom/`, emits 1555 typed components + `ShellIcon` dispatcher -- [x] Test suite: 46 unit + integration tests (xUnit + bUnit) -- [x] Docs site at [docs/ShellIcons.Docs](docs/ShellIcons.Docs) — ShellDocs-powered, runs at `dotnet run` on http://localhost:5145 -- [x] Live icon browser at `/icons` — searchable, 1555-cell grid with click-to-copy -- [x] GH Pages workflow + `CNAME` for [shellicons.shellui.dev](https://shellicons.shellui.dev) -- [x] CI + Release pipelines — see [RELEASING.md](RELEASING.md) for the runbook -- [ ] Blocker: `shelldocs.cli` build doesn't emit `index.html` or the WASM runtime — GH Pages deploy waits on a CLI fix upstream -- [ ] First publish `ShellIcons.Blazor 0.1.0-alpha` to NuGet — bump version, tag `v0.1.0-alpha`, approve in the Actions UI -- [ ] Avalonia target (Phase 4) -- [ ] MAUI target (Phase 5) +| Package | Status | +|---|---| +| `ShellIcons.Blazor` | **0.1.0-alpha on [NuGet](https://www.nuget.org/packages/ShellIcons.Blazor)** — 1,555 Lucide 0.475.0 icons | +| `ShellIcons.Maui` | **Preview, unpublished** — builds for Android, iOS, Mac Catalyst and Windows; see [src/ShellIcons.Maui](src/ShellIcons.Maui/README.md) | +| `ShellIcons.Avalonia` | Planned — will reuse the MAUI path conversion | + +- [x] Source generators emit every icon from the vendored catalog (`LUCIDE_VERSION.txt`) plus drop-in `catalog/custom/` icons +- [x] Blazor: suffixed components (``), `Icon.*` factory, `` dispatcher — safe next to UI kits +- [x] MAUI: typed controls, `IconName` dispatcher, `IconCatalog` metadata with alias lookup +- [x] 135 tests (xUnit + bUnit + headless MAUI), CI for both solutions, tag-driven release — see [RELEASING.md](RELEASING.md) +- [x] Docs site at [docs/ShellIcons.Docs](docs/ShellIcons.Docs) (ShellDocs 0.1.7-alpha), with a searchable icon browser +- [x] Static build for GitHub Pages ([shellicons.shellui.dev](https://shellicons.shellui.dev)) — deploys on push to `main` +- [ ] MAUI: check rendering on Android/iOS devices, then publish +- [ ] Avalonia target ## Blazor quickstart @@ -153,14 +150,27 @@ Without `Title` icons are decorative (`aria-hidden="true"`); with it they get `r ``` +## MAUI quickstart (preview) + +```xml + + + + +``` + +Not on NuGet yet — reference `src/ShellIcons.Maui` from source. Docs: [MAUI getting started](docs/ShellIcons.Docs/content/docs/maui/getting-started.md). + ## Repo layout ``` shell-icons/ -├── SHELLICONS.md design proposal +├── CHANGELOG.md release notes +├── RELEASING.md how to cut a release ├── LUCIDE_VERSION.txt pinned upstream tag ├── NOTICE.md Lucide ISC attribution -├── ShellIcons.slnx +├── ShellIcons.slnx Blazor, generators, tests, docs — no MAUI workload needed +├── ShellIcons.Maui.slnx MAUI projects — needs `dotnet workload install maui` ├── Directory.Build.props ├── catalog/ │ ├── lucide/icons/ vendored Lucide SVGs (populated by sync-lucide.ps1) @@ -170,15 +180,20 @@ shell-icons/ ├── scripts/ │ └── sync-lucide.ps1 refresh vendored Lucide catalog ├── src/ -│ ├── ShellIcons.Blazor/ Razor Class Library (net8.0;net9.0) -│ └── ShellIcons.Generator/ Roslyn incremental source generator (netstandard2.0) +│ ├── ShellIcons.Blazor/ Razor Class Library (net8.0;net9.0) +│ ├── ShellIcons.Generator/ Blazor source generator (netstandard2.0) +│ ├── ShellIcons.Maui/ MAUI library (preview) +│ └── ShellIcons.Generator.Xaml/ MAUI source generator + SVG → path conversion ├── tests/ -│ ├── ShellIcons.Generator.Tests/ xUnit: SvgParser, Naming -│ └── ShellIcons.Blazor.Tests/ xUnit + bUnit: IconCore, generated icons, dispatcher +│ ├── ShellIcons.Generator.Tests/ xUnit: SvgParser, Naming, path conversion +│ ├── ShellIcons.Blazor.Tests/ xUnit + bUnit: components, factory, dispatcher, collisions +│ └── ShellIcons.Maui.Tests/ headless MAUI: controls, XAML, whole-catalog geometry ├── docs/ │ └── ShellIcons.Docs/ ShellDocs site — dev at http://localhost:5145 └── .github/workflows/ - └── docs.yml GH Pages deploy pipeline (waits on shelldocs.cli fix) + ├── ci.yml ubuntu: ShellIcons.slnx · windows: ShellIcons.Maui.slnx + ├── release.yml tag → NuGet (Trusted Publishing) + GitHub Release + └── docs.yml static build → GitHub Pages ``` ## Docs @@ -192,7 +207,16 @@ dotnet run Serves at http://localhost:5145. Pages live in `docs/ShellIcons.Docs/content/docs/*.md`. Live component previews via `razor:preview` code fences work out of the box. -The `/icons` route is a searchable browser of the entire catalog with click-to-copy component names. +`/docs/icons` is a searchable browser of the entire catalog with click-to-copy component names. + +Build the static site the way the deploy workflow does (needs `dotnet tool install -g ShellDocs.CLI --version 0.1.7-alpha`): + +```bash +cd docs/ShellIcons.Docs +shelldocs build --output ../../publish --spa-fallback --site-url https://shellicons.shellui.dev +``` + +Every page under `content/` is prerendered to plain HTML, so any static host can serve `publish/`. Pages added as Razor `@page` routes outside `content/` are not prerendered — put new pages in `content/` (a component can be used from markdown, as `content/docs/icons.md` does). ## Refreshing the Lucide catalog diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..767e31c --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,211 @@ +# Releasing ShellIcons + +How to ship a new version of `ShellIcons.Blazor` to NuGet.org and create the matching GitHub Release. + +**Who can release:** anyone who can approve the `release` environment on GitHub. Contributors without that access open a PR; a maintainer does the release. + +**What's released:** only `ShellIcons.Blazor`. `ShellIcons.Maui` is in preview — `release.yml` doesn't pack it yet, and publishing it will need a Windows or macOS job with the MAUI workload. The docs site isn't part of a release either: it deploys to [shellicons.shellui.dev](https://shellicons.shellui.dev) on every push to `main` (`docs.yml`). + +Publishing uses **NuGet Trusted Publishing** — GitHub Actions authenticates via OIDC and gets a short-lived (~1 hour) API key per run. No long-lived key lives in the repo. + +## One-time setup (repo owner — already done) + +Skip to [Every release](#every-release) unless you're setting up the repo or a new package ID. These steps need the nuget.org package owner and repo admin rights. + +### 1. Reserve the package on nuget.org (if not already) + +Trusted Publishing policies can only be attached to packages you own. For the first release the package ID doesn't exist yet — you need to reserve it OR create the policy with your account as owner and rely on first-push claiming. + +- Sign in to +- Preferred: reserve the ID prefix `ShellIcons.` under your account so any `ShellIcons.*` push is yours + - Profile menu → **Package IDs** → **Reserve** → `ShellIcons.*` + - (Needs review by the NuGet team — takes 1–2 days) +- Alternate: skip the reservation, first-push will claim the ID under whoever publishes + +### 2. Create the Trusted Publishing policy on nuget.org + +- Signed in on nuget.org → click your username → **Trusted Publishing** + (direct link: ) +- **Add Trusted Publishing Policy** — GitHub Actions + +Fill in exactly these values: + +| Field | Value | +|----------------------|------------------------------------| +| Policy Name | `shell-icons release` | +| Package Owner | *your nuget.org username* | +| Package Glob | `ShellIcons.*` | +| Repository Owner | `shellui-dev` | +| Repository | `shell-icons` | +| Workflow File | `release.yml` | +| Environment | `release` | + +**Every field matters.** If the workflow filename or environment name in the policy doesn't match the actual workflow, the OIDC exchange refuses. The `environment: release` line in `release.yml` and the environment in the policy must be identical. + +- **Enable Policy** → **Add** + +### 3. Add the `NUGET_USER` secret to the GitHub repo + +The workflow needs your nuget.org **username** (not email, not the GH org name) to negotiate the OIDC exchange. It stays a secret so it doesn't leak in workflow logs. + +- Repo → **Settings** → **Secrets and variables** → **Actions** +- **New repository secret** + - Name: `NUGET_USER` + - Value: your nuget.org profile name — the one that appears in your profile URL (`https://www.nuget.org/profiles/`) + +That's the only secret you need. There is **no `NUGET_API_KEY`** — it's minted per-run. + +### 4. Create the `release` environment + +The workflow uses `environment: release`, and the Trusted Publishing policy pins to that name. Gating the environment makes every release require an approver click, so no rogue tag push auto-publishes. + +- Repo → **Settings** → **Environments** → **New environment** → name it exactly `release` +- Under **Deployment protection rules** → **Required reviewers** — add the maintainers who may release +- Save + +## Every release + +### 1. Decide the version + +Follow SemVer: + +- `0.1.0-alpha` → `0.1.0-alpha.1` → `0.1.0-alpha.2` — same alpha line, iterating +- `0.1.0-alpha` → `0.1.0-beta` — moving toward stable +- `0.1.0-alpha` → `0.1.0-rc.1` → `0.1.0` — release candidate then stable +- `0.1.0` → `0.1.1` — patch fix +- `0.1.0` → `0.2.0` — additive change +- `0.1.0` → `1.0.0` — breaking change + +Prerelease suffixes (`-alpha`, `-beta`, `-rc`) automatically mark the GitHub Release as prerelease and NuGet lists it under "Include prerelease" only. + +### 2. Bump the version and the CHANGELOG + +In one PR: + +- [`Directory.Build.props`](Directory.Build.props) — set `` to the new version, e.g. `0.2.0-alpha`. +- [`CHANGELOG.md`](CHANGELOG.md) — rename `## [Unreleased]` to `## [0.2.0-alpha] — YYYY-MM-DD` and add a fresh, empty `## [Unreleased]` above it. + +Merge it to `main`. + +### 3. Sanity check locally (optional) + +```bash +dotnet test ShellIcons.slnx --configuration Release +dotnet pack src/ShellIcons.Blazor/ShellIcons.Blazor.csproj --configuration Release --output nupkgs +``` + +Should produce `nupkgs/ShellIcons.Blazor..nupkg` and `.snupkg`. + +### 4. Tag the release + +From an up-to-date `main`. The tag **must match** `` in `Directory.Build.props`, prefixed with `v` — the workflow refuses to publish if they disagree. + +```bash +git tag v0.2.0-alpha -m "ShellIcons 0.2.0-alpha" +git push origin v0.2.0-alpha +``` + +### 5. Approve the run + +Pushing the tag triggers `.github/workflows/release.yml`. Because of the `release` environment gate, the workflow pauses at the "Push to NuGet" step waiting for you to approve. + +- Watch the run at +- When it says "Waiting for review", click **Review deployments** → check `release` → **Approve and deploy** + +### 6. Verify + +- **NuGet.org**: — should show the new version within a couple of minutes (indexing) and be installable after ~10 minutes. +- **GitHub Release**: — auto-generated release notes based on merged PRs since the last tag, with `.nupkg` + `.snupkg` attached. + +## What the workflow does, step by step + +``` +push tag v + │ + ▼ + [checkout with full history] + │ + ▼ + [restore + build (Release) + test] + │ + ▼ + [dotnet pack → nupkgs/ShellIcons.Blazor..nupkg + .snupkg] + │ + ▼ + [verify tag == Directory.Build.props version] ← fails if mismatch + │ + ▼ + [verify version not already on nuget.org] ← fails if already published + │ + ▼ + *** environment: release — waits for reviewer *** + │ + ▼ + [NuGet/login@v1 — OIDC exchange with nuget.org] ← uses NUGET_USER + [dotnet nuget push --skip-duplicate] ← uses short-lived key + │ + ▼ + [softprops/action-gh-release] ← creates GitHub Release, + attaches .nupkg + .snupkg, + generates release notes, + marks prerelease if -alpha/-beta/-rc +``` + +## Dry runs + +Manual `workflow_dispatch` supports a `dry_run` boolean (defaults to `true`) — packs, verifies, but skips the actual NuGet push and GitHub Release. Useful for confirming the pipeline is healthy without shipping anything. + +- Actions → Release → **Run workflow** → leave `dry_run` checked → **Run** + +## Recovering from mistakes + +### Version already published, needs a fix + +**You cannot unpublish or overwrite** a published NuGet version. If a broken `0.2.0-alpha` shipped, don't re-tag `v0.2.0-alpha` — the "already on nuget.org" check fails anyway. + +Instead: + +1. Fix the bug on `main` +2. Bump `Directory.Build.props` (e.g. `0.2.1-alpha`) and add a CHANGELOG entry +3. Tag `v0.2.1-alpha` and push +4. Optional: the package owner marks the broken version as deprecated on nuget.org (Package → Manage → Deprecate) + +### Tag pushed with the wrong version + +The workflow will fail at the "tag matches props version" check before any publish happens. Delete the tag and re-tag: + +```bash +git tag -d v0.2.0-alpha +git push origin :refs/tags/v0.2.0-alpha +``` + +Fix `Directory.Build.props` if needed, merge, then tag again. + +## Secrets used + +| Name | Where set | What for | Rotation | +|----------------|------------------|--------------------------------------------------------------------|----------| +| `NUGET_USER` | Repo secret | The package owner's nuget.org username — feeds the OIDC exchange for a short-lived key | Only if the owner's nuget.org handle changes | +| `GITHUB_TOKEN` | Auto-provided | Creates the GitHub Release + OIDC identity claims | Never — regenerated per run by GitHub | + +**No long-lived NuGet API key.** Trusted Publishing mints one per run, valid ~1 hour, scoped to this workflow + environment. If the repo is ever compromised, an attacker can't steal a persistent NuGet credential — because there isn't one. + +## Troubleshooting + +**`NuGet/login@v1` fails with "no matching trusted publishing policy"** + +The Trusted Publishing policy on nuget.org doesn't match what the workflow claims. Check every field: + +- Repository owner exactly `shellui-dev` (case sensitive) +- Repository exactly `shell-icons` +- Workflow file exactly `release.yml` (not `.github/workflows/release.yml`, just the filename) +- Environment exactly `release` +- Policy is **Enabled** (there's a toggle after creating) + +**`dotnet nuget push` fails with 403 after successful OIDC login** + +The short-lived key was minted but doesn't authorize the package glob. Check the policy's **Package Glob** matches `ShellIcons.*` (or is broader). If you renamed the package ID and the glob is now stale, edit the policy. + +**Workflow says "Waiting for review" and never proceeds** + +The `release` environment gate is doing its job — a required reviewer has to click. Actions → Release → **Review deployments** → check `release` → **Approve and deploy**. A reviewer can approve their own run. diff --git a/ShellIcons.Maui.slnx b/ShellIcons.Maui.slnx new file mode 100644 index 0000000..b21d6f5 --- /dev/null +++ b/ShellIcons.Maui.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/catalog/custom/README.md b/catalog/custom/README.md index 4534be0..6db2dfd 100644 --- a/catalog/custom/README.md +++ b/catalog/custom/README.md @@ -23,7 +23,20 @@ Match Lucide's shape contract so custom icons are visually cohesive with the res - **stroke-width:** `2` - **stroke-linecap / stroke-linejoin:** `round` - **fill:** `none` on the root, unless a specific shape needs a fill (fill variants use `fill="currentColor"` on the elements that should be filled) -- **Inner children:** any of ``, ``, ``, ``, ``, ``. The generator strips the outer `` and preserves the shapes. +- **Inner children:** any of ``, ``, ``, ``, ``, ``, ``. The generator strips the outer `` and preserves the shapes. + +## Staying portable to MAUI (and Avalonia) + +Blazor renders your SVG as-is, so almost anything works there. The XAML targets convert every shape to native path geometry at build time, and they only understand the flat contract above. Break it and the icon still works in Blazor, but the build reports a problem for MAUI: + +| You use | What happens for MAUI | Diagnostic | +|---|---|---| +| ``, ``, ``, gradients, masks, text | That element is skipped | `SHELLICONS002` (warning) | +| `transform="…"` on any shape | Reported; bake the transform into the coordinates instead | `SHELLICONS002` (warning) | +| A shape entirely outside `0 0 24 24` | Dropped — browsers clip it, a native `Path` wouldn't | `SHELLICONS005` (info) | +| `fill` other than `none` | Drawn as a separate filled + stroked path | — | + +Keep every shape inside the 24×24 grid, with no groups and no transforms, and the icon renders identically on every target. ## File naming diff --git a/docs/ShellIcons.Docs/Components/App.razor b/docs/ShellIcons.Docs/Components/App.razor index af23430..9b14062 100644 --- a/docs/ShellIcons.Docs/Components/App.razor +++ b/docs/ShellIcons.Docs/Components/App.razor @@ -5,38 +5,37 @@ - - - + @* Plain asset paths, not @Assets[…]: fingerprinted URLs only resolve through the server and 404 on a static host. *@ - + + - + (function () { + var saved = null; + try { saved = localStorage.getItem('shelldocs-theme'); } catch (e) {} + var systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + if ((saved || (systemDark ? 'dark' : 'light')) === 'dark') { + document.documentElement.classList.add('dark'); + } + })(); + + - - - + + + diff --git a/docs/ShellIcons.Docs/Components/IconBrowser.razor b/docs/ShellIcons.Docs/Components/IconBrowser.razor new file mode 100644 index 0000000..ca65fdf --- /dev/null +++ b/docs/ShellIcons.Docs/Components/IconBrowser.razor @@ -0,0 +1,137 @@ +@namespace ShellIcons.Docs.Components +@using ShellIcons + +@* Used from content/docs/icons.md so `shelldocs build` prerenders it; behavior lives in wwwroot/icon-browser.js. *@ + + + +

Click an icon to copy its component, e.g. <ChevronRightIcon />.

+ +
+ @foreach (var name in _names) + { + + } +
+ + + + + + + +@code { + private readonly IReadOnlyList _names = + ShellIcon.Names.OrderBy(n => n, StringComparer.Ordinal).ToArray(); +} diff --git a/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor b/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor deleted file mode 100644 index e740b0c..0000000 --- a/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor +++ /dev/null @@ -1,31 +0,0 @@ - - - -
- -

- Rejoining the server... -

-

- Rejoin failed... trying again in seconds. -

-

- Failed to rejoin.
Please retry or reload the page. -

- -

- The session has been paused by the server. -

-

- Failed to resume the session.
Please retry or reload the page. -

- -
-
diff --git a/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor.css b/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor.css deleted file mode 100644 index 3ad3773..0000000 --- a/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor.css +++ /dev/null @@ -1,157 +0,0 @@ -.components-reconnect-first-attempt-visible, -.components-reconnect-repeated-attempt-visible, -.components-reconnect-failed-visible, -.components-pause-visible, -.components-resume-failed-visible, -.components-rejoining-animation { - display: none; -} - -#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, -#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, -#components-reconnect-modal.components-reconnect-paused .components-pause-visible, -#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, -#components-reconnect-modal.components-reconnect-retrying, -#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, -#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, -#components-reconnect-modal.components-reconnect-failed, -#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { - display: block; -} - - -#components-reconnect-modal { - background-color: white; - width: 20rem; - margin: 20vh auto; - padding: 2rem; - border: 0; - border-radius: 0.5rem; - box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); - opacity: 0; - transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; - animation: components-reconnect-modal-fadeOutOpacity 0.5s both; - &[open] - -{ - animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; - animation-fill-mode: both; -} - -} - -#components-reconnect-modal::backdrop { - background-color: rgba(0, 0, 0, 0.4); - animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; - opacity: 1; -} - -@keyframes components-reconnect-modal-slideUp { - 0% { - transform: translateY(30px) scale(0.95); - } - - 100% { - transform: translateY(0); - } -} - -@keyframes components-reconnect-modal-fadeInOpacity { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -@keyframes components-reconnect-modal-fadeOutOpacity { - 0% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} - -.components-reconnect-container { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; -} - -#components-reconnect-modal p { - margin: 0; - text-align: center; -} - -#components-reconnect-modal button { - border: 0; - background-color: #6b9ed2; - color: white; - padding: 4px 24px; - border-radius: 4px; -} - - #components-reconnect-modal button:hover { - background-color: #3b6ea2; - } - - #components-reconnect-modal button:active { - background-color: #6b9ed2; - } - -.components-rejoining-animation { - position: relative; - width: 80px; - height: 80px; -} - - .components-rejoining-animation div { - position: absolute; - border: 3px solid #0087ff; - opacity: 1; - border-radius: 50%; - animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; - } - - .components-rejoining-animation div:nth-child(2) { - animation-delay: -0.5s; - } - -@keyframes components-rejoining-animation { - 0% { - top: 40px; - left: 40px; - width: 0; - height: 0; - opacity: 0; - } - - 4.9% { - top: 40px; - left: 40px; - width: 0; - height: 0; - opacity: 0; - } - - 5% { - top: 40px; - left: 40px; - width: 0; - height: 0; - opacity: 1; - } - - 100% { - top: 0px; - left: 0px; - width: 80px; - height: 80px; - opacity: 0; - } -} diff --git a/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor.js b/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor.js deleted file mode 100644 index a44de78..0000000 --- a/docs/ShellIcons.Docs/Components/Layout/ReconnectModal.razor.js +++ /dev/null @@ -1,63 +0,0 @@ -// Set up event handlers -const reconnectModal = document.getElementById("components-reconnect-modal"); -reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); - -const retryButton = document.getElementById("components-reconnect-button"); -retryButton.addEventListener("click", retry); - -const resumeButton = document.getElementById("components-resume-button"); -resumeButton.addEventListener("click", resume); - -function handleReconnectStateChanged(event) { - if (event.detail.state === "show") { - reconnectModal.showModal(); - } else if (event.detail.state === "hide") { - reconnectModal.close(); - } else if (event.detail.state === "failed") { - document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); - } else if (event.detail.state === "rejected") { - location.reload(); - } -} - -async function retry() { - document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); - - try { - // Reconnect will asynchronously return: - // - true to mean success - // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) - // - exception to mean we didn't reach the server (this can be sync or async) - const successful = await Blazor.reconnect(); - if (!successful) { - // We have been able to reach the server, but the circuit is no longer available. - // We'll reload the page so the user can continue using the app as quickly as possible. - const resumeSuccessful = await Blazor.resumeCircuit(); - if (!resumeSuccessful) { - location.reload(); - } else { - reconnectModal.close(); - } - } - } catch (err) { - // We got an exception, server is currently unavailable - document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); - } -} - -async function resume() { - try { - const successful = await Blazor.resumeCircuit(); - if (!successful) { - location.reload(); - } - } catch { - reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); - } -} - -async function retryWhenDocumentBecomesVisible() { - if (document.visibilityState === "visible") { - await retry(); - } -} diff --git a/docs/ShellIcons.Docs/Components/Pages/Home.razor b/docs/ShellIcons.Docs/Components/Pages/Home.razor index fda1f22..b2fb897 100644 --- a/docs/ShellIcons.Docs/Components/Pages/Home.razor +++ b/docs/ShellIcons.Docs/Components/Pages/Home.razor @@ -29,13 +29,15 @@ Get started - + Browse @ShellIcon.Names.Count icons
dotnet add package ShellIcons.Blazor
+ +

Building with .NET MAUI? ShellIcons.Maui is in preview →

@@ -176,6 +178,13 @@ color: var(--foreground); } + .hero-also { + margin: 1rem 0 0; + font-size: 0.875rem; + color: var(--muted-foreground); + } + .hero-also a { color: var(--foreground); } + .showcase { max-width: 60rem; margin: 0 auto 4rem; diff --git a/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor b/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor deleted file mode 100644 index 436bc5d..0000000 --- a/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor +++ /dev/null @@ -1,215 +0,0 @@ -@page "/icons" -@layout ShellDocs.Components.Layouts.DocsLayout -@using ShellIcons - -Browse icons — @_names.Count total — ShellIcons - -
-

Browse the catalog

-

- Every icon in ShellIcons — @_names.Count icons from Lucide @LucideVersion. - Type to filter by name. -

- -
- -
- @foreach (var name in _names) - { - - } -
- - - - - - - - - -@code { - private const string LucideVersion = "0.475.0"; - - private IReadOnlyList _names = Array.Empty(); - - protected override void OnInitialized() - { - _names = ShellIcon.Names.OrderBy(n => n, StringComparer.Ordinal).ToArray(); - } -} diff --git a/docs/ShellIcons.Docs/Program.cs b/docs/ShellIcons.Docs/Program.cs index 4f1dafd..1c1f096 100644 --- a/docs/ShellIcons.Docs/Program.cs +++ b/docs/ShellIcons.Docs/Program.cs @@ -12,22 +12,19 @@ { o.ContentRoot = Path.Combine(builder.Environment.ContentRootPath, "content"); o.SiteName = "ShellIcons"; - o.SiteTagline = "Lucide-derived SVG icons for Blazor."; + o.SiteTagline = "Lucide-derived icons for Blazor and .NET MAUI."; o.GitHubRepo = "shellui-dev/shell-icons"; - - // Sidebar-nav layout — top-nav links move into the sidebar's header. o.LayoutVariant = DocsLayoutVariant.Sidebar; o.AddNavLink("Docs", "/docs/introduction"); - o.AddNavLink("Icons", "/icons"); + o.AddNavLink("Icons", "/docs/icons"); o.AddNavLink("GitHub", "https://github.com/shellui-dev/shell-icons"); - // Every generated ShellIcons component is available inside razor:preview blocks. + // Every icon component, usable in razor:preview blocks. o.RegisterComponentsFromAssembly(); - // Preview wrappers — razor:preview fences require a registered component as - // the outer tag, and Markdig mangles inline HTML like between component - // slots. PreviewRow / PreviewCell survive both because they're registered too. + /* This site's own components: PreviewRow/PreviewCell (razor:preview needs a registered outer tag, + and Markdig mangles plain wrappers) and IconBrowser (used from content/docs/icons.md). */ o.RegisterComponentsFromAssembly(); }); diff --git a/docs/ShellIcons.Docs/ShellIcons.Docs.csproj b/docs/ShellIcons.Docs/ShellIcons.Docs.csproj index d990847..5b1119f 100644 --- a/docs/ShellIcons.Docs/ShellIcons.Docs.csproj +++ b/docs/ShellIcons.Docs/ShellIcons.Docs.csproj @@ -10,10 +10,10 @@ - - - - + + + + @@ -21,7 +21,8 @@ - + + diff --git a/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md b/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md index ff60cb6..75687d3 100644 --- a/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md +++ b/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md @@ -111,4 +111,4 @@ For dynamic use cases — icon names from JSON, markdown-authored content, CMS-d - [Sizing & stroke](/docs/guides/sizing-and-stroke) — details on `AbsoluteStroke`, CSS units - [Color & theming](/docs/guides/color-and-theming) — dark mode, CSS variables - [Accessibility](/docs/guides/accessibility) — when `Title` is right, when it's wrong -- [Browse the catalog](/icons) — search all 1,555 icons +- [Browse the catalog](/docs/icons) — search all 1,555 icons diff --git a/docs/ShellIcons.Docs/content/docs/guides/custom-icons.md b/docs/ShellIcons.Docs/content/docs/guides/custom-icons.md index 89971ab..6a149e9 100644 --- a/docs/ShellIcons.Docs/content/docs/guides/custom-icons.md +++ b/docs/ShellIcons.Docs/content/docs/guides/custom-icons.md @@ -14,7 +14,7 @@ The Roslyn source generator scans both `catalog/lucide/icons/` (vendored) and `c Match Lucide's format so custom icons feel visually cohesive: -```svg +```xml `` now renders your version everywhere. +## On MAUI + +Custom icons are converted for [MAUI](/docs/maui/rendering) too. Keep them flat — no ``, no `transform`, every shape inside the 24×24 grid — and they render the same on every target. The build warns (`SHELLICONS002`) when something won't convert. + ## Licensing Custom-pack icons are your own work under whatever license your project uses. They are **not** attributed in `NOTICE.md` — that file covers only the Lucide pack. If you fork this repo and add a lot of custom icons, treat them as first-class source code, not vendored content. diff --git a/docs/ShellIcons.Docs/content/docs/icons.md b/docs/ShellIcons.Docs/content/docs/icons.md new file mode 100644 index 0000000..6d279de --- /dev/null +++ b/docs/ShellIcons.Docs/content/docs/icons.md @@ -0,0 +1,11 @@ +--- +title: Browse icons +description: Search every icon in the catalog and copy its component in one click. +order: 2 +--- + +# Browse icons + +Every icon in ShellIcons, from Lucide 0.475.0. The same names work in Blazor (``) and MAUI (``). + + diff --git a/docs/ShellIcons.Docs/content/docs/introduction.md b/docs/ShellIcons.Docs/content/docs/introduction.md index c53dcae..b82e364 100644 --- a/docs/ShellIcons.Docs/content/docs/introduction.md +++ b/docs/ShellIcons.Docs/content/docs/introduction.md @@ -23,12 +23,14 @@ The .NET icon story is fractured: ShellIcons is the answer to all three from one Lucide-derived catalog: - **Blazor today.** Full 1,555-icon catalog, auto-generated by a Roslyn source generator from a pinned Lucide tag. -- **Avalonia + MAUI on the roadmap.** Same catalog, framework-native rendering — `Path` shapes emitted at build time, no runtime SVG parser. +- **MAUI in preview.** Same catalog as native `Path` shapes, converted at build time — no SVG parsing on the device. See [MAUI](/docs/maui/getting-started). +- **Avalonia next.** It will share the MAUI conversion. ## What you get - **1,555 icons** — the entire Lucide 0.475.0 catalog. Bumping is one `git pull` + rebuild. -- **Typed components** — ``, ``, ``. Tree-shakes under Blazor trimming. +- **Typed components** — ``, ``, ``. Safe next to any UI kit, and unused icons are trimmed. +- **Icons as values** — `@Icon.ChevronRight()` for component parameters and lists. - **Dynamic dispatcher** — `` for markdown, CMS-driven content, dynamic lookup. - **Custom icons pack** — drop your own SVGs into `catalog/custom/icons/`, they compile in alongside Lucide. - **`currentColor`-native** — coloring inherits from any CSS `color` on an ancestor. No `Color` prop. @@ -49,4 +51,4 @@ ShellIcons is the answer to all three from one Lucide-derived catalog: - [Installation](/docs/getting-started/installation) — one `dotnet add package` - [Quick start](/docs/getting-started/quick-start) — first icon in a Blazor page -- [Browse the catalog](/icons) — all 1,555 icons, searchable +- [Browse the catalog](/docs/icons) — all 1,555 icons, searchable diff --git a/docs/ShellIcons.Docs/content/docs/maui/getting-started.md b/docs/ShellIcons.Docs/content/docs/maui/getting-started.md new file mode 100644 index 0000000..b7783fe --- /dev/null +++ b/docs/ShellIcons.Docs/content/docs/maui/getting-started.md @@ -0,0 +1,98 @@ +--- +title: MAUI getting started +description: ShellIcons for .NET MAUI — the same 1,555 icons as native Path shapes. +order: 1 +--- + +# ShellIcons for .NET MAUI + +The same Lucide catalog as the Blazor package, drawn as native MAUI `Path` shapes. The SVG is converted at build time, so nothing parses SVG on the device. + + +ShellIcons.Maui builds for Android, iOS, Mac Catalyst and Windows and is covered by tests, but it hasn't been checked visually on Android or iOS devices yet. Reference the project from source until it's published. + + +## Install + +Until the package is published, reference the project. You need the MAUI workloads installed (`dotnet workload install maui`). + +```xml + +``` + +Supported targets: `net10.0-android`, `net10.0-ios`, `net10.0-maccatalyst`, `net10.0-windows10.0.19041.0`. + +## Use in XAML + +One namespace covers everything: + +```xml + + + + + + +``` + +There are two forms: + +| Form | Example | Use for | +|---|---|---| +| Typed control | `` | Icons fixed in your layout. Each carries its own shape, so unused icons are trimmed. | +| `Icon` dispatcher | `` | Icons that are values — bindings, view-model properties, component parameters. Keeps the whole catalog. | + +XAML prefixes keep names apart, so `` (the icon) and `` (MAUI's control) coexist on the same page. + +## Properties + +| Property | Default | Notes | +|---|---|---| +| `Size` | `24` | Width and height | +| `StrokeThickness` | `2` | On Lucide's 24-unit grid; scales with `Size` | +| `AbsoluteStroke` | `false` | Keeps the rendered stroke at `StrokeThickness` at any size | +| `Color` | `null` | Stroke (and fill) color. Bindable, so `{DynamicResource …}` works | +| `Title` | `null` | Accessible description; when null the icon stays out of the accessibility tree | + +## Color and theming + +MAUI has no CSS `currentColor`, so icons don't inherit text color. With `Color` unset, an icon is black in the light theme and white in the dark theme. To follow your own palette everywhere, set it once with a style: + +```xml + +``` + +`IconView` is the base of both the typed controls and `Icon`, so `ApplyToDerivedTypes` covers every icon. + +## Taps and accessibility + +Icons are `InputTransparent`: a tap goes to the button or row that hosts the icon, which is what you want in lists and toolbars. To make an icon clickable, put the gesture on its container: + +```xml + + + + + + +``` + +Set `Title` whenever the icon is the only thing conveying meaning, as in the icon-only button above. + +## From C# + +```csharp +using ShellIcons.Maui; + +var chevron = new ShellIcons.Maui.Icons.ChevronRight { Size = 16 }; +var status = new Icon { Name = IconName.CircleCheck, Color = Colors.Green }; +``` + +The typed controls live in `ShellIcons.Maui.Icons` so names like `Image` or `Grid` don't clash with MAUI's own types in C#. + +## Next + +- [Icon names & catalog](/docs/maui/names-and-catalog) — `IconName`, aliases, search, bindings +- [How rendering works](/docs/maui/rendering) — SVG → `Path`, custom icons on MAUI diff --git a/docs/ShellIcons.Docs/content/docs/maui/meta.json b/docs/ShellIcons.Docs/content/docs/maui/meta.json new file mode 100644 index 0000000..b1405fd --- /dev/null +++ b/docs/ShellIcons.Docs/content/docs/maui/meta.json @@ -0,0 +1,4 @@ +{ + "title": "MAUI (preview)", + "pages": ["getting-started", "names-and-catalog", "rendering"] +} diff --git a/docs/ShellIcons.Docs/content/docs/maui/names-and-catalog.md b/docs/ShellIcons.Docs/content/docs/maui/names-and-catalog.md new file mode 100644 index 0000000..9b50199 --- /dev/null +++ b/docs/ShellIcons.Docs/content/docs/maui/names-and-catalog.md @@ -0,0 +1,60 @@ +--- +title: Icon names & catalog +description: The IconName enum, Lucide aliases, and searching the catalog from C#. +order: 2 +--- + +# Icon names & catalog + +## `IconName` + +Every icon has an `IconName` member: PascalCase of its Lucide file name (`chevron-right` → `ChevronRight`), the same rule the Blazor components use. `IconName.None` renders nothing. + +Because it's an enum, an icon can be a view-model property: + +```csharp +public partial class StatusViewModel : ObservableObject +{ + [ObservableProperty] + private IconName _statusIcon = IconName.CircleCheck; +} +``` + +```xml + +``` + +## Names from strings + +Icons often arrive as strings — JSON config, a CMS, settings. `IconCatalog.TryParse` accepts a kebab-case id, a PascalCase name or a Lucide alias, ignoring case: + +```csharp +IconCatalog.TryParse("chevron-right", out var a); // IconName.ChevronRight +IconCatalog.TryParse("ChevronRight", out var b); // IconName.ChevronRight +IconCatalog.TryParse("home", out var c); // IconName.House — Lucide renamed home → house +IconCatalog.TryParse("nope", out var d); // false, IconName.None +``` + +Aliases matter because Lucide renames icons between versions. Stored names like `home` keep working after the catalog is bumped. A real icon id always wins over another icon's alias. + +## Metadata and search + +Each icon carries the tags, categories and aliases from its Lucide JSON sidecar: + +```csharp +IconInfo house = IconCatalog.Get(IconName.House); +// house.Id "house" +// house.Source "lucide" (or "custom") +// house.Tags ["home", "living", "building", "residence", "architecture"] +// house.Categories ["buildings", "home"] +// house.Aliases ["home"] +``` + +`IconCatalog.Search` powers icon pickers. Matches on the id come first, then aliases, tags and categories: + +```csharp +foreach (var icon in IconCatalog.Search("cart")) + Console.WriteLine(icon.Id); // shopping-cart, … then icons tagged "cart" +``` + +`IconCatalog.All` lists every icon; `IconCatalog.Count` is the catalog size. Everything is built lazily on first use. diff --git a/docs/ShellIcons.Docs/content/docs/maui/rendering.md b/docs/ShellIcons.Docs/content/docs/maui/rendering.md new file mode 100644 index 0000000..8402acb --- /dev/null +++ b/docs/ShellIcons.Docs/content/docs/maui/rendering.md @@ -0,0 +1,40 @@ +--- +title: How rendering works +description: How SVG icons become native MAUI Path shapes, and what that means for custom icons. +order: 3 +--- + +# How rendering works + +Blazor renders each icon's SVG directly. MAUI has no SVG support in XAML, so ShellIcons converts every icon to path data when the package is built. + +## From SVG to one path + +A source generator reads each SVG and turns every shape into part of **one** path, using absolute coordinates only: + +| SVG | Becomes | +|---|---| +| `` | The same path, with relative commands made absolute and shorthand curves expanded | +| ``, `` | Two half-arcs | +| `` | Four lines and four arc corners | +| ``, ``, `` | Straight segments (closed for a polygon) | + +One path per icon means one native view per icon — lists and toolbars render many of them. Only the few Lucide shapes with `fill="currentColor"` get a second path, drawn filled and stroked like the original. + +On the device, the control reads that pre-converted path once per icon and caches the geometry, so a hundred `ChevronRight`s share one shape. + +## Sizing + +Every icon is drawn on Lucide's 24×24 grid and scaled to `Size`. The grid, not each icon's own bounds, decides the scale, so a `minus` and a `circle-alert` of the same `Size` line up the way they do in Lucide. The stroke is scaled along with it unless you set `AbsoluteStroke`. + +## Custom icons on MAUI + +[Custom icons](/docs/guides/custom-icons) dropped into `catalog/custom/icons/` are converted too, as long as they follow the flat SVG contract. Blazor renders almost any SVG, so a custom icon can look right in the browser and still have problems on MAUI. The build tells you: + +| You use | On MAUI | Diagnostic | +|---|---|---| +| ``, ``, gradients, masks, text | That part is skipped | `SHELLICONS002` warning | +| `transform="…"` | Reported — bake it into the coordinates | `SHELLICONS002` warning | +| A shape entirely outside `0 0 24 24` | Dropped — browsers clip it, a native path wouldn't | `SHELLICONS005` info | + +Lucide itself has one of those: `save-off.svg` contains a stray shape outside the viewBox. It's invisible in a browser, and ShellIcons drops it on MAUI so it can't draw beside the icon. diff --git a/docs/ShellIcons.Docs/content/docs/meta.json b/docs/ShellIcons.Docs/content/docs/meta.json index 1e88cc2..536c45a 100644 --- a/docs/ShellIcons.Docs/content/docs/meta.json +++ b/docs/ShellIcons.Docs/content/docs/meta.json @@ -2,8 +2,10 @@ "title": "Docs", "pages": [ "introduction", + "icons", "getting-started", "guides", + "maui", "reference" ] } diff --git a/docs/ShellIcons.Docs/content/docs/reference/catalog-source-generator.md b/docs/ShellIcons.Docs/content/docs/reference/catalog-source-generator.md index c77de1a..c2d927b 100644 --- a/docs/ShellIcons.Docs/content/docs/reference/catalog-source-generator.md +++ b/docs/ShellIcons.Docs/content/docs/reference/catalog-source-generator.md @@ -34,11 +34,11 @@ Both feed the same generator. Both produce components in the same `ShellIcons.Ic - `Naming.KebabToPascal` turns `chevron-right` into `ChevronRight`. - `ResolvePack` tags the icon `"lucide"` or `"custom"` based on file path. 4. Collision resolution — custom-pack icons win over same-name Lucide icons. A `SHELLICONS001` info diagnostic is logged. -5. Emit — one `.g.cs` per icon plus a single `ShellIcon.g.cs` dispatcher: +5. Emit, per icon: a component in `ShellIcons.Icons`, its `{Name}Icon` alias in `ShellIcons`, and an `Icon.{Name}()` factory method. Plus one `ShellIcon` dispatcher for the whole catalog: ```csharp // Icons/ChevronRight.g.cs (auto-generated) -public sealed class ChevronRight : global::ShellIcons.IconCore +public class ChevronRight : global::ShellIcons.IconCore { protected override string IconName => "chevron-right"; protected override void EmitChildren(RenderTreeBuilder builder, int seq) => @@ -64,6 +64,10 @@ Rebuild after. The generator picks up the diff automatically — new icons emit, | ID | Severity | When | |---|---|---| | `SHELLICONS001` | Info | A `catalog/custom/` icon has the same name as a `catalog/lucide/` icon. Custom wins; the message logs the override. | +| `SHELLICONS002` | Warning | MAUI only: an icon uses SVG the XAML targets can't draw (``, `transform`, …). That part is skipped. | +| `SHELLICONS003` | Info | An icon has no `{Name}Icon` alias because the name is taken (Lucide's `shell` → `ShellIcon`, the dispatcher). Use `Icon.Shell()`. | +| `SHELLICONS004` | Warning | MAUI only: an icon name can't become an enum member (e.g. `none`, or starts with a digit). It's skipped. | +| `SHELLICONS005` | Info | MAUI only: a shape lies entirely outside the 24×24 viewBox and is dropped. | ## Debugging the generator @@ -72,11 +76,13 @@ Set `true` in `ShellIco ## Naming edge cases - Lucide 0.475.0 ships **no numeric-prefix icons** (e.g. `1st-place-medal`), so we don't ship a NumberPrefix map yet. If a future Lucide bump introduces one, we'll port Lucide's own map — `"1st" → "firstPlace"`. -- Aliases (e.g. `home` → `house`) are not currently emitted as separate components. The alias metadata in the JSON sidecar is parsed but not used yet. Planned. +- Aliases (e.g. `home` → `house`) aren't emitted as separate components. On MAUI, `IconCatalog.TryParse("home", …)` resolves them; the Blazor package doesn't use them yet. ## Trimming -Both forms behave well under the trimmer: - -- Typed icons — only referenced types survive +- Typed icons, `{Name}Icon` aliases and `Icon.*` factory methods — only the icons you reference survive - `ShellIcon` dispatcher — referencing it (or `ShellIcon.Names`) roots the entire dictionary + +## MAUI + +`ShellIcons.Maui` has its own generator, `ShellIcons.Generator.Xaml`, which reads the same SVGs plus the JSON sidecars. It converts each icon to a single path (see [How rendering works](/docs/maui/rendering)) and emits the `IconName` enum, `IconCatalog` metadata and one typed control per icon. diff --git a/docs/ShellIcons.Docs/wwwroot/icon-browser.js b/docs/ShellIcons.Docs/wwwroot/icon-browser.js new file mode 100644 index 0000000..69d8d18 --- /dev/null +++ b/docs/ShellIcons.Docs/wwwroot/icon-browser.js @@ -0,0 +1,67 @@ +/* Icon browser behavior. Listeners sit on `document` and look elements up per event, so they keep + working on static hosting, after Blazor enhanced navigation, and when an interactive render + replaces the prerendered DOM. */ +(function () { + function filter(input) { + const cells = document.querySelectorAll('#icon-grid .icon-cell'); + const q = input.value.trim().toLowerCase(); + let visible = 0; + for (const cell of cells) { + const match = !q || cell.dataset.name.includes(q); + cell.hidden = !match; + if (match) visible++; + } + + const count = document.getElementById('icon-count'); + if (count) count.textContent = q ? visible + ' / ' + cells.length : cells.length; + + const empty = document.getElementById('empty-state'); + if (empty) { + empty.hidden = visible !== 0; + const query = document.getElementById('empty-query'); + if (query) query.textContent = q; + } + } + + function pascal(name) { + return name.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(''); + } + + function showToast(text) { + const toast = document.getElementById('copy-toast'); + if (!toast) return; + toast.textContent = text; + toast.hidden = false; + requestAnimationFrame(() => toast.classList.add('show')); + clearTimeout(showToast.timer); + showToast.timer = setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => (toast.hidden = true), 200); + }, 1500); + } + + // Debounced with a timer rather than requestAnimationFrame, which stalls when the page isn't painting. + let timer = 0; + document.addEventListener('input', e => { + if (e.target.id !== 'icon-search') return; + clearTimeout(timer); + timer = setTimeout(() => filter(e.target), 50); + }); + + document.addEventListener('click', e => { + const cell = e.target.closest && e.target.closest('#icon-grid .icon-cell'); + if (!cell) return; + const name = cell.dataset.name; + // `shell` has no suffixed form (ShellIcon is the dispatcher). + const snippet = name === 'shell' ? '@Icon.Shell()' : '<' + pascal(name) + 'Icon />'; + navigator.clipboard?.writeText(snippet).then(() => showToast('Copied: ' + snippet)); + }); + + document.addEventListener('keydown', e => { + const input = document.getElementById('icon-search'); + if (e.key === '/' && input && document.activeElement !== input) { + e.preventDefault(); + input.focus(); + } + }); +})(); diff --git a/src/ShellIcons.Generator.Xaml/IconMetadataReader.cs b/src/ShellIcons.Generator.Xaml/IconMetadataReader.cs new file mode 100644 index 0000000..208bfa4 --- /dev/null +++ b/src/ShellIcons.Generator.Xaml/IconMetadataReader.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace ShellIcons.Generator.Xaml; + +internal sealed class IconMetadata +{ + public static readonly IconMetadata Empty = new(Array.Empty(), Array.Empty(), Array.Empty()); + + public IconMetadata(IReadOnlyList tags, IReadOnlyList categories, IReadOnlyList aliases) + { + Tags = tags; + Categories = categories; + Aliases = aliases; + } + + public IReadOnlyList Tags { get; } + public IReadOnlyList Categories { get; } + public IReadOnlyList Aliases { get; } +} + +/* Targeted extraction instead of System.Text.Json, which Roslyn hosts don't reliably load + inside an analyzer; the sidecar schema is flat enough for it. */ +internal static class IconMetadataReader +{ + private static readonly Regex StringLiteral = new(@"""((?:\\.|[^""\\])*)""", RegexOptions.Compiled); + + public static IconMetadata Read(string json) + { + if (string.IsNullOrWhiteSpace(json)) return IconMetadata.Empty; + return new IconMetadata(ReadArray(json, "tags"), ReadArray(json, "categories"), ReadArray(json, "aliases")); + } + + private static IReadOnlyList ReadArray(string json, string key) + { + var m = Regex.Match(json, "\"" + key + @"""\s*:\s*\[(?[^\]]*)\]"); + if (!m.Success) return Array.Empty(); + + var body = m.Groups["body"].Value; + // Newer Lucide writes aliases as objects ({ "name": "home", … }); take only "name". + if (body.IndexOf('{') >= 0) + { + var names = new List(); + foreach (Match o in Regex.Matches(body, @"""name""\s*:\s*""((?:\\.|[^""\\])*)""")) + names.Add(Unescape(o.Groups[1].Value)); + return names; + } + + var result = new List(); + foreach (Match s in StringLiteral.Matches(body)) + result.Add(Unescape(s.Groups[1].Value)); + return result; + } + + private static string Unescape(string s) + { + if (s.IndexOf('\\') < 0) return s; + var sb = new StringBuilder(s.Length); + for (var i = 0; i < s.Length; i++) + { + if (s[i] != '\\' || i + 1 >= s.Length) { sb.Append(s[i]); continue; } + var next = s[++i]; + switch (next) + { + case 'n': sb.Append('\n'); break; + case 't': sb.Append('\t'); break; + case 'u' when i + 4 < s.Length: + sb.Append((char)Convert.ToInt32(s.Substring(i + 1, 4), 16)); + i += 4; + break; + default: sb.Append(next); break; + } + } + return sb.ToString(); + } +} diff --git a/src/ShellIcons.Generator.Xaml/MauiIconGenerator.cs b/src/ShellIcons.Generator.Xaml/MauiIconGenerator.cs new file mode 100644 index 0000000..ae59ec0 --- /dev/null +++ b/src/ShellIcons.Generator.Xaml/MauiIconGenerator.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ShellIcons.Generator.Xaml; + +/* From the same catalog as the Blazor generator, emits: the IconName enum, IconData (path data for + the Icon dispatcher), IconCatalog metadata, and one typed control per icon carrying its own path. */ +[Generator] +public sealed class MauiIconGenerator : IIncrementalGenerator +{ + private const string Ns = "ShellIcons.Maui"; + private const string IconsNs = "ShellIcons.Maui.Icons"; + + private static readonly DiagnosticDescriptor OverrideDescriptor = new( + id: "SHELLICONS001", + title: "Custom icon overrides Lucide icon", + messageFormat: "Icon '{0}' from catalog/custom/ overrides the Lucide-derived icon of the same name", + category: "ShellIcons", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UnsupportedDescriptor = new( + id: "SHELLICONS002", + title: "Icon uses SVG the XAML targets can't draw", + messageFormat: "Icon '{0}' ({1}) uses {2}; that part is skipped in ShellIcons.Maui. Stick to path/circle/ellipse/rect/line/polyline/polygon without transforms.", + category: "ShellIcons", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor OffCanvasDescriptor = new( + id: "SHELLICONS005", + title: "Shape outside the viewBox dropped", + messageFormat: "Icon '{0}' ({1}) has shapes entirely outside the 24x24 viewBox; browsers clip them, so they are dropped for ShellIcons.Maui: {2}", + category: "ShellIcons", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ReservedDescriptor = new( + id: "SHELLICONS004", + title: "Icon name not representable", + messageFormat: "Icon '{0}' maps to '{1}', which is reserved or not a valid identifier; it is skipped in ShellIcons.Maui", + category: "ShellIcons", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var files = context.AdditionalTextsProvider + .Where(t => t.Path.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) + || t.Path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + .Select((t, ct) => new SourceFile(t.Path, t.GetText(ct)?.ToString() ?? string.Empty)) + .Collect(); + + context.RegisterSourceOutput(files, Emit); + } + + private sealed class SourceFile + { + public SourceFile(string path, string content) { Path = path; Content = content; } + public string Path { get; } + public string Content { get; } + } + + private sealed class MauiIcon + { + public string Kebab = ""; + public string Pascal = ""; + public string Pack = ""; + public XamlIconShapes Shapes = null!; + public IconMetadata Meta = IconMetadata.Empty; + } + + private static void Emit(SourceProductionContext ctx, ImmutableArray files) + { + var metadata = new Dictionary(StringComparer.Ordinal); + foreach (var f in files.Where(f => f.Path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))) + metadata[Key(f.Path)] = IconMetadataReader.Read(f.Content); + + var resolved = new Dictionary(StringComparer.Ordinal); + foreach (var f in files.Where(f => f.Path.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))) + { + var kebab = Path.GetFileNameWithoutExtension(f.Path); + var pascal = Naming.KebabToPascal(kebab); + if (pascal.Length == 0) continue; + + if (pascal == "None" || !char.IsLetter(pascal[0])) + { + ctx.ReportDiagnostic(Diagnostic.Create(ReservedDescriptor, Location.None, kebab, pascal)); + continue; + } + + var pack = ResolvePack(f.Path); + var shapes = SvgShapeReader.Read(f.Content); + if (shapes.Unsupported.Count > 0) + { + ctx.ReportDiagnostic(Diagnostic.Create(UnsupportedDescriptor, Location.None, + kebab, pack, string.Join(", ", shapes.Unsupported.Distinct()))); + } + + if (shapes.OffCanvas.Count > 0) + { + ctx.ReportDiagnostic(Diagnostic.Create(OffCanvasDescriptor, Location.None, + kebab, pack, string.Join("; ", shapes.OffCanvas))); + } + + var icon = new MauiIcon + { + Kebab = kebab, + Pascal = pascal, + Pack = pack, + Shapes = shapes, + Meta = metadata.TryGetValue(Key(f.Path), out var m) ? m : IconMetadata.Empty, + }; + + if (!resolved.TryGetValue(kebab, out var existing)) + { + resolved[kebab] = icon; + } + else if (pack == "custom" && existing.Pack != "custom") + { + resolved[kebab] = icon; + ctx.ReportDiagnostic(Diagnostic.Create(OverrideDescriptor, Location.None, kebab)); + } + } + + var icons = resolved.Values.OrderBy(i => i.Kebab, StringComparer.Ordinal).ToArray(); + + ctx.AddSource("IconName.g.cs", SourceText.From(EmitEnum(icons), Encoding.UTF8)); + ctx.AddSource("IconData.g.cs", SourceText.From(EmitData(icons), Encoding.UTF8)); + ctx.AddSource("IconCatalog.g.cs", SourceText.From(EmitCatalog(icons), Encoding.UTF8)); + foreach (var icon in icons) + ctx.AddSource($"Icons/{icon.Pascal}.g.cs", SourceText.From(EmitTypedControl(icon), Encoding.UTF8)); + } + + // Pairs "…/catalog/lucide/icons/house.svg" with "…/catalog/lucide/icons/house.json". + private static string Key(string path) + { + var normalized = path.Replace('\\', '/'); + var dot = normalized.LastIndexOf('.'); + return dot < 0 ? normalized : normalized.Substring(0, dot); + } + + private static string ResolvePack(string path) => + path.Replace('\\', '/').Contains("/catalog/custom/") ? "custom" : "lucide"; + + private static string EmitEnum(MauiIcon[] icons) + { + var sb = Header(); + sb.AppendLine($"namespace {Ns};"); + sb.AppendLine(); + sb.AppendLine("/// Every icon in the ShellIcons catalog. PascalCase of the Lucide file name (chevron-right → ChevronRight)."); + sb.AppendLine("public enum IconName"); + sb.AppendLine("{"); + sb.AppendLine(" /// No icon. The Icon control renders nothing."); + sb.AppendLine(" None = 0,"); + for (var i = 0; i < icons.Length; i++) + { + sb.AppendLine($" /// {icons[i].Kebab} ({icons[i].Pack})."); + sb.AppendLine($" {icons[i].Pascal} = {i + 1},"); + } + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string EmitData(MauiIcon[] icons) + { + var sb = Header(); + sb.AppendLine($"namespace {Ns};"); + sb.AppendLine(); + sb.AppendLine("internal static class IconData"); + sb.AppendLine("{"); + sb.AppendLine(" /// Stroke and (optional) fill path data for . Roots the full catalog — only the Icon dispatcher uses it."); + sb.AppendLine(" public static (string Stroke, string? Fill) Get(IconName name) => name switch"); + sb.AppendLine(" {"); + foreach (var icon in icons) + sb.AppendLine($" IconName.{icon.Pascal} => ({Literal(icon.Shapes.StrokeData)}, {LiteralOrNull(icon.Shapes.FillData)}),"); + sb.AppendLine(" _ => (string.Empty, null),"); + sb.AppendLine(" };"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string EmitCatalog(MauiIcon[] icons) + { + var sb = Header(); + sb.AppendLine($"namespace {Ns};"); + sb.AppendLine(); + sb.AppendLine("public static partial class IconCatalog"); + sb.AppendLine("{"); + sb.AppendLine($" /// Number of icons in the catalog (excluding )."); + sb.AppendLine($" public const int Count = {icons.Length};"); + sb.AppendLine(); + sb.AppendLine(" private static IconInfo? Create(IconName name) => name switch"); + sb.AppendLine(" {"); + foreach (var icon in icons) + { + sb.Append($" IconName.{icon.Pascal} => new IconInfo(IconName.{icon.Pascal}, {Literal(icon.Kebab)}, {Literal(icon.Pack)}, "); + sb.Append(Array(icon.Meta.Tags)).Append(", ").Append(Array(icon.Meta.Categories)).Append(", ").Append(Array(icon.Meta.Aliases)); + sb.AppendLine("),"); + } + sb.AppendLine(" _ => null,"); + sb.AppendLine(" };"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string EmitTypedControl(MauiIcon icon) + { + var sb = Header(); + sb.AppendLine($"namespace {IconsNs};"); + sb.AppendLine(); + sb.AppendLine($"/// Icon {icon.Kebab} ({icon.Pack}). Carries its own path data, so unused icons are trimmed."); + sb.AppendLine($"public sealed class {icon.Pascal} : global::{Ns}.IconView"); + sb.AppendLine("{"); + sb.AppendLine($" /// Creates the {icon.Kebab} icon."); + sb.AppendLine($" public {icon.Pascal}() : base(global::{Ns}.IconName.{icon.Pascal}, {Literal(icon.Shapes.StrokeData)}, {LiteralOrNull(icon.Shapes.FillData)}) {{ }}"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static StringBuilder Header() + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + return sb; + } + + private static string Array(IReadOnlyList values) => + values.Count == 0 + ? "global::System.Array.Empty()" + : "new[] { " + string.Join(", ", values.Select(Literal)) + " }"; + + private static string LiteralOrNull(string? value) => value is null ? "null" : Literal(value); + + private static string Literal(string value) + { + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4")); + else sb.Append(c); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } +} diff --git a/src/ShellIcons.Generator.Xaml/PathNormalizer.cs b/src/ShellIcons.Generator.Xaml/PathNormalizer.cs new file mode 100644 index 0000000..445c96c --- /dev/null +++ b/src/ShellIcons.Generator.Xaml/PathNormalizer.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace ShellIcons.Generator.Xaml; + +/* SVG shapes → one path with absolute commands only, space-separated: + M x y | L x y | C x1 y1 x2 y2 x y | Q x1 y1 x y | A rx ry rot large sweep x y | Z + Valid for MAUI's PathGeometryConverter and Avalonia's StreamGeometry.Parse. */ +internal static class PathNormalizer +{ + // Relative → absolute, H/V → L, S → C, T → Q. + public static string NormalizePathData(string d) + { + var sb = new StringBuilder(d.Length + 16); + var reader = new Reader(d); + + double cx = 0, cy = 0; // current point + double sx = 0, sy = 0; // current sub-path start + double lcx = 0, lcy = 0; // last control point (for S/T reflection) + var lastCmd = '\0'; + var cmd = '\0'; + + while (true) + { + reader.SkipSeparators(); + if (reader.AtEnd) break; + + if (reader.PeekIsCommand()) + { + cmd = reader.ReadCommand(); + } + else if (cmd == '\0') + { + throw new FormatException($"Path data must start with a command: '{d}'"); + } + else if (cmd == 'M') cmd = 'L'; // implicit repeat after a moveto is a lineto + else if (cmd == 'm') cmd = 'l'; + + var rel = char.IsLower(cmd); + var ox = rel ? cx : 0; + var oy = rel ? cy : 0; + + switch (char.ToUpperInvariant(cmd)) + { + case 'M': + cx = ox + reader.ReadNumber(); cy = oy + reader.ReadNumber(); + sx = cx; sy = cy; + Emit(sb, 'M', cx, cy); + break; + + case 'L': + cx = ox + reader.ReadNumber(); cy = oy + reader.ReadNumber(); + Emit(sb, 'L', cx, cy); + break; + + case 'H': + cx = ox + reader.ReadNumber(); + Emit(sb, 'L', cx, cy); + break; + + case 'V': + cy = (rel ? cy : 0) + reader.ReadNumber(); + Emit(sb, 'L', cx, cy); + break; + + case 'C': + { + var x1 = ox + reader.ReadNumber(); var y1 = oy + reader.ReadNumber(); + var x2 = ox + reader.ReadNumber(); var y2 = oy + reader.ReadNumber(); + cx = ox + reader.ReadNumber(); cy = oy + reader.ReadNumber(); + Emit(sb, 'C', x1, y1, x2, y2, cx, cy); + lcx = x2; lcy = y2; + break; + } + + case 'S': + { + // Reflect the previous cubic's second control point, or use the current point. + var prevCubic = "CcSs".IndexOf(lastCmd) >= 0; + var x1 = prevCubic ? 2 * cx - lcx : cx; + var y1 = prevCubic ? 2 * cy - lcy : cy; + var x2 = ox + reader.ReadNumber(); var y2 = oy + reader.ReadNumber(); + cx = ox + reader.ReadNumber(); cy = oy + reader.ReadNumber(); + Emit(sb, 'C', x1, y1, x2, y2, cx, cy); + lcx = x2; lcy = y2; + break; + } + + case 'Q': + { + var x1 = ox + reader.ReadNumber(); var y1 = oy + reader.ReadNumber(); + cx = ox + reader.ReadNumber(); cy = oy + reader.ReadNumber(); + Emit(sb, 'Q', x1, y1, cx, cy); + lcx = x1; lcy = y1; + break; + } + + case 'T': + { + var prevQuad = "QqTt".IndexOf(lastCmd) >= 0; + var x1 = prevQuad ? 2 * cx - lcx : cx; + var y1 = prevQuad ? 2 * cy - lcy : cy; + cx = ox + reader.ReadNumber(); cy = oy + reader.ReadNumber(); + Emit(sb, 'Q', x1, y1, cx, cy); + lcx = x1; lcy = y1; + break; + } + + case 'A': + { + var rx = Math.Abs(reader.ReadNumber()); + var ry = Math.Abs(reader.ReadNumber()); + var rotation = reader.ReadNumber(); + var large = reader.ReadFlag(); + var sweep = reader.ReadFlag(); + cx = ox + reader.ReadNumber(); cy = oy + reader.ReadNumber(); + Arc(sb, rx, ry, rotation, large, sweep, cx, cy); + break; + } + + case 'Z': + sb.Append(sb.Length == 0 ? "Z" : " Z"); + cx = sx; cy = sy; + break; + + default: + throw new FormatException($"Unsupported path command '{cmd}' in '{d}'"); + } + + lastCmd = cmd; + } + + return sb.ToString(); + } + + public static string Circle(double cx, double cy, double r) => Ellipse(cx, cy, r, r); + + public static string Ellipse(double cx, double cy, double rx, double ry) + { + var sb = new StringBuilder(64); + Emit(sb, 'M', cx + rx, cy); + Arc(sb, rx, ry, 0, true, true, cx - rx, cy); + Arc(sb, rx, ry, 0, true, true, cx + rx, cy); + sb.Append(" Z"); + return sb.ToString(); + } + + // SVG radius rules: a missing radius copies the other; each is clamped to half the side. + public static string Rect(double x, double y, double w, double h, double? rxIn, double? ryIn) + { + var rx = rxIn ?? ryIn ?? 0; + var ry = ryIn ?? rxIn ?? 0; + rx = Math.Min(Math.Abs(rx), w / 2); + ry = Math.Min(Math.Abs(ry), h / 2); + + var sb = new StringBuilder(96); + if (rx <= 0 || ry <= 0) + { + Emit(sb, 'M', x, y); + Emit(sb, 'L', x + w, y); + Emit(sb, 'L', x + w, y + h); + Emit(sb, 'L', x, y + h); + sb.Append(" Z"); + return sb.ToString(); + } + + Emit(sb, 'M', x + rx, y); + Emit(sb, 'L', x + w - rx, y); + Arc(sb, rx, ry, 0, false, true, x + w, y + ry); + Emit(sb, 'L', x + w, y + h - ry); + Arc(sb, rx, ry, 0, false, true, x + w - rx, y + h); + Emit(sb, 'L', x + rx, y + h); + Arc(sb, rx, ry, 0, false, true, x, y + h - ry); + Emit(sb, 'L', x, y + ry); + Arc(sb, rx, ry, 0, false, true, x + rx, y); + sb.Append(" Z"); + return sb.ToString(); + } + + public static string Line(double x1, double y1, double x2, double y2) + { + var sb = new StringBuilder(32); + Emit(sb, 'M', x1, y1); + Emit(sb, 'L', x2, y2); + return sb.ToString(); + } + + public static string Poly(string points, bool close) + { + var reader = new Reader(points); + var sb = new StringBuilder(points.Length + 16); + var first = true; + while (true) + { + reader.SkipSeparators(); + if (reader.AtEnd) break; + var px = reader.ReadNumber(); + var py = reader.ReadNumber(); + Emit(sb, first ? 'M' : 'L', px, py); + first = false; + } + if (close && !first) sb.Append(" Z"); + return sb.ToString(); + } + + // 3 decimals, invariant culture, never "-0". + public static string Num(double v) + { + var rounded = Math.Round(v, 3, MidpointRounding.AwayFromZero); + if (rounded == 0) return "0"; // also folds -0 + return rounded.ToString("0.###", CultureInfo.InvariantCulture); + } + + private static void Emit(StringBuilder sb, char command, params double[] coords) + { + if (sb.Length > 0) sb.Append(' '); + sb.Append(command); + foreach (var c in coords) sb.Append(' ').Append(Num(c)); + } + + private static void Arc(StringBuilder sb, double rx, double ry, double rotation, bool large, bool sweep, double x, double y) + { + if (sb.Length > 0) sb.Append(' '); + sb.Append("A ").Append(Num(rx)).Append(' ').Append(Num(ry)).Append(' ').Append(Num(rotation)) + .Append(large ? " 1" : " 0").Append(sweep ? " 1" : " 0") + .Append(' ').Append(Num(x)).Append(' ').Append(Num(y)); + } + + /* Handles what a regex split gets wrong: run-together numbers (.53.53, 1-2), exponents, + and arc flags without separators (a2 2 0 011 1 → flags 0, 1 then 1 1). */ + private sealed class Reader + { + private readonly string _s; + private int _i; + + public Reader(string s) { _s = s ?? string.Empty; } + + public bool AtEnd => _i >= _s.Length; + + public void SkipSeparators() + { + while (_i < _s.Length && (char.IsWhiteSpace(_s[_i]) || _s[_i] == ',')) _i++; + } + + public bool PeekIsCommand() + { + if (AtEnd) return false; + var c = _s[_i]; + // 'e'/'E' only ever appear inside numbers, which ReadNumber consumes whole. + return char.IsLetter(c) && c != 'e' && c != 'E'; + } + + public char ReadCommand() => _s[_i++]; + + public bool ReadFlag() + { + SkipSeparators(); + if (AtEnd) throw new FormatException($"Expected arc flag at end of '{_s}'"); + var c = _s[_i++]; + if (c == '0') return false; + if (c == '1') return true; + throw new FormatException($"Invalid arc flag '{c}' at {_i - 1} in '{_s}'"); + } + + public double ReadNumber() + { + SkipSeparators(); + var start = _i; + + if (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-')) _i++; + + var sawDigits = false; + while (_i < _s.Length && char.IsDigit(_s[_i])) { _i++; sawDigits = true; } + + if (_i < _s.Length && _s[_i] == '.') + { + _i++; + while (_i < _s.Length && char.IsDigit(_s[_i])) { _i++; sawDigits = true; } + } + + if (!sawDigits) throw new FormatException($"Expected number at {start} in '{_s}'"); + + if (_i < _s.Length && (_s[_i] == 'e' || _s[_i] == 'E')) + { + var save = _i; + _i++; + if (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-')) _i++; + var expDigits = false; + while (_i < _s.Length && char.IsDigit(_s[_i])) { _i++; expDigits = true; } + if (!expDigits) _i = save; // a bare 'e' isn't an exponent + } + + return double.Parse(_s.Substring(start, _i - start), NumberStyles.Float, CultureInfo.InvariantCulture); + } + } +} diff --git a/src/ShellIcons.Generator.Xaml/ShellIcons.Generator.Xaml.csproj b/src/ShellIcons.Generator.Xaml/ShellIcons.Generator.Xaml.csproj new file mode 100644 index 0000000..206b07d --- /dev/null +++ b/src/ShellIcons.Generator.Xaml/ShellIcons.Generator.Xaml.csproj @@ -0,0 +1,26 @@ + + + + + netstandard2.0 + enable + latest + false + true + true + false + false + $(NoWarn);RS2008 + + + + + + + + + + + + + diff --git a/src/ShellIcons.Generator.Xaml/SvgShapeReader.cs b/src/ShellIcons.Generator.Xaml/SvgShapeReader.cs new file mode 100644 index 0000000..6e09df3 --- /dev/null +++ b/src/ShellIcons.Generator.Xaml/SvgShapeReader.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace ShellIcons.Generator.Xaml; + +internal sealed class XamlIconShapes +{ + public XamlIconShapes(string strokeData, string? fillData, IReadOnlyList unsupported, IReadOnlyList offCanvas) + { + StrokeData = strokeData; + FillData = fillData; + Unsupported = unsupported; + OffCanvas = offCanvas; + } + + // All stroke-only shapes, merged into one path. + public string StrokeData { get; } + + // fill="currentColor" shapes, drawn by a second filled + stroked path. + public string? FillData { get; } + + // Elements/attributes the XAML targets can't draw (SHELLICONS002). + public IReadOnlyList Unsupported { get; } + + // Shapes entirely outside the viewBox: browsers clip them, a native Path wouldn't (SHELLICONS005). + public IReadOnlyList OffCanvas { get; } +} + +// Regex-based like SvgParser: the contract is flat, so anything outside it is reported, not guessed at. +internal static class SvgShapeReader +{ + private static readonly Regex Element = new( + @"<(?[a-zA-Z][\w:-]*)(?[^>]*?)/?>", + RegexOptions.Compiled | RegexOptions.Singleline); + + private static readonly Regex Attribute = new( + @"(?[a-zA-Z_:][\w:.-]*)\s*=\s*""(?[^""]*)""", + RegexOptions.Compiled); + + private static readonly HashSet Ignored = new(StringComparer.Ordinal) { "svg", "title", "desc" }; + + public static XamlIconShapes Read(string svg) + { + var stroke = new StringBuilder(); + var fill = new StringBuilder(); + var unsupported = new List(); + var offCanvas = new List(); + + foreach (Match m in Element.Matches(svg ?? string.Empty)) + { + var name = m.Groups["name"].Value; + if (Ignored.Contains(name)) continue; + + var attrs = ParseAttributes(m.Groups["attrs"].Value); + if (attrs.ContainsKey("transform")) unsupported.Add($"<{name} transform>"); + + string? data; + try + { + data = ToPath(name, attrs); + } + catch (FormatException ex) + { + unsupported.Add($"<{name}> ({ex.Message})"); + continue; + } + + if (data is null) + { + unsupported.Add($"<{name}>"); + continue; + } + if (data.Length == 0) continue; + + if (IsOffCanvas(data)) + { + offCanvas.Add($"<{name}> {data}"); + continue; + } + + var target = IsFilled(attrs) ? fill : stroke; + if (target.Length > 0) target.Append(' '); + target.Append(data); + } + + return new XamlIconShapes(stroke.ToString(), fill.Length == 0 ? null : fill.ToString(), unsupported, offCanvas); + } + + /* Conservative: control points count, arcs count endpoint ± radius, and the box grows by half + of Lucide's 2-unit stroke, so anything that could leave a visible mark is kept. */ + internal static bool IsOffCanvas(string normalized) + { + const double min = -1, max = 25; + double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue; + + void Add(double x, double y, double pad) + { + minX = Math.Min(minX, x - pad); maxX = Math.Max(maxX, x + pad); + minY = Math.Min(minY, y - pad); maxY = Math.Max(maxY, y + pad); + } + + var t = normalized.Split(' '); + for (var i = 0; i < t.Length;) + { + switch (t[i++]) + { + case "M": case "L": Add(D(t[i]), D(t[i + 1]), 0); i += 2; break; + case "Q": Add(D(t[i]), D(t[i + 1]), 0); Add(D(t[i + 2]), D(t[i + 3]), 0); i += 4; break; + case "C": Add(D(t[i]), D(t[i + 1]), 0); Add(D(t[i + 2]), D(t[i + 3]), 0); Add(D(t[i + 4]), D(t[i + 5]), 0); i += 6; break; + case "A": Add(D(t[i + 5]), D(t[i + 6]), Math.Max(D(t[i]), D(t[i + 1]))); i += 7; break; + default: break; // Z + } + } + + if (minX > maxX) return false; // no points at all: nothing to judge + return maxX < min || minX > max || maxY < min || minY > max; + } + + private static double D(string s) => double.Parse(s, NumberStyles.Float, CultureInfo.InvariantCulture); + + // null = unsupported element; "" = supported but draws nothing (e.g. zero radius). + private static string? ToPath(string name, Dictionary a) + { + switch (name) + { + case "path": + return a.TryGetValue("d", out var d) ? PathNormalizer.NormalizePathData(d) : string.Empty; + case "circle": + { + var r = Num(a, "r"); + return r > 0 ? PathNormalizer.Circle(Num(a, "cx"), Num(a, "cy"), r) : string.Empty; + } + case "ellipse": + { + var rx = Num(a, "rx"); var ry = Num(a, "ry"); + return rx > 0 && ry > 0 ? PathNormalizer.Ellipse(Num(a, "cx"), Num(a, "cy"), rx, ry) : string.Empty; + } + case "rect": + { + var w = Num(a, "width"); var h = Num(a, "height"); + if (w <= 0 || h <= 0) return string.Empty; + return PathNormalizer.Rect(Num(a, "x"), Num(a, "y"), w, h, NumOrNull(a, "rx"), NumOrNull(a, "ry")); + } + case "line": + return PathNormalizer.Line(Num(a, "x1"), Num(a, "y1"), Num(a, "x2"), Num(a, "y2")); + case "polyline": + return a.TryGetValue("points", out var pl) ? PathNormalizer.Poly(pl, close: false) : string.Empty; + case "polygon": + return a.TryGetValue("points", out var pg) ? PathNormalizer.Poly(pg, close: true) : string.Empty; + default: + return null; + } + } + + // Lucide shapes are stroke-only unless they opt into a fill; `fill="none"` is the root default. + private static bool IsFilled(Dictionary a) => + a.TryGetValue("fill", out var f) && f.Length > 0 && !string.Equals(f, "none", StringComparison.OrdinalIgnoreCase); + + private static Dictionary ParseAttributes(string raw) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (Match m in Attribute.Matches(raw)) + result[m.Groups["name"].Value] = m.Groups["value"].Value; + return result; + } + + private static double Num(Dictionary a, string key) => NumOrNull(a, key) ?? 0; + + private static double? NumOrNull(Dictionary a, string key) + { + if (!a.TryGetValue(key, out var raw)) return null; + raw = raw.Trim(); + if (raw.EndsWith("px", StringComparison.Ordinal)) raw = raw.Substring(0, raw.Length - 2); + if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) + throw new FormatException($"{key}=\"{a[key]}\" is not a number"); + return v; + } +} diff --git a/src/ShellIcons.Maui/AssemblyInfo.cs b/src/ShellIcons.Maui/AssemblyInfo.cs new file mode 100644 index 0000000..b23a4a0 --- /dev/null +++ b/src/ShellIcons.Maui/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// One xmlns for the dispatcher and the typed controls; the prefix keeps names like Image apart from MAUI's. +[assembly: Microsoft.Maui.Controls.XmlnsDefinition("https://shellicons.dev/maui", "ShellIcons.Maui")] +[assembly: Microsoft.Maui.Controls.XmlnsDefinition("https://shellicons.dev/maui", "ShellIcons.Maui.Icons")] +[assembly: Microsoft.Maui.Controls.XmlnsPrefix("https://shellicons.dev/maui", "icons")] diff --git a/src/ShellIcons.Maui/Icon.cs b/src/ShellIcons.Maui/Icon.cs new file mode 100644 index 0000000..b87d781 --- /dev/null +++ b/src/ShellIcons.Maui/Icon.cs @@ -0,0 +1,26 @@ +namespace ShellIcons.Maui; + +/// +/// Renders any icon by , so an icon can be a property value or binding. +/// Keeps the whole catalog; typed controls (<icons:Search />) trim better. +/// +public class Icon : IconView +{ + /// The icon to show. renders nothing. + public static readonly BindableProperty NameProperty = BindableProperty.Create( + nameof(Name), typeof(IconName), typeof(Icon), IconName.None, + propertyChanged: (b, _, n) => ((Icon)b).Load((IconName)n)); + + /// + public IconName Name + { + get => (IconName)GetValue(NameProperty); + set => SetValue(NameProperty, value); + } + + private void Load(IconName name) + { + var (stroke, fill) = IconData.Get(name); + SetData(name, stroke, fill); + } +} diff --git a/src/ShellIcons.Maui/IconCatalog.cs b/src/ShellIcons.Maui/IconCatalog.cs new file mode 100644 index 0000000..5532eb7 --- /dev/null +++ b/src/ShellIcons.Maui/IconCatalog.cs @@ -0,0 +1,85 @@ +namespace ShellIcons.Maui; + +/// Metadata for one icon, from its Lucide (or custom) JSON sidecar. +/// The enum value. +/// Kebab-case catalog id, e.g. chevron-right. +/// lucide or custom. +/// Search keywords. +/// Lucide categories. +/// Former or alternative ids, e.g. home for house. +public sealed record IconInfo( + IconName Name, + string Id, + string Source, + IReadOnlyList Tags, + IReadOnlyList Categories, + IReadOnlyList Aliases); + +/// Maps ids, names and Lucide aliases to , with tags and search. +public static partial class IconCatalog +{ + private static readonly Lazy> AllLazy = new(() => + Enum.GetValues() + .Where(n => n != IconName.None) + .Select(n => Create(n)!) + .ToArray()); + + private static readonly Lazy> Lookup = new(BuildLookup); + + /// Every icon, in catalog order. + public static IReadOnlyList All => AllLazy.Value; + + /// Metadata for . + /// is or undefined. + public static IconInfo Get(IconName name) => + Create(name) ?? throw new ArgumentOutOfRangeException(nameof(name), name, "Not an icon in the catalog."); + + /// Resolves chevron-right, ChevronRight or an alias like home (→ House). Case-insensitive. + public static bool TryParse(string? value, out IconName name) + { + name = IconName.None; + if (string.IsNullOrWhiteSpace(value)) return false; + return Lookup.Value.TryGetValue(value.Trim(), out name); + } + + /// Icons whose id, alias, tag or category contains (case-insensitive). Id matches first. + public static IEnumerable Search(string query) + { + if (string.IsNullOrWhiteSpace(query)) return All; + var q = query.Trim(); + + return All + .Select(i => (Info: i, Rank: Rank(i, q))) + .Where(x => x.Rank >= 0) + .OrderBy(x => x.Rank) + .Select(x => x.Info); + } + + private static int Rank(IconInfo info, string q) + { + if (info.Id.Contains(q, StringComparison.OrdinalIgnoreCase)) return 0; + if (info.Aliases.Any(a => a.Contains(q, StringComparison.OrdinalIgnoreCase))) return 1; + if (info.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))) return 2; + if (info.Categories.Any(c => c.Contains(q, StringComparison.OrdinalIgnoreCase))) return 3; + return -1; + } + + private static Dictionary BuildLookup() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Canonical names first so an alias can never shadow a real icon id. + foreach (var info in All) + { + map[info.Id] = info.Name; + map[info.Name.ToString()] = info.Name; + } + foreach (var info in All) + { + foreach (var alias in info.Aliases) + map.TryAdd(alias, info.Name); + } + + return map; + } +} diff --git a/src/ShellIcons.Maui/IconGeometry.cs b/src/ShellIcons.Maui/IconGeometry.cs new file mode 100644 index 0000000..1428a5a --- /dev/null +++ b/src/ShellIcons.Maui/IconGeometry.cs @@ -0,0 +1,93 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Microsoft.Maui.Controls.Shapes; + +namespace ShellIcons.Maui; + +/* Walks the generator's pre-normalized path data (no SVG parsing, no PathGeometryConverter). + Geometry is cached and shared by every control showing the same icon. */ +internal static class IconGeometry +{ + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + public static Geometry? Get(string? data) => + string.IsNullOrEmpty(data) ? null : Cache.GetOrAdd(data, static d => Parse(d)); + + internal static PathGeometry Parse(string data) + { + var geometry = new PathGeometry(); + var tokens = data.Split(' ', StringSplitOptions.RemoveEmptyEntries); + PathFigure? figure = null; + var subpathStart = Point.Zero; + var i = 0; + + while (i < tokens.Length) + { + var command = tokens[i++]; + switch (command) + { + case "M": + subpathStart = ReadPoint(tokens, ref i); + figure = NewFigure(geometry, subpathStart); + break; + + case "L": + Current(geometry, ref figure, subpathStart).Segments.Add(new LineSegment(ReadPoint(tokens, ref i))); + break; + + case "C": + { + var target = Current(geometry, ref figure, subpathStart); + target.Segments.Add(new BezierSegment(ReadPoint(tokens, ref i), ReadPoint(tokens, ref i), ReadPoint(tokens, ref i))); + break; + } + + case "Q": + { + var target = Current(geometry, ref figure, subpathStart); + target.Segments.Add(new QuadraticBezierSegment(ReadPoint(tokens, ref i), ReadPoint(tokens, ref i))); + break; + } + + case "A": + { + var target = Current(geometry, ref figure, subpathStart); + var size = new Size(ReadDouble(tokens, ref i), ReadDouble(tokens, ref i)); + var rotation = ReadDouble(tokens, ref i); + var isLargeArc = tokens[i++] == "1"; + // SVG sweep-flag 1 is clockwise in y-down coordinates, as in MAUI. + var sweep = tokens[i++] == "1" ? SweepDirection.Clockwise : SweepDirection.CounterClockwise; + target.Segments.Add(new ArcSegment(ReadPoint(tokens, ref i), size, rotation, sweep, isLargeArc)); + break; + } + + case "Z": + if (figure is not null) figure.IsClosed = true; + // A command after Z without an M starts a new sub-path at the closed one's start. + figure = null; + break; + + default: + throw new FormatException($"Unexpected token '{command}' in icon path data."); + } + } + + return geometry; + } + + private static PathFigure Current(PathGeometry geometry, ref PathFigure? figure, Point subpathStart) => + figure ??= NewFigure(geometry, subpathStart); + + private static PathFigure NewFigure(PathGeometry geometry, Point start) + { + var figure = new PathFigure { StartPoint = start, IsClosed = false, IsFilled = true }; + geometry.Figures.Add(figure); + return figure; + } + + private static Point ReadPoint(string[] tokens, ref int i) => + new(ReadDouble(tokens, ref i), ReadDouble(tokens, ref i)); + + private static double ReadDouble(string[] tokens, ref int i) => + double.Parse(tokens[i++], NumberStyles.Float, CultureInfo.InvariantCulture); +} diff --git a/src/ShellIcons.Maui/IconView.cs b/src/ShellIcons.Maui/IconView.cs new file mode 100644 index 0000000..c4517ef --- /dev/null +++ b/src/ShellIcons.Maui/IconView.cs @@ -0,0 +1,196 @@ +using Microsoft.Maui.Controls.Shapes; +using Path = Microsoft.Maui.Controls.Shapes.Path; + +namespace ShellIcons.Maui; + +/// +/// Base control for every icon: one native on Lucide's 24×24 grid, plus a +/// second filled path only for icons with fill="currentColor" shapes. +/// +public class IconView : ContentView +{ + private const double GridUnits = 24; + + /// Width and height in device-independent units. Default 24. + public static readonly BindableProperty SizeProperty = BindableProperty.Create( + nameof(Size), typeof(double), typeof(IconView), GridUnits, propertyChanged: OnMetricsChanged); + + /// Stroke width on the 24-unit grid (Lucide's weight). Default 2. + public static readonly BindableProperty StrokeThicknessProperty = BindableProperty.Create( + nameof(StrokeThickness), typeof(double), typeof(IconView), 2d, propertyChanged: OnMetricsChanged); + + /// When true the rendered stroke stays wide at any size. + public static readonly BindableProperty AbsoluteStrokeProperty = BindableProperty.Create( + nameof(AbsoluteStroke), typeof(bool), typeof(IconView), false, propertyChanged: OnMetricsChanged); + + /// Stroke (and fill) color. When null the icon is black in light theme and white in dark. + public static readonly BindableProperty ColorProperty = BindableProperty.Create( + nameof(Color), typeof(Color), typeof(IconView), null, propertyChanged: (b, _, _) => ((IconView)b).ApplyColor()); + + /// Accessible description. When null the icon is decorative and hidden from screen readers. + public static readonly BindableProperty TitleProperty = BindableProperty.Create( + nameof(Title), typeof(string), typeof(IconView), null, propertyChanged: (b, _, _) => ((IconView)b).ApplyTitle()); + + private static readonly Brush LightThemeBrush = new SolidColorBrush(Colors.Black); + private static readonly Brush DarkThemeBrush = new SolidColorBrush(Colors.White); + + private readonly Path _stroke; + private Path? _fill; + + /// Creates an empty icon; call to give it a shape. + protected IconView() + { + _stroke = CreatePath(); + Content = _stroke; + + // Icons are decoration for hit testing: a tap lands on the button or row hosting them. + InputTransparent = true; + HorizontalOptions = LayoutOptions.Center; + VerticalOptions = LayoutOptions.Center; + + ApplyMetrics(); + ApplyColor(); + ApplyTitle(); + } + + /// Creates an icon with fixed path data (used by the generated typed controls). + protected IconView(IconName iconName, string strokeData, string? fillData) : this() + { + SetData(iconName, strokeData, fillData); + } + + /// The icon this control currently shows. + public IconName IconName { get; private set; } + + /// + public double Size + { + get => (double)GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + /// + public double StrokeThickness + { + get => (double)GetValue(StrokeThicknessProperty); + set => SetValue(StrokeThicknessProperty, value); + } + + /// + public bool AbsoluteStroke + { + get => (bool)GetValue(AbsoluteStrokeProperty); + set => SetValue(AbsoluteStrokeProperty, value); + } + + /// + public Color? Color + { + get => (Color?)GetValue(ColorProperty); + set => SetValue(ColorProperty, value); + } + + /// + public string? Title + { + get => (string?)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + /// The rendered stroke thickness after size scaling (what the native path draws). + public double EffectiveStrokeThickness => + AbsoluteStroke ? StrokeThickness : StrokeThickness * (Size / GridUnits); + + /// Replaces the drawn shapes. + protected void SetData(IconName iconName, string strokeData, string? fillData) + { + IconName = iconName; + _stroke.Data = IconGeometry.Get(strokeData); + + if (fillData is null) + { + if (_fill is not null) + { + _fill = null; + Content = _stroke; + } + return; + } + + if (_fill is null) + { + _fill = CreatePath(); + // The rare filled shapes are drawn on top, filled *and* stroked like the SVG. + Content = new Grid { Children = { _stroke, _fill } }; + ApplyMetrics(); + ApplyColor(); + } + _fill.Data = IconGeometry.Get(fillData); + } + + private static Path CreatePath() => new() + { + // Aspect=None keeps icons on the shared grid; Uniform would scale each to its own bounds. + Aspect = Stretch.None, + StrokeLineCap = PenLineCap.Round, + StrokeLineJoin = PenLineJoin.Round, + InputTransparent = true, + }; + + private static void OnMetricsChanged(BindableObject bindable, object oldValue, object newValue) => + ((IconView)bindable).ApplyMetrics(); + + private void ApplyMetrics() + { + var size = Size; + var scale = size / GridUnits; + var thickness = EffectiveStrokeThickness; + + WidthRequest = size; + HeightRequest = size; + + foreach (var path in Paths()) + { + path.WidthRequest = size; + path.HeightRequest = size; + // The transform scales the geometry, not the pen, so the stroke is scaled separately. + path.RenderTransform = new ScaleTransform(scale, scale); + path.StrokeThickness = thickness; + } + } + + private void ApplyColor() + { + foreach (var path in Paths()) + { + var isFill = ReferenceEquals(path, _fill); + path.RemoveBinding(Shape.StrokeProperty); + if (isFill) path.RemoveBinding(Shape.FillProperty); + + if (Color is { } color) + { + var brush = new SolidColorBrush(color); + path.Stroke = brush; + if (isFill) path.Fill = brush; + } + else + { + path.SetAppTheme(Shape.StrokeProperty, LightThemeBrush, DarkThemeBrush); + if (isFill) path.SetAppTheme(Shape.FillProperty, LightThemeBrush, DarkThemeBrush); + } + } + } + + private void ApplyTitle() + { + var title = Title; + SemanticProperties.SetDescription(this, title); + AutomationProperties.SetIsInAccessibleTree(this, title is not null); + } + + private IEnumerable Paths() + { + yield return _stroke; + if (_fill is not null) yield return _fill; + } +} diff --git a/src/ShellIcons.Maui/README.md b/src/ShellIcons.Maui/README.md new file mode 100644 index 0000000..c12ddf0 --- /dev/null +++ b/src/ShellIcons.Maui/README.md @@ -0,0 +1,90 @@ +# ShellIcons.Maui (preview) + +Lucide-derived icons for .NET MAUI as native `Path` shapes, from the same 1,555-icon catalog as +`ShellIcons.Blazor`. Docs: [shellicons.shellui.dev/docs/maui/getting-started](https://shellicons.shellui.dev/docs/maui/getting-started). + +Not published yet. The release workflow only packs `ShellIcons.Blazor`. + +## Use + +```xml + + + + + + + + + + +``` + +| Property | Default | Notes | +|---|---|---| +| `Size` | `24` | Width and height | +| `StrokeThickness` | `2` | On the 24-unit grid, scaled with `Size` like the Blazor target | +| `AbsoluteStroke` | `false` | Keep the rendered stroke at `StrokeThickness` at any size | +| `Color` | `null` | Bindable. Null means black in light theme, white in dark | +| `Title` | `null` | Sets `SemanticProperties.Description`; null keeps the icon out of the accessibility tree | + +Set a default color app-wide with a style: + +```xml + +``` + +Names and metadata: + +```csharp +IconCatalog.TryParse("home", out var name); // → IconName.House (Lucide alias) +IconCatalog.Get(IconName.House).Tags; // ["home", "living", "building", …] +IconCatalog.Search("cart"); // id matches first, then aliases/tags/categories +``` + +## How it maps SVG to MAUI + +`ShellIcons.Generator.Xaml` converts each SVG at build time. Every shape element becomes a +sub-path of one normalized path string — absolute commands only (`M L C Q A Z`), 3-decimal +coordinates: + +| SVG | Normalized as | +|---|---| +| `` | Tokenized (compact numbers, exponents, compact arc flags), relative → absolute, `H`/`V` → `L`, `S` → `C`, `T` → `Q` | +| `` / `` | Two half-arcs | +| `` | Lines + four arc corners (SVG radius rules: missing radius copies the other, clamped to half a side) | +| `` / `` / `` | `M` + `L…` (+ `Z`) | +| `fill="currentColor"` shapes | A second path that is filled and stroked | +| Shapes entirely outside the viewBox | Dropped (`SHELLICONS005`) — browsers clip them, a native `Path` wouldn't | + +At runtime the control walks that pre-normalized string into a `PathGeometry` once per icon and +caches it, so every control showing the same icon shares one geometry. + +## Design decisions + +| Decision | Why | +|---|---| +| Path data ships as compact normalized strings, turned into geometry once per icon and cached | Emitting per-icon construction code would be several times larger than the data. There's still no SVG parsing and no `PathGeometryConverter` at runtime. | +| One `Path` per icon; a second only for `fill="currentColor"` shapes | Lists and toolbars show many icons, so one native view per icon matters. | +| Typed controls **and** an `IconName` dispatcher | Typed controls trim per icon; the enum lets an icon be a binding or a component parameter. | +| `Color`, not `TintColor` | In MAUI, `TintColor` usually means image tinting. `Color` is bindable, so `{DynamicResource …}` works. | +| Defaults match the Blazor package | Size 24, stroke 2, round caps and joins — icons look the same on both targets. | +| No consumer-selected icon subsets yet | The generator runs when this package is built, not in the consumer's build, so it can't see which icons an app wants. Typed controls already trim unused icons. | +| Same `IconName` / `Icon` names as ShellUI Native's interim icon component | ShellUI Native can switch to this package without changing its components. | + +The conversion contract for custom icons is in [catalog/custom/README.md](../../catalog/custom/README.md); both generators share one naming rule (`Naming.KebabToPascal`). + +## Verified + +- Builds with zero warnings for `net10.0`, `net10.0-android`, `net10.0-ios`, `net10.0-maccatalyst`, `net10.0-windows10.0.19041.0` (on Windows, with the MAUI workloads). +- Tests on the plain `net10.0` build, including a compiled XAML fixture (xmlns, string → `IconName`, `icons:Image` next to MAUI's `Image`). +- A check that parses every icon into MAUI geometry and keeps every endpoint on the 24×24 grid. It caught a stray off-canvas shape in Lucide's own `save-off.svg`. +- **Not yet verified:** how it looks on a real device or emulator. The rendering follows the approach ShellUI Native verified on WinUI (`Aspect=None`, scale transform, explicit stroke scaling), but nobody has looked at it on Android or iOS yet. + +## Before publishing + +- Look at it on Android and iOS devices. +- Give the package its own readme — `Directory.Build.props` currently packs the repo's Blazor-centric `README.md` into every package. +- Add `ShellIcons.Maui` to `release.yml` (the job must run on Windows or macOS with the MAUI workload). diff --git a/src/ShellIcons.Maui/ShellIcons.Maui.csproj b/src/ShellIcons.Maui/ShellIcons.Maui.csproj new file mode 100644 index 0000000..117db87 --- /dev/null +++ b/src/ShellIcons.Maui/ShellIcons.Maui.csproj @@ -0,0 +1,45 @@ + + + + + net10.0;net10.0-android + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + true + true + ShellIcons.Maui + + true + ShellIcons.Maui + Lucide-derived icons for .NET MAUI as native Path shapes. Typed controls, an IconName enum dispatcher and tag/alias metadata — generated from the same 1,555-icon catalog as ShellIcons.Blazor, with no runtime SVG parsing. + maui;icons;svg;lucide;xaml;shellui + + 15.0 + 15.0 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ShellIcons.Generator.Tests/PathNormalizerTests.cs b/tests/ShellIcons.Generator.Tests/PathNormalizerTests.cs new file mode 100644 index 0000000..5fdaee9 --- /dev/null +++ b/tests/ShellIcons.Generator.Tests/PathNormalizerTests.cs @@ -0,0 +1,298 @@ +using ShellIcons.Generator.Xaml; + +namespace ShellIcons.Generator.Tests; + +public class PathNormalizerTests +{ + private static string N(string d) => PathNormalizer.NormalizePathData(d); + + [Fact] + public void CompactDecimals_RunTogether() + { + // ".53.53" is two numbers: 0.53 and 0.53 + Assert.Equal("M 0.53 0.53", N("M.53.53")); + } + + [Fact] + public void NegativeSign_StartsNewNumber() + { + Assert.Equal("M 1 -2 L 3 -0.44", N("M1-2L3-.44")); + } + + [Fact] + public void Exponent_IsPartOfTheNumber() + { + Assert.Equal("M 0 2 L 1 0", N("M1e-5 2L1 0")); + } + + [Fact] + public void CommasAndWhitespace_AreSeparators() + { + Assert.Equal("M 1 2 L 3 4", N("M 1,2\n\tL3 , 4")); + } + + [Fact] + public void CompactArcFlags_AreSingleCharacters() + { + // "a2 2 0 011 1" → rx=2 ry=2 rot=0 large=0 sweep=1, then x=1 y=1 (relative) + Assert.Equal("M 10 10 A 2 2 0 0 1 11 11", N("M10 10a2 2 0 011 1")); + } + + [Fact] + public void ArcFlags_WithSeparators() + { + Assert.Equal("M 0 0 A 3 4 30 1 0 5 6", N("M0 0A3 4 30 1 0 5 6")); + } + + [Fact] + public void Relative_Commands_BecomeAbsolute() + { + Assert.Equal("M 5 5 L 8 9 L 8 12 L 2 12", N("m5 5l3 4v3h-6")); + } + + [Fact] + public void ImplicitRepeat_AfterMove_IsLine() + { + // extra pairs after m are relative linetos; after M, absolute linetos + Assert.Equal("M 1 1 L 3 3 L 6 6", N("m1 1 2 2 3 3")); + Assert.Equal("M 1 1 L 2 2 L 3 3", N("M1 1 2 2 3 3")); + } + + [Fact] + public void ImplicitRepeat_OfOtherCommands() + { + Assert.Equal("M 0 0 L 1 0 L 3 0", N("M0 0h1 2")); + } + + [Fact] + public void ClosePath_ResetsCurrentPointToSubpathStart() + { + // After z the current point is back at (10,10); l1 1 → (11,11) + Assert.Equal("M 10 10 L 20 10 Z L 11 11", N("M10 10h10zl1 1")); + } + + [Fact] + public void RelativeCubic_AllPointsRelativeToSegmentStart() + { + Assert.Equal("M 10 10 C 11 11 12 12 13 13", N("M10 10c1 1 2 2 3 3")); + } + + [Fact] + public void SmoothCubic_ReflectsPreviousControlPoint() + { + // prev second control (12,10) reflected about (14,10) → (16,10) + Assert.Equal("M 10 10 C 10 12 12 10 14 10 C 16 10 18 12 20 10", N("M10 10C10 12 12 10 14 10S18 12 20 10")); + } + + [Fact] + public void SmoothCubic_WithoutPreviousCubic_UsesCurrentPoint() + { + Assert.Equal("M 10 10 C 10 10 12 12 14 10", N("M10 10S12 12 14 10")); + } + + [Fact] + public void RelativeSmoothCubic() + { + Assert.Equal("M 0 0 C 0 2 2 2 2 0 C 2 -2 4 -2 4 0", N("M0 0c0 2 2 2 2 0s2-2 2 0")); + } + + [Fact] + public void Quadratic_AndSmoothQuadraticReflection() + { + Assert.Equal("M 0 0 Q 1 2 2 0 Q 3 -2 4 0", N("M0 0Q1 2 2 0T4 0")); + } + + [Fact] + public void Rounding_ThreeDecimals_NoNegativeZero() + { + Assert.Equal("M 1.235 0 L 0 0", N("M1.23456-0.0001L0 0")); + Assert.Equal("0", PathNormalizer.Num(-0.0004)); + Assert.Equal("-0.5", PathNormalizer.Num(-0.5)); + } + + [Fact] + public void RealLucidePath_House() + { + Assert.Equal( + "M 15 21 L 15 13 A 1 1 0 0 0 14 12 L 10 12 A 1 1 0 0 0 9 13 L 9 21", + N("M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8")); + } + + [Fact] + public void InvalidArcFlag_Throws() + { + Assert.Throws(() => N("M0 0a2 2 0 2 1 1 1")); + } + + [Fact] + public void Circle_IsTwoHalfArcs() + { + Assert.Equal("M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z", PathNormalizer.Circle(12, 12, 10)); + } + + [Fact] + public void Ellipse_UsesBothRadii() + { + Assert.Equal("M 15 12 A 3 5 0 1 1 9 12 A 3 5 0 1 1 15 12 Z", PathNormalizer.Ellipse(12, 12, 3, 5)); + } + + [Fact] + public void Rect_WithoutRadius_IsFourLines() + { + Assert.Equal("M 2 3 L 12 3 L 12 8 L 2 8 Z", PathNormalizer.Rect(2, 3, 10, 5, null, null)); + } + + [Fact] + public void Rect_WithRadius_HasFourArcCorners() + { + Assert.Equal( + "M 4 2 L 20 2 A 2 2 0 0 1 22 4 L 22 20 A 2 2 0 0 1 20 22 L 4 22 A 2 2 0 0 1 2 20 L 2 4 A 2 2 0 0 1 4 2 Z", + PathNormalizer.Rect(2, 2, 20, 20, 2, null)); + } + + [Fact] + public void Rect_MissingRy_CopiesRx_AndRadiusIsClampedToHalfSide() + { + // rx=10 on a 4-tall rect: ry copies rx (10) then both clamp — rx to w/2=5, ry to h/2=2 + var d = PathNormalizer.Rect(0, 0, 10, 4, 10, null); + Assert.StartsWith("M 5 0 L 5 0 A 5 2 0 0 1 10 2", d); + } + + [Fact] + public void Line_IsMoveThenLine() + { + Assert.Equal("M 12 17 L 12.01 17", PathNormalizer.Line(12, 17, 12.01, 17)); + } + + [Fact] + public void Polyline_IsOpen_PolygonIsClosed() + { + Assert.Equal("M 1 2 L 3 4 L 5 6", PathNormalizer.Poly("1,2 3,4 5,6", close: false)); + Assert.Equal("M 1 2 L 3 4 L 5 6 Z", PathNormalizer.Poly("1 2 3 4 5 6", close: true)); + } +} + +public class SvgShapeReaderTests +{ + [Fact] + public void StrokeShapes_MergeIntoOnePath() + { + var svg = """ + + + + + """; + + var shapes = SvgShapeReader.Read(svg); + + Assert.Equal("M 19 11 A 8 8 0 1 1 3 11 A 8 8 0 1 1 19 11 Z M 21 21 L 16.7 16.7", shapes.StrokeData); + Assert.Null(shapes.FillData); + Assert.Empty(shapes.Unsupported); + } + + [Fact] + public void FillCurrentColor_GoesToTheFillPath() + { + var svg = """ + + + + + """; + + var shapes = SvgShapeReader.Read(svg); + + Assert.Equal("M 3 3 L 21 3", shapes.StrokeData); + Assert.Equal("M 13 12 A 1 1 0 1 1 11 12 A 1 1 0 1 1 13 12 Z", shapes.FillData); + } + + [Fact] + public void GroupsAndTransforms_AreReportedAsUnsupported() + { + var svg = """ + + + + """; + + var shapes = SvgShapeReader.Read(svg); + + Assert.Contains("", shapes.Unsupported); + Assert.Contains("", shapes.Unsupported); + } + + [Fact] + public void RectAttributes_Parsed() + { + var shapes = SvgShapeReader.Read(""""""); + Assert.StartsWith("M 5 3 L 19 3 A 2 2 0 0 1 21 5", shapes.StrokeData); + } + + [Fact] + public void Polygon_AndPolyline_Parsed() + { + var shapes = SvgShapeReader.Read(""""""); + Assert.Equal("M 12 2 L 15 8 L 9 8 Z M 1 1 L 2 2", shapes.StrokeData); + } +} + +public class OffCanvasTests +{ + [Fact] + public void ShapeEntirelyOutsideViewBox_IsDropped() + { + var shapes = SvgShapeReader.Read(""""""); + + Assert.Equal("M 2 2 L 22 22", shapes.StrokeData); + Assert.Single(shapes.OffCanvas); + } + + [Theory] + [InlineData("M 12 24.8 L 13 24.8")] // off-grid, but its stroke reaches into the bottom edge + [InlineData("M 30 12 A 10 10 0 0 1 30 14")] // arc endpoints outside, but the radius reaches in + [InlineData("M 22 4 C 22 4 25 10 20 24")] // control point outside, curve inside + public void ShapeThatCanReachTheViewBox_IsKept(string data) + { + Assert.False(SvgShapeReader.IsOffCanvas(data)); + } +} + +public class IconMetadataReaderTests +{ + [Fact] + public void ReadsTagsCategoriesAliases() + { + var json = """ + { + "$schema": "../icon.schema.json", + "contributors": ["jguddas"], + "tags": ["home", "living", "building"], + "categories": ["buildings", "home"], + "aliases": ["home"] + } + """; + + var meta = IconMetadataReader.Read(json); + + Assert.Equal(new[] { "home", "living", "building" }, meta.Tags); + Assert.Equal(new[] { "buildings", "home" }, meta.Categories); + Assert.Equal(new[] { "home" }, meta.Aliases); + } + + [Fact] + public void ObjectAliases_TakeOnlyTheName() + { + var json = """{ "aliases": [ { "name": "home", "deprecated": true, "deprecationReason": "renamed" } ] }"""; + Assert.Equal(new[] { "home" }, IconMetadataReader.Read(json).Aliases); + } + + [Fact] + public void MissingKeys_AreEmpty() + { + var meta = IconMetadataReader.Read("""{ "tags": [] }"""); + Assert.Empty(meta.Tags); + Assert.Empty(meta.Categories); + Assert.Empty(meta.Aliases); + } +} diff --git a/tests/ShellIcons.Generator.Tests/ShellIcons.Generator.Tests.csproj b/tests/ShellIcons.Generator.Tests/ShellIcons.Generator.Tests.csproj index f62f275..95344b8 100644 --- a/tests/ShellIcons.Generator.Tests/ShellIcons.Generator.Tests.csproj +++ b/tests/ShellIcons.Generator.Tests/ShellIcons.Generator.Tests.csproj @@ -20,14 +20,13 @@ - + + + + diff --git a/tests/ShellIcons.Maui.Tests/CatalogTests.cs b/tests/ShellIcons.Maui.Tests/CatalogTests.cs new file mode 100644 index 0000000..4c64bf8 --- /dev/null +++ b/tests/ShellIcons.Maui.Tests/CatalogTests.cs @@ -0,0 +1,73 @@ +namespace ShellIcons.Maui.Tests; + +public class CatalogTests +{ + [Fact] + public void Enum_HasEveryIcon_PlusNone() + { + Assert.True(IconCatalog.Count > 1500, $"catalog has {IconCatalog.Count} icons"); + Assert.Equal(IconCatalog.Count + 1, Enum.GetValues().Length); + Assert.Equal(0, (int)IconName.None); + } + + [Theory] + [InlineData("chevron-right", IconName.ChevronRight)] + [InlineData("ChevronRight", IconName.ChevronRight)] + [InlineData("CHEVRON-RIGHT", IconName.ChevronRight)] + [InlineData(" zap ", IconName.Zap)] + [InlineData("home", IconName.House)] // Lucide alias: home was renamed to house + public void TryParse_ResolvesIdsNamesAndAliases(string input, IconName expected) + { + Assert.True(IconCatalog.TryParse(input, out var name)); + Assert.Equal(expected, name); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-an-icon")] + public void TryParse_Unknown_ReturnsFalse(string? input) + { + Assert.False(IconCatalog.TryParse(input, out var name)); + Assert.Equal(IconName.None, name); + } + + [Fact] + public void TryParse_CanonicalIdBeatsAnotherIconsAlias() + { + foreach (var info in IconCatalog.All) + { + Assert.True(IconCatalog.TryParse(info.Id, out var name)); + Assert.Equal(info.Name, name); + } + } + + [Fact] + public void Get_ReturnsSidecarMetadata() + { + var house = IconCatalog.Get(IconName.House); + + Assert.Equal("house", house.Id); + Assert.Equal("lucide", house.Source); + Assert.Contains("home", house.Aliases); + Assert.Contains("building", house.Tags); + Assert.NotEmpty(house.Categories); + } + + [Fact] + public void Get_None_Throws() + { + Assert.Throws(() => IconCatalog.Get(IconName.None)); + } + + [Fact] + public void Search_RanksIdMatchesFirst_ThenOtherMatches() + { + var results = IconCatalog.Search("cart").ToList(); + + Assert.Contains(results, r => r.Name == IconName.ShoppingCart); + var firstNonIdMatch = results.FindIndex(r => !r.Id.Contains("cart")); + var lastIdMatch = results.FindLastIndex(r => r.Id.Contains("cart")); + Assert.True(firstNonIdMatch == -1 || lastIdMatch < firstNonIdMatch); + } +} diff --git a/tests/ShellIcons.Maui.Tests/ControlTests.cs b/tests/ShellIcons.Maui.Tests/ControlTests.cs new file mode 100644 index 0000000..bf5732b --- /dev/null +++ b/tests/ShellIcons.Maui.Tests/ControlTests.cs @@ -0,0 +1,127 @@ +using Microsoft.Maui.Controls.Shapes; +using Path = Microsoft.Maui.Controls.Shapes.Path; + +namespace ShellIcons.Maui.Tests; + +public class ControlTests +{ + [Fact] + public void TypedControl_KnowsItsName_AndDrawsOnePath() + { + var icon = new Icons.ChevronRight(); + + Assert.Equal(IconName.ChevronRight, icon.IconName); + var path = Assert.IsType(icon.Content); + var geometry = Assert.IsType(path.Data); + Assert.Single(geometry.Figures); + } + + [Fact] + public void Dispatcher_LoadsByName_AndReloadsOnChange() + { + var icon = new Icon { Name = IconName.House }; + Assert.Equal(IconName.House, icon.IconName); + Assert.Equal(2, ((PathGeometry)((Path)icon.Content).Data).Figures.Count); + + icon.Name = IconName.ChevronRight; + Assert.Equal(IconName.ChevronRight, icon.IconName); + Assert.Single(((PathGeometry)((Path)icon.Content).Data).Figures); + } + + [Fact] + public void Dispatcher_None_DrawsNothing() + { + var icon = new Icon(); + Assert.Null(((Path)icon.Content).Data); + } + + [Fact] + public void TypedAndDispatcher_ShareTheSameCachedGeometry() + { + var typed = (Path)new Icons.Zap().Content; + var dispatched = (Path)new Icon { Name = IconName.Zap }.Content; + + Assert.Same(typed.Data, dispatched.Data); + } + + [Fact] + public void Defaults_MatchTheBlazorTarget() + { + var icon = new Icons.Zap(); + var path = (Path)icon.Content; + + Assert.Equal(24, icon.Size); + Assert.Equal(2, icon.StrokeThickness); + Assert.Equal(24, icon.WidthRequest); + Assert.True(icon.InputTransparent); + Assert.Equal(LayoutOptions.Center, icon.HorizontalOptions); + Assert.Equal(Stretch.None, path.Aspect); + Assert.Equal(PenLineCap.Round, path.StrokeLineCap); + Assert.Equal(PenLineJoin.Round, path.StrokeLineJoin); + } + + [Fact] + public void Size_ScalesGeometryAndStroke() + { + var icon = new Icons.Zap { Size = 16 }; + var path = (Path)icon.Content; + + Assert.Equal(16, icon.WidthRequest); + var scale = Assert.IsType(path.RenderTransform); + Assert.Equal(16d / 24, scale.ScaleX, 6); + Assert.Equal(2 * 16d / 24, path.StrokeThickness, 6); + } + + [Fact] + public void AbsoluteStroke_KeepsStrokeWidthAtAnySize() + { + var icon = new Icons.Zap { Size = 48, StrokeThickness = 1.5, AbsoluteStroke = true }; + Assert.Equal(1.5, ((Path)icon.Content).StrokeThickness); + } + + [Fact] + public void Color_SetsStroke() + { + var icon = new Icons.Zap { Color = Colors.Crimson }; + var brush = Assert.IsType(((Path)icon.Content).Stroke); + Assert.Equal(Colors.Crimson, brush.Color); + } + + [Fact] + public void FilledIcon_UsesASecondPath_ThatIsFilledAndStroked() + { + var name = Enum.GetValues().First(n => IconData.Get(n).Fill is not null); + var icon = new Icon { Name = name, Color = Colors.Blue }; + + var grid = Assert.IsType(icon.Content); + Assert.Equal(2, grid.Children.Count); + var fill = (Path)grid.Children[1]; + Assert.Equal(Colors.Blue, Assert.IsType(fill.Fill).Color); + Assert.Equal(Colors.Blue, Assert.IsType(fill.Stroke).Color); + + // Switching to a stroke-only icon collapses back to a single Path. + icon.Name = IconName.ChevronRight; + Assert.IsType(icon.Content); + } + + [Fact] + public void Title_MakesTheIconAccessible() + { + var icon = new Icons.X(); + Assert.False(AutomationProperties.GetIsInAccessibleTree(icon)); + + icon.Title = "Close dialog"; + Assert.Equal("Close dialog", SemanticProperties.GetDescription(icon)); + Assert.True(AutomationProperties.GetIsInAccessibleTree(icon)); + } + + [Fact] + public void EveryIcon_HasATypedControl() + { + var typed = typeof(IconView).Assembly.GetTypes() + .Where(t => t.Namespace == "ShellIcons.Maui.Icons" && t.IsSubclassOf(typeof(IconView))) + .ToList(); + + Assert.Equal(IconCatalog.Count, typed.Count); + } +} diff --git a/tests/ShellIcons.Maui.Tests/GeometryTests.cs b/tests/ShellIcons.Maui.Tests/GeometryTests.cs new file mode 100644 index 0000000..688f7d9 --- /dev/null +++ b/tests/ShellIcons.Maui.Tests/GeometryTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace ShellIcons.Maui.Tests; + +// A relative→absolute bug pushes points off the 24×24 grid, so checking every icon catches it. +public class GeometryTests +{ + [Fact] + public void EveryIcon_Parses_AndStaysOnTheGrid() + { + var failures = new List(); + + foreach (var name in Enum.GetValues().Where(n => n != IconName.None)) + { + var (stroke, fill) = IconData.Get(name); + if (string.IsNullOrEmpty(stroke) && fill is null) + { + failures.Add($"{name}: no shapes"); + continue; + } + + foreach (var data in new[] { stroke, fill }.Where(d => !string.IsNullOrEmpty(d))) + { + PathGeometry geometry; + try { geometry = IconGeometry.Parse(data!); } + catch (Exception ex) { failures.Add($"{name}: {ex.Message}"); continue; } + + if (geometry.Figures.Count == 0) failures.Add($"{name}: no figures"); + + // Control points may poke out while the curve stays inside (twitter's does, by 0.7). + foreach (var (p, isControl) in Points(geometry)) + { + var tolerance = isControl ? 6 : 0.5; + if (p.X < -tolerance || p.X > 24 + tolerance || p.Y < -tolerance || p.Y > 24 + tolerance) + { + failures.Add($"{name}: {(isControl ? "control point" : "point")} ({p.X}, {p.Y}) is off the 24x24 grid"); + break; + } + } + } + } + + Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures.Take(25))); + } + + [Fact] + public void FillShapes_AreSplitIntoTheirOwnPath() + { + var withFill = Enum.GetValues().Where(n => IconData.Get(n).Fill is not null).ToList(); + + Assert.NotEmpty(withFill); + Assert.True(withFill.Count < 50); + } + + [Fact] + public void KnownIcon_ChevronRight_ExactPath() + { + Assert.Equal(("M 9 18 L 15 12 L 9 6", (string?)null), IconData.Get(IconName.ChevronRight)); + } + + [Fact] + public void CircleCheck_CircleBecomesArcs() + { + var (stroke, _) = IconData.Get(IconName.CircleCheck); + Assert.Equal("M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 9 12 L 11 14 L 15 10", stroke); + } + + [Fact] + public void Parse_CommandAfterClose_StartsNewFigureAtSubpathStart() + { + var g = IconGeometry.Parse("M 10 10 L 20 10 Z L 11 11"); + + Assert.Equal(2, g.Figures.Count); + Assert.True(g.Figures[0].IsClosed); + Assert.Equal(new Point(10, 10), g.Figures[1].StartPoint); + } + + [Fact] + public void Parse_Arc_MapsSvgSweepFlagToClockwise() + { + var arc = (ArcSegment)IconGeometry.Parse("M 0 0 A 2 3 45 1 1 4 4").Figures[0].Segments[0]; + + Assert.Equal(new Size(2, 3), arc.Size); + Assert.Equal(45, arc.RotationAngle); + Assert.True(arc.IsLargeArc); + Assert.Equal(SweepDirection.Clockwise, arc.SweepDirection); + Assert.Equal(new Point(4, 4), arc.Point); + } + + [Fact] + public void Geometry_IsCachedPerIcon() + { + Assert.Same(IconGeometry.Get("M 1 1 L 2 2"), IconGeometry.Get("M 1 1 L 2 2")); + } + + [Fact] + public void SaveOff_StrayOffCanvasShapeFromLucide_IsDropped() + { + // Lucide's save-off.svg has a stray `M29.5 11.5s5 5 4 5` outside the viewBox. + var (stroke, _) = IconData.Get(IconName.SaveOff); + Assert.DoesNotContain("29.5", stroke); + Assert.Contains("M 2 2 L 22 22", stroke); // the real slash is still there + } + + private static IEnumerable<(Point Point, bool IsControl)> Points(PathGeometry geometry) + { + foreach (var figure in geometry.Figures) + { + yield return (figure.StartPoint, false); + foreach (var segment in figure.Segments) + { + switch (segment) + { + case LineSegment l: yield return (l.Point, false); break; + case BezierSegment b: yield return (b.Point1, true); yield return (b.Point2, true); yield return (b.Point3, false); break; + case QuadraticBezierSegment q: yield return (q.Point1, true); yield return (q.Point2, false); break; + case ArcSegment a: yield return (a.Point, false); break; + } + } + } + } +} diff --git a/tests/ShellIcons.Maui.Tests/ShellIcons.Maui.Tests.csproj b/tests/ShellIcons.Maui.Tests/ShellIcons.Maui.Tests.csproj new file mode 100644 index 0000000..df156dd --- /dev/null +++ b/tests/ShellIcons.Maui.Tests/ShellIcons.Maui.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + false + ShellIcons.Maui.Tests + false + + true + true + Library + + + + + + + + + + + + + + + + + + + diff --git a/tests/ShellIcons.Maui.Tests/XamlFixture.xaml b/tests/ShellIcons.Maui.Tests/XamlFixture.xaml new file mode 100644 index 0000000..0b85db8 --- /dev/null +++ b/tests/ShellIcons.Maui.Tests/XamlFixture.xaml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/tests/ShellIcons.Maui.Tests/XamlFixture.xaml.cs b/tests/ShellIcons.Maui.Tests/XamlFixture.xaml.cs new file mode 100644 index 0000000..f7efaa9 --- /dev/null +++ b/tests/ShellIcons.Maui.Tests/XamlFixture.xaml.cs @@ -0,0 +1,6 @@ +namespace ShellIcons.Maui.Tests; + +public partial class XamlFixture : ContentView +{ + public XamlFixture() => InitializeComponent(); +} diff --git a/tests/ShellIcons.Maui.Tests/XamlTests.cs b/tests/ShellIcons.Maui.Tests/XamlTests.cs new file mode 100644 index 0000000..6835766 --- /dev/null +++ b/tests/ShellIcons.Maui.Tests/XamlTests.cs @@ -0,0 +1,27 @@ +namespace ShellIcons.Maui.Tests; + +public class XamlTests +{ + [Fact] + public void Xmlns_ResolvesDispatcherAndTypedControls() + { + var view = new XamlFixture(); + + Assert.Equal(IconName.Search, view.Dispatched.Name); // string → enum in XAML + Assert.Equal(IconName.Search, view.Dispatched.IconName); + Assert.Equal(16, view.Dispatched.Size); + Assert.Equal(Colors.Crimson, view.Dispatched.Color); + + Assert.Equal(IconName.ChevronRight, view.Typed.IconName); + Assert.Equal(1.5, view.Typed.EffectiveStrokeThickness); + } + + [Fact] + public void TypedIconNamedImage_CoexistsWithMauiImage() + { + var view = new XamlFixture(); + + Assert.IsType(view.TypedImageIcon); + Assert.IsType(view.MauiImage); + } +}