Add the possibility to configure the shortcut

This commit is contained in:
Thorsten Sommer 2026-01-21 09:49:01 +01:00
parent 6ed9338d23
commit 7dae8c3788
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
13 changed files with 968 additions and 22 deletions

View File

@ -1675,6 +1675,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218
-- Use app default -- Use app default
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "Use app default" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "Use app default"
-- No shortcut configured
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T3099115336"] = "No shortcut configured"
-- Configure
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T373171691"] = "Configure"
-- Configure Keyboard Shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Configure Keyboard Shortcut"
-- Yes, let the AI decide which data sources are needed. -- Yes, let the AI decide which data sources are needed.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Yes, let the AI decide which data sources are needed." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Yes, let the AI decide which data sources are needed."
@ -2017,6 +2026,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"]
-- Do you want to show preview features in the app? -- Do you want to show preview features in the app?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?"
-- Voice recording shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1278320412"] = "Voice recording shortcut"
-- How often should we check for app updates? -- How often should we check for app updates?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "How often should we check for app updates?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "How often should we check for app updates?"
@ -2047,6 +2059,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"]
-- Select the language for the app. -- Select the language for the app.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app."
-- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused."
-- Disable dictation and transcription -- Disable dictation and transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription"
@ -4612,6 +4627,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T3832
-- Preselect one of your profiles? -- Preselect one of your profiles?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Preselect one of your profiles?" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Preselect one of your profiles?"
-- Save
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1294818664"] = "Save"
-- Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1464973299"] = "Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused."
-- Press a key combination...
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1468443151"] = "Press a key combination..."
-- Clear Shortcut
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1807313248"] = "Clear Shortcut"
-- Invalid shortcut: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T189893682"] = "Invalid shortcut: {0}"
-- This shortcut conflicts with: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T2633102934"] = "This shortcut conflicts with: {0}"
-- Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3060573513"] = "Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd)."
-- Shortcut is valid and available.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3159532525"] = "Shortcut is valid and available."
-- Supported modifiers: Ctrl/Cmd, Shift, Alt. Example: Ctrl+Shift+R
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3774517957"] = "Supported modifiers: Ctrl/Cmd, Shift, Alt. Example: Ctrl+Shift+R"
-- Configure Keyboard Shortcut
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T636303786"] = "Configure Keyboard Shortcut"
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T900713019"] = "Cancel"
-- Please enter a value. -- Please enter a value.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value."

View File

@ -0,0 +1,25 @@
@inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@this.Icon" Color="@this.IconColor"/>
<MudText Typo="Typo.body1" Class="flex-grow-1">
@if (string.IsNullOrWhiteSpace(this.Shortcut()))
{
<span style="color: var(--mud-palette-text-secondary);">@T("No shortcut configured")</span>
}
else
{
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Outlined">
@this.GetDisplayShortcut()
</MudChip>
}
</MudText>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Edit"
OnClick="@this.OpenDialog"
Disabled="@this.IsDisabled">
@T("Configure")
</MudButton>
</MudStack>

View File

@ -0,0 +1,94 @@
using AIStudio.Dialogs;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components;
/// <summary>
/// A configuration component for capturing and displaying keyboard shortcuts.
/// </summary>
public partial class ConfigurationShortcut : ConfigurationBaseCore
{
[Inject]
private IDialogService DialogService { get; init; } = null!;
/// <summary>
/// The current shortcut value.
/// </summary>
[Parameter]
public Func<string> Shortcut { get; set; } = () => string.Empty;
/// <summary>
/// An action which is called when the shortcut was changed.
/// </summary>
[Parameter]
public Func<string, Task> ShortcutUpdate { get; set; } = _ => Task.CompletedTask;
/// <summary>
/// The name/identifier of the shortcut (used for conflict detection and registration).
/// </summary>
[Parameter]
public string ShortcutName { get; set; } = string.Empty;
/// <summary>
/// The icon to display.
/// </summary>
[Parameter]
public string Icon { get; set; } = Icons.Material.Filled.Keyboard;
/// <summary>
/// The color of the icon.
/// </summary>
[Parameter]
public Color IconColor { get; set; } = Color.Default;
#region Overrides of ConfigurationBase
protected override bool Stretch => true;
protected override Variant Variant => Variant.Outlined;
protected override string Label => this.OptionDescription;
#endregion
private string GetDisplayShortcut()
{
var shortcut = this.Shortcut();
if (string.IsNullOrWhiteSpace(shortcut))
return string.Empty;
// Convert internal format to display format
return shortcut
.Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl")
.Replace("CommandOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl");
}
private async Task OpenDialog()
{
var currentShortcut = this.Shortcut();
var dialogParameters = new DialogParameters<ShortcutDialog>
{
{ x => x.InitialShortcut, currentShortcut },
{ x => x.ShortcutName, this.ShortcutName },
};
var dialogReference = await this.DialogService.ShowAsync<ShortcutDialog>(
this.T("Configure Keyboard Shortcut"),
dialogParameters,
DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
if (dialogResult.Data is string newShortcut)
{
await this.ShortcutUpdate(newShortcut);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
}
}

View File

@ -33,5 +33,6 @@
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
{ {
<ConfigurationSelect OptionDescription="@T("Select a transcription provider")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)" Data="@this.GetFilteredTranscriptionProviders()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider = selectedValue)" OptionHelp="@T("Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UseTranscriptionProvider, out var meta) && meta.IsLocked"/> <ConfigurationSelect OptionDescription="@T("Select a transcription provider")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)" Data="@this.GetFilteredTranscriptionProviders()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider = selectedValue)" OptionHelp="@T("Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UseTranscriptionProvider, out var meta) && meta.IsLocked"/>
<ConfigurationShortcut OptionDescription="@T("Voice recording shortcut")" Shortcut="@(() => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording)" ShortcutUpdate="@this.UpdateVoiceRecordingShortcut" ShortcutName="voice_recording_toggle" OptionHelp="@T("The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShortcutVoiceRecording, out var meta) && meta.IsLocked"/>
} }
</ExpansionPanel> </ExpansionPanel>

View File

@ -35,4 +35,10 @@ public partial class SettingsPanelApp : SettingsPanelBase
this.SettingsManager.ConfigurationData.App.LanguagePluginId = pluginId; this.SettingsManager.ConfigurationData.App.LanguagePluginId = pluginId;
await this.MessageBus.SendMessage<bool>(this, Event.PLUGINS_RELOADED); await this.MessageBus.SendMessage<bool>(this, Event.PLUGINS_RELOADED);
} }
private async Task UpdateVoiceRecordingShortcut(string shortcut)
{
this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording = shortcut;
await this.RustService.UpdateGlobalShortcut("voice_recording_toggle", shortcut);
}
} }

View File

@ -1,5 +1,4 @@
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Tools;
using AIStudio.Tools.MIME; using AIStudio.Tools.MIME;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
@ -145,6 +144,10 @@ public partial class VoiceRecorder : MSGComponentBase
// Clean up the recording stream if starting failed: // Clean up the recording stream if starting failed:
await this.FinalizeRecordingStream(); await this.FinalizeRecordingStream();
} }
finally
{
this.StateHasChanged();
}
} }
else else
{ {

View File

@ -0,0 +1,63 @@
@inherits MSGComponentBase
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6" Class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Keyboard" Class="mr-2"/>
@T("Configure Keyboard Shortcut")
</MudText>
</TitleContent>
<DialogContent>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.")
</MudJustifiedText>
<MudPaper Class="pa-4 mb-3 d-flex align-center justify-center shortcut-capture-area"
Style="min-height: 80px; cursor: pointer;"
Elevation="2"
@onclick="@this.FocusInput">
@* Hidden input to capture keyboard events *@
<input type="text"
@ref="this.hiddenInput"
@onkeydown="@this.HandleKeyDown"
style="position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none;"
autocomplete="off"
readonly />
@if (string.IsNullOrWhiteSpace(this.currentShortcut))
{
<MudText Typo="Typo.h6" Color="Color.Secondary">
@T("Press a key combination...")
</MudText>
}
else
{
<MudText Typo="Typo.h5" Color="Color.Primary">
@this.GetDisplayShortcut()
</MudText>
}
</MudPaper>
@if (!string.IsNullOrWhiteSpace(this.validationMessage))
{
<MudAlert Severity="@this.validationSeverity" Class="mb-3">
@this.validationMessage
</MudAlert>
}
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
@T("Supported modifiers: Ctrl/Cmd, Shift, Alt. Example: Ctrl+Shift+R")
</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.ClearShortcut" Variant="Variant.Text" Color="Color.Warning" StartIcon="@Icons.Material.Filled.Clear">
@T("Clear Shortcut")
</MudButton>
<MudSpacer/>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="Color.Primary" Disabled="@this.hasValidationError">
@T("Save")
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,379 @@
using AIStudio.Components;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace AIStudio.Dialogs;
/// <summary>
/// A dialog for capturing and configuring keyboard shortcuts.
/// </summary>
public partial class ShortcutDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
/// <summary>
/// The initial shortcut value (in internal format, e.g., "CmdOrControl+1").
/// </summary>
[Parameter]
public string InitialShortcut { get; set; } = string.Empty;
/// <summary>
/// The name/identifier of the shortcut for conflict detection.
/// </summary>
[Parameter]
public string ShortcutName { get; set; } = string.Empty;
private ElementReference hiddenInput;
private string currentShortcut = string.Empty;
private string validationMessage = string.Empty;
private Severity validationSeverity = Severity.Info;
private bool hasValidationError;
// Current key state
private bool hasCtrl;
private bool hasShift;
private bool hasAlt;
private bool hasMeta;
private string? currentKey;
private bool isFirstRender = true;
#region Overrides of ComponentBase
protected override void OnInitialized()
{
base.OnInitialized();
this.currentShortcut = this.InitialShortcut;
this.ParseExistingShortcut();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
// Auto-focus the hidden input when the dialog opens
if (this.isFirstRender)
{
this.isFirstRender = false;
await this.hiddenInput.FocusAsync();
}
}
#endregion
private void ParseExistingShortcut()
{
if (string.IsNullOrWhiteSpace(this.currentShortcut))
return;
// Parse the existing shortcut to set the state
var parts = this.currentShortcut.Split('+');
foreach (var part in parts)
{
switch (part.ToLowerInvariant())
{
case "cmdorcontrol":
case "commandorcontrol":
case "ctrl":
case "control":
case "cmd":
case "command":
this.hasCtrl = true;
break;
case "shift":
this.hasShift = true;
break;
case "alt":
this.hasAlt = true;
break;
case "meta":
case "super":
this.hasMeta = true;
break;
default:
this.currentKey = part;
break;
}
}
}
private async Task FocusInput()
{
// Focus the hidden input to capture keyboard events
await this.hiddenInput.FocusAsync();
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
// Ignore pure modifier key presses
if (IsModifierKey(e.Code))
{
this.UpdateModifiers(e);
this.currentKey = null;
this.UpdateShortcutString();
return;
}
// Update modifiers
this.UpdateModifiers(e);
// Get the key
this.currentKey = TranslateKeyCode(e.Code);
// Validate: must have at least one modifier + a key
if (!this.hasCtrl && !this.hasShift && !this.hasAlt && !this.hasMeta)
{
this.validationMessage = T("Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).");
this.validationSeverity = Severity.Warning;
this.hasValidationError = true;
this.StateHasChanged();
return;
}
// Build the shortcut string
this.UpdateShortcutString();
// Validate the shortcut
await this.ValidateShortcut();
}
private void UpdateModifiers(KeyboardEventArgs e)
{
this.hasCtrl = e.CtrlKey || e.MetaKey; // Treat Meta (Cmd on Mac) same as Ctrl for cross-platform
this.hasShift = e.ShiftKey;
this.hasAlt = e.AltKey;
this.hasMeta = e is { MetaKey: true, CtrlKey: false }; // Only set meta if not already using ctrl
}
private void UpdateShortcutString()
{
var parts = new List<string>();
if (this.hasCtrl)
parts.Add("CmdOrControl");
if (this.hasShift)
parts.Add("Shift");
if (this.hasAlt)
parts.Add("Alt");
if (!string.IsNullOrWhiteSpace(this.currentKey))
parts.Add(this.currentKey);
this.currentShortcut = parts.Count > 0 ? string.Join("+", parts) : string.Empty;
this.StateHasChanged();
}
private async Task ValidateShortcut()
{
if (string.IsNullOrWhiteSpace(this.currentShortcut) || string.IsNullOrWhiteSpace(this.currentKey))
{
this.validationMessage = string.Empty;
this.hasValidationError = false;
return;
}
// Check if the shortcut is valid by trying to register it with Rust
var result = await this.RustService.ValidateShortcut(this.currentShortcut);
if (result.IsValid)
{
if (result.HasConflict)
{
this.validationMessage = string.Format(T("This shortcut conflicts with: {0}"), result.ConflictDescription);
this.validationSeverity = Severity.Warning;
this.hasValidationError = false; // Allow saving, but warn
}
else
{
this.validationMessage = T("Shortcut is valid and available.");
this.validationSeverity = Severity.Success;
this.hasValidationError = false;
}
}
else
{
this.validationMessage = string.Format(T("Invalid shortcut: {0}"), result.ErrorMessage);
this.validationSeverity = Severity.Error;
this.hasValidationError = true;
}
this.StateHasChanged();
}
private string GetDisplayShortcut()
{
if (string.IsNullOrWhiteSpace(this.currentShortcut))
return string.Empty;
// Convert internal format to display format
return this.currentShortcut
.Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl")
.Replace("CommandOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl");
}
private void ClearShortcut()
{
this.currentShortcut = string.Empty;
this.currentKey = null;
this.hasCtrl = false;
this.hasShift = false;
this.hasAlt = false;
this.hasMeta = false;
this.validationMessage = string.Empty;
this.hasValidationError = false;
this.StateHasChanged();
}
private void Cancel() => this.MudDialog.Cancel();
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.currentShortcut));
/// <summary>
/// Checks if the key code represents a modifier key.
/// </summary>
private static bool IsModifierKey(string code) => code switch
{
"ShiftLeft" or "ShiftRight" => true,
"ControlLeft" or "ControlRight" => true,
"AltLeft" or "AltRight" => true,
"MetaLeft" or "MetaRight" => true,
_ => false,
};
/// <summary>
/// Translates a JavaScript KeyboardEvent.code to Tauri shortcut format.
/// </summary>
private static string TranslateKeyCode(string code) => code switch
{
// Letters
"KeyA" => "A",
"KeyB" => "B",
"KeyC" => "C",
"KeyD" => "D",
"KeyE" => "E",
"KeyF" => "F",
"KeyG" => "G",
"KeyH" => "H",
"KeyI" => "I",
"KeyJ" => "J",
"KeyK" => "K",
"KeyL" => "L",
"KeyM" => "M",
"KeyN" => "N",
"KeyO" => "O",
"KeyP" => "P",
"KeyQ" => "Q",
"KeyR" => "R",
"KeyS" => "S",
"KeyT" => "T",
"KeyU" => "U",
"KeyV" => "V",
"KeyW" => "W",
"KeyX" => "X",
"KeyY" => "Y",
"KeyZ" => "Z",
// Numbers
"Digit0" => "0",
"Digit1" => "1",
"Digit2" => "2",
"Digit3" => "3",
"Digit4" => "4",
"Digit5" => "5",
"Digit6" => "6",
"Digit7" => "7",
"Digit8" => "8",
"Digit9" => "9",
// Function keys
"F1" => "F1",
"F2" => "F2",
"F3" => "F3",
"F4" => "F4",
"F5" => "F5",
"F6" => "F6",
"F7" => "F7",
"F8" => "F8",
"F9" => "F9",
"F10" => "F10",
"F11" => "F11",
"F12" => "F12",
"F13" => "F13",
"F14" => "F14",
"F15" => "F15",
"F16" => "F16",
"F17" => "F17",
"F18" => "F18",
"F19" => "F19",
"F20" => "F20",
"F21" => "F21",
"F22" => "F22",
"F23" => "F23",
"F24" => "F24",
// Special keys
"Space" => "Space",
"Enter" => "Enter",
"Tab" => "Tab",
"Escape" => "Escape",
"Backspace" => "Backspace",
"Delete" => "Delete",
"Insert" => "Insert",
"Home" => "Home",
"End" => "End",
"PageUp" => "PageUp",
"PageDown" => "PageDown",
// Arrow keys
"ArrowUp" => "Up",
"ArrowDown" => "Down",
"ArrowLeft" => "Left",
"ArrowRight" => "Right",
// Numpad
"Numpad0" => "Num0",
"Numpad1" => "Num1",
"Numpad2" => "Num2",
"Numpad3" => "Num3",
"Numpad4" => "Num4",
"Numpad5" => "Num5",
"Numpad6" => "Num6",
"Numpad7" => "Num7",
"Numpad8" => "Num8",
"Numpad9" => "Num9",
"NumpadAdd" => "NumAdd",
"NumpadSubtract" => "NumSubtract",
"NumpadMultiply" => "NumMultiply",
"NumpadDivide" => "NumDivide",
"NumpadDecimal" => "NumDecimal",
"NumpadEnter" => "NumEnter",
// Punctuation
"Minus" => "Minus",
"Equal" => "Equal",
"BracketLeft" => "BracketLeft",
"BracketRight" => "BracketRight",
"Backslash" => "Backslash",
"Semicolon" => "Semicolon",
"Quote" => "Quote",
"Backquote" => "Backquote",
"Comma" => "Comma",
"Period" => "Period",
"Slash" => "Slash",
// Default: return as-is
_ => code,
};
}

View File

@ -113,9 +113,30 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
await this.UpdateThemeConfiguration(); await this.UpdateThemeConfiguration();
this.LoadNavItems(); this.LoadNavItems();
await this.RegisterGlobalShortcuts();
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
/// <summary>
/// Registers global shortcuts based on the current configuration.
/// </summary>
private async Task RegisterGlobalShortcuts()
{
// Only register the voice recording shortcut if the preview feature is enabled:
if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
{
var shortcut = this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording;
if (!string.IsNullOrWhiteSpace(shortcut))
{
var success = await this.RustService.UpdateGlobalShortcut("voice_recording_toggle", shortcut);
if (success)
this.Logger.LogInformation("Global voice recording shortcut '{Shortcut}' registered successfully.", shortcut);
else
this.Logger.LogWarning("Failed to register global voice recording shortcut '{Shortcut}'.", shortcut);
}
}
}
private void LoadNavItems() private void LoadNavItems()
{ {
this.navItems = new List<NavBarItem>(this.GetNavItems()); this.navItems = new List<NavBarItem>(this.GetNavItems());

View File

@ -82,6 +82,13 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary> /// </summary>
public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty); public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty);
/// <summary>
/// The global keyboard shortcut for toggling voice recording.
/// Uses Tauri's shortcut format, e.g., "CmdOrControl+1" (Cmd+1 on macOS, Ctrl+1 on Windows/Linux).
/// Set to empty string to disable the global shortcut.
/// </summary>
public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty);
/// <summary> /// <summary>
/// Should the user be allowed to add providers? /// Should the user be allowed to add providers?
/// </summary> /// </summary>

View File

@ -0,0 +1,88 @@
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
/// <summary>
/// Registers or updates a global keyboard shortcut.
/// </summary>
/// <param name="name">The name/identifier for the shortcut (e.g., "voice_recording_toggle").</param>
/// <param name="shortcut">The shortcut string in Tauri format (e.g., "CmdOrControl+1"). Use empty string to disable.</param>
/// <returns>True if the shortcut was registered successfully, false otherwise.</returns>
public async Task<bool> UpdateGlobalShortcut(string name, string shortcut)
{
try
{
var request = new RegisterShortcutRequest(name, shortcut);
var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, this.jsonRustSerializerOptions);
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError("Failed to register global shortcut '{Name}' due to network error: {StatusCode}", name, response.StatusCode);
return false;
}
var result = await response.Content.ReadFromJsonAsync<ShortcutResponse>(this.jsonRustSerializerOptions);
if (result is null || !result.Success)
{
this.logger?.LogError("Failed to register global shortcut '{Name}': {Error}", name, result?.ErrorMessage ?? "Unknown error");
return false;
}
this.logger?.LogInformation("Global shortcut '{Name}' registered successfully with key '{Shortcut}'.", name, shortcut);
return true;
}
catch (Exception ex)
{
this.logger?.LogError(ex, "Exception while registering global shortcut '{Name}'.", name);
return false;
}
}
/// <summary>
/// Validates a shortcut string without registering it.
/// </summary>
/// <param name="shortcut">The shortcut string to validate.</param>
/// <returns>A validation result indicating if the shortcut is valid and any conflicts.</returns>
public async Task<ShortcutValidationResult> ValidateShortcut(string shortcut)
{
try
{
var request = new ValidateShortcutRequest(shortcut);
var response = await this.http.PostAsJsonAsync("/shortcuts/validate", request, this.jsonRustSerializerOptions);
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError("Failed to validate shortcut due to network error: {StatusCode}", response.StatusCode);
return new ShortcutValidationResult(false, "Network error during validation", false, string.Empty);
}
var result = await response.Content.ReadFromJsonAsync<ShortcutValidationResponse>(this.jsonRustSerializerOptions);
if (result is null)
return new ShortcutValidationResult(false, "Invalid response from server", false, string.Empty);
return new ShortcutValidationResult(result.IsValid, result.ErrorMessage, result.HasConflict, result.ConflictDescription);
}
catch (Exception ex)
{
this.logger?.LogError(ex, "Exception while validating shortcut.");
return new ShortcutValidationResult(false, ex.Message, false, string.Empty);
}
}
private sealed record RegisterShortcutRequest(string Name, string Shortcut);
private sealed record ShortcutResponse(bool Success, string ErrorMessage);
private sealed record ValidateShortcutRequest(string Shortcut);
private sealed record ShortcutValidationResponse(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription);
}
/// <summary>
/// Result of validating a keyboard shortcut.
/// </summary>
/// <param name="IsValid">Whether the shortcut syntax is valid.</param>
/// <param name="ErrorMessage">Error message if not valid.</param>
/// <param name="HasConflict">Whether the shortcut conflicts with another registered shortcut.</param>
/// <param name="ConflictDescription">Description of the conflict if any.</param>
public sealed record ShortcutValidationResult(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription);

View File

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
@ -27,6 +28,9 @@ static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> =
/// The event broadcast sender for Tauri events. /// The event broadcast sender for Tauri events.
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None)); static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
/// Stores the currently registered global shortcuts (name -> shortcut string).
static REGISTERED_SHORTCUTS: Lazy<Mutex<HashMap<String, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
/// Starts the Tauri app. /// Starts the Tauri app.
pub fn start_tauri() { pub fn start_tauri() {
info!("Starting Tauri app..."); info!("Starting Tauri app...");
@ -65,9 +69,6 @@ pub fn start_tauri() {
// Get the main window: // Get the main window:
let window = app.get_window("main").expect("Failed to get main window."); let window = app.get_window("main").expect("Failed to get main window.");
// Clone the event sender for the global shortcut handler before moving it into the window event closure:
let shortcut_event_sender = event_sender.clone();
// Register a callback for window events, such as file drops. We have to use // Register a callback for window events, such as file drops. We have to use
// this handler in addition to the app event handler, because file drop events // this handler in addition to the app event handler, because file drop events
// are only available in the window event handler (is a bug, cf. https://github.com/tauri-apps/tauri/issues/14338): // are only available in the window event handler (is a bug, cf. https://github.com/tauri-apps/tauri/issues/14338):
@ -86,24 +87,6 @@ pub fn start_tauri() {
// Save the main window for later access: // Save the main window for later access:
*MAIN_WINDOW.lock().unwrap() = Some(window); *MAIN_WINDOW.lock().unwrap() = Some(window);
// Register global shortcuts for voice recording toggle.
// CmdOrControl+1 will be Cmd+1 on macOS and Ctrl+1 on Windows/Linux:
let mut shortcut_manager = app.global_shortcut_manager();
match shortcut_manager.register("CmdOrControl+1", move || {
info!(Source = "Tauri"; "Global shortcut CmdOrControl+1 triggered for voice recording toggle.");
let event = Event::new(TauriEventType::GlobalShortcutPressed, vec!["voice_recording_toggle".to_string()]);
let sender = shortcut_event_sender.clone();
tauri::async_runtime::spawn(async move {
match sender.send(event) {
Ok(_) => {},
Err(error) => error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"),
}
});
}) {
Ok(_) => info!(Source = "Bootloader Tauri"; "Global shortcut CmdOrControl+1 registered successfully."),
Err(error) => error!(Source = "Bootloader Tauri"; "Failed to register global shortcut: {error}"),
}
info!(Source = "Bootloader Tauri"; "Setup is running."); info!(Source = "Bootloader Tauri"; "Setup is running.");
let data_path = app.path_resolver().app_local_data_dir().unwrap(); let data_path = app.path_resolver().app_local_data_dir().unwrap();
let data_path = data_path.join("data"); let data_path = data_path.join("data");
@ -706,6 +689,232 @@ pub struct FileSaveResponse {
save_file_path: String, save_file_path: String,
} }
/// Request payload for registering a global shortcut.
#[derive(Clone, Deserialize)]
pub struct RegisterShortcutRequest {
/// The name/identifier for the shortcut (e.g., "voice_recording_toggle").
name: String,
/// The shortcut string in Tauri format (e.g., "CmdOrControl+1").
/// Use empty string to unregister the shortcut.
shortcut: String,
}
/// Response for shortcut registration.
#[derive(Serialize)]
pub struct ShortcutResponse {
success: bool,
error_message: String,
}
/// Registers or updates a global shortcut. If the shortcut string is empty,
/// the existing shortcut for that name will be unregistered.
#[post("/shortcuts/register", data = "<payload>")]
pub fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
let name = payload.name.clone();
let new_shortcut = payload.shortcut.clone();
info!(Source = "Tauri"; "Registering global shortcut '{name}' with key '{new_shortcut}'.");
// Get the main window to access the global shortcut manager:
let main_window_lock = MAIN_WINDOW.lock().unwrap();
let main_window = match main_window_lock.as_ref() {
Some(window) => window,
None => {
error!(Source = "Tauri"; "Cannot register shortcut: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
let mut registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
// Unregister the old shortcut if one exists for this name:
if let Some(old_shortcut) = registered_shortcuts.get(&name) {
if !old_shortcut.is_empty() {
match shortcut_manager.unregister(old_shortcut.as_str()) {
Ok(_) => info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{name}'."),
Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"),
}
}
}
// When the new shortcut is empty, we're done (just unregistering):
if new_shortcut.is_empty() {
registered_shortcuts.remove(&name);
info!(Source = "Tauri"; "Shortcut '{name}' has been disabled.");
return Json(ShortcutResponse {
success: true,
error_message: String::new(),
});
}
// Get the event broadcast sender for the shortcut callback:
let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap();
let event_sender = match event_broadcast_lock.as_ref() {
Some(sender) => sender.clone(),
None => {
error!(Source = "Tauri"; "Cannot register shortcut: event broadcast not initialized.");
return Json(ShortcutResponse {
success: false,
error_message: "Event broadcast not initialized".to_string(),
});
}
};
drop(event_broadcast_lock);
// Register the new shortcut:
let shortcut_name = name.clone();
match shortcut_manager.register(new_shortcut.as_str(), move || {
info!(Source = "Tauri"; "Global shortcut triggered for '{shortcut_name}'.");
let event = Event::new(TauriEventType::GlobalShortcutPressed, vec![shortcut_name.clone()]);
let sender = event_sender.clone();
tauri::async_runtime::spawn(async move {
match sender.send(event) {
Ok(_) => {},
Err(error) => error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"),
}
});
})
{
Ok(_) => {
info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{name}'.");
registered_shortcuts.insert(name, new_shortcut);
Json(ShortcutResponse {
success: true,
error_message: String::new(),
})
},
Err(error) => {
let error_msg = format!("Failed to register shortcut: {error}");
error!(Source = "Tauri"; "{error_msg}");
Json(ShortcutResponse {
success: false,
error_message: error_msg,
})
}
}
}
/// Request payload for validating a shortcut.
#[derive(Clone, Deserialize)]
pub struct ValidateShortcutRequest {
/// The shortcut string to validate (e.g., "CmdOrControl+1").
shortcut: String,
}
/// Response for shortcut validation.
#[derive(Serialize)]
pub struct ShortcutValidationResponse {
is_valid: bool,
error_message: String,
has_conflict: bool,
conflict_description: String,
}
/// Validates a shortcut string without registering it.
/// Checks if the shortcut syntax is valid and if it
/// conflicts with existing shortcuts.
#[post("/shortcuts/validate", data = "<payload>")]
pub fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
let shortcut = payload.shortcut.clone();
// Empty shortcuts are always valid (means "disabled"):
if shortcut.is_empty() {
return Json(ShortcutValidationResponse {
is_valid: true,
error_message: String::new(),
has_conflict: false,
conflict_description: String::new(),
});
}
// Check if the shortcut is already registered:
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
for (name, registered_shortcut) in registered_shortcuts.iter() {
if registered_shortcut.eq_ignore_ascii_case(&shortcut) {
return Json(ShortcutValidationResponse {
is_valid: true,
error_message: String::new(),
has_conflict: true,
conflict_description: format!("Already used by: {}", name),
});
}
}
drop(registered_shortcuts);
// Try to parse the shortcut to validate syntax.
// We can't easily validate without registering in Tauri 1.x,
// so we do basic syntax validation here:
let is_valid = validate_shortcut_syntax(&shortcut);
if is_valid {
Json(ShortcutValidationResponse {
is_valid: true,
error_message: String::new(),
has_conflict: false,
conflict_description: String::new(),
})
} else {
Json(ShortcutValidationResponse {
is_valid: false,
error_message: format!("Invalid shortcut syntax: {}", shortcut),
has_conflict: false,
conflict_description: String::new(),
})
}
}
/// Validates the syntax of a shortcut string.
fn validate_shortcut_syntax(shortcut: &str) -> bool {
let parts: Vec<&str> = shortcut.split('+').collect();
if parts.is_empty() {
return false;
}
let mut has_key = false;
for part in parts {
let part_lower = part.to_lowercase();
match part_lower.as_str() {
// Modifiers
"cmdorcontrol" | "commandorcontrol" | "ctrl" | "control" | "cmd" | "command" |
"shift" | "alt" | "meta" | "super" | "option" => continue,
// Keys - letters
"a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" |
"n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" => has_key = true,
// Keys - numbers
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => has_key = true,
// Keys - function keys
_ if part_lower.starts_with('f') && part_lower[1..].parse::<u32>().is_ok() => has_key = true,
// Keys - special
"space" | "enter" | "tab" | "escape" | "backspace" | "delete" | "insert" |
"home" | "end" | "pageup" | "pagedown" |
"up" | "down" | "left" | "right" |
"arrowup" | "arrowdown" | "arrowleft" | "arrowright" |
"minus" | "equal" | "bracketleft" | "bracketright" | "backslash" |
"semicolon" | "quote" | "backquote" | "comma" | "period" | "slash" => has_key = true,
// Keys - numpad
_ if part_lower.starts_with("num") => has_key = true,
// Unknown
_ => return false,
}
}
has_key
}
fn set_pdfium_path(path_resolver: PathResolver) { fn set_pdfium_path(path_resolver: PathResolver) {
let pdfium_relative_source_path = String::from("resources/libraries/"); let pdfium_relative_source_path = String::from("resources/libraries/");
let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path); let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path);

View File

@ -87,6 +87,8 @@ pub fn start_runtime_api() {
crate::file_data::extract_data, crate::file_data::extract_data,
crate::log::get_log_paths, crate::log::get_log_paths,
crate::log::log_event, crate::log::log_event,
crate::app_window::register_shortcut,
crate::app_window::validate_shortcut,
]) ])
.ignite().await.unwrap() .ignite().await.unwrap()
.launch().await.unwrap(); .launch().await.unwrap();