diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61f65d5..c1642f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Icon picker for tag icons.** The Tags view now has a **Choose…** button beside
+ each tag that opens a searchable grid of icons to pick from, so you no longer
+ have to know a Segoe Fluent Icons code point. Search filters by name, keyword or
+ code point, and the free-text field remains for pasting a glyph directly. The
+ catalogue is a curated set in `appsettings.json` beside the app — edit that file
+ to add or remove icons, and the picker reflects the change the next time it opens.
+
### Changed
- **Home, navigation and shared-parameters polish.** The Home page leads with a
full-bleed hero banner (drop `Assets/HomeHero.png` to supply the image), the
diff --git a/src/Snipdeck.App/App.xaml b/src/Snipdeck.App/App.xaml
index fc25777..1c15dc1 100644
--- a/src/Snipdeck.App/App.xaml
+++ b/src/Snipdeck.App/App.xaml
@@ -56,6 +56,7 @@
+
diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs
index ac1f450..d2a2a1d 100644
--- a/src/Snipdeck.App/Bootstrap.cs
+++ b/src/Snipdeck.App/Bootstrap.cs
@@ -49,6 +49,7 @@ public static IServiceProvider Build()
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton(settingsStore)
diff --git a/src/Snipdeck.App/Services/GlyphCatalogueProvider.cs b/src/Snipdeck.App/Services/GlyphCatalogueProvider.cs
new file mode 100644
index 0000000..0f8952f
--- /dev/null
+++ b/src/Snipdeck.App/Services/GlyphCatalogueProvider.cs
@@ -0,0 +1,87 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Snipdeck.Core.Abstractions;
+using Snipdeck.Core.Models;
+using Snipdeck.Core.ViewModels;
+
+namespace Snipdeck.App.Services
+{
+ ///
+ /// Reads the curated glyph catalogue from appsettings.json (next to the
+ /// executable). Re-reads on every call so editing the file is reflected the
+ /// next time the picker opens. Degrades to an empty catalogue — never throws —
+ /// when the file is missing or malformed, so a bad edit can't break the picker.
+ ///
+ internal sealed class GlyphCatalogueProvider : IGlyphCatalogueProvider
+ {
+ private readonly string _path;
+
+ public GlyphCatalogueProvider()
+ {
+ _path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
+ }
+
+ public IReadOnlyList GetEntries()
+ {
+ GlyphCatalogueFile? file;
+ try
+ {
+ if (!File.Exists(_path))
+ {
+ return [];
+ }
+
+ using var stream = File.OpenRead(_path);
+ file = JsonSerializer.Deserialize(stream, GlyphCatalogueJsonContext.Default.GlyphCatalogueFile);
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
+ {
+ return [];
+ }
+
+ if (file?.GlyphCatalogue is not { Count: > 0 } raw)
+ {
+ return [];
+ }
+
+ var entries = new List(raw.Count);
+ foreach (var entry in raw)
+ {
+ // A code point that won't resolve to a glyph (or a row with no
+ // name) is skipped rather than rendered as a blank cell.
+ var glyph = GlyphInput.Resolve(entry.Code);
+ if (glyph.Length == 0 || string.IsNullOrWhiteSpace(entry.Name))
+ {
+ continue;
+ }
+
+ entries.Add(new GlyphCatalogueEntry(glyph, entry.Name.Trim(), entry.Keywords ?? []));
+ }
+
+ return entries;
+ }
+ }
+
+ /// The appsettings.json shape the catalogue is read from.
+ internal sealed class GlyphCatalogueFile
+ {
+ public List? GlyphCatalogue { get; set; }
+ }
+
+ /// One raw catalogue row before the code point is resolved to a glyph.
+ internal sealed class GlyphCatalogueFileEntry
+ {
+ public string? Code { get; set; }
+
+ public string? Name { get; set; }
+
+ public List? Keywords { get; set; }
+ }
+
+ [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+ [JsonSerializable(typeof(GlyphCatalogueFile))]
+ internal sealed partial class GlyphCatalogueJsonContext : JsonSerializerContext
+ {
+ }
+}
diff --git a/src/Snipdeck.App/Services/WindowsShellInteractions.cs b/src/Snipdeck.App/Services/WindowsShellInteractions.cs
index 1104470..6bd71a0 100644
--- a/src/Snipdeck.App/Services/WindowsShellInteractions.cs
+++ b/src/Snipdeck.App/Services/WindowsShellInteractions.cs
@@ -18,18 +18,22 @@ internal sealed class WindowsShellInteractions : IShellInteractions
private readonly IServiceProvider _services;
private readonly IIconNormaliser _iconNormaliser;
private readonly IFilePickerService _filePicker;
+ private readonly IGlyphCatalogueProvider _glyphCatalogue;
public WindowsShellInteractions(
IServiceProvider services,
IIconNormaliser iconNormaliser,
- IFilePickerService filePicker)
+ IFilePickerService filePicker,
+ IGlyphCatalogueProvider glyphCatalogue)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(iconNormaliser);
ArgumentNullException.ThrowIfNull(filePicker);
+ ArgumentNullException.ThrowIfNull(glyphCatalogue);
_services = services;
_iconNormaliser = iconNormaliser;
_filePicker = filePicker;
+ _glyphCatalogue = glyphCatalogue;
}
public async Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel", bool destructive = false)
@@ -127,6 +131,22 @@ public async Task NotifyAsync(string title, string message, string buttonText =
: null;
}
+ public async Task PickGlyphAsync(string? currentGlyph)
+ {
+ // Re-read the catalogue each open, so edits to appsettings.json take
+ // effect without a restart.
+ var picker = new GlyphPickerViewModel(_glyphCatalogue.GetEntries(), currentGlyph);
+ var dialog = new GlyphPickerDialog(picker)
+ {
+ XamlRoot = GetXamlRoot(),
+ RequestedTheme = CurrentTheme(),
+ };
+ _ = await dialog.ShowAsync();
+ // The dialog records the chosen glyph itself (Choose button or
+ // double-tap); a cancel leaves it null.
+ return dialog.ChosenGlyph;
+ }
+
private XamlRoot GetXamlRoot()
{
var mainWindow = (MainWindow)_services.GetService(typeof(MainWindow))!;
diff --git a/src/Snipdeck.App/Snipdeck.App.csproj b/src/Snipdeck.App/Snipdeck.App.csproj
index 9e2c95c..0e4ccad 100644
--- a/src/Snipdeck.App/Snipdeck.App.csproj
+++ b/src/Snipdeck.App/Snipdeck.App.csproj
@@ -28,6 +28,12 @@
+
+
+
+
+
diff --git a/src/Snipdeck.App/Views/GlyphPickerDialog.xaml b/src/Snipdeck.App/Views/GlyphPickerDialog.xaml
new file mode 100644
index 0000000..ca13ecc
--- /dev/null
+++ b/src/Snipdeck.App/Views/GlyphPickerDialog.xaml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Snipdeck.App/Views/GlyphPickerDialog.xaml.cs b/src/Snipdeck.App/Views/GlyphPickerDialog.xaml.cs
new file mode 100644
index 0000000..fff39a9
--- /dev/null
+++ b/src/Snipdeck.App/Views/GlyphPickerDialog.xaml.cs
@@ -0,0 +1,66 @@
+using System.ComponentModel;
+
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+
+using Snipdeck.Core.ViewModels;
+
+namespace Snipdeck.App.Views
+{
+ ///
+ /// A searchable grid of the curated glyph catalogue. The user filters and
+ /// picks an icon; holds the result (the resolved
+ /// glyph character) or stays null when cancelled. Double-tapping a glyph
+ /// confirms the same as the Choose button.
+ ///
+ public sealed partial class GlyphPickerDialog : ContentDialog
+ {
+ public GlyphPickerDialog(GlyphPickerViewModel viewModel)
+ {
+ ArgumentNullException.ThrowIfNull(viewModel);
+ ViewModel = viewModel;
+ InitializeComponent();
+
+ // Choose is meaningless without a selection; keep it disabled until
+ // one exists, and track changes as the search clears the selection.
+ IsPrimaryButtonEnabled = viewModel.SelectedEntry is not null;
+ viewModel.PropertyChanged += OnViewModelPropertyChanged;
+ }
+
+ public GlyphPickerViewModel ViewModel { get; }
+
+ /// The glyph the user chose, or null if they cancelled.
+ public string? ChosenGlyph { get; private set; }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(GlyphPickerViewModel.SelectedEntry))
+ {
+ IsPrimaryButtonEnabled = ViewModel.SelectedEntry is not null;
+ }
+ }
+
+ private void OnChooseClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ if (ViewModel.SelectedEntry is null)
+ {
+ // Nothing selected: keep the dialog open rather than returning blank.
+ args.Cancel = true;
+ return;
+ }
+
+ ChosenGlyph = ViewModel.SelectedGlyph;
+ }
+
+ private void OnGlyphDoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+ {
+ if (ViewModel.SelectedEntry is null)
+ {
+ return;
+ }
+
+ ChosenGlyph = ViewModel.SelectedGlyph;
+ Hide();
+ }
+ }
+}
diff --git a/src/Snipdeck.App/Views/ShellPage.xaml b/src/Snipdeck.App/Views/ShellPage.xaml
index d665b3c..fc9a866 100644
--- a/src/Snipdeck.App/Views/ShellPage.xaml
+++ b/src/Snipdeck.App/Views/ShellPage.xaml
@@ -564,7 +564,7 @@
-
@@ -600,6 +600,7 @@
+
-
+
diff --git a/src/Snipdeck.App/appsettings.json b/src/Snipdeck.App/appsettings.json
new file mode 100644
index 0000000..20ec22d
--- /dev/null
+++ b/src/Snipdeck.App/appsettings.json
@@ -0,0 +1,55 @@
+{
+ "$comment": "Curated glyph catalogue for the icon picker. Each entry is a Segoe Fluent Icons glyph: 'code' is the hex code point, 'name' is shown and searched, 'keywords' add extra search terms. Edit this file to add or remove glyphs; the picker re-reads it each time it opens, no rebuild needed. The free-text icon field remains available for anything not listed here.",
+ "glyphCatalogue": [
+ { "code": "E80F", "name": "Home", "keywords": [ "house", "dashboard", "start" ] },
+ { "code": "E713", "name": "Settings", "keywords": [ "gear", "cog", "config", "preferences", "options" ] },
+ { "code": "E74E", "name": "Save", "keywords": [ "disk", "store", "floppy" ] },
+ { "code": "E74D", "name": "Delete", "keywords": [ "trash", "bin", "remove", "discard" ] },
+ { "code": "E70F", "name": "Edit", "keywords": [ "pencil", "modify", "change", "write" ] },
+ { "code": "E710", "name": "Add", "keywords": [ "plus", "new", "create" ] },
+ { "code": "E711", "name": "Cancel", "keywords": [ "close", "cross", "dismiss", "remove" ] },
+ { "code": "E8FB", "name": "Accept", "keywords": [ "check", "tick", "done", "confirm", "ok" ] },
+ { "code": "E721", "name": "Search", "keywords": [ "find", "magnify", "lookup", "query" ] },
+ { "code": "E72C", "name": "Refresh", "keywords": [ "reload", "sync", "update", "retry" ] },
+ { "code": "E8B7", "name": "Folder", "keywords": [ "directory", "files", "path" ] },
+ { "code": "E8E5", "name": "Open file", "keywords": [ "open", "document", "load" ] },
+ { "code": "E8A5", "name": "Document", "keywords": [ "file", "page", "text", "doc" ] },
+ { "code": "E8C8", "name": "Copy", "keywords": [ "duplicate", "clipboard", "clone" ] },
+ { "code": "E715", "name": "Mail", "keywords": [ "email", "envelope", "message", "inbox" ] },
+ { "code": "E724", "name": "Send", "keywords": [ "submit", "forward", "deliver" ] },
+ { "code": "E774", "name": "Globe", "keywords": [ "world", "web", "internet", "network", "online" ] },
+ { "code": "E753", "name": "Cloud", "keywords": [ "remote", "storage", "sky", "hosted" ] },
+ { "code": "E896", "name": "Download", "keywords": [ "pull", "save", "fetch", "arrow down" ] },
+ { "code": "E898", "name": "Upload", "keywords": [ "push", "publish", "arrow up" ] },
+ { "code": "E768", "name": "Play", "keywords": [ "run", "start", "execute", "go" ] },
+ { "code": "E769", "name": "Pause", "keywords": [ "hold", "suspend", "wait" ] },
+ { "code": "E71A", "name": "Stop", "keywords": [ "halt", "end", "kill", "cancel" ] },
+ { "code": "E787", "name": "Calendar", "keywords": [ "date", "schedule", "event", "day" ] },
+ { "code": "E917", "name": "Clock", "keywords": [ "time", "history", "recent", "timer" ] },
+ { "code": "E72E", "name": "Lock", "keywords": [ "secure", "private", "locked", "protected" ] },
+ { "code": "E785", "name": "Unlock", "keywords": [ "open", "unsecure", "unlocked" ] },
+ { "code": "E716", "name": "People", "keywords": [ "users", "group", "team", "members" ] },
+ { "code": "E77B", "name": "Contact", "keywords": [ "person", "user", "account", "profile" ] },
+ { "code": "E8EC", "name": "Tag", "keywords": [ "label", "category", "default" ] },
+ { "code": "E718", "name": "Pin", "keywords": [ "pushpin", "attach", "stick" ] },
+ { "code": "E734", "name": "Favourite", "keywords": [ "star", "favorite", "bookmark", "important" ] },
+ { "code": "EA18", "name": "Shield", "keywords": [ "security", "protect", "safe", "defend", "guard" ] },
+ { "code": "E7C1", "name": "Flag", "keywords": [ "mark", "report", "milestone" ] },
+ { "code": "E71C", "name": "Filter", "keywords": [ "funnel", "refine", "narrow" ] },
+ { "code": "E8CB", "name": "Sort", "keywords": [ "order", "arrange", "rank" ] },
+ { "code": "E72D", "name": "Share", "keywords": [ "distribute", "export", "send" ] },
+ { "code": "E71B", "name": "Link", "keywords": [ "url", "chain", "hyperlink", "connect" ] },
+ { "code": "E749", "name": "Print", "keywords": [ "printer", "paper" ] },
+ { "code": "E722", "name": "Camera", "keywords": [ "photo", "snapshot", "capture" ] },
+ { "code": "E8B9", "name": "Picture", "keywords": [ "image", "photo", "gallery", "media" ] },
+ { "code": "EBE8", "name": "Bug", "keywords": [ "issue", "defect", "error", "debug" ] },
+ { "code": "E7BA", "name": "Warning", "keywords": [ "alert", "caution", "attention", "exclamation" ] },
+ { "code": "E783", "name": "Error", "keywords": [ "important", "alert", "danger", "critical" ] },
+ { "code": "E946", "name": "Info", "keywords": [ "information", "about", "details", "note" ] },
+ { "code": "E897", "name": "Help", "keywords": [ "question", "support", "faq", "assist" ] },
+ { "code": "EA80", "name": "Lightbulb", "keywords": [ "idea", "tip", "hint", "suggestion" ] },
+ { "code": "E756", "name": "Terminal", "keywords": [ "command", "prompt", "shell", "console", "cli" ] },
+ { "code": "E943", "name": "Code", "keywords": [ "script", "source", "developer", "programming" ] },
+ { "code": "E90F", "name": "Repair", "keywords": [ "wrench", "tools", "fix", "maintenance", "spanner" ] }
+ ]
+}
diff --git a/src/Snipdeck.Core/Abstractions/IGlyphCatalogueProvider.cs b/src/Snipdeck.Core/Abstractions/IGlyphCatalogueProvider.cs
new file mode 100644
index 0000000..bb22837
--- /dev/null
+++ b/src/Snipdeck.Core/Abstractions/IGlyphCatalogueProvider.cs
@@ -0,0 +1,20 @@
+using Snipdeck.Core.Models;
+
+namespace Snipdeck.Core.Abstractions
+{
+ ///
+ /// Supplies the glyph picker's browsable catalogue. The implementation lives
+ /// in the App project and reads the user-editable appsettings.json, so editing
+ /// that file (adding or removing glyphs) is reflected the next time the picker
+ /// opens — no rebuild required.
+ ///
+ public interface IGlyphCatalogueProvider
+ {
+ ///
+ /// Returns the current catalogue. Re-read on each call so edits to the
+ /// backing file take effect without a restart. Never null; returns an
+ /// empty list when the file is missing or malformed.
+ ///
+ IReadOnlyList GetEntries();
+ }
+}
diff --git a/src/Snipdeck.Core/Abstractions/IShellInteractions.cs b/src/Snipdeck.Core/Abstractions/IShellInteractions.cs
index ed985e0..ed94631 100644
--- a/src/Snipdeck.Core/Abstractions/IShellInteractions.cs
+++ b/src/Snipdeck.Core/Abstractions/IShellInteractions.cs
@@ -33,6 +33,13 @@ Task NotifyAsync(
Task EditParameterAsync(string title, Parameter? existing);
Task FillParametersAsync(Snip snip, IReadOnlyList parameters);
+
+ ///
+ /// Opens the glyph picker so the user can browse and choose an icon.
+ /// Pass to pre-select the glyph in effect.
+ /// Returns the chosen glyph character, or null if the user cancelled.
+ ///
+ Task PickGlyphAsync(string? currentGlyph);
}
public sealed record SnipEditResult(Snip Snip);
diff --git a/src/Snipdeck.Core/Models/GlyphCatalogueEntry.cs b/src/Snipdeck.Core/Models/GlyphCatalogueEntry.cs
new file mode 100644
index 0000000..72d12e6
--- /dev/null
+++ b/src/Snipdeck.Core/Models/GlyphCatalogueEntry.cs
@@ -0,0 +1,47 @@
+using System.Globalization;
+
+namespace Snipdeck.Core.Models
+{
+ ///
+ /// One entry in the glyph picker's browsable catalogue: a Segoe Fluent Icons
+ /// glyph, a friendly name, and optional search keywords. The catalogue is a
+ /// curated, user-editable subset (see the App's appsettings.json) — not the
+ /// full ~1.5k font, which would need heavier virtualisation to stay usable.
+ ///
+ /// The resolved glyph character to render (e.g. "").
+ /// The friendly display name, e.g. "Home".
+ /// Extra terms the search matches beyond the name.
+ public sealed record GlyphCatalogueEntry(string Glyph, string Name, IReadOnlyList Keywords)
+ {
+ /// True when matches the name, a keyword,
+ /// or the glyph's code point (e.g. "e80f"). Blank matches everything.
+ public bool Matches(string term)
+ {
+ if (string.IsNullOrWhiteSpace(term))
+ {
+ return true;
+ }
+
+ var needle = term.Trim();
+ if (Name.Contains(needle, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ foreach (var keyword in Keywords)
+ {
+ if (keyword.Contains(needle, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return CodePoint.Contains(needle, StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// The glyph's Unicode code point as hex (e.g. "E80F"), for display
+ /// and search. Empty when the glyph isn't a single code point.
+ public string CodePoint =>
+ Glyph.Length == 0 ? string.Empty : char.ConvertToUtf32(Glyph, 0).ToString("X4", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/src/Snipdeck.Core/ViewModels/GlyphPickerViewModel.cs b/src/Snipdeck.Core/ViewModels/GlyphPickerViewModel.cs
new file mode 100644
index 0000000..8577d0d
--- /dev/null
+++ b/src/Snipdeck.Core/ViewModels/GlyphPickerViewModel.cs
@@ -0,0 +1,74 @@
+using System.Collections.ObjectModel;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+
+using Snipdeck.Core.Models;
+
+namespace Snipdeck.Core.ViewModels
+{
+ ///
+ /// Backs the glyph picker: a searchable view over the curated catalogue. The
+ /// user filters by name, keyword or code point and selects a glyph; the dialog
+ /// reads on confirm.
+ ///
+ public sealed partial class GlyphPickerViewModel : ObservableObject
+ {
+ private readonly List _all;
+
+ public GlyphPickerViewModel(IEnumerable entries, string? currentGlyph = null)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+
+ _all = [.. entries.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)];
+ Results = new ObservableCollection(_all);
+
+ // Pre-select the row matching the tag's current glyph, so opening the
+ // picker on an already-iconed tag highlights what's in effect.
+ var resolved = GlyphInput.Resolve(currentGlyph);
+ if (resolved.Length != 0)
+ {
+ SelectedEntry = _all.FirstOrDefault(e => e.Glyph == resolved);
+ }
+ }
+
+ public ObservableCollection Results { get; }
+
+ /// The free-text filter over name, keywords and code point.
+ [ObservableProperty]
+ public partial string SearchText { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial GlyphCatalogueEntry? SelectedEntry { get; set; }
+
+ public bool IsEmpty => _all.Count == 0;
+
+ public bool HasNoResults => Results.Count == 0 && _all.Count != 0;
+
+ /// The chosen glyph character, or empty when nothing is selected.
+ public string SelectedGlyph => SelectedEntry?.Glyph ?? string.Empty;
+
+ partial void OnSearchTextChanged(string value)
+ {
+ var matches = _all.Where(e => e.Matches(value)).ToList();
+
+ // Rebuild in place so the bound GridView animates rather than resets.
+ Results.Clear();
+ foreach (var entry in matches)
+ {
+ Results.Add(entry);
+ }
+
+ // Drop a selection that's been filtered out, so confirm can't return a
+ // glyph the user can no longer see.
+ if (SelectedEntry is not null && !matches.Contains(SelectedEntry))
+ {
+ SelectedEntry = null;
+ }
+
+ OnPropertyChanged(nameof(HasNoResults));
+ }
+
+ partial void OnSelectedEntryChanged(GlyphCatalogueEntry? value) =>
+ OnPropertyChanged(nameof(SelectedGlyph));
+ }
+}
diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs
index c3fd238..b8938ae 100644
--- a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs
@@ -241,7 +241,7 @@ private async Task DeleteSharedParameterAsync(ParameterDisplayViewModel? row)
public void OpenTagIcons()
{
- CurrentContent = new TagIconsViewModel(SnipFilter.DistinctTagsFor(_document.Snips), _document.TagIcons);
+ CurrentContent = new TagIconsViewModel(SnipFilter.DistinctTagsFor(_document.Snips), _document.TagIcons, _interactions);
}
[RelayCommand]
diff --git a/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs b/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs
index 37368ef..5fa84c6 100644
--- a/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs
@@ -1,11 +1,14 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+using Snipdeck.Core.Abstractions;
namespace Snipdeck.Core.ViewModels
{
/// One editable row in the "Tags" management view: a tag and its icon glyph.
- public sealed partial class TagIconRowViewModel(string tagName, string glyph) : ObservableObject
+ public sealed partial class TagIconRowViewModel(string tagName, string glyph, IShellInteractions? interactions = null) : ObservableObject
{
public string TagName { get; } = tagName;
@@ -27,6 +30,26 @@ public string PreviewGlyph
}
partial void OnGlyphChanged(string value) => OnPropertyChanged(nameof(PreviewGlyph));
+
+ ///
+ /// Opens the glyph picker and, if the user chooses, stores the picked
+ /// glyph. Picking persists the resolved character directly, so it reads
+ /// back identically through .
+ ///
+ [RelayCommand]
+ private async Task ChooseGlyphAsync()
+ {
+ if (interactions is null)
+ {
+ return;
+ }
+
+ var picked = await interactions.PickGlyphAsync(Glyph).ConfigureAwait(true);
+ if (picked is not null)
+ {
+ Glyph = picked;
+ }
+ }
}
///
@@ -36,7 +59,10 @@ public string PreviewGlyph
///
public sealed partial class TagIconsViewModel : ObservableObject
{
- public TagIconsViewModel(IEnumerable tagNames, IReadOnlyDictionary tagIcons)
+ public TagIconsViewModel(
+ IEnumerable tagNames,
+ IReadOnlyDictionary tagIcons,
+ IShellInteractions? interactions = null)
{
ArgumentNullException.ThrowIfNull(tagNames);
ArgumentNullException.ThrowIfNull(tagIcons);
@@ -48,7 +74,7 @@ public TagIconsViewModel(IEnumerable tagNames, IReadOnlyDictionary t, StringComparer.OrdinalIgnoreCase)
- .Select(t => new TagIconRowViewModel(t, tagIcons.TryGetValue(t, out var g) ? g : string.Empty)));
+ .Select(t => new TagIconRowViewModel(t, tagIcons.TryGetValue(t, out var g) ? g : string.Empty, interactions)));
}
public ObservableCollection Rows { get; }
diff --git a/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs b/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs
index 9752b28..9e1673d 100644
--- a/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs
+++ b/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs
@@ -20,6 +20,12 @@ public sealed class FakeShellInteractions : IShellInteractions
public Parameter? NextEditParameterResult { get; set; }
+ public string? NextPickGlyphResult { get; set; }
+
+ public string? LastPickGlyphCurrent { get; private set; }
+
+ public int PickGlyphCount { get; private set; }
+
public string? LastEditParameterTitle { get; private set; }
public Parameter? LastEditParameterExisting { get; private set; }
@@ -82,5 +88,12 @@ public Task NotifyAsync(string title, string message, string buttonText = "OK")
LastFilledParameters = parameters;
return Task.FromResult(NextParameterFillResult);
}
+
+ public Task PickGlyphAsync(string? currentGlyph)
+ {
+ LastPickGlyphCurrent = currentGlyph;
+ PickGlyphCount++;
+ return Task.FromResult(NextPickGlyphResult);
+ }
}
}
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/GlyphPickerViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/GlyphPickerViewModelTests.cs
new file mode 100644
index 0000000..747ac50
--- /dev/null
+++ b/tests/Snipdeck.Core.Tests/ViewModels/GlyphPickerViewModelTests.cs
@@ -0,0 +1,114 @@
+using Snipdeck.Core.Models;
+using Snipdeck.Core.ViewModels;
+
+namespace Snipdeck.Core.Tests.ViewModels
+{
+ public class GlyphPickerViewModelTests
+ {
+ // Glyphs built from their code points so the source stays ASCII; these
+ // mirror the real Segoe Fluent Icons Home/Settings/Folder glyphs.
+ private static string Glyph(int codePoint) => char.ConvertFromUtf32(codePoint);
+
+ private static IReadOnlyList SampleCatalogue() =>
+ [
+ new(Glyph(0xE80F), "Home", ["house", "dashboard"]),
+ new(Glyph(0xE713), "Settings", ["gear", "config"]),
+ new(Glyph(0xE8B7), "Folder", ["directory", "files"]),
+ ];
+
+ [Fact]
+ public void Entries_are_sorted_by_name_and_shown_in_full_initially()
+ {
+ var vm = new GlyphPickerViewModel(SampleCatalogue());
+
+ Assert.Equal(["Folder", "Home", "Settings"], vm.Results.Select(e => e.Name));
+ }
+
+ [Fact]
+ public void Search_matches_name_case_insensitively()
+ {
+ var vm = new GlyphPickerViewModel(SampleCatalogue())
+ {
+ SearchText = "fold",
+ };
+
+ Assert.Equal(["Folder"], vm.Results.Select(e => e.Name));
+ Assert.False(vm.HasNoResults);
+ }
+
+ [Fact]
+ public void Search_matches_keywords()
+ {
+ var vm = new GlyphPickerViewModel(SampleCatalogue())
+ {
+ SearchText = "gear",
+ };
+
+ Assert.Equal(["Settings"], vm.Results.Select(e => e.Name));
+ }
+
+ [Fact]
+ public void Search_matches_code_point()
+ {
+ var vm = new GlyphPickerViewModel(SampleCatalogue())
+ {
+ SearchText = "e80f",
+ };
+
+ Assert.Equal(["Home"], vm.Results.Select(e => e.Name));
+ }
+
+ [Fact]
+ public void Search_with_no_match_clears_results_and_flags_no_results()
+ {
+ var vm = new GlyphPickerViewModel(SampleCatalogue())
+ {
+ SearchText = "nonsense",
+ };
+
+ Assert.Empty(vm.Results);
+ Assert.True(vm.HasNoResults);
+ }
+
+ [Fact]
+ public void Current_glyph_preselects_the_matching_entry()
+ {
+ var vm = new GlyphPickerViewModel(SampleCatalogue(), Glyph(0xE713));
+
+ Assert.Equal("Settings", vm.SelectedEntry?.Name);
+ Assert.Equal(Glyph(0xE713), vm.SelectedGlyph);
+ }
+
+ [Fact]
+ public void Current_glyph_accepts_a_typed_code_point()
+ {
+ // The stored value might be a code point rather than the character;
+ // resolution should still preselect.
+ var vm = new GlyphPickerViewModel(SampleCatalogue(), "E80F");
+
+ Assert.Equal("Home", vm.SelectedEntry?.Name);
+ }
+
+ [Fact]
+ public void Filtering_out_the_selection_drops_it()
+ {
+ var vm = new GlyphPickerViewModel(SampleCatalogue(), Glyph(0xE713));
+ Assert.NotNull(vm.SelectedEntry);
+
+ vm.SearchText = "folder";
+
+ Assert.Null(vm.SelectedEntry);
+ Assert.Equal(string.Empty, vm.SelectedGlyph);
+ }
+
+ [Fact]
+ public void Empty_catalogue_reports_empty()
+ {
+ var vm = new GlyphPickerViewModel([]);
+
+ Assert.True(vm.IsEmpty);
+ Assert.False(vm.HasNoResults); // empty catalogue, not "filtered to nothing"
+ Assert.Empty(vm.Results);
+ }
+ }
+}
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs
index d6ca071..06c710b 100644
--- a/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs
+++ b/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs
@@ -1,3 +1,4 @@
+using Snipdeck.Core.Tests.Support;
using Snipdeck.Core.ViewModels;
namespace Snipdeck.Core.Tests.ViewModels
@@ -41,5 +42,38 @@ public void BuildTagIcons_keeps_only_non_default_glyphs()
Assert.Equal(["a"], map.Keys);
Assert.Equal("X", map["a"]);
}
+
+ [Fact]
+ public async Task ChooseGlyph_stores_the_picked_glyph()
+ {
+ var interactions = new FakeShellInteractions { NextPickGlyphResult = "Y" };
+ var row = new TagIconRowViewModel("ops", "X", interactions);
+
+ await row.ChooseGlyphCommand.ExecuteAsync(null);
+
+ Assert.Equal("Y", row.Glyph);
+ Assert.Equal("X", interactions.LastPickGlyphCurrent); // passes the current glyph for preselection
+ }
+
+ [Fact]
+ public async Task ChooseGlyph_leaves_the_glyph_unchanged_when_cancelled()
+ {
+ var interactions = new FakeShellInteractions { NextPickGlyphResult = null };
+ var row = new TagIconRowViewModel("ops", "X", interactions);
+
+ await row.ChooseGlyphCommand.ExecuteAsync(null);
+
+ Assert.Equal("X", row.Glyph);
+ }
+
+ [Fact]
+ public async Task ChooseGlyph_is_a_no_op_without_interactions()
+ {
+ var row = new TagIconRowViewModel("ops", "X");
+
+ await row.ChooseGlyphCommand.ExecuteAsync(null);
+
+ Assert.Equal("X", row.Glyph);
+ }
}
}