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:
co-authored by
Claude Sonnet 5.5
parent
aef41f0594
commit
7c0fe92a3e
@@ -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.<key>" 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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user