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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Snipdeck.App/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
<Style TargetType="views:CliEditorDialog" BasedOn="{StaticResource SnipdeckContentDialogStyle}" />
<Style TargetType="views:ParameterFillDialog" BasedOn="{StaticResource SnipdeckContentDialogStyle}" />
<Style TargetType="views:ParameterEditorDialog" BasedOn="{StaticResource SnipdeckContentDialogStyle}" />
<Style TargetType="views:GlyphPickerDialog" BasedOn="{StaticResource SnipdeckContentDialogStyle}" />
</ResourceDictionary>
</Application.Resources>
</Application>
1 change: 1 addition & 0 deletions src/Snipdeck.App/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public static IServiceProvider Build()
.AddSingleton<IHotkeyService, WindowsHotkeyService>()
.AddSingleton<ITrayService, HNotifyIconTrayService>()
.AddSingleton<IShellInteractions, WindowsShellInteractions>()
.AddSingleton<IGlyphCatalogueProvider, GlyphCatalogueProvider>()
.AddSingleton<IThemeApplier, WindowsThemeApplier>()
.AddSingleton<IUpdateService, WindowsUpdateService>()
.AddSingleton<ISettingsStore>(settingsStore)
Expand Down
87 changes: 87 additions & 0 deletions src/Snipdeck.App/Services/GlyphCatalogueProvider.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <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.
/// </summary>
internal sealed class GlyphCatalogueProvider : IGlyphCatalogueProvider
{
private readonly string _path;

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

public IReadOnlyList<GlyphCatalogueEntry> 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<GlyphCatalogueEntry>(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;
}
}

/// <summary>The appsettings.json shape the catalogue is read from.</summary>
internal sealed class GlyphCatalogueFile
{
public List<GlyphCatalogueFileEntry>? GlyphCatalogue { get; set; }
}

/// <summary>One raw catalogue row before the code point is resolved to a glyph.</summary>
internal sealed class GlyphCatalogueFileEntry
{
public string? Code { get; set; }

public string? Name { get; set; }

public List<string>? Keywords { get; set; }
}

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(GlyphCatalogueFile))]
internal sealed partial class GlyphCatalogueJsonContext : JsonSerializerContext
{
}
}
22 changes: 21 additions & 1 deletion src/Snipdeck.App/Services/WindowsShellInteractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel", bool destructive = false)
Expand Down Expand Up @@ -127,6 +131,22 @@ public async Task NotifyAsync(string title, string message, string buttonText =
: null;
}

public async Task<string?> 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))!;
Expand Down
6 changes: 6 additions & 0 deletions src/Snipdeck.App/Snipdeck.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
<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. -->
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

<!-- Theme hero images are optional: packaged only once the user drops them in. -->
<ItemGroup Condition="Exists('Assets\HomeHeroLight.png')">
<Content Include="Assets\HomeHeroLight.png" CopyToOutputDirectory="PreserveNewest" />
Expand Down
72 changes: 72 additions & 0 deletions src/Snipdeck.App/Views/GlyphPickerDialog.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<ContentDialog
x:Class="Snipdeck.App.Views.GlyphPickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:converters="using:Snipdeck.App.Converters"
xmlns:models="using:Snipdeck.Core.Models"
mc:Ignorable="d"
Title="Choose an icon"
PrimaryButtonText="Choose"
CloseButtonText="Cancel"
DefaultButton="Primary"
PrimaryButtonClick="OnChooseClicked">

<ContentDialog.Resources>
<converters:BoolToVisibilityConverter x:Key="GlyphBoolToVisibility" />
</ContentDialog.Resources>

<Grid Width="520" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="360" />
</Grid.RowDefinitions>

<TextBox Grid.Row="0"
PlaceholderText="Search by name, keyword or code (e.g. folder, run, E80F)"
Text="{x:Bind ViewModel.SearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

<Grid Grid.Row="1">
<GridView x:Name="GlyphGrid"
ItemsSource="{x:Bind ViewModel.Results}"
SelectedItem="{x:Bind ViewModel.SelectedEntry, Mode=TwoWay}"
SelectionMode="Single"
IsItemClickEnabled="False"
DoubleTapped="OnGlyphDoubleTapped"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<GridView.ItemTemplate>
<DataTemplate x:DataType="models:GlyphCatalogueEntry">
<Grid Width="64" Height="64"
ToolTipService.ToolTip="{x:Bind Name}">
<FontIcon FontFamily="{ThemeResource SymbolThemeFontFamily}"
Glyph="{x:Bind Glyph}"
FontSize="24" />
</Grid>
</DataTemplate>
</GridView.ItemTemplate>
</GridView>

<!-- No-results state, overlaid when the search filters everything out. -->
<TextBlock Text="No matching icons. Try a different term, or type the glyph directly in the field."
Style="{ThemeResource BodyTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="24"
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."
Style="{ThemeResource BodyTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="24"
Visibility="{x:Bind ViewModel.IsEmpty, Converter={StaticResource GlyphBoolToVisibility}}" />
</Grid>
</Grid>
</ContentDialog>
66 changes: 66 additions & 0 deletions src/Snipdeck.App/Views/GlyphPickerDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// A searchable grid of the curated glyph catalogue. The user filters and
/// picks an icon; <see cref="ChosenGlyph"/> holds the result (the resolved
/// glyph character) or stays null when cancelled. Double-tapping a glyph
/// confirms the same as the Choose button.
/// </summary>
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; }

/// <summary>The glyph the user chose, or null if they cancelled.</summary>
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();
}
}
}
9 changes: 7 additions & 2 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 (a Segoe Fluent Icons glyph, e.g. 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 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."
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
Expand Down Expand Up @@ -600,6 +600,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="160" />
</Grid.ColumnDefinitions>
<FontIcon Grid.Column="0"
Expand All @@ -611,7 +612,11 @@
Text="{x:Bind TagName}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBox Grid.Column="2"
<Button Grid.Column="2"
Content="Choose…"
VerticalAlignment="Center"
Command="{x:Bind ChooseGlyphCommand}" />
<TextBox Grid.Column="3"
PlaceholderText="Icon (blank = default)"
Text="{x:Bind Glyph, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
Expand Down
Loading
Loading