Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/Snipdeck.App/Views/ShellPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="Tags"
Style="{ThemeResource TitleTextBlockStyle}" />
<TextBlock Text="Give a tag an icon. Choose one from the picker, or type a Segoe Fluent Icons glyph directly (paste a character or enter its code). Shown beside the tag in the left navigation; blank uses the default tag icon."
<TextBlock Text="Give a tag an icon. Choose one from the picker, or type its name (e.g. Settings) or a Segoe Fluent Icons code directly. Shown beside the tag in the left navigation; blank uses the default tag icon."
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
Expand Down Expand Up @@ -593,9 +593,11 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagIconRowViewModel">
<Border Background="{ThemeResource SubtleFillColorSecondaryBrush}"
CornerRadius="6"
Padding="12">
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="16">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
Expand All @@ -617,8 +619,8 @@
VerticalAlignment="Center"
Command="{x:Bind ChooseGlyphCommand}" />
<TextBox Grid.Column="3"
PlaceholderText="Icon (blank = default)"
Text="{x:Bind Glyph, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
PlaceholderText="Icon name or code (blank = default)"
Text="{x:Bind GlyphText, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
</Grid>
</Border>
</DataTemplate>
Expand Down
38 changes: 38 additions & 0 deletions src/Snipdeck.Core/ViewModels/GlyphNameLookup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Snipdeck.Core.Models;

namespace Snipdeck.Core.ViewModels
{
/// <summary>
/// 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.
/// </summary>
public sealed class GlyphNameLookup
{
private readonly Dictionary<string, string> _glyphToName = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _nameToGlyph = new(StringComparer.OrdinalIgnoreCase);

/// <summary>An empty lookup — every query misses (callers fall back to hex).</summary>
public static GlyphNameLookup Empty { get; } = new([]);

public GlyphNameLookup(IEnumerable<GlyphCatalogueEntry> 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);
}
}

/// <summary>The friendly name for a glyph character, or null if unknown.</summary>
public string? NameFor(string glyph) =>
_glyphToName.TryGetValue(glyph, out var name) ? name : null;

/// <summary>The glyph character for a friendly name, or null if unknown.</summary>
public string? GlyphFor(string name) =>
_nameToGlyph.TryGetValue(name.Trim(), out var glyph) ? glyph : null;
}
}
13 changes: 11 additions & 2 deletions src/Snipdeck.Core/ViewModels/ShellViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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<CliChoice> CliChoices { get; } = [];
Expand Down Expand Up @@ -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]
Expand Down
78 changes: 74 additions & 4 deletions src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.Globalization;

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
Expand All @@ -8,8 +9,14 @@
namespace Snipdeck.Core.ViewModels
{
/// <summary>One editable row in the "Tags" management view: a tag and its icon glyph.</summary>
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;

/// <summary>The raw glyph the user has entered; empty means "use the default".</summary>
Expand All @@ -29,7 +36,69 @@ public string PreviewGlyph
}
}

partial void OnGlyphChanged(string value) => OnPropertyChanged(nameof(PreviewGlyph));
/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// Opens the glyph picker and, if the user chooses, stores the picked
Expand Down Expand Up @@ -62,7 +131,8 @@ public sealed partial class TagIconsViewModel : ObservableObject
public TagIconsViewModel(
IEnumerable<string> tagNames,
IReadOnlyDictionary<string, string> tagIcons,
IShellInteractions? interactions = null)
IShellInteractions? interactions = null,
GlyphNameLookup? names = null)
{
ArgumentNullException.ThrowIfNull(tagNames);
ArgumentNullException.ThrowIfNull(tagIcons);
Expand All @@ -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<TagIconRowViewModel> Rows { get; }
Expand Down
65 changes: 65 additions & 0 deletions tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Snipdeck.Core.Models;
using Snipdeck.Core.Tests.Support;
using Snipdeck.Core.ViewModels;

Expand All @@ -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()
{
Expand All @@ -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()
{
Expand Down
Loading