From 7dae8c378877a82aa8a91885f737f447bd9bf157 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 21 Jan 2026 09:49:01 +0100 Subject: [PATCH] Add the possibility to configure the shortcut --- .../Assistants/I18N/allTexts.lua | 48 +++ .../Components/ConfigurationShortcut.razor | 25 ++ .../Components/ConfigurationShortcut.razor.cs | 94 +++++ .../Settings/SettingsPanelApp.razor | 1 + .../Settings/SettingsPanelApp.razor.cs | 6 + .../Components/VoiceRecorder.razor.cs | 5 +- .../Dialogs/ShortcutDialog.razor | 63 +++ .../Dialogs/ShortcutDialog.razor.cs | 379 ++++++++++++++++++ .../Layout/MainLayout.razor.cs | 21 + .../Settings/DataModel/DataApp.cs | 7 + .../Tools/Services/RustService.Shortcuts.cs | 88 ++++ runtime/src/app_window.rs | 251 +++++++++++- runtime/src/runtime_api.rs | 2 + 13 files changed, 968 insertions(+), 22 deletions(-) create mode 100644 app/MindWork AI Studio/Components/ConfigurationShortcut.razor create mode 100644 app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs create mode 100644 app/MindWork AI Studio/Dialogs/ShortcutDialog.razor create mode 100644 app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs create mode 100644 app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs 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();