diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 039ef55..295c908 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,9 @@ jobs: # 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 + run: | + dotnet pack src/ShellIcons.Blazor/ShellIcons.Blazor.csproj --configuration Release --no-build --output nupkgs + dotnet pack src/ShellIcons.Avalonia/ShellIcons.Avalonia.csproj --configuration Release --no-build --output nupkgs env: ContinuousIntegrationBuild: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9541f92..aba6e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -All notable changes to the ShellIcons packages. `ShellIcons.Blazor` is published on NuGet; `ShellIcons.Maui` is in preview and not published yet. +All notable changes to the ShellIcons packages. `ShellIcons.Blazor` is published on NuGet; `ShellIcons.Maui` and `ShellIcons.Avalonia` are 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). @@ -12,7 +12,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versio - **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). +- **`ShellIcons.Avalonia` (preview, not yet on NuGet)** — the same catalog for Avalonia 12 (`net8.0`): typed controls (``), a dispatcher (``) and the same `IconName` / `IconCatalog` API as the MAUI package. Icons inherit `Foreground`, so they take the color of the surrounding text. +- Build diagnostics for custom icons that can't be converted for MAUI or Avalonia: `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 diff --git a/README.md b/README.md index ac61efc..08e5add 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Lucide-derived SVG icons for **Blazor**, **Avalonia**, and **.NET MAUI**. - Zero JS, zero required CSS - Same visual system across all three .NET UI frameworks -- Native rendering per target: inline `` in Blazor, native `Path` shapes in Avalonia/MAUI +- Native rendering per target: inline `` in Blazor, native `Path` shapes in MAUI, direct drawing in Avalonia - Tree-shakeable typed components per icon Docs: [shellicons.shellui.dev](https://shellicons.shellui.dev) @@ -17,16 +17,17 @@ Docs: [shellicons.shellui.dev](https://shellicons.shellui.dev) |---|---| | `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 | +| `ShellIcons.Avalonia` | **Preview, unpublished** — Avalonia 12, `net8.0`; see [src/ShellIcons.Avalonia](src/ShellIcons.Avalonia/README.md) | - [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] Avalonia: typed controls, `Kind` dispatcher, inherited `Foreground`, the same `IconCatalog` +- [x] 168 tests (xUnit + bUnit + headless MAUI + headless Avalonia with Skia rendering), 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.11-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 +- [ ] Avalonia: run in a real desktop app, then publish ## Blazor quickstart @@ -163,6 +164,17 @@ Without `Title` icons are decorative (`aria-hidden="true"`); with it they get `r Not on NuGet yet — reference `src/ShellIcons.Maui` from source. Docs: [MAUI getting started](docs/ShellIcons.Docs/content/docs/maui/getting-started.md). +## Avalonia quickstart (preview) + +```xml + + + + +``` + +Icons inherit `Foreground`, so they match the surrounding text by default. Not on NuGet yet — reference `src/ShellIcons.Avalonia` from source. Docs: [Avalonia getting started](docs/ShellIcons.Docs/content/docs/avalonia/getting-started.md). + ## Repo layout ``` @@ -171,7 +183,7 @@ shell-icons/ ├── RELEASING.md how to cut a release ├── LUCIDE_VERSION.txt pinned upstream tag ├── NOTICE.md Lucide ISC attribution -├── ShellIcons.slnx Blazor, generators, tests, docs — no MAUI workload needed +├── ShellIcons.slnx Blazor, Avalonia, generators, tests, docs — no MAUI workload needed ├── ShellIcons.Maui.slnx MAUI projects — needs `dotnet workload install maui` ├── Directory.Build.props ├── catalog/ @@ -185,11 +197,13 @@ shell-icons/ │ ├── 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 +│ ├── ShellIcons.Avalonia/ Avalonia library (preview, net8.0) +│ └── ShellIcons.Generator.Xaml/ MAUI + Avalonia source generator, SVG → path conversion ├── tests/ │ ├── 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 +│ ├── ShellIcons.Maui.Tests/ headless MAUI: controls, XAML, whole-catalog geometry +│ └── ShellIcons.Avalonia.Tests/ headless Avalonia + Skia: controls, XAML, whole-catalog rendering ├── docs/ │ └── ShellIcons.Docs/ ShellDocs site — dev at http://localhost:5145 └── .github/workflows/ @@ -211,7 +225,7 @@ Serves at http://localhost:5145. Pages live in `docs/ShellIcons.Docs/content/doc `/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`): +Build the static site the way the deploy workflow does (needs `dotnet tool install -g ShellDocs.CLI --version 0.1.11-alpha`): ```bash cd docs/ShellIcons.Docs diff --git a/RELEASING.md b/RELEASING.md index 767e31c..0215c63 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,7 +4,7 @@ How to ship a new version of `ShellIcons.Blazor` to NuGet.org and create the mat **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`). +**What's released:** only `ShellIcons.Blazor`. `ShellIcons.Maui` and `ShellIcons.Avalonia` are in preview — `release.yml` doesn't pack them yet. Publishing MAUI will need a Windows or macOS job with the MAUI workload; Avalonia packs on ubuntu, so it only needs adding to the existing pack step and the "already published" check. 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. diff --git a/ShellIcons.slnx b/ShellIcons.slnx index 5976a44..0e01fc8 100644 --- a/ShellIcons.slnx +++ b/ShellIcons.slnx @@ -8,10 +8,13 @@ + + + diff --git a/catalog/custom/README.md b/catalog/custom/README.md index 6db2dfd..9489fbb 100644 --- a/catalog/custom/README.md +++ b/catalog/custom/README.md @@ -25,11 +25,11 @@ Match Lucide's shape contract so custom icons are visually cohesive with the res - **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. -## Staying portable to MAUI (and Avalonia) +## 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: +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 and Avalonia: -| You use | What happens for MAUI | Diagnostic | +| You use | What happens on MAUI and Avalonia | Diagnostic | |---|---|---| | ``, ``, ``, gradients, masks, text | That element is skipped | `SHELLICONS002` (warning) | | `transform="…"` on any shape | Reported; bake the transform into the coordinates instead | `SHELLICONS002` (warning) | diff --git a/docs/ShellIcons.Docs/Components/Preview/PreviewCell.razor b/docs/ShellIcons.Docs/Components/Preview/PreviewCell.razor index b7aa51a..efe982b 100644 --- a/docs/ShellIcons.Docs/Components/Preview/PreviewCell.razor +++ b/docs/ShellIcons.Docs/Components/Preview/PreviewCell.razor @@ -1,8 +1,6 @@ @namespace ShellIcons.Docs.Preview -@* Colored cell for icon showcases — sets `color` so nested icons pick it up - via currentColor. Alternative to which can't be used - directly as a razor:preview root either. *@ +@* Colored cell for icon showcases: sets `color` so nested icons pick it up via currentColor. *@ @ChildContent diff --git a/docs/ShellIcons.Docs/Components/Preview/PreviewRow.razor b/docs/ShellIcons.Docs/Components/Preview/PreviewRow.razor index 1103217..528d977 100644 --- a/docs/ShellIcons.Docs/Components/Preview/PreviewRow.razor +++ b/docs/ShellIcons.Docs/Components/Preview/PreviewRow.razor @@ -1,8 +1,6 @@ @namespace ShellIcons.Docs.Preview -@* Simple flex-row wrapper for razor:preview blocks that showcase multiple icons. - ShellDocs's razor:preview fences require a registered component as the outer - tag — a bare
won't resolve — so this exists to be that outer component. *@ +@* Flex-row wrapper for razor:preview blocks that showcase several icons. *@
@ChildContent
diff --git a/docs/ShellIcons.Docs/Program.cs b/docs/ShellIcons.Docs/Program.cs index 1c1f096..1defcc8 100644 --- a/docs/ShellIcons.Docs/Program.cs +++ b/docs/ShellIcons.Docs/Program.cs @@ -12,7 +12,7 @@ { o.ContentRoot = Path.Combine(builder.Environment.ContentRootPath, "content"); o.SiteName = "ShellIcons"; - o.SiteTagline = "Lucide-derived icons for Blazor and .NET MAUI."; + o.SiteTagline = "Lucide-derived icons for Blazor, .NET MAUI and Avalonia."; o.GitHubRepo = "shellui-dev/shell-icons"; o.LayoutVariant = DocsLayoutVariant.Sidebar; @@ -23,8 +23,7 @@ // Every icon component, usable in razor:preview blocks. o.RegisterComponentsFromAssembly(); - /* 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). */ + // This site's own components: PreviewRow/PreviewCell layout helpers and IconBrowser (content/docs/icons.md). o.RegisterComponentsFromAssembly(); }); diff --git a/docs/ShellIcons.Docs/ShellIcons.Docs.csproj b/docs/ShellIcons.Docs/ShellIcons.Docs.csproj index 5b1119f..796e835 100644 --- a/docs/ShellIcons.Docs/ShellIcons.Docs.csproj +++ b/docs/ShellIcons.Docs/ShellIcons.Docs.csproj @@ -10,10 +10,10 @@ - - - - + + + + diff --git a/docs/ShellIcons.Docs/content/docs/avalonia/getting-started.md b/docs/ShellIcons.Docs/content/docs/avalonia/getting-started.md new file mode 100644 index 0000000..ebdbb60 --- /dev/null +++ b/docs/ShellIcons.Docs/content/docs/avalonia/getting-started.md @@ -0,0 +1,110 @@ +--- +title: Avalonia getting started +description: ShellIcons for Avalonia — the same 1,555 icons, drawn natively and colored like text. +order: 1 +--- + +# ShellIcons for Avalonia + +The same Lucide catalog as the Blazor and MAUI packages, drawn directly by Avalonia. The SVG is converted at build time, so nothing parses SVG in your app. + + +ShellIcons.Avalonia is covered by tests that render every icon through Avalonia's Skia backend, but it hasn't been run in a real desktop window yet. Reference the project from source until it's published. + + +## Install + +Until the package is published, reference the project. No extra workloads are needed. + +```xml + +``` + +Targets `net8.0` and Avalonia 12.1. + +## 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 and view-model properties. Keeps the whole catalog. | + +The dispatcher's property is `Kind`, not `Name`: in Avalonia, `Name` is the element's own name. + +XAML prefixes keep names apart, so `` (the icon) and `` (Avalonia's control) coexist in the same file. + +## 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 | +| `Foreground` | inherited | Stroke (and fill) brush | +| `Title` | `null` | Accessible name; when null the icon stays out of the accessibility tree | + +## Color and theming + +`Foreground` is the same inherited property that text uses. An icon takes the color of the text around it, like `currentColor` in the browser, and follows the theme with no extra work: + +```xml + +``` + +Set it directly to override, or style every icon at once — `IconView` is the base of both the typed controls and `Icon`: + +```xml + +``` + +## Clicks and accessibility + +Icons are not hit-test visible: a click goes to the button or row that hosts the icon. To make an icon clickable, put it in a `Button`: + +```xml + +``` + +Set `Title` whenever the icon is the only thing conveying meaning. It becomes the icon's automation name, and the icon is exposed to screen readers as an image. + +## From C# + +```csharp +using Avalonia.Media; +using ShellIcons.Avalonia; + +var chevron = new ShellIcons.Avalonia.Icons.ChevronRight { Size = 16 }; +var status = new Icon { Kind = IconName.CircleCheck, Foreground = Brushes.Green }; +``` + +The typed controls live in `ShellIcons.Avalonia.Icons` so names like `Image` or `Grid` don't clash with Avalonia's own types in C#. + +## How it draws + +Each icon is a single custom-drawn control with no child elements. Its path data is the same build-time conversion the MAUI package uses (see [How rendering works](/docs/maui/rendering)); Avalonia turns it into a geometry once per icon and every control showing that icon shares it. Icons are drawn on Lucide's 24×24 grid and scaled to `Size`, so different icons of the same size line up. + +## Next + +- [Icon names & catalog](/docs/avalonia/names-and-catalog) — `IconName`, aliases, search, bindings diff --git a/docs/ShellIcons.Docs/content/docs/avalonia/meta.json b/docs/ShellIcons.Docs/content/docs/avalonia/meta.json new file mode 100644 index 0000000..5ae9251 --- /dev/null +++ b/docs/ShellIcons.Docs/content/docs/avalonia/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Avalonia (preview)", + "pages": ["getting-started", "names-and-catalog"] +} diff --git a/docs/ShellIcons.Docs/content/docs/avalonia/names-and-catalog.md b/docs/ShellIcons.Docs/content/docs/avalonia/names-and-catalog.md new file mode 100644 index 0000000..5f8f4e1 --- /dev/null +++ b/docs/ShellIcons.Docs/content/docs/avalonia/names-and-catalog.md @@ -0,0 +1,69 @@ +--- +title: Icon names & catalog +description: The IconName enum, Lucide aliases, and searching the catalog from C# on Avalonia. +order: 2 +--- + +# Icon names & catalog + +`IconName`, `IconCatalog` and `IconInfo` are generated from the same source as the MAUI package, so the API is identical — only the namespace (`ShellIcons.Avalonia`) differs. + +## `IconName` + +Every icon has an `IconName` member: PascalCase of its Lucide file name (`chevron-right` → `ChevronRight`). `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 + +`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 +``` + +A real icon id always wins over another icon's alias. + +## Metadata and search + +```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"] + +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. That is enough to build an icon picker: + +```xml + + + + + + + + + + +``` diff --git a/docs/ShellIcons.Docs/content/docs/introduction.md b/docs/ShellIcons.Docs/content/docs/introduction.md index b82e364..6d74e57 100644 --- a/docs/ShellIcons.Docs/content/docs/introduction.md +++ b/docs/ShellIcons.Docs/content/docs/introduction.md @@ -24,7 +24,7 @@ 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. - **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. +- **Avalonia in preview.** The same conversion, drawn directly and colored like the surrounding text. See [Avalonia](/docs/avalonia/getting-started). ## What you get diff --git a/docs/ShellIcons.Docs/content/docs/meta.json b/docs/ShellIcons.Docs/content/docs/meta.json index 536c45a..e63407e 100644 --- a/docs/ShellIcons.Docs/content/docs/meta.json +++ b/docs/ShellIcons.Docs/content/docs/meta.json @@ -6,6 +6,7 @@ "getting-started", "guides", "maui", + "avalonia", "reference" ] } diff --git a/src/ShellIcons.Avalonia/AssemblyInfo.cs b/src/ShellIcons.Avalonia/AssemblyInfo.cs new file mode 100644 index 0000000..d263c62 --- /dev/null +++ b/src/ShellIcons.Avalonia/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using Avalonia.Metadata; + +// One xmlns for the dispatcher and the typed controls; the prefix keeps names like Image apart from Avalonia's. +[assembly: XmlnsDefinition("https://shellicons.dev/avalonia", "ShellIcons.Avalonia")] +[assembly: XmlnsDefinition("https://shellicons.dev/avalonia", "ShellIcons.Avalonia.Icons")] +[assembly: XmlnsPrefix("https://shellicons.dev/avalonia", "icons")] diff --git a/src/ShellIcons.Avalonia/Icon.cs b/src/ShellIcons.Avalonia/Icon.cs new file mode 100644 index 0000000..87b3baf --- /dev/null +++ b/src/ShellIcons.Avalonia/Icon.cs @@ -0,0 +1,33 @@ +using Avalonia; + +namespace ShellIcons.Avalonia; + +/// +/// 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. + /// Called Kind because Avalonia's Name is already the element name. + public static readonly StyledProperty KindProperty = + AvaloniaProperty.Register(nameof(Kind)); + + static Icon() + { + KindProperty.Changed.AddClassHandler((icon, e) => icon.Load(e.GetNewValue())); + } + + /// + public IconName Kind + { + get => GetValue(KindProperty); + set => SetValue(KindProperty, value); + } + + private void Load(IconName kind) + { + var (stroke, fill) = IconData.Get(kind); + SetData(kind, stroke, fill); + } +} diff --git a/src/ShellIcons.Avalonia/IconGeometry.cs b/src/ShellIcons.Avalonia/IconGeometry.cs new file mode 100644 index 0000000..6ffbf1b --- /dev/null +++ b/src/ShellIcons.Avalonia/IconGeometry.cs @@ -0,0 +1,14 @@ +using System.Collections.Concurrent; +using Avalonia.Media; + +namespace ShellIcons.Avalonia; + +/* The generator's path data is already plain "M L C Q A Z", which Avalonia parses natively. + 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 => StreamGeometry.Parse(d)); +} diff --git a/src/ShellIcons.Avalonia/IconView.cs b/src/ShellIcons.Avalonia/IconView.cs new file mode 100644 index 0000000..134bcd6 --- /dev/null +++ b/src/ShellIcons.Avalonia/IconView.cs @@ -0,0 +1,162 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Layout; +using Avalonia.Media; + +namespace ShellIcons.Avalonia; + +/// +/// Base control for every icon: draws the icon's geometry on Lucide's 24×24 grid, scaled to +/// . Shapes with fill="currentColor" are filled as well as stroked. +/// +public class IconView : Control +{ + private const double GridUnits = 24; + + /// Width and height in device-independent units. Default 24. + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size), GridUnits); + + /// Stroke width on the 24-unit grid (Lucide's weight). Default 2. + public static readonly StyledProperty StrokeThicknessProperty = + AvaloniaProperty.Register(nameof(StrokeThickness), 2d); + + /// When true the rendered stroke stays wide at any size. + public static readonly StyledProperty AbsoluteStrokeProperty = + AvaloniaProperty.Register(nameof(AbsoluteStroke)); + + /// Stroke (and fill) brush. Inherited, so an icon takes its parent's text color by default. + public static readonly StyledProperty ForegroundProperty = + TextElement.ForegroundProperty.AddOwner(); + + /// Accessible name. When null the icon is decorative and hidden from screen readers. + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + private Geometry? _stroke; + private Geometry? _fill; + + static IconView() + { + AffectsMeasure(SizeProperty); + AffectsRender(SizeProperty, StrokeThicknessProperty, AbsoluteStrokeProperty, ForegroundProperty); + + // Icons are decoration for hit testing: a click lands on the button or row hosting them. + IsHitTestVisibleProperty.OverrideDefaultValue(false); + HorizontalAlignmentProperty.OverrideDefaultValue(HorizontalAlignment.Center); + VerticalAlignmentProperty.OverrideDefaultValue(VerticalAlignment.Center); + AutomationProperties.AccessibilityViewProperty.OverrideDefaultValue(AccessibilityView.Raw); + + TitleProperty.Changed.AddClassHandler((icon, _) => icon.ApplyTitle()); + } + + /// Creates an empty icon; call to give it a shape. + protected IconView() + { + } + + /// Creates an icon with fixed path data (used by the generated typed controls). + protected IconView(IconName iconName, string strokeData, string? fillData) + { + SetData(iconName, strokeData, fillData); + } + + /// The icon this control currently shows. + public IconName IconName { get; private set; } + + /// + public double Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + /// + public double StrokeThickness + { + get => GetValue(StrokeThicknessProperty); + set => SetValue(StrokeThicknessProperty, value); + } + + /// + public bool AbsoluteStroke + { + get => GetValue(AbsoluteStrokeProperty); + set => SetValue(AbsoluteStrokeProperty, value); + } + + /// + public IBrush? Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + /// + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + /// The rendered stroke thickness after size scaling. + public double EffectiveStrokeThickness => + AbsoluteStroke ? StrokeThickness : StrokeThickness * (Size / GridUnits); + + internal Geometry? StrokeGeometry => _stroke; + + internal Geometry? FillGeometry => _fill; + + /// Replaces the drawn shapes. + protected void SetData(IconName iconName, string strokeData, string? fillData) + { + IconName = iconName; + _stroke = IconGeometry.Get(strokeData); + _fill = IconGeometry.Get(fillData); + InvalidateVisual(); + } + + /// + protected override global::Avalonia.Size MeasureOverride(global::Avalonia.Size availableSize) + { + var size = Math.Max(0, Size); + return new global::Avalonia.Size(size, size); + } + + /// + public override void Render(DrawingContext context) + { + var size = Size; + if (size <= 0 || Foreground is not { } brush || (_stroke is null && _fill is null)) return; + + var scale = size / GridUnits; + + // The transform scales the pen with the geometry, so the pen is sized in grid units. + var pen = new Pen(brush, EffectiveStrokeThickness / scale, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round); + + using (context.PushTransform(Matrix.CreateScale(scale, scale))) + { + if (_stroke is not null) context.DrawGeometry(null, pen, _stroke); + // The rare filled shapes are drawn on top, filled *and* stroked like the SVG. + if (_fill is not null) context.DrawGeometry(brush, pen, _fill); + } + } + + /// + protected override AutomationPeer OnCreateAutomationPeer() => new IconAutomationPeer(this); + + private void ApplyTitle() + { + var title = Title; + AutomationProperties.SetName(this, title); + AutomationProperties.SetAccessibilityView(this, title is null ? AccessibilityView.Raw : AccessibilityView.Content); + } + + private sealed class IconAutomationPeer(IconView owner) : ControlAutomationPeer(owner) + { + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Image; + } +} diff --git a/src/ShellIcons.Avalonia/README.md b/src/ShellIcons.Avalonia/README.md new file mode 100644 index 0000000..f090000 --- /dev/null +++ b/src/ShellIcons.Avalonia/README.md @@ -0,0 +1,78 @@ +# ShellIcons.Avalonia (preview) + +Lucide-derived icons for Avalonia, from the same 1,555-icon catalog as `ShellIcons.Blazor` and +`ShellIcons.Maui`. Docs: [shellicons.shellui.dev/docs/avalonia/getting-started](https://shellicons.shellui.dev/docs/avalonia/getting-started). + +Not published yet. The release workflow only packs `ShellIcons.Blazor`. + +Targets `net8.0` and Avalonia 12.1. + +## Use + +```xml + + + + + + + + + + +``` + +| Property | Default | Notes | +|---|---|---| +| `Size` | `24` | Width and height | +| `StrokeThickness` | `2` | On the 24-unit grid, scaled with `Size` like the other targets | +| `AbsoluteStroke` | `false` | Keep the rendered stroke at `StrokeThickness` at any size | +| `Foreground` | inherited | The same inherited property text uses, so an icon in a `Button` takes the button's foreground | +| `Title` | `null` | Sets `AutomationProperties.Name`; null keeps the icon out of the accessibility tree | + +Names and metadata are the same API as the MAUI package: + +```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 +``` + +## Differences from ShellIcons.Maui + +| | MAUI | Avalonia | Why | +|---|---|---|---| +| Dispatcher property | `Name` | `Kind` | Avalonia's `Name` is already the element name (`x:Name`) | +| Color | `Color` (a `Color`) | `Foreground` (a brush, inherited) | Avalonia has an inherited foreground, so icons follow text color the way `currentColor` does in Blazor | +| Hit testing | `InputTransparent` | `IsHitTestVisible="False"` | Each framework's own property; same behavior | + +`IconName`, `IconCatalog`, `IconInfo`, `Size`, `StrokeThickness`, `AbsoluteStroke` and `Title` are identical. + +## How it draws + +`ShellIcons.Generator.Xaml` converts each SVG at build time into one normalized path string +(`M L C Q A Z`, absolute coordinates) — the same conversion and the same data the MAUI package uses. +Avalonia parses that grammar natively, so the control turns it into a `StreamGeometry` once per icon, +caches it, and draws it in `Render` under a scale transform. There are no child controls: an icon is +one visual. + +## Design decisions + +| Decision | Why | +|---|---| +| Custom-drawn `Control`, not a `Path` inside a template | One visual per icon, no template to apply, no layout pass for children. Lists and toolbars show many icons. | +| `Foreground` added from `TextElement.ForegroundProperty` | It inherits, so icons match surrounding text without a style. | +| `Kind` for the dispatcher | `Name` is taken; `Kind` is what Avalonia icon libraries already use. | +| Shared generator with MAUI, picked by `` | One catalog reader and one emitter, so the two XAML packages can't drift apart. | + +## Verified + +- Builds with zero warnings; part of `ShellIcons.slnx`, so it needs no extra workloads. +- 33 headless tests that render through Skia: every icon parses, stays on the 24×24 grid and draws ink inside its own cell; size, stroke, color, XAML namespace and accessibility are checked from real pixels or the real property system. +- **Not yet verified:** a real desktop window on Windows, macOS or Linux. The headless renderer uses the same Skia backend, so differences are unlikely, but nobody has looked. + +## Before publishing + +- Run it once in a real Avalonia app on each desktop OS. +- Give the package its own readme — `Directory.Build.props` currently packs the repo's Blazor-centric `README.md` into every package. +- Add `ShellIcons.Avalonia` to the pack step and the "already published" check in `release.yml`. It packs on ubuntu, so no new job is needed. diff --git a/src/ShellIcons.Avalonia/ShellIcons.Avalonia.csproj b/src/ShellIcons.Avalonia/ShellIcons.Avalonia.csproj new file mode 100644 index 0000000..e503a74 --- /dev/null +++ b/src/ShellIcons.Avalonia/ShellIcons.Avalonia.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + ShellIcons.Avalonia + Avalonia + + true + ShellIcons.Avalonia + Lucide-derived icons for Avalonia, drawn straight to the DrawingContext. 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. + avalonia;icons;svg;lucide;xaml;shellui + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ShellIcons.Generator.Xaml/CatalogEmitter.cs b/src/ShellIcons.Generator.Xaml/CatalogEmitter.cs new file mode 100644 index 0000000..51e8986 --- /dev/null +++ b/src/ShellIcons.Generator.Xaml/CatalogEmitter.cs @@ -0,0 +1,214 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace ShellIcons.Generator.Xaml; + +/* Emits the parts every XAML target shares: IconName, IconData, IconInfo/IconCatalog and the typed + controls. Only the namespace differs, so MAUI and Avalonia get the same names and behavior. */ +internal static class CatalogEmitter +{ + public static string Enum(string ns, XamlIcon[] 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(); + } + + public static string Data(string ns, XamlIcon[] icons) + { + var sb = Header(); + sb.AppendLine($"namespace {ns};"); + sb.AppendLine(); + sb.AppendLine("// Path data for the Icon dispatcher. Referencing it keeps the whole catalog."); + sb.AppendLine("internal static class IconData"); + sb.AppendLine("{"); + 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(); + } + + public static string Catalog(string ns, XamlIcon[] icons) + { + var cases = new StringBuilder(); + foreach (var icon in icons) + { + cases.Append($" IconName.{icon.Pascal} => new IconInfo(IconName.{icon.Pascal}, {Literal(icon.Kebab)}, {Literal(icon.Pack)}, "); + cases.Append(Array(icon.Meta.Tags)).Append(", ").Append(Array(icon.Meta.Categories)).Append(", ").Append(Array(icon.Meta.Aliases)); + cases.AppendLine("),"); + } + + return $$""" + // + #nullable enable + + using System; + using System.Collections.Generic; + using System.Linq; + + namespace {{ns}}; + + /// 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 class IconCatalog + { + /// Number of icons in the catalog (excluding ). + public const int Count = {{icons.Length}}; + + 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; + } + + private static IconInfo? Create(IconName name) => name switch + { + {{cases}} _ => null, + }; + } + """; + } + + public static string TypedControl(string ns, XamlIcon icon) + { + var sb = Header(); + sb.AppendLine($"namespace {ns}.Icons;"); + 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/MauiIconGenerator.cs b/src/ShellIcons.Generator.Xaml/MauiIconGenerator.cs deleted file mode 100644 index ae59ec0..0000000 --- a/src/ShellIcons.Generator.Xaml/MauiIconGenerator.cs +++ /dev/null @@ -1,265 +0,0 @@ -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/ShellIcons.Generator.Xaml.csproj b/src/ShellIcons.Generator.Xaml/ShellIcons.Generator.Xaml.csproj index 206b07d..67e8c55 100644 --- a/src/ShellIcons.Generator.Xaml/ShellIcons.Generator.Xaml.csproj +++ b/src/ShellIcons.Generator.Xaml/ShellIcons.Generator.Xaml.csproj @@ -1,6 +1,6 @@ - + netstandard2.0 enable diff --git a/src/ShellIcons.Generator.Xaml/XamlCatalog.cs b/src/ShellIcons.Generator.Xaml/XamlCatalog.cs new file mode 100644 index 0000000..a0dd749 --- /dev/null +++ b/src/ShellIcons.Generator.Xaml/XamlCatalog.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace ShellIcons.Generator.Xaml; + +internal sealed class SourceFile +{ + public SourceFile(string path, string content) { Path = path; Content = content; } + public string Path { get; } + public string Content { get; } +} + +internal sealed class XamlIcon +{ + public XamlIcon(string kebab, string pascal, string pack, XamlIconShapes shapes, IconMetadata meta) + { + Kebab = kebab; + Pascal = pascal; + Pack = pack; + Shapes = shapes; + Meta = meta; + } + + public string Kebab { get; } + public string Pascal { get; } + public string Pack { get; } + public XamlIconShapes Shapes { get; } + public IconMetadata Meta { get; } +} + +// Reads the catalog's SVGs and JSON sidecars into one icon list shared by every XAML target. +internal static class XamlCatalog +{ + 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 on the XAML targets. Stick to path/circle/ellipse/rect/line/polyline/polygon without transforms.", + category: "ShellIcons", + defaultSeverity: DiagnosticSeverity.Warning, + 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 on the XAML targets", + 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 on the XAML targets: {2}", + category: "ShellIcons", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + + public static XamlIcon[] Read(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 XamlIcon(kebab, pascal, pack, shapes, + 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)); + } + } + + return resolved.Values.OrderBy(i => i.Kebab, StringComparer.Ordinal).ToArray(); + } + + // 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"; +} diff --git a/src/ShellIcons.Generator.Xaml/XamlIconGenerator.cs b/src/ShellIcons.Generator.Xaml/XamlIconGenerator.cs new file mode 100644 index 0000000..2e5750d --- /dev/null +++ b/src/ShellIcons.Generator.Xaml/XamlIconGenerator.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ShellIcons.Generator.Xaml; + +/* Generates a XAML target's catalog. The consuming project picks the target with + Maui|Avalonia (made visible via CompilerVisibleProperty). */ +[Generator] +public sealed class XamlIconGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor UnknownTargetDescriptor = new( + id: "SHELLICONS006", + title: "ShellIconsTarget not set", + messageFormat: "ShellIcons.Generator.Xaml needs set to 'Maui' or 'Avalonia' (got '{0}'); no icons were generated", + 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(); + + var target = context.AnalyzerConfigOptionsProvider.Select((options, _) => + options.GlobalOptions.TryGetValue("build_property.ShellIconsTarget", out var value) ? value.Trim() : string.Empty); + + context.RegisterSourceOutput(files.Combine(target), (ctx, input) => Emit(ctx, input.Left, input.Right)); + } + + private static void Emit(SourceProductionContext ctx, ImmutableArray files, string target) + { + var ns = NamespaceFor(target); + if (ns is null) + { + ctx.ReportDiagnostic(Diagnostic.Create(UnknownTargetDescriptor, Location.None, target)); + return; + } + + var icons = XamlCatalog.Read(ctx, files); + + ctx.AddSource("IconName.g.cs", SourceText.From(CatalogEmitter.Enum(ns, icons), Encoding.UTF8)); + ctx.AddSource("IconData.g.cs", SourceText.From(CatalogEmitter.Data(ns, icons), Encoding.UTF8)); + ctx.AddSource("IconCatalog.g.cs", SourceText.From(CatalogEmitter.Catalog(ns, icons), Encoding.UTF8)); + foreach (var icon in icons) + ctx.AddSource($"Icons/{icon.Pascal}.g.cs", SourceText.From(CatalogEmitter.TypedControl(ns, icon), Encoding.UTF8)); + } + + private static string? NamespaceFor(string target) + { + if (string.Equals(target, "Maui", StringComparison.OrdinalIgnoreCase)) return "ShellIcons.Maui"; + if (string.Equals(target, "Avalonia", StringComparison.OrdinalIgnoreCase)) return "ShellIcons.Avalonia"; + return null; + } +} diff --git a/src/ShellIcons.Maui/IconCatalog.cs b/src/ShellIcons.Maui/IconCatalog.cs deleted file mode 100644 index 5532eb7..0000000 --- a/src/ShellIcons.Maui/IconCatalog.cs +++ /dev/null @@ -1,85 +0,0 @@ -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/ShellIcons.Maui.csproj b/src/ShellIcons.Maui/ShellIcons.Maui.csproj index 117db87..1c8be9b 100644 --- a/src/ShellIcons.Maui/ShellIcons.Maui.csproj +++ b/src/ShellIcons.Maui/ShellIcons.Maui.csproj @@ -9,6 +9,7 @@ true true ShellIcons.Maui + Maui true ShellIcons.Maui @@ -32,8 +33,9 @@ ReferenceOutputAssembly="false" /> - + + diff --git a/tests/ShellIcons.Avalonia.Tests/CatalogTests.cs b/tests/ShellIcons.Avalonia.Tests/CatalogTests.cs new file mode 100644 index 0000000..2a99fa5 --- /dev/null +++ b/tests/ShellIcons.Avalonia.Tests/CatalogTests.cs @@ -0,0 +1,89 @@ +namespace ShellIcons.Avalonia.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); + } + + [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 EveryIcon_HasATypedControl() + { + var typed = typeof(IconView).Assembly.GetTypes() + .Where(t => t.Namespace == "ShellIcons.Avalonia.Icons" && t.IsSubclassOf(typeof(IconView))) + .ToList(); + + Assert.Equal(IconCatalog.Count, typed.Count); + } +} diff --git a/tests/ShellIcons.Avalonia.Tests/ControlTests.cs b/tests/ShellIcons.Avalonia.Tests/ControlTests.cs new file mode 100644 index 0000000..dbc8226 --- /dev/null +++ b/tests/ShellIcons.Avalonia.Tests/ControlTests.cs @@ -0,0 +1,119 @@ +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace ShellIcons.Avalonia.Tests; + +public class ControlTests +{ + [AvaloniaFact] + public void TypedControl_KnowsItsName_AndHasGeometry() + { + var icon = new Icons.ChevronRight(); + + Assert.Equal(IconName.ChevronRight, icon.IconName); + Assert.NotNull(icon.StrokeGeometry); + Assert.Null(icon.FillGeometry); + } + + [AvaloniaFact] + public void Dispatcher_LoadsByKind_AndReloadsOnChange() + { + var icon = new Icon { Kind = IconName.House }; + Assert.Equal(IconName.House, icon.IconName); + var house = icon.StrokeGeometry; + + icon.Kind = IconName.ChevronRight; + Assert.Equal(IconName.ChevronRight, icon.IconName); + Assert.NotSame(house, icon.StrokeGeometry); + } + + [AvaloniaFact] + public void Dispatcher_None_DrawsNothing() + { + var icon = new Icon(); + Assert.Null(icon.StrokeGeometry); + + icon.Kind = IconName.Zap; + icon.Kind = IconName.None; + Assert.Null(icon.StrokeGeometry); + } + + [AvaloniaFact] + public void TypedAndDispatcher_ShareTheSameCachedGeometry() + { + Assert.Same(new Icons.Zap().StrokeGeometry, new Icon { Kind = IconName.Zap }.StrokeGeometry); + } + + [AvaloniaFact] + public void Defaults_MatchTheOtherTargets() + { + var icon = new Icons.Zap(); + + Assert.Equal(24, icon.Size); + Assert.Equal(2, icon.StrokeThickness); + Assert.Equal(2, icon.EffectiveStrokeThickness); + Assert.False(icon.IsHitTestVisible); + Assert.Equal(HorizontalAlignment.Center, icon.HorizontalAlignment); + Assert.Equal(VerticalAlignment.Center, icon.VerticalAlignment); + } + + [AvaloniaFact] + public void Size_DrivesLayout_AndScalesTheStroke() + { + var icon = new Icons.Zap { Size = 16 }; + icon.Measure(global::Avalonia.Size.Infinity); + + Assert.Equal(new global::Avalonia.Size(16, 16), icon.DesiredSize); + Assert.Equal(2 * 16d / 24, icon.EffectiveStrokeThickness, 6); + } + + [AvaloniaFact] + public void AbsoluteStroke_KeepsStrokeWidthAtAnySize() + { + var icon = new Icons.Zap { Size = 48, StrokeThickness = 1.5, AbsoluteStroke = true }; + Assert.Equal(1.5, icon.EffectiveStrokeThickness); + } + + [AvaloniaFact] + public void Foreground_IsInheritedFromTheParent() + { + var icon = new Icons.Zap(); + var window = new Window { Content = new Button { Foreground = Brushes.Crimson, Content = icon } }; + window.Show(); + + Assert.Same(Brushes.Crimson, icon.Foreground); + + icon.Foreground = Brushes.Blue; + Assert.Same(Brushes.Blue, icon.Foreground); + window.Close(); + } + + [AvaloniaFact] + public void FilledIcon_HasASeparateFillGeometry() + { + var name = Enum.GetValues().First(n => IconData.Get(n).Fill is not null); + var icon = new Icon { Kind = name }; + Assert.NotNull(icon.FillGeometry); + + icon.Kind = IconName.ChevronRight; + Assert.Null(icon.FillGeometry); + } + + [AvaloniaFact] + public void Title_MakesTheIconAccessible() + { + var icon = new Icons.X(); + Assert.Equal(AccessibilityView.Raw, AutomationProperties.GetAccessibilityView(icon)); + + icon.Title = "Close dialog"; + Assert.Equal("Close dialog", AutomationProperties.GetName(icon)); + Assert.Equal(AccessibilityView.Content, AutomationProperties.GetAccessibilityView(icon)); + + var peer = ControlAutomationPeer.CreatePeerForElement(icon); + Assert.Equal(AutomationControlType.Image, peer.GetAutomationControlType()); + Assert.Equal("Close dialog", peer.GetName()); + } +} diff --git a/tests/ShellIcons.Avalonia.Tests/RenderTests.cs b/tests/ShellIcons.Avalonia.Tests/RenderTests.cs new file mode 100644 index 0000000..204605e --- /dev/null +++ b/tests/ShellIcons.Avalonia.Tests/RenderTests.cs @@ -0,0 +1,212 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace ShellIcons.Avalonia.Tests; + +// Renders through Skia and reads pixels back, so a geometry Avalonia can't parse or draws off-grid fails here. +public class RenderTests +{ + private const int Cell = 24; + + [AvaloniaFact] + public void EveryIcon_ParsesAndStaysOnTheGrid() + { + 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))) + { + try + { + // Bounds follow the curve itself, so control points poking out don't count. + var b = IconGeometry.Get(data)!.Bounds; + if (b.Left < -0.5 || b.Top < -0.5 || b.Right > 24.5 || b.Bottom > 24.5) + failures.Add($"{name}: bounds {b} are off the 24x24 grid"); + } + catch (Exception ex) + { + failures.Add($"{name}: {ex.Message}"); + } + } + } + + Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures.Take(25))); + } + + [AvaloniaFact] + public void EveryIcon_DrawsInk_InsideItsCell() + { + var names = Enum.GetValues().Where(n => n != IconName.None).ToArray(); + const int columns = 40; + var rows = (names.Length + columns - 1) / columns; + + var grid = new UniformGrid { Columns = columns, Rows = rows }; + foreach (var name in names) + grid.Children.Add(new Icon { Kind = name, Foreground = Brushes.Black }); + + using var frame = Capture(grid, columns * Cell, rows * Cell); + var pixels = Pixels(frame); + + var blank = new List(); + for (var i = 0; i < names.Length; i++) + { + if (CountInk(pixels, frame.PixelSize.Width, i % columns * Cell, i / columns * Cell, Cell, Cell) == 0) + blank.Add(names[i]); + } + + Save(frame, "catalog.png"); + Assert.True(blank.Count == 0, "Icons that drew nothing: " + string.Join(", ", blank.Take(25))); + } + + [AvaloniaFact] + public void Size_ScalesTheDrawing() + { + var small = InkBounds(new Icons.Square { Size = 24, Foreground = Brushes.Black }, 96); + var large = InkBounds(new Icons.Square { Size = 72, Foreground = Brushes.Black }, 96); + + // Lucide's square spans 3..21 with a 2-unit stroke: 20 of the 24 grid units. + Assert.InRange(small.Width, 19, 21); + Assert.InRange(large.Width, 58, 62); + } + + [AvaloniaFact] + public void AbsoluteStroke_DrawsAThinnerLineThanTheScaledDefault() + { + var scaled = Ink(new Icons.Minus { Size = 72, Foreground = Brushes.Black }, 96); + var absolute = Ink(new Icons.Minus { Size = 72, AbsoluteStroke = true, Foreground = Brushes.Black }, 96); + + // Scaled stroke is 6px, absolute stays 2px: roughly a third of the ink. + Assert.InRange((double)absolute / scaled, 0.2, 0.5); + } + + [AvaloniaFact] + public void Foreground_ColorsTheStroke() + { + using var frame = Capture(new Icons.Square { Size = 48, Foreground = Brushes.Red }, 48, 48); + var pixels = Pixels(frame); + + // Middle of the left edge (x = 3 grid units → 6px). + var offset = (24 * frame.PixelSize.Width + 6) * 4; + Assert.True(pixels[offset + 2] > 200, "red channel"); + Assert.True(pixels[offset + 1] < 60, "green channel"); + Assert.True(pixels[offset] < 60, "blue channel"); + } + + [AvaloniaFact] + public void ContactSheet_ForEyeballing() + { + IconName[] names = + [ + IconName.House, IconName.Search, IconName.Settings, IconName.CircleCheck, IconName.Heart, + IconName.ShoppingCart, IconName.Zap, IconName.Github, IconName.Twitter, IconName.SaveOff, + ]; + + var panel = new StackPanel { Spacing = 12, Margin = new global::Avalonia.Thickness(12) }; + foreach (var size in new[] { 16d, 24, 48 }) + { + var row = new StackPanel { Orientation = global::Avalonia.Layout.Orientation.Horizontal, Spacing = 12 }; + foreach (var name in names) + row.Children.Add(new Icon { Kind = name, Size = size, Foreground = Brushes.Black }); + panel.Children.Add(row); + } + + var filled = new StackPanel { Orientation = global::Avalonia.Layout.Orientation.Horizontal, Spacing = 12 }; + foreach (var name in Enum.GetValues().Where(n => IconData.Get(n).Fill is not null).Take(10)) + filled.Children.Add(new Icon { Kind = name, Size = 48, Foreground = Brushes.RoyalBlue }); + panel.Children.Add(filled); + + using var frame = Capture(panel, 640, 240); + Save(frame, "contact-sheet.png"); + } + + private static WriteableBitmap Capture(Control content, int width, int height) + { + var window = new Window + { + Width = width, + Height = height, + Background = Brushes.White, + Content = content, + }; + window.Show(); + var frame = window.CaptureRenderedFrame() ?? throw new InvalidOperationException("No frame rendered."); + window.Close(); + return frame; + } + + private static byte[] Pixels(WriteableBitmap bitmap) + { + using var buffer = bitmap.Lock(); + Assert.True(buffer.Format == PixelFormat.Bgra8888 || buffer.Format == PixelFormat.Rgba8888, buffer.Format.ToString()); + + var pixels = new byte[buffer.Size.Width * buffer.Size.Height * 4]; + for (var y = 0; y < buffer.Size.Height; y++) + { + System.Runtime.InteropServices.Marshal.Copy( + buffer.Address + y * buffer.RowBytes, pixels, y * buffer.Size.Width * 4, buffer.Size.Width * 4); + } + + // Normalize to BGRA so callers can index channels the same way. + if (buffer.Format == PixelFormat.Rgba8888) + { + for (var i = 0; i < pixels.Length; i += 4) + (pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]); + } + return pixels; + } + + private static bool IsInk(byte[] pixels, int offset) => + pixels[offset] < 128 || pixels[offset + 1] < 128 || pixels[offset + 2] < 128; + + private static int CountInk(byte[] pixels, int stride, int x0, int y0, int width, int height) + { + var count = 0; + for (var y = y0; y < y0 + height; y++) + for (var x = x0; x < x0 + width; x++) + if (IsInk(pixels, (y * stride + x) * 4)) count++; + return count; + } + + private static int Ink(Control icon, int canvas) + { + using var frame = Capture(icon, canvas, canvas); + return CountInk(Pixels(frame), frame.PixelSize.Width, 0, 0, frame.PixelSize.Width, frame.PixelSize.Height); + } + + private static (int Width, int Height) InkBounds(Control icon, int canvas) + { + using var frame = Capture(icon, canvas, canvas); + var pixels = Pixels(frame); + int w = frame.PixelSize.Width, h = frame.PixelSize.Height; + int minX = w, minY = h, maxX = -1, maxY = -1; + + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) + { + if (!IsInk(pixels, (y * w + x) * 4)) continue; + minX = Math.Min(minX, x); maxX = Math.Max(maxX, x); + minY = Math.Min(minY, y); maxY = Math.Max(maxY, y); + } + + return (maxX - minX + 1, maxY - minY + 1); + } + + private static void Save(WriteableBitmap frame, string fileName) + { + var dir = Path.Combine(AppContext.BaseDirectory, "renders"); + Directory.CreateDirectory(dir); + frame.Save(Path.Combine(dir, fileName), PngBitmapEncoderOptions.Default); + } +} diff --git a/tests/ShellIcons.Avalonia.Tests/ShellIcons.Avalonia.Tests.csproj b/tests/ShellIcons.Avalonia.Tests/ShellIcons.Avalonia.Tests.csproj new file mode 100644 index 0000000..37ccf0c --- /dev/null +++ b/tests/ShellIcons.Avalonia.Tests/ShellIcons.Avalonia.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + Exe + false + ShellIcons.Avalonia.Tests + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ShellIcons.Avalonia.Tests/TestApp.cs b/tests/ShellIcons.Avalonia.Tests/TestApp.cs new file mode 100644 index 0000000..34f7673 --- /dev/null +++ b/tests/ShellIcons.Avalonia.Tests/TestApp.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Headless; +using Avalonia.Themes.Fluent; +using ShellIcons.Avalonia.Tests; + +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] + +namespace ShellIcons.Avalonia.Tests; + +public class TestApp : Application +{ + public override void Initialize() => Styles.Add(new FluentTheme()); +} + +public static class TestAppBuilder +{ + // UseHeadlessDrawing = false swaps the no-op renderer for Skia, so captured frames have pixels. + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); +} diff --git a/tests/ShellIcons.Avalonia.Tests/XamlTests.cs b/tests/ShellIcons.Avalonia.Tests/XamlTests.cs new file mode 100644 index 0000000..e9f908a --- /dev/null +++ b/tests/ShellIcons.Avalonia.Tests/XamlTests.cs @@ -0,0 +1,44 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Media; + +namespace ShellIcons.Avalonia.Tests; + +public class XamlTests +{ + // Covers the xmlns, string → IconName conversion, and icons:Image next to Avalonia's Image. + private const string Xaml = """ + + + + + + + """; + + [AvaloniaFact] + public void Xmlns_ResolvesDispatcherAndTypedControls() + { + var panel = AvaloniaRuntimeXamlLoader.Parse(Xaml, typeof(IconView).Assembly); + + var dispatched = Assert.IsType(panel.Children[0]); + Assert.Equal(IconName.Search, dispatched.Kind); + Assert.Equal(IconName.Search, dispatched.IconName); + Assert.Equal(16, dispatched.Size); + Assert.Equal(Colors.Crimson, Assert.IsAssignableFrom(dispatched.Foreground).Color); + + var typed = Assert.IsType(panel.Children[1]); + Assert.Equal(1.5, typed.EffectiveStrokeThickness); + } + + [AvaloniaFact] + public void TypedIconNamedImage_CoexistsWithAvaloniaImage() + { + var panel = AvaloniaRuntimeXamlLoader.Parse(Xaml, typeof(IconView).Assembly); + + Assert.IsType(panel.Children[2]); + Assert.IsType(panel.Children[3]); + } +}