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 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() {