feat(ui): searchable Material Design icon library for lists and folders
This commit is contained in:
@@ -473,6 +473,8 @@
|
||||
"sectionIcon": "ICON",
|
||||
"icon": "Listen-Icon",
|
||||
"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",
|
||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
||||
"sectionVerify": "VERIFIKATION",
|
||||
|
||||
@@ -473,6 +473,8 @@
|
||||
"sectionIcon": "ICON",
|
||||
"icon": "List icon",
|
||||
"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",
|
||||
"resetAgentSettings": "Reset agent settings",
|
||||
"sectionVerify": "VERIFICATION",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" 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="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||
|
||||
@@ -2,27 +2,49 @@ using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using Material.Icons;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
/// <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>
|
||||
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();
|
||||
|
||||
private static readonly Dictionary<string, Geometry?> MdiCache = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string key || string.IsNullOrEmpty(key))
|
||||
return null;
|
||||
|
||||
if (key.StartsWith(MdiPrefix, StringComparison.Ordinal))
|
||||
return MdiGeometry(key[MdiPrefix.Length..]);
|
||||
|
||||
var resourceKey = $"Icon.{key}";
|
||||
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
|
||||
return res as StreamGeometry;
|
||||
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)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Converters;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Agent;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Material.Icons;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
@@ -132,7 +134,66 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
"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),
|
||||
// scoped to this list (list → global inheritance).
|
||||
@@ -174,7 +235,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
IsFolder = true;
|
||||
CanToggleFolder = false;
|
||||
Name = name;
|
||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultFolderIcon;
|
||||
Icon = IsKnownIcon(icon) ? icon! : DefaultFolderIcon;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(
|
||||
@@ -198,7 +259,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
FindingsTracked = findingsTracked;
|
||||
// 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 = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
||||
Icon = IsKnownIcon(icon) ? icon! : DefaultIcon;
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
|
||||
@@ -143,8 +143,13 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="8" Margin="0,8,0,0">
|
||||
<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}"
|
||||
SelectedItem="{Binding Icon, Mode=TwoWay}"
|
||||
SelectedItem="{Binding SelectedIconOption, Mode=TwoWay}"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
HorizontalAlignment="Left">
|
||||
<ListBox.ItemsPanel>
|
||||
@@ -157,6 +162,7 @@
|
||||
<!-- No Foreground here on purpose: the icon inherits the container's, so the
|
||||
selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). -->
|
||||
<PathIcon Width="20" Height="20" Margin="6"
|
||||
ToolTip.Tip="{Binding}"
|
||||
Data="{Binding Converter={StaticResource IconKey}}"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
|
||||
@@ -74,6 +74,27 @@ public class ListSettingsModalViewModelTests
|
||||
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]
|
||||
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user