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

### Added
- **Rebindable global hotkey.** The global hotkey is now editable from
Settings: click the capture box and press a shortcut (at least one of
Ctrl/Alt/Shift plus a key). The new binding registers and persists
immediately; if the chord is already taken by another app, the previous
binding is restored and a brief notice is shown. A "Reset" button restores
the default (Ctrl+Alt+S). Previously the hotkey was display-only.
- **Markdown rendering for snip descriptions.** A snip's description is now
rendered as Markdown (headings, bold/italic, inline and block code, links,
ordered/unordered lists) in the copy flyout, instead of being hidden. The
Expand Down
26 changes: 26 additions & 0 deletions src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="Snipdeck.App.Controls.HotkeyCaptureBox"
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"
mc:Ignorable="d"
IsTabStop="True"
UseSystemFocusVisuals="True"
Tapped="OnTapped"
KeyDown="OnKeyDown"
LostFocus="OnLostFocus">

<Border x:Name="Root"
Background="{ThemeResource ButtonBackground}"
BorderBrush="{ThemeResource ButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{ThemeResource ControlCornerRadius}"
Padding="11,5"
MinWidth="160">
<TextBlock x:Name="Label"
HorizontalAlignment="Center"
TextAlignment="Center" />
</Border>
</UserControl>
198 changes: 198 additions & 0 deletions src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using System.Windows.Input;

using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;

using Snipdeck.Core.Models;

using Windows.System;
using Windows.UI.Core;

namespace Snipdeck.App.Controls
{
/// <summary>
/// A focusable box that records a global-hotkey chord. Click (or focus) it,
/// then press a modifier combination plus a key; it builds a
/// <see cref="HotkeyBinding"/> and invokes <see cref="Command"/>. Esc cancels.
/// Modifier-only and unsupported keys are ignored so the box keeps listening
/// until a usable chord (≥1 modifier + key) is pressed.
/// </summary>
public sealed partial class HotkeyCaptureBox : UserControl
{
private const string _recordingPrompt = "Press a shortcut… (Esc to cancel)";

private bool _recording;

public static readonly DependencyProperty CommandProperty =
DependencyProperty.Register(nameof(Command), typeof(ICommand), typeof(HotkeyCaptureBox),
new PropertyMetadata(null));

public static readonly DependencyProperty CurrentTextProperty =
DependencyProperty.Register(nameof(CurrentText), typeof(string), typeof(HotkeyCaptureBox),
new PropertyMetadata(string.Empty, OnCurrentTextChanged));

public HotkeyCaptureBox()
{
InitializeComponent();
UpdateLabel();
}

/// <summary>Executed with the captured <see cref="HotkeyBinding"/> when a chord is recorded.</summary>
public ICommand? Command
{
get => (ICommand?)GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}

/// <summary>The current binding's display text, shown when not recording.</summary>
public string CurrentText
{
get => (string)GetValue(CurrentTextProperty);
set => SetValue(CurrentTextProperty, value);
}

private static void OnCurrentTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((HotkeyCaptureBox)d).UpdateLabel();
}

private void OnTapped(object sender, TappedRoutedEventArgs e)
{
StartRecording();
_ = Focus(FocusState.Programmatic);
}

private void OnLostFocus(object sender, RoutedEventArgs e)
{
StopRecording();
}

private void OnKeyDown(object sender, KeyRoutedEventArgs e)
{
if (!_recording)
{
// Keyboard activation: Enter or Space begins recording (pointer
// users start it by tapping). Every other key passes through so
// Tab navigation through Settings still works.
if (e.Key is VirtualKey.Enter or VirtualKey.Space)
{
StartRecording();
e.Handled = true;
}
return;
}

if (e.Key == VirtualKey.Escape)
{
StopRecording();
e.Handled = true;
return;
}

// A modifier on its own isn't a chord yet — keep listening.
if (IsModifierKey(e.Key))
{
e.Handled = true;
return;
}

var modifiers = ReadModifiers();
if (modifiers == HotkeyModifiers.None)
{
// A bare key while recording (e.g. Tab to move on) — cancel and
// let it act normally so keyboard focus isn't trapped in the box.
StopRecording();
return;
}

var key = MapKey(e.Key);
if (key is null)
{
// Modifiers held but an unsupported key — swallow and keep waiting.
e.Handled = true;
return;
}

var binding = new HotkeyBinding { Modifiers = modifiers, Key = key };
StopRecording();
if (Command?.CanExecute(binding) == true)
{
Command.Execute(binding);
}
e.Handled = true;
}

private void StartRecording()
{
_recording = true;
Label.Text = _recordingPrompt;
}

private void StopRecording()
{
_recording = false;
UpdateLabel();
}

private void UpdateLabel()
{
if (!_recording)
{
Label.Text = string.IsNullOrEmpty(CurrentText) ? "(unbound)" : CurrentText;
}
}

private static bool IsModifierKey(VirtualKey key) =>
key is VirtualKey.Control or VirtualKey.LeftControl or VirtualKey.RightControl
or VirtualKey.Menu or VirtualKey.LeftMenu or VirtualKey.RightMenu
or VirtualKey.Shift or VirtualKey.LeftShift or VirtualKey.RightShift
or VirtualKey.LeftWindows or VirtualKey.RightWindows;

private static bool IsDown(VirtualKey key) =>
InputKeyboardSource.GetKeyStateForCurrentThread(key).HasFlag(CoreVirtualKeyStates.Down);

private static HotkeyModifiers ReadModifiers()
{
var modifiers = HotkeyModifiers.None;
if (IsDown(VirtualKey.Control))
{
modifiers |= HotkeyModifiers.Control;
}
if (IsDown(VirtualKey.Menu))
{
modifiers |= HotkeyModifiers.Alt;
}
if (IsDown(VirtualKey.Shift))
{
modifiers |= HotkeyModifiers.Shift;
}
if (IsDown(VirtualKey.LeftWindows) || IsDown(VirtualKey.RightWindows))
{
modifiers |= HotkeyModifiers.Windows;
}
return modifiers;
}

/// <summary>
/// Maps a virtual key to the Core key vocabulary the hotkey service
/// understands (A-Z, 0-9, F1-F12, Space, Tab, Enter). Returns null for
/// anything unsupported so the caller keeps listening.
/// </summary>
// Expressed as conditional chains rather than a switch over VirtualKey:
// that enum has hundreds of members, so a switch can satisfy neither the
// "populate every case" nor the "use a switch expression" analysers.
private static string? MapKey(VirtualKey key) =>
key is >= VirtualKey.A and <= VirtualKey.Z ? key.ToString()
: key is >= VirtualKey.Number0 and <= VirtualKey.Number9 ? ((char)('0' + (key - VirtualKey.Number0))).ToString()
: key is >= VirtualKey.F1 and <= VirtualKey.F12 ? key.ToString()
: NamedKey(key);

private static string? NamedKey(VirtualKey key) =>
key == VirtualKey.Space ? "Space"
: key == VirtualKey.Tab ? "Tab"
: key == VirtualKey.Enter ? "Enter"
: null;
}
}
22 changes: 22 additions & 0 deletions src/Snipdeck.App/Converters/EmptyStringToVisibilityConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;

namespace Snipdeck.App.Converters
{
/// <summary>
/// Visible when the bound string is non-empty, Collapsed otherwise — so
/// status/error text takes no layout space until there's something to show.
/// </summary>
public sealed partial class EmptyStringToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
return string.IsNullOrEmpty(value as string) ? Visibility.Collapsed : Visibility.Visible;
}

public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotSupportedException();
}
}
}
20 changes: 17 additions & 3 deletions src/Snipdeck.App/Views/ShellPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<converters:BoolToVisibilityConverter x:Key="BoolToVisibility" />
<converters:BoolToVisibilityConverter x:Key="InvertedBoolToVisibility" Invert="True" />
<converters:CountToVisibilityConverter x:Key="CountToVisibility" />
<converters:EmptyStringToVisibilityConverter x:Key="EmptyStringToVisibility" />

<DataTemplate x:Key="HomeContentTemplate" x:DataType="vm:HomeViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
Expand Down Expand Up @@ -167,9 +168,22 @@
</tk:SettingsCard>

<tk:SettingsCard Header="Global hotkey"
Description="Rebinding lands in a future phase.">
<TextBlock Text="{x:Bind HotkeyDisplay}"
Style="{ThemeResource BodyStrongTextBlockStyle}" />
Description="Click, then press a shortcut (needs Ctrl, Alt or Shift). Summons Snipdeck from anywhere.">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="{x:Bind HotkeyError, Mode=OneWay}"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{ThemeResource CaptionTextBlockStyle}"
VerticalAlignment="Center"
TextWrapping="Wrap"
MaxWidth="200"
Visibility="{x:Bind HotkeyError, Mode=OneWay, Converter={StaticResource EmptyStringToVisibility}}" />
<controls:HotkeyCaptureBox
CurrentText="{x:Bind HotkeyDisplay, Mode=OneWay}"
Command="{x:Bind RebindHotkeyCommand}" />
<Button Content="Reset"
Command="{x:Bind ResetHotkeyCommand}"
ToolTipService.ToolTip="Reset to Ctrl+Alt+S" />
</StackPanel>
</tk:SettingsCard>

<tk:SettingsCard Header="Storage location"
Expand Down
39 changes: 39 additions & 0 deletions src/Snipdeck.Core/Models/HotkeyBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,49 @@ public sealed class HotkeyBinding
public bool IsEmpty =>
Modifiers == HotkeyModifiers.None && string.IsNullOrWhiteSpace(Key);

/// <summary>
/// A binding the platform can actually register: at least one modifier
/// plus a key. A bare key (no modifier) is rejected — global hotkeys
/// need a modifier so they don't swallow ordinary typing.
/// </summary>
public bool IsValid =>
Modifiers != HotkeyModifiers.None && !string.IsNullOrWhiteSpace(Key);

public static HotkeyBinding Default => new()
{
Modifiers = HotkeyModifiers.Control | HotkeyModifiers.Alt,
Key = "S",
};

/// <summary>Human-readable chord, e.g. "Ctrl+Alt+S" (or "(unbound)").</summary>
public string ToDisplayString()
{
if (IsEmpty)
{
return "(unbound)";
}
var parts = new List<string>();
if (Modifiers.HasFlag(HotkeyModifiers.Control))
{
parts.Add("Ctrl");
}
if (Modifiers.HasFlag(HotkeyModifiers.Alt))
{
parts.Add("Alt");
}
if (Modifiers.HasFlag(HotkeyModifiers.Shift))
{
parts.Add("Shift");
}
if (Modifiers.HasFlag(HotkeyModifiers.Windows))
{
parts.Add("Win");
}
if (!string.IsNullOrWhiteSpace(Key))
{
parts.Add(Key);
}
return string.Join('+', parts);
}
}
}
Loading
Loading