feat(ui): searchable Material Design icon library for lists and folders
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user