mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-02-12 11:41:38 +00:00
Add the possibility to configure the shortcut
This commit is contained in:
parent
6ed9338d23
commit
7dae8c3788
@ -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."
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
63
app/MindWork AI Studio/Dialogs/ShortcutDialog.razor
Normal file
63
app/MindWork AI Studio/Dialogs/ShortcutDialog.razor
Normal 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>
|
||||||
379
app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs
Normal file
379
app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user