refactor(ui): Material icons for list, folder and smart-list icon keys with legacy mapping

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-10-01 16:50:31 +02:00
co-authored by Claude Sonnet 5.5
parent aef41f0594
commit 7c0fe92a3e
4 changed files with 93 additions and 18 deletions
+48 -2
View File
@@ -7,14 +7,57 @@ using Material.Icons;
namespace ClaudeDo.Ui.Converters;
/// <summary>
/// Converts an icon key to its geometry: "Sun" → the "Icon.Sun" StreamGeometry resource,
/// "mdi:RocketLaunch" → the Material Design Icons path of that <see cref="MaterialIconKind"/>.
/// Converts an icon key to its geometry: "mdi:RocketLaunch" → the Material Design Icons path of that
/// <see cref="MaterialIconKind"/>; a legacy bare key ("Rocket") → its <see cref="LegacyKinds"/> entry;
/// any other bare key → the "Icon.&lt;key&gt;" StreamGeometry resource.
/// </summary>
public class IconKeyConverter : IValueConverter
{
/// <summary>Key prefix marking a Material Design Icons kind.</summary>
public const string MdiPrefix = "mdi:";
/// <summary>Bare keys stored in the DB by older versions → the MDI kind that replaces their hand-drawn geometry.</summary>
public static readonly IReadOnlyDictionary<string, MaterialIconKind> LegacyKinds = new Dictionary<string, MaterialIconKind>
{
["Dot"] = MaterialIconKind.CircleMedium,
["Folder"] = MaterialIconKind.FolderOutline,
["Star"] = MaterialIconKind.StarOutline,
["Sun"] = MaterialIconKind.WeatherSunny,
["Calendar"] = MaterialIconKind.CalendarOutline,
["Clock"] = MaterialIconKind.ClockOutline,
["Inbox"] = MaterialIconKind.InboxOutline,
["Mail"] = MaterialIconKind.EmailOutline,
["Text"] = MaterialIconKind.NoteTextOutline,
["Search"] = MaterialIconKind.Magnify,
["Grid"] = MaterialIconKind.ViewGridOutline,
["Code"] = MaterialIconKind.CodeTags,
["Terminal"] = MaterialIconKind.Console,
["Database"] = MaterialIconKind.DatabaseOutline,
["Server"] = MaterialIconKind.ServerOutline,
["Chip"] = MaterialIconKind.Chip,
["Bolt"] = MaterialIconKind.LightningBoltOutline,
["Wand"] = MaterialIconKind.AutoFix,
["Flask"] = MaterialIconKind.FlaskOutline,
["Shield"] = MaterialIconKind.ShieldOutline,
["Activity"] = MaterialIconKind.Pulse,
["Eye"] = MaterialIconKind.EyeOutline,
["Check"] = MaterialIconKind.CheckCircleOutline,
["Warning"] = MaterialIconKind.AlertOutline,
["Chart"] = MaterialIconKind.ChartLineVariant,
["Rocket"] = MaterialIconKind.RocketLaunchOutline,
["Bulb"] = MaterialIconKind.LightbulbOutline,
["Broom"] = MaterialIconKind.Broom,
["AgentSuggested"] = MaterialIconKind.CreationOutline,
["Settings"] = MaterialIconKind.Cog,
["Box"] = MaterialIconKind.PackageVariantClosed,
["Truck"] = MaterialIconKind.TruckOutline,
["MapPin"] = MaterialIconKind.MapMarkerOutline,
["Camera"] = MaterialIconKind.CameraOutline,
["Image"] = MaterialIconKind.ImageOutline,
["Barcode"] = MaterialIconKind.Barcode,
["Skull"] = MaterialIconKind.SkullOutline,
};
public static IconKeyConverter Instance { get; } = new();
private static readonly Dictionary<string, Geometry?> MdiCache = new();
@@ -27,6 +70,9 @@ public class IconKeyConverter : IValueConverter
if (key.StartsWith(MdiPrefix, StringComparison.Ordinal))
return MdiGeometry(key[MdiPrefix.Length..]);
if (LegacyKinds.TryGetValue(key, out var legacy))
return MdiGeometry(legacy.ToString());
var resourceKey = $"Icon.{key}";
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
return res as StreamGeometry;
@@ -315,13 +315,13 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var smart = new[]
{
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" },
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" },
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" },
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "mdi:WeatherSunny" },
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "mdi:NoteTextOutline" },
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "mdi:StarOutline" },
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "mdi:CalendarOutline" },
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "mdi:InboxOutline" },
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "mdi:Pulse" },
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "mdi:EyeOutline" },
};
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
@@ -157,12 +157,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
public const string DefaultFolderIcon = "Folder";
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
// Grouped by theme so the picker grid reads in rows; every entry needs a matching
// "Icon.<key>" StreamGeometry or it renders as an empty slot.
public static readonly string[] IconOptions =
[
// Curated picker set, grouped by theme so the grid reads in rows. Each legacy key is offered as its
// MDI replacement (IconKeyConverter.LegacyKinds); "Dot" and "Folder" stay bare because they are the
// default-icon constants the sidebar compares against.
public static readonly string[] IconOptions = new[]
{
// general
"Dot", "Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
// dev
@@ -171,7 +170,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
"Activity", "Eye", "Check", "Warning", "Chart", "Rocket", "Bulb", "Broom", "AgentSuggested", "Settings",
// things
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
];
}.Select(k => k is DefaultIcon or DefaultFolderIcon ? k : IconKeyConverter.MdiPrefix + IconKeyConverter.LegacyKinds[k]).ToArray();
/// <summary>Cap on search hits so the picker's WrapPanel stays responsive.</summary>
public const int MaxIconResults = 200;
@@ -195,9 +194,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Separate from Icon: swapping Icons makes the ListBox push null here, which must not wipe the choice.
[ObservableProperty] private string? _selectedIconOption = DefaultIcon;
/// <summary>True for a curated key or an existing "mdi:" kind.</summary>
/// <summary>True for a curated key, a legacy bare key still stored in the DB, or an existing "mdi:" kind.</summary>
public static bool IsKnownIcon(string? key) =>
key is not null && (IconOptions.Contains(key)
|| IconKeyConverter.LegacyKinds.ContainsKey(key)
|| (key.StartsWith(IconKeyConverter.MdiPrefix, StringComparison.Ordinal)
&& Enum.TryParse<MaterialIconKind>(key[IconKeyConverter.MdiPrefix.Length..], out _)));
@@ -1,6 +1,8 @@
using ClaudeDo.Data;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Converters;
using ClaudeDo.Ui.Localization;
using Material.Icons;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
@@ -88,7 +90,7 @@ public class ListSettingsModalViewModelTests
vm.IconSearch = "rocket";
Assert.Contains("mdi:RocketLaunch", vm.Icons);
Assert.DoesNotContain("Star", vm.Icons);
Assert.DoesNotContain("mdi:StarOutline", vm.Icons);
vm.SelectedIconOption = "mdi:RocketLaunch";
vm.SelectedIconOption = null; // what the ListBox pushes when Icons is swapped
@@ -102,6 +104,33 @@ public class ListSettingsModalViewModelTests
Assert.False(ListSettingsModalViewModel.IsKnownIcon("mdi:NoSuchIcon"));
}
[Fact]
public void IsKnownIcon_AcceptsEveryLegacyBareKeyAndCuratedKey()
{
Assert.True(ListSettingsModalViewModel.IsKnownIcon("Rocket"));
Assert.All(IconKeyConverter.LegacyKinds.Keys, k => Assert.True(ListSettingsModalViewModel.IsKnownIcon(k), k));
Assert.All(ListSettingsModalViewModel.IconOptions, k => Assert.True(ListSettingsModalViewModel.IsKnownIcon(k), k));
Assert.False(ListSettingsModalViewModel.IsKnownIcon("NoSuchBareKey"));
}
[Fact]
public void IconKeys_ResolveToMdiPathData()
{
// StreamGeometry.Parse (the converter's last step) needs a render interface, i.e. a bootstrapped
// Avalonia app, so this checks the step before it: key -> MaterialIconKind -> non-empty path data.
var keys = ListSettingsModalViewModel.IconOptions
.Concat(IconKeyConverter.LegacyKinds.Keys)
.Concat(["mdi:WeatherSunny", "mdi:NoteTextOutline", "mdi:StarOutline", "mdi:CalendarOutline",
"mdi:InboxOutline", "mdi:Pulse", "mdi:EyeOutline"]);
Assert.All(keys, k =>
{
var kind = k.StartsWith(IconKeyConverter.MdiPrefix, StringComparison.Ordinal)
? Enum.Parse<MaterialIconKind>(k[IconKeyConverter.MdiPrefix.Length..])
: IconKeyConverter.LegacyKinds[k];
Assert.False(string.IsNullOrEmpty(MaterialIconDataProvider.GetData(kind)), k);
});
}
[Fact]
public void Color_SwatchAndCustomStaySynced()
{