Improved voice recording shortcut labels (#800)
Some checks failed
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled

This commit is contained in:
Thorsten Sommer 2026-06-09 20:11:32 +02:00 committed by GitHub
parent e9da7d31df
commit 1c2d243c1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 201 additions and 57 deletions

View File

@ -3,7 +3,7 @@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@this.Icon" Color="@this.IconColor"/> <MudIcon Icon="@this.Icon" Color="@this.IconColor"/>
<MudText Typo="Typo.body1" Class="flex-grow-1"> <MudText Typo="Typo.body1" Class="flex-grow-1">
@if (string.IsNullOrWhiteSpace(this.Shortcut())) @if (string.IsNullOrWhiteSpace(this.Data.Value()))
{ {
@T("No shortcut configured") @T("No shortcut configured")
} }

View File

@ -1,5 +1,4 @@
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@ -19,22 +18,10 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore
private RustService RustService { get; init; } = null!; private RustService RustService { get; init; } = null!;
/// <summary> /// <summary>
/// The current shortcut value. /// The shortcut binding data.
/// </summary> /// </summary>
[Parameter] [Parameter]
public Func<string> Shortcut { get; set; } = () => string.Empty; public ConfigurationShortcutData Data { get; set; } = ConfigurationShortcutData.Empty;
/// <summary>
/// An action which is called when the shortcut was changed.
/// </summary>
[Parameter]
public Action<string> ShortcutUpdate { get; set; } = _ => { };
/// <summary>
/// The name/identifier of the shortcut (used for conflict detection and registration).
/// </summary>
[Parameter]
public Shortcut ShortcutId { get; init; }
/// <summary> /// <summary>
/// The icon to display. /// The icon to display.
@ -60,10 +47,18 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore
private string GetDisplayShortcut() private string GetDisplayShortcut()
{ {
var shortcut = this.Shortcut(); var shortcut = this.Data.Value();
if (string.IsNullOrWhiteSpace(shortcut)) if (string.IsNullOrWhiteSpace(shortcut))
return string.Empty; return string.Empty;
var shortcutDisplayName = this.Data.DisplayName();
var shortcutDisplaySource = this.Data.DisplaySource();
if (!string.IsNullOrWhiteSpace(shortcutDisplayName)
&& string.Equals(shortcutDisplaySource, shortcut, StringComparison.Ordinal))
{
return shortcutDisplayName;
}
// Convert internal format to display format: // Convert internal format to display format:
return shortcut return shortcut
.Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl") .Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl")
@ -80,8 +75,8 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore
{ {
var dialogParameters = new DialogParameters<ShortcutDialog> var dialogParameters = new DialogParameters<ShortcutDialog>
{ {
{ x => x.InitialShortcut, this.Shortcut() }, { x => x.InitialShortcut, this.Data.Value() },
{ x => x.ShortcutId, this.ShortcutId }, { x => x.ShortcutId, this.Data.Id },
}; };
var dialogReference = await this.DialogService.ShowAsync<ShortcutDialog>( var dialogReference = await this.DialogService.ShowAsync<ShortcutDialog>(
@ -93,9 +88,17 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore
if (dialogResult is null || dialogResult.Canceled) if (dialogResult is null || dialogResult.Canceled)
return; return;
if (dialogResult.Data is string newShortcut) if (dialogResult.Data is ShortcutDialogResult shortcutResult)
{ {
this.ShortcutUpdate(newShortcut); this.Data.ValueUpdate(shortcutResult.Shortcut);
this.Data.DisplayUpdate(shortcutResult.DisplayName, shortcutResult.DisplaySource);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
else if (dialogResult.Data is string newShortcut)
{
this.Data.ValueUpdate(newShortcut);
this.Data.DisplayUpdate(string.Empty, string.Empty);
await this.SettingsManager.StoreSettings(); await this.SettingsManager.StoreSettings();
await this.InformAboutChange(); await this.InformAboutChange();
} }

View File

@ -0,0 +1,44 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Components;
/// <summary>
/// UI binding data for a configurable keyboard shortcut.
/// </summary>
public sealed class ConfigurationShortcutData
{
/// <summary>
/// Empty shortcut binding.
/// </summary>
public static ConfigurationShortcutData Empty { get; } = new();
/// <summary>
/// The name/identifier of the shortcut, used for conflict detection and registration.
/// </summary>
public Shortcut Id { get; init; } = Shortcut.NONE;
/// <summary>
/// The current shortcut value.
/// </summary>
public Func<string> Value { get; init; } = () => string.Empty;
/// <summary>
/// An action that is called when the shortcut was changed.
/// </summary>
public Action<string> ValueUpdate { get; init; } = _ => { };
/// <summary>
/// The optional user-facing shortcut label.
/// </summary>
public Func<string> DisplayName { get; init; } = () => string.Empty;
/// <summary>
/// The canonical shortcut value the optional user-facing label belongs to.
/// </summary>
public Func<string> DisplaySource { get; init; } = () => string.Empty;
/// <summary>
/// An action that is called when the user-facing shortcut label was changed.
/// </summary>
public Action<string, string> DisplayUpdate { get; init; } = (_, _) => { };
}

View File

@ -37,7 +37,7 @@
@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 ShortcutId="Shortcut.VOICE_RECORDING_TOGGLE" OptionDescription="@T("Voice recording shortcut")" Shortcut="@(() => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording)" ShortcutUpdate="@(shortcut => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording = shortcut)" 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"/> <ConfigurationShortcut Data="@this.VoiceRecordingShortcut" OptionDescription="@T("Voice recording shortcut")" 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"/>
} }
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)

View File

@ -1,11 +1,22 @@
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.Rust;
namespace AIStudio.Components.Settings; namespace AIStudio.Components.Settings;
public partial class SettingsPanelApp : SettingsPanelBase public partial class SettingsPanelApp : SettingsPanelBase
{ {
private ConfigurationShortcutData VoiceRecordingShortcut => new()
{
Id = Shortcut.VOICE_RECORDING_TOGGLE,
Value = () => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording,
ValueUpdate = shortcut => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording = shortcut,
DisplayName = () => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecordingDisplayName,
DisplaySource = () => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecordingDisplaySource,
DisplayUpdate = this.UpdateShortcutVoiceRecordingDisplay,
};
private async Task GenerateEncryptionSecret() private async Task GenerateEncryptionSecret()
{ {
var secret = EnterpriseEncryption.GenerateSecret(); var secret = EnterpriseEncryption.GenerateSecret();
@ -93,6 +104,12 @@ public partial class SettingsPanelApp : SettingsPanelBase
this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures = selectedFeatures; this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures = selectedFeatures;
} }
private void UpdateShortcutVoiceRecordingDisplay(string displayName, string displaySource)
{
this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecordingDisplayName = displayName;
this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecordingDisplaySource = displaySource;
}
private async Task UpdateLangBehaviour(LangBehavior behavior) private async Task UpdateLangBehaviour(LangBehavior behavior)
{ {
this.SettingsManager.ConfigurationData.App.LanguageBehavior = behavior; this.SettingsManager.ConfigurationData.App.LanguageBehavior = behavior;

View File

@ -34,6 +34,7 @@ public partial class ShortcutDialog : MSGComponentBase
private string currentShortcut = string.Empty; private string currentShortcut = string.Empty;
private string originalShortcut = string.Empty; private string originalShortcut = string.Empty;
private string currentDisplayName = string.Empty;
private string validationMessage = string.Empty; private string validationMessage = string.Empty;
private Severity validationSeverity = Severity.Info; private Severity validationSeverity = Severity.Info;
private bool hasValidationError; private bool hasValidationError;
@ -115,6 +116,7 @@ public partial class ShortcutDialog : MSGComponentBase
{ {
this.UpdateModifiers(e); this.UpdateModifiers(e);
this.currentKey = null; this.currentKey = null;
this.currentDisplayName = string.Empty;
this.UpdateShortcutString(); this.UpdateShortcutString();
return; return;
} }
@ -123,10 +125,12 @@ public partial class ShortcutDialog : MSGComponentBase
// Get the key: // Get the key:
this.currentKey = TranslateKeyCode(e.Code); this.currentKey = TranslateKeyCode(e.Code);
this.currentDisplayName = this.BuildDisplayShortcut(e.Key);
// Validate: must have at least one modifier + a key // Validate: must have at least one modifier + a key
if (!this.hasCtrl && !this.hasShift && !this.hasAlt && !this.hasMeta) if (!this.hasCtrl && !this.hasShift && !this.hasAlt && !this.hasMeta)
{ {
this.currentDisplayName = string.Empty;
this.validationMessage = T("Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd)."); this.validationMessage = T("Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).");
this.validationSeverity = Severity.Warning; this.validationSeverity = Severity.Warning;
this.hasValidationError = true; this.hasValidationError = true;
@ -216,6 +220,9 @@ public partial class ShortcutDialog : MSGComponentBase
private string GetDisplayShortcut() private string GetDisplayShortcut()
{ {
if (!string.IsNullOrWhiteSpace(this.currentDisplayName))
return this.currentDisplayName;
// Convert internal format to display format: // Convert internal format to display format:
return this.currentShortcut return this.currentShortcut
.Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl") .Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl")
@ -225,6 +232,7 @@ public partial class ShortcutDialog : MSGComponentBase
private void ClearShortcut() private void ClearShortcut()
{ {
this.currentShortcut = string.Empty; this.currentShortcut = string.Empty;
this.currentDisplayName = string.Empty;
this.currentKey = null; this.currentKey = null;
this.hasCtrl = false; this.hasCtrl = false;
this.hasShift = false; this.hasShift = false;
@ -237,7 +245,17 @@ public partial class ShortcutDialog : MSGComponentBase
private void Cancel() => this.MudDialog.Cancel(); private void Cancel() => this.MudDialog.Cancel();
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.currentShortcut)); private void Confirm()
{
var displaySource = string.IsNullOrWhiteSpace(this.currentDisplayName)
? string.Empty
: this.currentShortcut;
this.MudDialog.Close(DialogResult.Ok(new ShortcutDialogResult(
this.currentShortcut,
this.currentDisplayName,
displaySource)));
}
/// <summary> /// <summary>
/// Checks if the key code represents a modifier key. /// Checks if the key code represents a modifier key.
@ -377,6 +395,36 @@ public partial class ShortcutDialog : MSGComponentBase
_ => code, _ => code,
}; };
private string BuildDisplayShortcut(string? key)
{
var displayKey = GetDisplayKey(key);
if (string.IsNullOrWhiteSpace(displayKey))
return string.Empty;
var parts = new List<string>();
if (this.hasCtrl)
parts.Add(OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl");
if (this.hasShift)
parts.Add("Shift");
if (this.hasAlt)
parts.Add("Alt");
parts.Add(displayKey);
return string.Join("+", parts);
}
private static string GetDisplayKey(string? key) => key switch
{
null or "" => string.Empty,
" " => "Space",
"Control" or "Shift" or "Alt" or "Meta" => string.Empty,
_ when key.Length == 1 && key[0] >= 'a' && key[0] <= 'z' => key.ToUpperInvariant(),
_ => key,
};
private void HandleBlur() private void HandleBlur()
{ {
// Re-focus the input field to keep capturing keys: // Re-focus the input field to keep capturing keys:

View File

@ -0,0 +1,3 @@
namespace AIStudio.Dialogs;
public readonly record struct ShortcutDialogResult(string Shortcut, string DisplayName, string DisplaySource);

View File

@ -99,6 +99,16 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary> /// </summary>
public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty); public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty);
/// <summary>
/// The user-facing label for the voice recording shortcut, based on the user's keyboard layout.
/// </summary>
public string ShortcutVoiceRecordingDisplayName { get; set; } = string.Empty;
/// <summary>
/// The canonical voice recording shortcut value this display label belongs to.
/// </summary>
public string ShortcutVoiceRecordingDisplaySource { get; set; } = string.Empty;
/// <summary> /// <summary>
/// The HTTP timeout in seconds for external HTTP clients. /// The HTTP timeout in seconds for external HTTP clients.
/// </summary> /// </summary>

View File

@ -8,6 +8,7 @@
- Improved workspaces by highlighting the currently open chat in the workspace view. - Improved workspaces by highlighting the currently open chat in the workspace view.
- Improved workspaces by adding a shortcut to start a new chat directly from each workspace row. - Improved workspaces by adding a shortcut to start a new chat directly from each workspace row.
- Improved workspaces by allowing new workspaces to be created while moving a chat. - Improved workspaces by allowing new workspaces to be created while moving a chat.
- Improved voice recording shortcut labels so they match the user's keyboard layout after being configured.
- Improved the enterprise configuration details on the information page by showing where each configuration comes from and which configuration slot was used. - Improved the enterprise configuration details on the information page by showing where each configuration comes from and which configuration slot was used.
- Fixed workspace creation and renaming to prevent new workspaces from using an existing name. - Fixed workspace creation and renaming to prevent new workspaces from using an existing name.
- Fixed an issue on Microsoft Windows where reading attached documents could briefly open a terminal window while processing files. - Fixed an issue on Microsoft Windows where reading attached documents could briefly open a terminal window while processing files.

View File

@ -22,7 +22,7 @@ public sealed class EmptyStringAnalyzer : DiagnosticAnalyzer
Use string.Empty instead of "" Use string.Empty instead of ""
"""; """;
private static readonly string DESCRIPTION = """Empty string literals ("") should be replaced with string.Empty for better code consistency and readability except in const contexts."""; private static readonly string DESCRIPTION = """Empty string literals ("") should be replaced with string.Empty for better code consistency and readability except in contexts requiring compile-time constants.""";
private const string CATEGORY = "Usage"; private const string CATEGORY = "Usage";
@ -43,20 +43,26 @@ public sealed class EmptyStringAnalyzer : DiagnosticAnalyzer
if (stringLiteral.Token.ValueText != string.Empty) if (stringLiteral.Token.ValueText != string.Empty)
return; return;
if (IsInConstContext(stringLiteral)) if (RequiresCompileTimeConstant(stringLiteral))
return;
if (IsInParameterDefaultValue(stringLiteral))
return; return;
var diagnostic = Diagnostic.Create(RULE, stringLiteral.GetLocation()); var diagnostic = Diagnostic.Create(RULE, stringLiteral.GetLocation());
context.ReportDiagnostic(diagnostic); context.ReportDiagnostic(diagnostic);
} }
private static bool IsInConstContext(LiteralExpressionSyntax stringLiteral) private static bool RequiresCompileTimeConstant(LiteralExpressionSyntax stringLiteral)
{
return IsInConstDeclarationInitializer(stringLiteral)
|| IsInParameterDefaultValue(stringLiteral)
|| IsInAttributeArgument(stringLiteral)
|| IsInSwitchCaseLabel(stringLiteral)
|| IsInConstantPattern(stringLiteral);
}
private static bool IsInConstDeclarationInitializer(LiteralExpressionSyntax stringLiteral)
{ {
var variableDeclarator = stringLiteral.FirstAncestorOrSelf<VariableDeclaratorSyntax>(); var variableDeclarator = stringLiteral.FirstAncestorOrSelf<VariableDeclaratorSyntax>();
if (variableDeclarator is null) if (variableDeclarator?.Initializer is null || !ContainsNode(variableDeclarator.Initializer.Value, stringLiteral))
return false; return false;
var declaration = variableDeclarator.Parent?.Parent; var declaration = variableDeclarator.Parent?.Parent;
@ -71,18 +77,30 @@ public sealed class EmptyStringAnalyzer : DiagnosticAnalyzer
private static bool IsInParameterDefaultValue(LiteralExpressionSyntax stringLiteral) private static bool IsInParameterDefaultValue(LiteralExpressionSyntax stringLiteral)
{ {
// Prüfen, ob das String-Literal Teil eines Parameter-Defaults ist
var parameter = stringLiteral.FirstAncestorOrSelf<ParameterSyntax>(); var parameter = stringLiteral.FirstAncestorOrSelf<ParameterSyntax>();
if (parameter is null) return parameter?.Default is not null && ContainsNode(parameter.Default.Value, stringLiteral);
return false; }
// Überprüfen, ob das String-Literal im Default-Wert des Parameters verwendet wird private static bool IsInAttributeArgument(LiteralExpressionSyntax stringLiteral)
if (parameter.Default is not null &&
parameter.Default.Value == stringLiteral)
{ {
return true; var attributeArgument = stringLiteral.FirstAncestorOrSelf<AttributeArgumentSyntax>();
return attributeArgument is not null && ContainsNode(attributeArgument.Expression, stringLiteral);
} }
return false; private static bool IsInSwitchCaseLabel(LiteralExpressionSyntax stringLiteral)
{
var caseSwitchLabel = stringLiteral.FirstAncestorOrSelf<CaseSwitchLabelSyntax>();
return caseSwitchLabel is not null && ContainsNode(caseSwitchLabel.Value, stringLiteral);
}
private static bool IsInConstantPattern(LiteralExpressionSyntax stringLiteral)
{
var constantPattern = stringLiteral.FirstAncestorOrSelf<ConstantPatternSyntax>();
return constantPattern is not null && ContainsNode(constantPattern.Expression, stringLiteral);
}
private static bool ContainsNode(SyntaxNode parent, SyntaxNode child)
{
return parent.SpanStart <= child.SpanStart && child.Span.End <= parent.Span.End;
} }
} }