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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added — Phase 5: Platform services
- **Global hotkey** via Win32 `RegisterHotKey`. Default Ctrl+Alt+S; pressed
anywhere brings the existing Snipdeck window to the foreground.
`WindowsHotkeyService` subclasses the main window's WndProc with
`SetWindowSubclass` to catch `WM_HOTKEY`. `HotkeyModifiers` map straight
through to `MOD_*` constants (intentional alignment from Phase 1).
- **Tray icon** via `H.NotifyIcon`. Left-click brings the window forward,
right-click shows a context menu with **Show Snipdeck** and **Exit**.
- **Close-to-tray** behaviour: when `AppConfig.CloseBehaviour` is
`HideToTray`, `AppWindow.Closing` is cancelled and the window hides. The
tray's **Exit** flips an internal flag and lets the next close pass
through cleanly.
- **`IFilePickerService`** abstraction (App-side `WindowsFilePickerService`)
centralises file-picker setup. `CliEditorDialog` no longer pokes Win32 itself
— it consumes the abstraction.
- **CLI cards now render uploaded icons** (when `Cli.IconRef` is set), falling
back to identicons when not. `Identicon` control gains an `IconRef`
dependency property and resolves the absolute path via
`IIconAssetStorage`.

### Added — Phase 4: Authoring + parameter-fill flyout
- Snip card actions are now live: **Copy** opens a parameter-fill
`ContentDialog` (or copies the template directly when the Snip has no
Expand Down
63 changes: 59 additions & 4 deletions src/Snipdeck.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
using Microsoft.Windows.AppLifecycle;

using Snipdeck.Core.Abstractions;
using Snipdeck.Core.Models;
using Snipdeck.Core.Services;

namespace Snipdeck.App
{
public partial class App : Application
{
private Window? _mainWindow;
private MainWindow? _mainWindow;
private IHotkeyService? _hotkey;
private ITrayService? _tray;
private AppConfig? _config;
private bool _allowClose;

public App()
{
Expand All @@ -32,8 +37,13 @@ protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventA

await SeedFirstRunIfEmptyAsync();

_config = Services.GetRequiredService<AppConfig>();
_mainWindow = Services.GetRequiredService<MainWindow>();
_mainWindow.Activate();

WireCloseToTray(_mainWindow, _config);
await InitialiseTrayAsync();
InitialiseHotkey(_config);
}

private static async Task SeedFirstRunIfEmptyAsync()
Expand All @@ -49,10 +59,55 @@ private static async Task SeedFirstRunIfEmptyAsync()
private void OnInstanceActivated(object? sender, AppActivationArguments e)
{
var dispatcher = Services.GetRequiredService<IDispatcher>();
dispatcher.Enqueue(() =>
dispatcher.Enqueue(BringToFront);
}

private void WireCloseToTray(MainWindow window, AppConfig config)
{
window.AppWindow.Closing += (_, args) =>
{
_mainWindow?.Activate();
});
if (_allowClose || config.CloseBehaviour == CloseBehaviour.Exit)
{
return;
}
args.Cancel = true;
window.AppWindow.Hide();
};
}

private async Task InitialiseTrayAsync()
{
_tray = Services.GetRequiredService<ITrayService>();
_tray.ShowRequested += OnTrayShowRequested;
_tray.ExitRequested += OnTrayExitRequested;
await _tray.InitialiseAsync();
}

private void InitialiseHotkey(AppConfig config)
{
_hotkey = Services.GetRequiredService<IHotkeyService>();
_hotkey.Pressed += OnHotkeyPressed;
_ = _hotkey.TryRegister(config.Hotkey);
}

private void OnTrayShowRequested(object? sender, EventArgs e) => BringToFront();

private void OnTrayExitRequested(object? sender, EventArgs e)
{
_allowClose = true;
_mainWindow?.Close();
}

private void OnHotkeyPressed(object? sender, EventArgs e) => BringToFront();

private void BringToFront()
{
if (_mainWindow is null)
{
return;
}
_mainWindow.AppWindow.Show();
_mainWindow.Activate();
}
}
}
3 changes: 3 additions & 0 deletions src/Snipdeck.App/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public static IServiceProvider Build()
.AddSingleton<IDispatcher, WinUiDispatcher>()
.AddSingleton<IClipboardService, WindowsClipboardService>()
.AddSingleton<IIconNormaliser, WindowsIconNormaliser>()
.AddSingleton<IFilePickerService, WindowsFilePickerService>()
.AddSingleton<IHotkeyService, WindowsHotkeyService>()
.AddSingleton<ITrayService, HNotifyIconTrayService>()
.AddSingleton<IShellInteractions, WindowsShellInteractions>()
.AddSingleton<ISettingsStore>(settingsStore)
.AddSingleton<ISnipStore>(snipStore)
Expand Down
1 change: 1 addition & 0 deletions src/Snipdeck.App/Controls/CliCard.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<controls:Identicon
Grid.Row="0"
Seed="{x:Bind ViewModel.Id, Mode=OneWay}"
IconRef="{x:Bind ViewModel.IconRef, Mode=OneWay}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="96"
Expand Down
44 changes: 34 additions & 10 deletions src/Snipdeck.App/Controls/Identicon.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,27 @@
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;

using Snipdeck.Core.Abstractions;
using Snipdeck.Core.Services;

using Windows.Storage.Streams;

namespace Snipdeck.App.Controls
{
/// <summary>
/// Renders an identicon from a <see cref="Guid"/> seed. The seed should be
/// the immutable <c>Cli.Id</c> so renaming a CLI doesn't change its icon.
/// Renders a CLI's icon. Falls back to a deterministic identicon seeded
/// off <see cref="Seed"/> when <see cref="IconRef"/> is empty or the
/// referenced file can't be read.
/// </summary>
public sealed partial class Identicon : UserControl
{
public static readonly DependencyProperty SeedProperty =
DependencyProperty.Register(
nameof(Seed),
typeof(Guid),
typeof(Identicon),
new PropertyMetadata(Guid.Empty, OnSeedChanged));
DependencyProperty.Register(nameof(Seed), typeof(Guid), typeof(Identicon),
new PropertyMetadata(Guid.Empty, OnVisualPropertyChanged));

public static readonly DependencyProperty IconRefProperty =
DependencyProperty.Register(nameof(IconRef), typeof(string), typeof(Identicon),
new PropertyMetadata(null, OnVisualPropertyChanged));

public Identicon()
{
Expand All @@ -32,7 +35,13 @@ public Guid Seed
set => SetValue(SeedProperty, value);
}

private static void OnSeedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
public string? IconRef
{
get => (string?)GetValue(IconRefProperty);
set => SetValue(IconRefProperty, value);
}

private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is Identicon icon)
{
Expand All @@ -42,13 +51,28 @@ private static void OnSeedChanged(DependencyObject d, DependencyPropertyChangedE

private async Task UpdateImageAsync()
{
// Prefer the uploaded icon if present.
var storage = Snipdeck.App.App.Services.GetService(typeof(IIconAssetStorage)) as IIconAssetStorage;
var absolute = storage?.ResolveAbsolutePath(IconRef);
if (!string.IsNullOrEmpty(absolute) && System.IO.File.Exists(absolute))
{
var bytes = await System.IO.File.ReadAllBytesAsync(absolute);
IconImage.Source = await DecodeAsync(bytes);
return;
}

if (Seed == Guid.Empty)
{
IconImage.Source = null;
return;
}

var bytes = IdenticonService.GeneratePng(Seed);
var identicon = IdenticonService.GeneratePng(Seed);
IconImage.Source = await DecodeAsync(identicon);
}

private static async Task<BitmapImage> DecodeAsync(byte[] bytes)
{
var image = new BitmapImage();
using var stream = new InMemoryRandomAccessStream();
var writer = new DataWriter(stream);
Expand All @@ -57,7 +81,7 @@ private async Task UpdateImageAsync()
_ = writer.DetachStream();
stream.Seek(0);
await image.SetSourceAsync(stream);
IconImage.Source = image;
return image;
}
}
}
117 changes: 117 additions & 0 deletions src/Snipdeck.App/Services/HNotifyIconTrayService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using H.NotifyIcon;

using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;

using Snipdeck.Core.Abstractions;
using Snipdeck.Core.Services;

using Windows.Storage.Streams;

namespace Snipdeck.App.Services
{
internal sealed partial class HNotifyIconTrayService : ITrayService
{
// Stable seed so the tray icon never changes between runs of Snipdeck.
private static readonly Guid _iconSeed = Guid.Parse("5147DEC0-0000-0000-0000-000000000001");

private TaskbarIcon? _icon;
private bool _disposed;

public event EventHandler? ShowRequested;

public event EventHandler? ExitRequested;

public async Task InitialiseAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);

if (_icon is not null)
{
return;
}

var image = await BuildTrayBitmapAsync();

_icon = new TaskbarIcon
{
ToolTipText = "Snipdeck",
IconSource = image,
ContextFlyout = BuildContextMenu(),
NoLeftClickDelay = true,
LeftClickCommand = new RelayCommand(RaiseShowRequested),
};
_icon.ForceCreate();
}

public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_icon?.Dispose();
_icon = null;
}

private MenuFlyout BuildContextMenu()
{
var showItem = new MenuFlyoutItem { Text = "Show Snipdeck" };
showItem.Click += OnShowItemClick;

var exitItem = new MenuFlyoutItem { Text = "Exit" };
exitItem.Click += OnExitItemClick;

return new MenuFlyout
{
Items =
{
showItem,
new MenuFlyoutSeparator(),
exitItem,
},
};
}

private void OnShowItemClick(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
RaiseShowRequested();
}

private void OnExitItemClick(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
ExitRequested?.Invoke(this, EventArgs.Empty);
}

private void RaiseShowRequested()
{
ShowRequested?.Invoke(this, EventArgs.Empty);
}

private static async Task<BitmapImage> BuildTrayBitmapAsync()
{
var bytes = IdenticonService.GeneratePng(_iconSeed, size: 32);
var image = new BitmapImage();
using var stream = new InMemoryRandomAccessStream();
var writer = new DataWriter(stream);
writer.WriteBytes(bytes);
_ = await writer.StoreAsync();
_ = writer.DetachStream();
stream.Seek(0);
await image.SetSourceAsync(stream);
return image;
}

private sealed partial class RelayCommand(Action execute) : System.Windows.Input.ICommand
{
#pragma warning disable CS0067 // 'CanExecuteChanged' is never used — relay never changes.
public event EventHandler? CanExecuteChanged;
#pragma warning restore CS0067

public bool CanExecute(object? parameter) => true;

public void Execute(object? parameter) => execute();
}
}
}
Loading
Loading