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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
catalogue ships with 100 curated icons and is a plain JSON file
(`icon-catalogue.json`) in the Snipdeck app-data folder — edit it to add or
remove icons (the picker reflects the change the next time it opens), and your
edits survive app updates. Delete it to restore the default set.

### Changed
- **Home, navigation and shared-parameters polish.** The Home page leads with a
Expand Down
105 changes: 105 additions & 0 deletions src/Snipdeck.App/Assets/icon-catalogue.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
{
"$comment": "Default icon 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. This file is the bundled default; on first run it is copied to %LOCALAPPDATA%\\Snipdeck\\icon-catalogue.json, which is what the app actually reads. Edit that copy to add or remove glyphs — the picker re-reads it each time it opens, and your edits survive app updates. Delete the copy to restore this default. 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": "E930", "name": "Completed", "keywords": [ "check circle", "success", "passed", "approved" ] },
{ "code": "E721", "name": "Search", "keywords": [ "find", "magnify", "lookup", "query" ] },
{ "code": "E72C", "name": "Refresh", "keywords": [ "reload", "sync", "update", "retry" ] },
{ "code": "E895", "name": "Sync", "keywords": [ "synchronise", "refresh", "two way", "update" ] },
{ "code": "E8EE", "name": "Renewal", "keywords": [ "renew", "recurring", "subscription", "repeat", "auto renew" ] },
{ "code": "E81C", "name": "History", "keywords": [ "recent", "past", "log", "timeline", "audit" ] },
{ "code": "E8B7", "name": "Folder", "keywords": [ "directory", "files", "path" ] },
{ "code": "E8E5", "name": "Open file", "keywords": [ "open", "document", "load" ] },
{ "code": "E8DA", "name": "Open folder", "keywords": [ "browse", "local", "directory" ] },
{ "code": "E8A5", "name": "Document", "keywords": [ "file", "page", "text", "doc" ] },
{ "code": "E8C8", "name": "Copy", "keywords": [ "duplicate", "clipboard", "clone" ] },
{ "code": "E8C6", "name": "Cut", "keywords": [ "scissors", "clipboard", "move" ] },
{ "code": "E77F", "name": "Paste", "keywords": [ "clipboard", "insert" ] },
{ "code": "E715", "name": "Mail", "keywords": [ "email", "envelope", "message", "inbox" ] },
{ "code": "E724", "name": "Send", "keywords": [ "submit", "forward", "deliver" ] },
{ "code": "E8BD", "name": "Message", "keywords": [ "chat", "comment", "speech", "reply" ] },
{ "code": "E72D", "name": "Share", "keywords": [ "distribute", "export", "send" ] },
{ "code": "E72A", "name": "Forward", "keywords": [ "next", "right", "advance" ] },
{ "code": "E72B", "name": "Back", "keywords": [ "previous", "left", "return" ] },
{ "code": "E8B5", "name": "Import", "keywords": [ "ingest", "load", "bring in" ] },
{ "code": "E8AB", "name": "Transfer", "keywords": [ "move", "swap", "migrate", "switch", "exchange" ] },
{ "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": "E74A", "name": "Move up", "keywords": [ "promote", "top", "arrow up", "raise" ] },
{ "code": "E7B8", "name": "Package", "keywords": [ "box", "parcel", "shipment", "bundle", "release" ] },
{ "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": "EC92", "name": "Date and time", "keywords": [ "datetime", "schedule", "timestamp" ] },
{ "code": "E917", "name": "Clock", "keywords": [ "time", "recent", "timer", "duration" ] },
{ "code": "E72E", "name": "Lock", "keywords": [ "secure", "private", "locked", "protected" ] },
{ "code": "E785", "name": "Unlock", "keywords": [ "open", "unsecure", "unlocked" ] },
{ "code": "EA18", "name": "Shield", "keywords": [ "security", "protect", "safe", "defend", "guard" ] },
{ "code": "E77B", "name": "Person", "keywords": [ "contact", "user", "individual", "account", "profile" ] },
{ "code": "E779", "name": "Customer", "keywords": [ "client", "contact", "account", "buyer", "tenant" ] },
{ "code": "E716", "name": "People", "keywords": [ "users", "team", "members", "audience" ] },
{ "code": "E902", "name": "Group", "keywords": [ "team", "people", "members", "organisation unit" ] },
{ "code": "E748", "name": "Switch account", "keywords": [ "switch user", "change account", "impersonate", "tenant" ] },
{ "code": "EC06", "name": "Organisation", "keywords": [ "company", "building", "city", "tenant", "enterprise" ] },
{ "code": "E821", "name": "Work", "keywords": [ "briefcase", "job", "business", "portfolio" ] },
{ "code": "E8C7", "name": "Billing", "keywords": [ "payment", "credit card", "charge", "pay", "card" ] },
{ "code": "E9F9", "name": "Invoice", "keywords": [ "bill", "statement", "receipt", "charges", "document" ] },
{ "code": "E8EF", "name": "Calculator", "keywords": [ "calculate", "maths", "sum", "total" ] },
{ "code": "E94C", "name": "Discount", "keywords": [ "percentage", "percent", "rate", "tax", "margin" ] },
{ "code": "EB95", "name": "Contract", "keywords": [ "agreement", "certificate", "terms", "signed", "licence" ] },
{ "code": "E719", "name": "Marketplace", "keywords": [ "shop", "store", "catalogue", "storefront", "buy" ] },
{ "code": "E7BF", "name": "Orders", "keywords": [ "cart", "shopping", "purchase", "basket", "checkout" ] },
{ "code": "E8FD", "name": "Price list", "keywords": [ "rate card", "pricing", "list", "catalogue", "tariff" ] },
{ "code": "E8EC", "name": "Tag", "keywords": [ "label", "category", "default" ] },
{ "code": "E718", "name": "Pin", "keywords": [ "pushpin", "attach", "stick" ] },
{ "code": "E723", "name": "Attach", "keywords": [ "paperclip", "attachment", "file" ] },
{ "code": "E71B", "name": "Link", "keywords": [ "url", "chain", "hyperlink", "connect" ] },
{ "code": "E734", "name": "Favourite", "keywords": [ "star", "favorite", "bookmark", "important" ] },
{ "code": "E8A4", "name": "Bookmark", "keywords": [ "save", "ribbon", "mark", "saved" ] },
{ "code": "EB51", "name": "Heart", "keywords": [ "like", "love", "wishlist" ] },
{ "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": "E8D7", "name": "Permissions", "keywords": [ "access", "roles", "rights", "key", "security", "scope" ] },
{ "code": "E9D5", "name": "Checklist", "keywords": [ "tasks", "todo", "checkboxes", "steps" ] },
{ "code": "E71D", "name": "Apps", "keywords": [ "all apps", "grid", "applications", "tiles" ] },
{ "code": "E890", "name": "View", "keywords": [ "show", "see", "display", "look" ] },
{ "code": "E8FF", "name": "Preview", "keywords": [ "view", "look", "inspect", "peek" ] },
{ "code": "E8A9", "name": "View all", "keywords": [ "list view", "show all", "details" ] },
{ "code": "ED1A", "name": "Hide", "keywords": [ "hidden", "invisible", "conceal", "off" ] },
{ "code": "E9D2", "name": "Report", "keywords": [ "chart", "analytics", "graph", "metrics", "area chart", "dashboard" ] },
{ "code": "EB05", "name": "Pie chart", "keywords": [ "chart", "analytics", "breakdown", "share", "distribution" ] },
{ "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": "E714", "name": "Video", "keywords": [ "film", "movie", "record", "media" ] },
{ "code": "E720", "name": "Microphone", "keywords": [ "mic", "record", "audio", "voice" ] },
{ "code": "E707", "name": "Location", "keywords": [ "map pin", "place", "geo", "region", "address" ] },
{ "code": "E717", "name": "Phone", "keywords": [ "call", "telephone", "mobile", "contact" ] },
{ "code": "E8CC", "name": "Mobile", "keywords": [ "tablet", "device", "phone", "handset" ] },
{ "code": "E772", "name": "Devices", "keywords": [ "pc", "computer", "hardware", "machines" ] },
{ "code": "E968", "name": "Server", "keywords": [ "host", "backend", "rack", "infrastructure" ] },
{ "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": "E82D", "name": "Dictionary", "keywords": [ "book", "glossary", "reference", "definitions" ] },
{ "code": "E8F1", "name": "Library", "keywords": [ "books", "collection", "catalogue", "docs" ] },
{ "code": "E929", "name": "Notes", "keywords": [ "handwriting", "signature", "annotation", "scribble" ] },
{ "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" ] }
]
}
2 changes: 1 addition & 1 deletion src/Snipdeck.App/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public static IServiceProvider Build()
.AddSingleton<IHotkeyService, WindowsHotkeyService>()
.AddSingleton<ITrayService, HNotifyIconTrayService>()
.AddSingleton<IShellInteractions, WindowsShellInteractions>()
.AddSingleton<IGlyphCatalogueProvider, GlyphCatalogueProvider>()
.AddSingleton<IGlyphCatalogueProvider>(new GlyphCatalogueProvider(pathProvider.IconCatalogueFilePath))
.AddSingleton<IThemeApplier, WindowsThemeApplier>()
.AddSingleton<IUpdateService, WindowsUpdateService>()
.AddSingleton<ISettingsStore>(settingsStore)
Expand Down
93 changes: 84 additions & 9 deletions src/Snipdeck.App/Services/GlyphCatalogueProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,78 @@
namespace Snipdeck.App.Services
{
/// <summary>
/// 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.
/// Supplies the icon picker's catalogue from a user-editable JSON file in
/// LocalAppData (so edits survive Velopack updates). On first run the file is
/// seeded from a default bundled into the app; thereafter the user's copy is
/// read, re-read on every call so edits take effect without a restart.
/// Degrades to the bundled default — never throws — when the user file is
/// missing or malformed, so a bad edit can't break the picker.
/// </summary>
internal sealed class GlyphCatalogueProvider : IGlyphCatalogueProvider
{
// The bundled default, embedded so it can't be deleted and ships fresh
// with each release. Its logical name is pinned in the csproj.
private const string _defaultResourceName = "Snipdeck.App.icon-catalogue.json";

private readonly string _path;

public GlyphCatalogueProvider()
public GlyphCatalogueProvider(string catalogueFilePath)
{
_path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
ArgumentException.ThrowIfNullOrWhiteSpace(catalogueFilePath);
_path = catalogueFilePath;
}

public IReadOnlyList<GlyphCatalogueEntry> GetEntries()
{
GlyphCatalogueFile? file;
SeedIfMissing();

// The user's copy wins; fall back to the bundled default if it's
// absent or unreadable (e.g. mid-edit or malformed).
var fromFile = ReadFile();
return fromFile.Count > 0 ? fromFile : ReadBundledDefault();
}

private void SeedIfMissing()
{
// Only seed when truly absent — never overwrite a user's file, even a
// broken one (they can fix it, or delete it to restore the default).
try
{
if (File.Exists(_path))
{
return;
}

var directory = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(directory))
{
_ = Directory.CreateDirectory(directory);
}

using var resource = OpenBundledDefault();
if (resource is null)
{
return;
}

// Write-then-rename so an interrupted seed can't leave a partial file.
var tempPath = _path + ".tmp";
using (var file = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
resource.CopyTo(file);
file.Flush();
}

File.Move(tempPath, _path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Seeding is best-effort: the bundled default still serves the picker.
}
}

private List<GlyphCatalogueEntry> ReadFile()
{
try
{
if (!File.Exists(_path))
Expand All @@ -33,13 +88,33 @@ public IReadOnlyList<GlyphCatalogueEntry> GetEntries()
}

using var stream = File.OpenRead(_path);
file = JsonSerializer.Deserialize(stream, GlyphCatalogueJsonContext.Default.GlyphCatalogueFile);
return Parse(stream);
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
return [];
}
}

private static List<GlyphCatalogueEntry> ReadBundledDefault()
{
try
{
using var stream = OpenBundledDefault();
return stream is null ? [] : Parse(stream);
}
catch (JsonException)
{
return [];
}
}

private static Stream? OpenBundledDefault() =>
typeof(GlyphCatalogueProvider).Assembly.GetManifestResourceStream(_defaultResourceName);

private static List<GlyphCatalogueEntry> Parse(Stream stream)
{
var file = JsonSerializer.Deserialize(stream, GlyphCatalogueJsonContext.Default.GlyphCatalogueFile);
if (file?.GlyphCatalogue is not { Count: > 0 } raw)
{
return [];
Expand All @@ -63,7 +138,7 @@ public IReadOnlyList<GlyphCatalogueEntry> GetEntries()
}
}

/// <summary>The appsettings.json shape the catalogue is read from.</summary>
/// <summary>The icon-catalogue JSON shape the catalogue is read from.</summary>
internal sealed class GlyphCatalogueFile
{
public List<GlyphCatalogueFileEntry>? GlyphCatalogue { get; set; }
Expand Down
2 changes: 2 additions & 0 deletions src/Snipdeck.App/Services/WindowsPathProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ internal sealed class WindowsPathProvider : IPathProvider

public string SettingsFilePath => DefaultPaths.SettingsFilePath;

public string IconCatalogueFilePath => DefaultPaths.IconCatalogueFilePath;

public string DefaultStorageDirectory => DefaultPaths.DefaultStorageDirectory;

public string DefaultBackupDirectory => DefaultPaths.DefaultBackupDirectory;
Expand Down
2 changes: 1 addition & 1 deletion src/Snipdeck.App/Services/WindowsShellInteractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public async Task NotifyAsync(string title, string message, string buttonText =

public async Task<string?> PickGlyphAsync(string? currentGlyph)
{
// Re-read the catalogue each open, so edits to appsettings.json take
// Re-read the catalogue each open, so edits to icon-catalogue.json take
// effect without a restart.
var picker = new GlyphPickerViewModel(_glyphCatalogue.GetEntries(), currentGlyph);
var dialog = new GlyphPickerDialog(picker)
Expand Down
11 changes: 8 additions & 3 deletions src/Snipdeck.App/Snipdeck.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>

<!-- The glyph picker's curated catalogue. Shipped beside the executable and
user-editable: the picker re-reads it on open, so edits need no rebuild. -->
<!-- The icon picker's default catalogue. Embedded (not copied to output) so it
can't be deleted and ships fresh with each release; on first run it is
seeded to %LOCALAPPDATA%\Snipdeck\icon-catalogue.json, the user-editable
copy the app actually reads. LogicalName is pinned so the provider can
load it by a stable name. -->
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<EmbeddedResource Include="Assets\icon-catalogue.json">
<LogicalName>Snipdeck.App.icon-catalogue.json</LogicalName>
</EmbeddedResource>
</ItemGroup>

<!-- Theme hero images are optional: packaged only once the user drops them in. -->
Expand Down
2 changes: 1 addition & 1 deletion src/Snipdeck.App/Views/GlyphPickerDialog.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
Visibility="{x:Bind ViewModel.HasNoResults, Mode=OneWay, Converter={StaticResource GlyphBoolToVisibility}}" />

<!-- Misconfiguration state: the catalogue file is missing or empty. -->
<TextBlock Text="The icon catalogue is empty. Add entries to appsettings.json beside the app, or type a glyph directly in the field."
<TextBlock Text="The icon catalogue is empty. Add entries to icon-catalogue.json in the Snipdeck app-data folder, or type a glyph directly in the field."
Style="{ThemeResource BodyTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Expand Down
Loading
Loading