feat(ui): searchable Material Design icon library for lists and folders

This commit is contained in:
mika kuns
2026-09-30 09:52:34 +02:00
parent 5da8036b9a
commit f2ff865dca
7 changed files with 120 additions and 5 deletions
@@ -473,6 +473,8 @@
"sectionIcon": "ICON", "sectionIcon": "ICON",
"icon": "Listen-Icon", "icon": "Listen-Icon",
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.", "iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
"iconSearchWatermark": "Alle Icons durchsuchen (Englisch, z.B. rocket, car, bug)",
"iconSearchTruncated": "Die ersten 200 Treffer werden angezeigt — Suche verfeinern.",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen", "resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"sectionVerify": "VERIFIKATION", "sectionVerify": "VERIFIKATION",
@@ -473,6 +473,8 @@
"sectionIcon": "ICON", "sectionIcon": "ICON",
"icon": "List icon", "icon": "List icon",
"iconHint": "Shown next to the list name in the sidebar.", "iconHint": "Shown next to the list name in the sidebar.",
"iconSearchWatermark": "Search all icons (English, e.g. rocket, car, bug)",
"iconSearchTruncated": "Showing the first 200 matches — refine the search.",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings", "resetAgentSettings": "Reset agent settings",
"sectionVerify": "VERIFICATION", "sectionVerify": "VERIFICATION",
+1
View File
@@ -10,6 +10,7 @@
<PackageReference Include="Avalonia" Version="12.0.4" /> <PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" /> <PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" /> <PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
<PackageReference Include="Material.Icons" Version="3.0.2" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" /> <PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
+23 -1
View File
@@ -2,27 +2,49 @@ using System.Globalization;
using Avalonia; using Avalonia;
using Avalonia.Data.Converters; using Avalonia.Data.Converters;
using Avalonia.Media; using Avalonia.Media;
using Material.Icons;
namespace ClaudeDo.Ui.Converters; namespace ClaudeDo.Ui.Converters;
/// <summary> /// <summary>
/// Converts an icon key string (e.g. "Sun") to the matching StreamGeometry resource "Icon.Sun". /// 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"/>.
/// </summary> /// </summary>
public class IconKeyConverter : IValueConverter public class IconKeyConverter : IValueConverter
{ {
/// <summary>Key prefix marking a Material Design Icons kind.</summary>
public const string MdiPrefix = "mdi:";
public static IconKeyConverter Instance { get; } = new(); public static IconKeyConverter Instance { get; } = new();
private static readonly Dictionary<string, Geometry?> MdiCache = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{ {
if (value is not string key || string.IsNullOrEmpty(key)) if (value is not string key || string.IsNullOrEmpty(key))
return null; return null;
if (key.StartsWith(MdiPrefix, StringComparison.Ordinal))
return MdiGeometry(key[MdiPrefix.Length..]);
var resourceKey = $"Icon.{key}"; var resourceKey = $"Icon.{key}";
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true) if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
return res as StreamGeometry; return res as StreamGeometry;
return null; return null;
} }
/// <summary>Parses (and caches) the geometry for an MDI kind name; null for unknown names.</summary>
private static Geometry? MdiGeometry(string name)
{
if (MdiCache.TryGetValue(name, out var cached))
return cached;
var geometry = Enum.TryParse<MaterialIconKind>(name, out var kind)
? StreamGeometry.Parse(MaterialIconDataProvider.GetData(kind))
: null;
MdiCache[name] = geometry;
return geometry;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException(); => throw new NotSupportedException();
} }
@@ -2,12 +2,14 @@ using System.Collections.ObjectModel;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Converters;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent; using ClaudeDo.Ui.ViewModels.Agent;
using ClaudeDo.Ui.ViewModels.Modals.Settings; using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Material.Icons;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals; namespace ClaudeDo.Ui.ViewModels.Modals;
@@ -132,7 +134,66 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull", "Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
]; ];
public ObservableCollection<string> Icons { get; } = new(IconOptions); /// <summary>Cap on search hits so the picker's WrapPanel stays responsive.</summary>
public const int MaxIconResults = 200;
// One entry per distinct MDI icon; the enum carries aliases, all of which stay searchable.
private static readonly Lazy<(string Key, string Names)[]> MdiIndex = new(() =>
Enum.GetNames<MaterialIconKind>()
.GroupBy(Enum.Parse<MaterialIconKind>)
.Select(g => (IconKeyConverter.MdiPrefix + g.First(), string.Join(' ', g)))
.ToArray());
/// <summary>Picker items: the curated set, or MDI search hits once the search has 2+ characters.</summary>
[ObservableProperty] private IReadOnlyList<string> _icons = IconOptions;
/// <summary>Free-text filter over the MDI icon names (English).</summary>
[ObservableProperty] private string _iconSearch = "";
/// <summary>True when the search hit more than <see cref="MaxIconResults"/> icons.</summary>
[ObservableProperty] private bool _iconResultsTruncated;
// 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>
public static bool IsKnownIcon(string? key) =>
key is not null && (IconOptions.Contains(key)
|| (key.StartsWith(IconKeyConverter.MdiPrefix, StringComparison.Ordinal)
&& Enum.TryParse<MaterialIconKind>(key[IconKeyConverter.MdiPrefix.Length..], out _)));
partial void OnIconSearchChanged(string value)
{
var q = value.Trim();
if (q.Length < 2)
{
IconResultsTruncated = false;
// Keep a chosen MDI icon visible (and selected) in the curated view.
Icons = IconOptions.Contains(Icon) ? IconOptions : [Icon, .. IconOptions];
}
else
{
var hits = MdiIndex.Value
.Where(e => e.Names.Contains(q, StringComparison.OrdinalIgnoreCase))
.Select(e => e.Key)
.Take(MaxIconResults + 1)
.ToList();
IconResultsTruncated = hits.Count > MaxIconResults;
Icons = hits.Take(MaxIconResults).ToList();
}
SelectedIconOption = Icons.Contains(Icon) ? Icon : null;
}
partial void OnSelectedIconOptionChanged(string? value)
{
if (value is not null) Icon = value;
}
partial void OnIconChanged(string value)
{
if (!Icons.Contains(value)) OnIconSearchChanged(IconSearch);
else SelectedIconOption = value;
}
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile), // The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
// scoped to this list (list → global inheritance). // scoped to this list (list → global inheritance).
@@ -174,7 +235,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
IsFolder = true; IsFolder = true;
CanToggleFolder = false; CanToggleFolder = false;
Name = name; Name = name;
Icon = IconOptions.Contains(icon) ? icon! : DefaultFolderIcon; Icon = IsKnownIcon(icon) ? icon! : DefaultFolderIcon;
} }
public async Task LoadAsync( public async Task LoadAsync(
@@ -198,7 +259,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
FindingsTracked = findingsTracked; FindingsTracked = findingsTracked;
// An unknown/legacy key would leave the picker with no selection and silently reset the // An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead. // icon on the next save — fall back to the default instead.
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon; Icon = IsKnownIcon(icon) ? icon! : DefaultIcon;
WorkingDir = workingDir ?? ""; WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
@@ -143,8 +143,13 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="8" Margin="0,8,0,0"> <StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.icon}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.icon}"/>
<TextBox Text="{Binding IconSearch}"
Watermark="{loc:Tr modals.listSettings.iconSearchWatermark}"/>
<TextBlock Text="{loc:Tr modals.listSettings.iconSearchTruncated}"
IsVisible="{Binding IconResultsTruncated}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<ListBox ItemsSource="{Binding Icons}" <ListBox ItemsSource="{Binding Icons}"
SelectedItem="{Binding Icon, Mode=TwoWay}" SelectedItem="{Binding SelectedIconOption, Mode=TwoWay}"
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left"> HorizontalAlignment="Left">
<ListBox.ItemsPanel> <ListBox.ItemsPanel>
@@ -157,6 +162,7 @@
<!-- No Foreground here on purpose: the icon inherits the container's, so the <!-- No Foreground here on purpose: the icon inherits the container's, so the
selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). --> selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). -->
<PathIcon Width="20" Height="20" Margin="6" <PathIcon Width="20" Height="20" Margin="6"
ToolTip.Tip="{Binding}"
Data="{Binding Converter={StaticResource IconKey}}"/> Data="{Binding Converter={StaticResource IconKey}}"/>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
@@ -74,6 +74,27 @@ public class ListSettingsModalViewModelTests
Assert.Equal("Star", vm.Icon); Assert.Equal("Star", vm.Icon);
} }
[Fact]
public void IconSearch_FindsMdiIcons_AndClearingKeepsTheChoice()
{
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
vm.IconSearch = "rocket";
Assert.Contains("mdi:RocketLaunch", vm.Icons);
Assert.DoesNotContain("Star", vm.Icons);
vm.SelectedIconOption = "mdi:RocketLaunch";
vm.SelectedIconOption = null; // what the ListBox pushes when Icons is swapped
Assert.Equal("mdi:RocketLaunch", vm.Icon);
vm.IconSearch = "";
Assert.Equal("mdi:RocketLaunch", vm.Icons[0]);
Assert.Equal("mdi:RocketLaunch", vm.SelectedIconOption);
Assert.True(ListSettingsModalViewModel.IsKnownIcon("mdi:RocketLaunch"));
Assert.False(ListSettingsModalViewModel.IsKnownIcon("mdi:NoSuchIcon"));
}
[Fact] [Fact]
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce() public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
{ {