Add keyboard shortcut functionality

pull/12/head
Steven Jennison 10 months ago
parent 06da982f0b
commit bd6f435991

@ -619,6 +619,14 @@ namespace dvmconsole.Controls
selectedChannelsManager.RemoveSelectedChannel(this);
}
public void TriggerPTTState(bool pttState)
{
if (!IsSelected)
return;
PttState = pttState;
PTTButtonClicked?.Invoke(null, this);
}
/// <summary>
///
/// </summary>

@ -0,0 +1,235 @@
// SPDX-License-Identifier: CC-BY-SA-4.0
/**
* Digital Voice Modem - Desktop Dispatch Console
* CC-BY-SA-4.0 License. Use is subject to license terms.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* @package DVM / Desktop Dispatch Console
* @license CC-BY-SA-4.0 License (https://creativecommons.org/licenses/by-sa/4.0/legalcode)
* Code copied in its entirety from https://stackoverflow.com/a/57710850
*
*/
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace dvmconsole;
public class GlobalKeyboardHookEventArgs : HandledEventArgs
{
public GlobalKeyboardHook.KeyboardState KeyboardState { get; private set; }
public GlobalKeyboardHook.LowLevelKeyboardInputEvent KeyboardData { get; private set; }
public GlobalKeyboardHookEventArgs(
GlobalKeyboardHook.LowLevelKeyboardInputEvent keyboardData,
GlobalKeyboardHook.KeyboardState keyboardState)
{
KeyboardData = keyboardData;
KeyboardState = keyboardState;
}
}
//Based on https://gist.github.com/Stasonix
public class GlobalKeyboardHook : IDisposable
{
public event EventHandler<GlobalKeyboardHookEventArgs> KeyboardPressed;
// EDT: Added an optional parameter (registeredKeys) that accepts keys to restict
// the logging mechanism.
/// <summary>
///
/// </summary>
/// <param name="registeredKeys">Keys that should trigger logging. Pass null for full logging.</param>
public GlobalKeyboardHook(Keys[] registeredKeys = null)
{
RegisteredKeys = registeredKeys;
_windowsHookHandle = IntPtr.Zero;
_user32LibraryHandle = IntPtr.Zero;
HookProcHandle = LowLevelKeyboardProc; // we must keep alive _hookProc, because GC is not aware about SetWindowsHookEx behaviour.
_user32LibraryHandle = LoadLibrary("User32");
if (_user32LibraryHandle == IntPtr.Zero)
{
int errorCode = Marshal.GetLastWin32Error();
throw new Win32Exception(errorCode, $"Failed to load library 'User32.dll'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}.");
}
_windowsHookHandle = SetWindowsHookEx(WH_KEYBOARD_LL, HookProcHandle, _user32LibraryHandle, 0);
if (_windowsHookHandle == IntPtr.Zero)
{
int errorCode = Marshal.GetLastWin32Error();
throw new Win32Exception(errorCode, $"Failed to adjust keyboard hooks for '{Process.GetCurrentProcess().ProcessName}'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}.");
}
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
// because we can unhook only in the same thread, not in garbage collector thread
if (_windowsHookHandle != IntPtr.Zero)
{
if (!UnhookWindowsHookEx(_windowsHookHandle))
{
int errorCode = Marshal.GetLastWin32Error();
throw new Win32Exception(errorCode, $"Failed to remove keyboard hooks for '{Process.GetCurrentProcess().ProcessName}'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}.");
}
_windowsHookHandle = IntPtr.Zero;
// ReSharper disable once DelegateSubtraction
HookProcHandle -= LowLevelKeyboardProc;
}
}
if (_user32LibraryHandle != IntPtr.Zero)
{
if (!FreeLibrary(_user32LibraryHandle)) // reduces reference to library by 1.
{
int errorCode = Marshal.GetLastWin32Error();
throw new Win32Exception(errorCode, $"Failed to unload library 'User32.dll'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}.");
}
_user32LibraryHandle = IntPtr.Zero;
}
}
~GlobalKeyboardHook()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private IntPtr _windowsHookHandle;
private IntPtr _user32LibraryHandle;
public static HookProc HookProcHandle;
public delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll")]
private static extern IntPtr LoadLibrary(string lpFileName);
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
private static extern bool FreeLibrary(IntPtr hModule);
/// <summary>
/// The SetWindowsHookEx function installs an application-defined hook procedure into a hook chain.
/// You would install a hook procedure to monitor the system for certain types of events. These events are
/// associated either with a specific thread or with all threads in the same desktop as the calling thread.
/// </summary>
/// <param name="idHook">hook type</param>
/// <param name="lpfn">hook procedure</param>
/// <param name="hMod">handle to application instance</param>
/// <param name="dwThreadId">thread identifier</param>
/// <returns>If the function succeeds, the return value is the handle to the hook procedure.</returns>
[DllImport("USER32", SetLastError = true)]
static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, int dwThreadId);
/// <summary>
/// The UnhookWindowsHookEx function removes a hook procedure installed in a hook chain by the SetWindowsHookEx function.
/// </summary>
/// <param name="hhk">handle to hook procedure</param>
/// <returns>If the function succeeds, the return value is true.</returns>
[DllImport("USER32", SetLastError = true)]
public static extern bool UnhookWindowsHookEx(IntPtr hHook);
/// <summary>
/// The CallNextHookEx function passes the hook information to the next hook procedure in the current hook chain.
/// A hook procedure can call this function either before or after processing the hook information.
/// </summary>
/// <param name="hHook">handle to current hook</param>
/// <param name="code">hook code passed to hook procedure</param>
/// <param name="wParam">value passed to hook procedure</param>
/// <param name="lParam">value passed to hook procedure</param>
/// <returns>If the function succeeds, the return value is true.</returns>
[DllImport("USER32", SetLastError = true)]
static extern IntPtr CallNextHookEx(IntPtr hHook, int code, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
public struct LowLevelKeyboardInputEvent
{
/// <summary>
/// A virtual-key code. The code must be a value in the range 1 to 254.
/// </summary>
public int VirtualCode;
// EDT: added a conversion from VirtualCode to Keys.
/// <summary>
/// The VirtualCode converted to typeof(Keys) for higher usability.
/// </summary>
public Keys Key { get { return (Keys)VirtualCode; } }
/// <summary>
/// A hardware scan code for the key.
/// </summary>
public int HardwareScanCode;
/// <summary>
/// The extended-key flag, event-injected Flags, context code, and transition-state flag. This member is specified as follows. An application can use the following values to test the keystroke Flags. Testing LLKHF_INJECTED (bit 4) will tell you whether the event was injected. If it was, then testing LLKHF_LOWER_IL_INJECTED (bit 1) will tell you whether or not the event was injected from a process running at lower integrity level.
/// </summary>
public int Flags;
/// <summary>
/// The time stamp stamp for this message, equivalent to what GetMessageTime would return for this message.
/// </summary>
public int TimeStamp;
/// <summary>
/// Additional information associated with the message.
/// </summary>
public IntPtr AdditionalInformation;
}
public const int WH_KEYBOARD_LL = 13;
//const int HC_ACTION = 0;
public enum KeyboardState
{
KeyDown = 0x0100,
KeyUp = 0x0101,
SysKeyDown = 0x0104,
SysKeyUp = 0x0105
}
// EDT: Replaced VkSnapshot(int) with RegisteredKeys(Keys[])
public Keys[] RegisteredKeys;
const int KfAltdown = 0x2000;
public const int LlkhfAltdown = (KfAltdown >> 8);
EventHandler<GlobalKeyboardHookEventArgs> handler;
public IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam)
{
bool fEatKeyStroke = false;
var wparamTyped = wParam.ToInt32();
handler = KeyboardPressed;
if (Enum.IsDefined(typeof(KeyboardState), wparamTyped))
{
object o = Marshal.PtrToStructure(lParam, typeof(LowLevelKeyboardInputEvent));
LowLevelKeyboardInputEvent p = (LowLevelKeyboardInputEvent)o;
var eventArguments = new GlobalKeyboardHookEventArgs(p, (KeyboardState)wparamTyped);
// EDT: Removed the comparison-logic from the usage-area so the user does not need to mess around with it.
// Either the incoming key has to be part of RegisteredKeys (see constructor on top) or RegisterdKeys
// has to be null for the event to get fired.
var key = (Keys)p.VirtualCode;
if (RegisteredKeys == null || RegisteredKeys.Contains(key))
{
handler?.Invoke(this, eventArguments);
fEatKeyStroke = eventArguments.Handled;
}
}
return fEatKeyStroke ? (IntPtr)1 : CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
}
}

@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-only
/**
* Digital Voice Modem - Desktop Dispatch Console
* AGPLv3 Open Source. Use is subject to license terms.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* @package DVM / Desktop Dispatch Console
* @license AGPLv3 License (https://opensource.org/licenses/AGPL-3.0)
*
* Copyright (C) 2025 Steven Jennison, KD8RHO
*
*/
using System.Collections.ObjectModel;
using System.Windows.Forms;
namespace dvmconsole;
public class KeyboardManager
{
/*
** Properties
*/
public bool IsListening { get; private set; } = false;
public event Action<Keys,GlobalKeyboardHook.KeyboardState> OnKeyEvent;
private GlobalKeyboardHook listenHook;
private GlobalKeyboardHook.HookProc hookProcHandle;
/*
** Methods
*/
public void SetListenKeys(List<Keys> keys)
{
if (listenHook == null)
{
listenHook = new GlobalKeyboardHook(keys.ToArray());
listenHook.KeyboardPressed += ListenHookOnKeyboardEvent;
hookProcHandle = GlobalKeyboardHook.HookProcHandle;
}
else
{
listenHook.RegisteredKeys = keys.ToArray();
}
}
private void ListenHookOnKeyboardEvent(object sender, GlobalKeyboardHookEventArgs e)
{
OnKeyEvent?.Invoke(e.KeyboardData.Key, e.KeyboardState);
}
/// <summary>
/// Gets the next key pressed globally, for use with user dialogs
/// </summary>
/// <returns>The next key pressed</returns>
public async Task<Keys> GetNextKeyPress()
{
GlobalKeyboardHook universalHook = new GlobalKeyboardHook();
Keys? result = null;
universalHook.KeyboardPressed += onUniversalHookOnKeyboardPressed;
while (result == null)
{
await Task.Delay(100);
}
universalHook.KeyboardPressed -= onUniversalHookOnKeyboardPressed;
return result.Value;
void onUniversalHookOnKeyboardPressed(object sender, GlobalKeyboardHookEventArgs args)
{
if (args.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown)
{
result = args.KeyboardData.Key;
// Stop listening for key presses on first key pressed
universalHook.KeyboardPressed -= onUniversalHookOnKeyboardPressed;
}
}
}
}

@ -35,6 +35,7 @@
</MenuItem>
<MenuItem Header="_Settings">
<MenuItem Header="_Toggle Push To Talk Mode" IsCheckable="True" Checked="TogglePTTMode_Click" Unchecked="TogglePTTMode_Click" x:Name="menuTogglePTTMode" />
<MenuItem Header="_Global PTT Keys All Channels" IsCheckable="True" Checked="ToggleGlobalPTTAllChannels_Click" Unchecked="ToggleGlobalPTTAllChannels_Click" x:Name="menuToggleGlobalPTTMode" />
<Separator />
<MenuItem Header="_Audio Settings" Click="AudioSettings_Click" />
<MenuItem Header="_Reset Settings" Click="ResetSettings_Click" />
@ -44,6 +45,10 @@
<MenuItem Header="Alerts">
<MenuItem Header="Add Alert Tone" Click="AddAlertTone_Click" />
</MenuItem>
<Separator />
<MenuItem Header="Keyboard Shortcuts">
<MenuItem Header="Set Global PTT Keybind" Click="SetGlobalPTTKeybind"></MenuItem>
</MenuItem>
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="Select _User Background..." Click="OpenUserBackground_Click" x:Name="menuUserBackground" />

@ -18,12 +18,10 @@ using System.IO;
using System.Timers;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Forms;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Microsoft.Win32;
using NAudio.Wave;
using NWaves.Signals;
@ -40,6 +38,11 @@ using fnecore.DMR;
using fnecore.P25;
using fnecore.P25.KMM;
using fnecore.P25.LC.TSBK;
using Application = System.Windows.Application;
using Cursors = System.Windows.Input.Cursors;
using MessageBox = System.Windows.MessageBox;
using MouseEventArgs = System.Windows.Input.MouseEventArgs;
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
using System.Net.Sockets;
namespace dvmconsole
@ -123,6 +126,7 @@ namespace dvmconsole
private FneSystemManager fneSystemManager = new FneSystemManager();
private bool selectAll = false;
private KeyboardManager keyboardManager;
private CancellationTokenSource maintainenceCancelToken = new CancellationTokenSource();
private Task maintainenceTask = null;
@ -146,13 +150,14 @@ namespace dvmconsole
public MainWindow()
{
InitializeComponent();
this.keyboardManager = new KeyboardManager();
MinWidth = Width = MIN_WIDTH;
MinHeight = Height = MIN_HEIGHT;
DisableControls();
settingsManager.LoadSettings();
InitializeKeyboardShortcuts();
callHistoryWindow = new CallHistoryWindow(settingsManager);
selectedChannelsManager = new SelectedChannelsManager();
@ -1169,6 +1174,7 @@ namespace dvmconsole
menuToggleLockWidgets.IsChecked = settingsManager.LockWidgets;
menuSnapCallHistory.IsChecked = settingsManager.SnapCallHistoryToWindow;
menuTogglePTTMode.IsChecked = settingsManager.TogglePTTMode;
menuToggleGlobalPTTMode.IsChecked = settingsManager.GlobalPTTKeysAllChannels;
menuKeepWindowOnTop.IsChecked = settingsManager.KeepWindowOnTop;
if (!string.IsNullOrEmpty(settingsManager.LastCodeplugPath) && File.Exists(settingsManager.LastCodeplugPath))
@ -2029,7 +2035,7 @@ namespace dvmconsole
}
/// <summary>
///
/// Activates Global PTT after a click or keyboard shortcut
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
@ -2103,7 +2109,7 @@ namespace dvmconsole
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private async void btnGlobalPtt_Click(object sender, RoutedEventArgs e)
private async void GlobalPTTActivate(object sender, RoutedEventArgs e)
{
if (globalPttState)
await Task.Delay(500);
@ -2119,8 +2125,16 @@ namespace dvmconsole
else
btnGlobalPtt.Background = btnGlobalPttDefaultBg;
});
primaryChannel.TriggerPTTState(globalPttState);
return;
}
primaryChannel.PttButton_Click(sender, e);
// Check for global PTT keys all preference, if not enabled, return early
if (!settingsManager.GlobalPTTKeysAllChannels)
{
return;
}
@ -2193,7 +2207,7 @@ namespace dvmconsole
globalPttState = !globalPttState;
btnGlobalPtt_Click(sender, e);
GlobalPTTActivate(sender, e);
}
/// <summary>
@ -2207,12 +2221,12 @@ namespace dvmconsole
if (settingsManager.TogglePTTMode)
{
globalPttState = !globalPttState;
btnGlobalPtt_Click(sender, e);
GlobalPTTActivate(sender, e);
}
else
{
globalPttState = true;
btnGlobalPtt_Click(sender, e);
GlobalPTTActivate(sender, e);
}
}
@ -2228,7 +2242,7 @@ namespace dvmconsole
return;
globalPttState = false;
btnGlobalPtt_Click(sender, e);
GlobalPTTActivate(sender, e);
}
/// <summary>
@ -2406,5 +2420,87 @@ namespace dvmconsole
});
}
}
/// <summary>
/// Sets the global PTT keybind
/// Hooks a listener to listen for a keypress, then saves that as the global PTT keybind
/// Global PTT keybind is effectively the same as pressing the Global PTT button
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
/// <exception cref="NotImplementedException"></exception>
private async void SetGlobalPTTKeybind(object sender, RoutedEventArgs e)
{
// Create and show a MessageBox with no buttons or standard close behavior
Window messageBox = new Window
{
Width = 500,
Height = 150,
WindowStyle = WindowStyle.None,
ShowInTaskbar = false,
ResizeMode = ResizeMode.NoResize,
Topmost = true,
Background = System.Windows.Media.Brushes.White,
Content = new System.Windows.Controls.TextBlock
{
Text = "Press any key to set the Global PTT shortcut...",
HorizontalAlignment = System.Windows.HorizontalAlignment.Center,
VerticalAlignment = System.Windows.VerticalAlignment.Center,
FontSize = 16,
FontWeight = System.Windows.FontWeights.Bold,
}
};
// Center messageBox on the main window
messageBox.Owner = this; // Set the current window as owner
messageBox.WindowStartupLocation = WindowStartupLocation.CenterOwner;
// Open and close the MessageBox after 500ms
messageBox.Show();
Keys keyPress = await keyboardManager.GetNextKeyPress();
messageBox.Close();
settingsManager.GlobalPTTShortcut = keyPress;
InitializeKeyboardShortcuts();
settingsManager.SaveSettings();
MessageBox.Show("Global PTT shortcut set to " + keyPress.ToString(), "Success", MessageBoxButton.OK, MessageBoxImage.Information);
}
/// <summary>
/// Initializes global keyboard shortcut listener
/// </summary>
private void InitializeKeyboardShortcuts()
{
var listeningKeys = new List<Keys> { settingsManager.GlobalPTTShortcut };
keyboardManager.SetListenKeys(listeningKeys);
// Clear event listener
keyboardManager.OnKeyEvent -= KeyboardManagerOnKeyEvent;
// Re-add listener
keyboardManager.OnKeyEvent += KeyboardManagerOnKeyEvent;
}
private void KeyboardManagerOnKeyEvent(Keys pressedKey,GlobalKeyboardHook.KeyboardState state)
{
if (pressedKey == settingsManager.GlobalPTTShortcut)
{
if(state is GlobalKeyboardHook.KeyboardState.KeyDown or GlobalKeyboardHook.KeyboardState.SysKeyDown)
{
globalPttState = true;
GlobalPTTActivate(null, null);
}
else
{
globalPttState = false;
GlobalPTTActivate(null, null);
}
}
}
private void ToggleGlobalPTTAllChannels_Click(object sender, RoutedEventArgs e)
{
settingsManager.GlobalPTTKeysAllChannels = !settingsManager.GlobalPTTKeysAllChannels;
}
} // public partial class MainWindow : Window
} // namespace dvmconsole

@ -9,6 +9,7 @@
*
* Copyright (C) 2024-2025 Caleb, K4PHP
* Copyright (C) 2025 Bryan Biedenkapp, N2PLL
* Copyright (C) 2025 Steven Jennison, KD8RHO
*
*/
@ -16,6 +17,7 @@ using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Windows.Forms;
using Newtonsoft.Json;
using fnecore.Utility;
@ -135,6 +137,11 @@ namespace dvmconsole
/// </summary>
public bool SaveTraceLog { get; set; }
public Keys GlobalPTTShortcut { get; set; } = Keys.None;
public bool GlobalPTTKeysAllChannels { get; set; }
/*
** Methods
*/
@ -166,6 +173,7 @@ namespace dvmconsole
if (loadedSettings != null)
{
GlobalPTTKeysAllChannels = loadedSettings.GlobalPTTKeysAllChannels;
ShowSystemStatus = loadedSettings.ShowSystemStatus;
ShowChannels = loadedSettings.ShowChannels;
ShowAlertTones = loadedSettings.ShowAlertTones;
@ -202,6 +210,8 @@ namespace dvmconsole
UserBackgroundImage = loadedSettings.UserBackgroundImage;
SaveTraceLog = loadedSettings.SaveTraceLog;
GlobalPTTShortcut = loadedSettings.GlobalPTTShortcut;
if (SaveTraceLog)
Log.SetupTextWriter(Environment.CurrentDirectory, "dvmconsole.log");

Loading…
Cancel
Save

Powered by TurnKey Linux.