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]);
+ }
+}