diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 5f0e15d8..4719afb4 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -1675,6 +1675,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218
-- 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.
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?
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?
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.
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
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?
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.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value."
diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor
new file mode 100644
index 00000000..c9b63092
--- /dev/null
+++ b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor
@@ -0,0 +1,25 @@
+@inherits ConfigurationBaseCore
+
+
+
+
+ @if (string.IsNullOrWhiteSpace(this.Shortcut()))
+ {
+ @T("No shortcut configured")
+ }
+ else
+ {
+
+ @this.GetDisplayShortcut()
+
+ }
+
+
+ @T("Configure")
+
+
diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs
new file mode 100644
index 00000000..4678b6ac
--- /dev/null
+++ b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs
@@ -0,0 +1,94 @@
+using AIStudio.Dialogs;
+using AIStudio.Tools.Services;
+
+using Microsoft.AspNetCore.Components;
+using DialogOptions = AIStudio.Dialogs.DialogOptions;
+
+namespace AIStudio.Components;
+
+///
+/// A configuration component for capturing and displaying keyboard shortcuts.
+///
+public partial class ConfigurationShortcut : ConfigurationBaseCore
+{
+ [Inject]
+ private IDialogService DialogService { get; init; } = null!;
+
+ ///
+ /// The current shortcut value.
+ ///
+ [Parameter]
+ public Func Shortcut { get; set; } = () => string.Empty;
+
+ ///
+ /// An action which is called when the shortcut was changed.
+ ///
+ [Parameter]
+ public Func ShortcutUpdate { get; set; } = _ => Task.CompletedTask;
+
+ ///
+ /// The name/identifier of the shortcut (used for conflict detection and registration).
+ ///
+ [Parameter]
+ public string ShortcutName { get; set; } = string.Empty;
+
+ ///
+ /// The icon to display.
+ ///
+ [Parameter]
+ public string Icon { get; set; } = Icons.Material.Filled.Keyboard;
+
+ ///
+ /// The color of the icon.
+ ///
+ [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
+ {
+ { x => x.InitialShortcut, currentShortcut },
+ { x => x.ShortcutName, this.ShortcutName },
+ };
+
+ var dialogReference = await this.DialogService.ShowAsync(
+ 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();
+ }
+ }
+}
diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor
index 0a5c89c8..5a1835e5 100644
--- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor
+++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor
@@ -33,5 +33,6 @@
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
{
+
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs
index 2fbb61ed..eae3fe59 100644
--- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs
+++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs
@@ -35,4 +35,10 @@ public partial class SettingsPanelApp : SettingsPanelBase
this.SettingsManager.ConfigurationData.App.LanguagePluginId = pluginId;
await this.MessageBus.SendMessage(this, Event.PLUGINS_RELOADED);
}
+
+ private async Task UpdateVoiceRecordingShortcut(string shortcut)
+ {
+ this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording = shortcut;
+ await this.RustService.UpdateGlobalShortcut("voice_recording_toggle", shortcut);
+ }
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs
index 8924c7bd..d829c177 100644
--- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs
+++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs
@@ -1,5 +1,4 @@
using AIStudio.Provider;
-using AIStudio.Tools;
using AIStudio.Tools.MIME;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
@@ -145,6 +144,10 @@ public partial class VoiceRecorder : MSGComponentBase
// Clean up the recording stream if starting failed:
await this.FinalizeRecordingStream();
}
+ finally
+ {
+ this.StateHasChanged();
+ }
}
else
{
diff --git a/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor
new file mode 100644
index 00000000..d515cfc2
--- /dev/null
+++ b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor
@@ -0,0 +1,63 @@
+@inherits MSGComponentBase
+
+
+
+
+
+ @T("Configure Keyboard Shortcut")
+
+
+
+
+ @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.")
+
+
+
+ @* Hidden input to capture keyboard events *@
+
+ @if (string.IsNullOrWhiteSpace(this.currentShortcut))
+ {
+
+ @T("Press a key combination...")
+
+ }
+ else
+ {
+
+ @this.GetDisplayShortcut()
+
+ }
+
+
+ @if (!string.IsNullOrWhiteSpace(this.validationMessage))
+ {
+
+ @this.validationMessage
+
+ }
+
+
+ @T("Supported modifiers: Ctrl/Cmd, Shift, Alt. Example: Ctrl+Shift+R")
+
+
+
+
+ @T("Clear Shortcut")
+
+
+
+ @T("Cancel")
+
+
+ @T("Save")
+
+
+
diff --git a/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs
new file mode 100644
index 00000000..3b91e456
--- /dev/null
+++ b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs
@@ -0,0 +1,379 @@
+using AIStudio.Components;
+using AIStudio.Tools.Services;
+
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+
+namespace AIStudio.Dialogs;
+
+///
+/// A dialog for capturing and configuring keyboard shortcuts.
+///
+public partial class ShortcutDialog : MSGComponentBase
+{
+ [CascadingParameter]
+ private IMudDialogInstance MudDialog { get; set; } = null!;
+
+ [Inject]
+ private RustService RustService { get; init; } = null!;
+
+ ///
+ /// The initial shortcut value (in internal format, e.g., "CmdOrControl+1").
+ ///
+ [Parameter]
+ public string InitialShortcut { get; set; } = string.Empty;
+
+ ///
+ /// The name/identifier of the shortcut for conflict detection.
+ ///
+ [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();
+
+ 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));
+
+ ///
+ /// Checks if the key code represents a modifier key.
+ ///
+ 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,
+ };
+
+ ///
+ /// Translates a JavaScript KeyboardEvent.code to Tauri shortcut format.
+ ///
+ 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,
+ };
+}
diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs
index fc89f248..83ca96f0 100644
--- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs
+++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs
@@ -113,9 +113,30 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
await this.UpdateThemeConfiguration();
this.LoadNavItems();
+ await this.RegisterGlobalShortcuts();
await base.OnInitializedAsync();
}
+ ///
+ /// Registers global shortcuts based on the current configuration.
+ ///
+ 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()
{
this.navItems = new List(this.GetNavItems());
diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs
index d2ac4bb7..fe4409de 100644
--- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs
+++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs
@@ -82,6 +82,13 @@ public sealed class DataApp(Expression>? configSelection = n
///
public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty);
+ ///
+ /// 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.
+ ///
+ public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty);
+
///
/// Should the user be allowed to add providers?
///
diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs
new file mode 100644
index 00000000..1394e851
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs
@@ -0,0 +1,88 @@
+namespace AIStudio.Tools.Services;
+
+public sealed partial class RustService
+{
+ ///
+ /// Registers or updates a global keyboard shortcut.
+ ///
+ /// The name/identifier for the shortcut (e.g., "voice_recording_toggle").
+ /// The shortcut string in Tauri format (e.g., "CmdOrControl+1"). Use empty string to disable.
+ /// True if the shortcut was registered successfully, false otherwise.
+ public async Task 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(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;
+ }
+ }
+
+ ///
+ /// Validates a shortcut string without registering it.
+ ///
+ /// The shortcut string to validate.
+ /// A validation result indicating if the shortcut is valid and any conflicts.
+ public async Task 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(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);
+}
+
+///
+/// Result of validating a keyboard shortcut.
+///
+/// Whether the shortcut syntax is valid.
+/// Error message if not valid.
+/// Whether the shortcut conflicts with another registered shortcut.
+/// Description of the conflict if any.
+public sealed record ShortcutValidationResult(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription);
diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs
index d3e0f466..d42f76ca 100644
--- a/runtime/src/app_window.rs
+++ b/runtime/src/app_window.rs
@@ -1,3 +1,4 @@
+use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use log::{debug, error, info, trace, warn};
@@ -27,6 +28,9 @@ static CHECK_UPDATE_RESPONSE: Lazy>>> =
/// The event broadcast sender for Tauri events.
static EVENT_BROADCAST: Lazy>>> = Lazy::new(|| Mutex::new(None));
+/// Stores the currently registered global shortcuts (name -> shortcut string).
+static REGISTERED_SHORTCUTS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new()));
+
/// Starts the Tauri app.
pub fn start_tauri() {
info!("Starting Tauri app...");
@@ -65,9 +69,6 @@ pub fn start_tauri() {
// Get the 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
// 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):
@@ -86,24 +87,6 @@ pub fn start_tauri() {
// Save the main window for later access:
*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.");
let data_path = app.path_resolver().app_local_data_dir().unwrap();
let data_path = data_path.join("data");
@@ -706,6 +689,232 @@ pub struct FileSaveResponse {
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 = "")]
+pub fn register_shortcut(_token: APIToken, payload: Json) -> Json {
+ 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 = "")]
+pub fn validate_shortcut(_token: APIToken, payload: Json) -> Json {
+ 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::().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) {
let pdfium_relative_source_path = String::from("resources/libraries/");
let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path);
diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs
index 0b1cc8c1..a4ba4782 100644
--- a/runtime/src/runtime_api.rs
+++ b/runtime/src/runtime_api.rs
@@ -87,6 +87,8 @@ pub fn start_runtime_api() {
crate::file_data::extract_data,
crate::log::get_log_paths,
crate::log::log_event,
+ crate::app_window::register_shortcut,
+ crate::app_window::validate_shortcut,
])
.ignite().await.unwrap()
.launch().await.unwrap();