diff --git a/src/ClaudeDo.Ui/Converters/IconKeyConverter.cs b/src/ClaudeDo.Ui/Converters/IconKeyConverter.cs index 8d0d4e34..aa0cb1eb 100644 --- a/src/ClaudeDo.Ui/Converters/IconKeyConverter.cs +++ b/src/ClaudeDo.Ui/Converters/IconKeyConverter.cs @@ -7,14 +7,57 @@ using Material.Icons; namespace ClaudeDo.Ui.Converters; /// -/// Converts an icon key to its geometry: "Sun" → the "Icon.Sun" StreamGeometry resource, -/// "mdi:RocketLaunch" → the Material Design Icons path of that . +/// Converts an icon key to its geometry: "mdi:RocketLaunch" → the Material Design Icons path of that +/// ; a legacy bare key ("Rocket") → its entry; +/// any other bare key → the "Icon.<key>" StreamGeometry resource. /// public class IconKeyConverter : IValueConverter { /// Key prefix marking a Material Design Icons kind. public const string MdiPrefix = "mdi:"; + /// Bare keys stored in the DB by older versions → the MDI kind that replaces their hand-drawn geometry. + public static readonly IReadOnlyDictionary LegacyKinds = new Dictionary + { + ["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 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; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 8e610a61..4ce086fe 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -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); } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index 88437b05..85f64c37 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -157,12 +157,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase /// Default icon for folders, kept apart from so lists never look like folders. 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." 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(); /// Cap on search hits so the picker's WrapPanel stays responsive. 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; - /// True for a curated key or an existing "mdi:" kind. + /// True for a curated key, a legacy bare key still stored in the DB, or an existing "mdi:" kind. 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(key[IconKeyConverter.MdiPrefix.Length..], out _))); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs index bb36e18d..4397903f 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs @@ -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(k[IconKeyConverter.MdiPrefix.Length..]) + : IconKeyConverter.LegacyKinds[k]; + Assert.False(string.IsNullOrEmpty(MaterialIconDataProvider.GetData(kind)), k); + }); + } + [Fact] public void Color_SwatchAndCustomStaySynced() {