From 28a429c76266821f8d41e223b00e2ef282901a1a Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Mon, 1 Jun 2026 08:36:27 +0000 Subject: [PATCH 1/2] Tags: show icon name (fallback hex) and align card styling The icon text box now shows a picked glyph's friendly catalogue name (e.g. Settings) instead of a tofu box, falling back to its hex code point when the glyph isn't in the catalogue, and resolves a typed name back to its glyph. It commits on focus loss so the friendly form only replaces typed input once editing finishes. Tag cards now use the same background and 1px border as the Shared parameters, Trash and Settings cards (previously a borderless grey fill). ShellViewModel takes an optional IGlyphCatalogueProvider so the name lookup is available in production while unit tests stay unchanged. Co-Authored-By: Claude Opus 4.8 --- src/Snipdeck.App/Views/ShellPage.xaml | 14 ++-- .../ViewModels/GlyphNameLookup.cs | 38 +++++++++ .../ViewModels/ShellViewModel.cs | 13 +++- .../ViewModels/TagIconsViewModel.cs | 78 ++++++++++++++++++- .../ViewModels/TagIconsViewModelTests.cs | 65 ++++++++++++++++ 5 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 src/Snipdeck.Core/ViewModels/GlyphNameLookup.cs diff --git a/src/Snipdeck.App/Views/ShellPage.xaml b/src/Snipdeck.App/Views/ShellPage.xaml index fc9a866..14e5a81 100644 --- a/src/Snipdeck.App/Views/ShellPage.xaml +++ b/src/Snipdeck.App/Views/ShellPage.xaml @@ -564,7 +564,7 @@ - @@ -593,9 +593,11 @@ - + @@ -617,8 +619,8 @@ VerticalAlignment="Center" Command="{x:Bind ChooseGlyphCommand}" /> + PlaceholderText="Icon name or code (blank = default)" + Text="{x:Bind GlyphText, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" /> diff --git a/src/Snipdeck.Core/ViewModels/GlyphNameLookup.cs b/src/Snipdeck.Core/ViewModels/GlyphNameLookup.cs new file mode 100644 index 0000000..dcb1e0e --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/GlyphNameLookup.cs @@ -0,0 +1,38 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + /// + /// A two-way map between catalogue glyph characters and their friendly names, + /// used by the Tags view to show (and accept) an icon's name rather than a raw + /// code point. Names are matched case-insensitively; glyph characters exactly. + /// + public sealed class GlyphNameLookup + { + private readonly Dictionary _glyphToName = new(StringComparer.Ordinal); + private readonly Dictionary _nameToGlyph = new(StringComparer.OrdinalIgnoreCase); + + /// An empty lookup — every query misses (callers fall back to hex). + public static GlyphNameLookup Empty { get; } = new([]); + + public GlyphNameLookup(IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + foreach (var entry in entries) + { + // First entry wins for any duplicate glyph or name, matching the + // catalogue's own first-listed-wins behaviour. + _ = _glyphToName.TryAdd(entry.Glyph, entry.Name); + _ = _nameToGlyph.TryAdd(entry.Name, entry.Glyph); + } + } + + /// The friendly name for a glyph character, or null if unknown. + public string? NameFor(string glyph) => + _glyphToName.TryGetValue(glyph, out var name) ? name : null; + + /// The glyph character for a friendly name, or null if unknown. + public string? GlyphFor(string name) => + _nameToGlyph.TryGetValue(name.Trim(), out var glyph) ? glyph : null; + } +} diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs index b8938ae..aed597f 100644 --- a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs @@ -31,6 +31,7 @@ public sealed partial class ShellViewModel : ObservableObject private readonly IShellInteractions _interactions; private readonly IIconAssetStorage _iconStorage; private readonly IExternalLinkService _externalLinks; + private readonly IGlyphCatalogueProvider? _glyphCatalogue; private SnipStoreDocument _document = new(); private bool _suppressShellRefresh; // When set (via a chosen search result), the snip list shows exactly this @@ -58,7 +59,8 @@ public ShellViewModel( IClock clock, IShellInteractions interactions, IIconAssetStorage iconStorage, - IExternalLinkService externalLinks) + IExternalLinkService externalLinks, + IGlyphCatalogueProvider? glyphCatalogue = null) { ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(clipboard); @@ -73,6 +75,10 @@ public ShellViewModel( _interactions = interactions; _iconStorage = iconStorage; _externalLinks = externalLinks; + // Optional: when present (production DI), the Tags view shows icons by + // their friendly catalogue name. Absent in unit tests → name lookup is + // empty and the view falls back to hex code points. + _glyphCatalogue = glyphCatalogue; } public ObservableCollection CliChoices { get; } = []; @@ -241,7 +247,10 @@ private async Task DeleteSharedParameterAsync(ParameterDisplayViewModel? row) public void OpenTagIcons() { - CurrentContent = new TagIconsViewModel(SnipFilter.DistinctTagsFor(_document.Snips), _document.TagIcons, _interactions); + var names = _glyphCatalogue is null + ? GlyphNameLookup.Empty + : new GlyphNameLookup(_glyphCatalogue.GetEntries()); + CurrentContent = new TagIconsViewModel(SnipFilter.DistinctTagsFor(_document.Snips), _document.TagIcons, _interactions, names); } [RelayCommand] diff --git a/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs b/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs index 5fa84c6..648e42f 100644 --- a/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -8,8 +9,14 @@ 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, IShellInteractions? interactions = null) : ObservableObject + public sealed partial class TagIconRowViewModel( + string tagName, + string glyph, + IShellInteractions? interactions = null, + GlyphNameLookup? names = null) : ObservableObject { + private readonly GlyphNameLookup _names = names ?? GlyphNameLookup.Empty; + public string TagName { get; } = tagName; /// The raw glyph the user has entered; empty means "use the default". @@ -29,7 +36,69 @@ public string PreviewGlyph } } - partial void OnGlyphChanged(string value) => OnPropertyChanged(nameof(PreviewGlyph)); + /// + /// The value shown in (and edited through) the icon text box. A glyph that's + /// in the catalogue is shown by its friendly name (e.g. "Settings"); an + /// unknown glyph character falls back to its hex code point (e.g. "E8EC") + /// rather than an unreadable tofu box. Typed codes and other text pass + /// through unchanged. The box commits on focus loss, so the friendly form + /// only replaces what was typed once editing finishes — never mid-keystroke. + /// On the way in, a recognised name is resolved back to its glyph. + /// + public string GlyphText + { + get + { + if (string.IsNullOrEmpty(Glyph)) + { + return string.Empty; + } + + // Prefer the friendly name of whatever the value resolves to (covers + // both a stored glyph character and a typed code that maps to one). + var resolved = GlyphInput.Resolve(Glyph); + if (resolved.Length != 0 && _names.NameFor(resolved) is { } name) + { + return name; + } + + // A glyph character with no catalogue entry: show its code point. + if (IsGlyphCharacter(Glyph, out var codePoint)) + { + return codePoint.ToString("X4", CultureInfo.InvariantCulture); + } + + // Typed code / free text: leave as entered. + return Glyph; + } + set => Glyph = _names.GlyphFor(value ?? string.Empty) ?? value ?? string.Empty; + } + + partial void OnGlyphChanged(string value) + { + OnPropertyChanged(nameof(PreviewGlyph)); + OnPropertyChanged(nameof(GlyphText)); + } + + // True when the value is a single literal glyph character (as opposed to a + // typed hex code or other text), so the text box can show its code point. + private static bool IsGlyphCharacter(string value, out int codePoint) + { + codePoint = 0; + if (value.Length == 1 && value[0] > 0x7F) + { + codePoint = value[0]; + return true; + } + + if (value.Length == 2 && char.IsHighSurrogate(value[0]) && char.IsLowSurrogate(value[1])) + { + codePoint = char.ConvertToUtf32(value[0], value[1]); + return true; + } + + return false; + } /// /// Opens the glyph picker and, if the user chooses, stores the picked @@ -62,7 +131,8 @@ public sealed partial class TagIconsViewModel : ObservableObject public TagIconsViewModel( IEnumerable tagNames, IReadOnlyDictionary tagIcons, - IShellInteractions? interactions = null) + IShellInteractions? interactions = null, + GlyphNameLookup? names = null) { ArgumentNullException.ThrowIfNull(tagNames); ArgumentNullException.ThrowIfNull(tagIcons); @@ -74,7 +144,7 @@ public TagIconsViewModel( tagNames .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) - .Select(t => new TagIconRowViewModel(t, tagIcons.TryGetValue(t, out var g) ? g : string.Empty, interactions))); + .Select(t => new TagIconRowViewModel(t, tagIcons.TryGetValue(t, out var g) ? g : string.Empty, interactions, names))); } public ObservableCollection Rows { get; } diff --git a/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs index 06c710b..28b649d 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs @@ -1,3 +1,4 @@ +using Snipdeck.Core.Models; using Snipdeck.Core.Tests.Support; using Snipdeck.Core.ViewModels; @@ -7,6 +8,13 @@ public class TagIconsViewModelTests { // "X" is a stand-in glyph — any non-blank, non-default string exercises the logic. + // A literal Segoe Fluent Icons glyph character, built from its code point so + // the source stays ASCII (mirrors the real "Tag" glyph). + private static string GlyphChar(int codePoint) => char.ConvertFromUtf32(codePoint); + + private static GlyphNameLookup CatalogueWith(params (int Code, string Name)[] entries) => + new(entries.Select(e => new GlyphCatalogueEntry(GlyphChar(e.Code), e.Name, []))); + [Fact] public void Rows_are_distinct_sorted_and_prefilled_with_stored_glyphs() { @@ -29,6 +37,63 @@ public void PreviewGlyph_falls_back_to_default_when_blank_else_shows_the_glyph() Assert.Equal("X", row.PreviewGlyph); } + [Fact] + public void GlyphText_shows_the_catalogue_name_for_a_known_glyph() + { + // A picked/known glyph is shown by its friendly name, not a tofu box or code. + var names = CatalogueWith((0xE8EC, "Tag"), (0xE713, "Settings")); + var row = new TagIconRowViewModel("ops", GlyphChar(0xE713), names: names); + + Assert.Equal("Settings", row.GlyphText); + } + + [Fact] + public void GlyphText_setter_resolves_a_known_name_to_its_glyph() + { + var names = CatalogueWith((0xE713, "Settings")); + var row = new TagIconRowViewModel("ops", string.Empty, names: names) + { + GlyphText = "settings", // case-insensitive + }; + + Assert.Equal(GlyphChar(0xE713), row.Glyph); + Assert.Equal("Settings", row.GlyphText); // round-trips back to the name + } + + [Fact] + public void GlyphText_falls_back_to_hex_when_the_glyph_is_not_in_the_catalogue() + { + // No lookup -> a literal glyph character is shown as its code point + // rather than an unreadable tofu box. + var row = new TagIconRowViewModel("ops", GlyphChar(0xE8EC)); + + Assert.Equal("E8EC", row.GlyphText); + } + + [Fact] + public void GlyphText_passes_typed_codes_and_text_through_unchanged() + { + var row = new TagIconRowViewModel("ops", "E8EC"); + Assert.Equal("E8EC", row.GlyphText); // typed code stays as typed + + row.Glyph = "U+E8EC"; + Assert.Equal("U+E8EC", row.GlyphText); + + row.Glyph = string.Empty; + Assert.Equal(string.Empty, row.GlyphText); + } + + [Fact] + public void GlyphText_setter_keeps_a_raw_code_when_it_is_not_a_known_name() + { + var row = new TagIconRowViewModel("ops", string.Empty) + { + GlyphText = "E713", + }; + + Assert.Equal("E713", row.Glyph); + } + [Fact] public void BuildTagIcons_keeps_only_non_default_glyphs() { From 8bdee8386e52fa1c8c34e735f0c52f118ce6b3e1 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Mon, 1 Jun 2026 08:37:18 +0000 Subject: [PATCH 2/2] CHANGELOG: note Tags name display and card alignment Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b3f38..ae44e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 close glyphs were always white, leaving them barely visible against the light title bar. They now take a theme-appropriate colour, update when you switch theme, and follow the OS when the theme is set to System. +- **Tags view polish.** The icon text box now shows a chosen icon by its friendly + name (e.g. Settings) — or its hex code when the glyph isn't in the catalogue — + instead of an unreadable box, and accepts a name as well as a code. The tag + cards now match the Shared parameters, Trash and Settings cards (consistent card + background and border), rather than a borderless grey fill. ### Changed - **Home, navigation and shared-parameters polish.** The Home page leads with a