From 1c2d243c1f5601ab476acff916ac95f2123fcf0d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 9 Jun 2026 20:11:32 +0200 Subject: [PATCH] Improved voice recording shortcut labels (#800) --- .../Components/ConfigurationShortcut.razor | 2 +- .../Components/ConfigurationShortcut.razor.cs | 43 +++++----- .../Components/ConfigurationShortcutData.cs | 44 ++++++++++ .../Settings/SettingsPanelApp.razor | 2 +- .../Settings/SettingsPanelApp.razor.cs | 17 ++++ .../Dialogs/ShortcutDialog.razor.cs | 50 ++++++++++- .../Dialogs/ShortcutDialogResult.cs | 3 + .../Settings/DataModel/DataApp.cs | 10 +++ app/MindWork AI Studio/Tools/Rust/Shortcut.cs | 2 +- .../wwwroot/changelog/v26.6.1.md | 1 + .../UsageAnalyzers/EmptyStringAnalyzer.cs | 84 +++++++++++-------- 11 files changed, 201 insertions(+), 57 deletions(-) create mode 100644 app/MindWork AI Studio/Components/ConfigurationShortcutData.cs create mode 100644 app/MindWork AI Studio/Dialogs/ShortcutDialogResult.cs diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor index 41f3b9a3..67929235 100644 --- a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor +++ b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor @@ -3,7 +3,7 @@ - @if (string.IsNullOrWhiteSpace(this.Shortcut())) + @if (string.IsNullOrWhiteSpace(this.Data.Value())) { @T("No shortcut configured") } diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs index aaa600b7..e717787c 100644 --- a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs @@ -1,5 +1,4 @@ using AIStudio.Dialogs; -using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -19,22 +18,10 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore private RustService RustService { get; init; } = null!; /// - /// The current shortcut value. + /// The shortcut binding data. /// [Parameter] - public Func Shortcut { get; set; } = () => string.Empty; - - /// - /// An action which is called when the shortcut was changed. - /// - [Parameter] - public Action ShortcutUpdate { get; set; } = _ => { }; - - /// - /// The name/identifier of the shortcut (used for conflict detection and registration). - /// - [Parameter] - public Shortcut ShortcutId { get; init; } + public ConfigurationShortcutData Data { get; set; } = ConfigurationShortcutData.Empty; /// /// The icon to display. @@ -60,10 +47,18 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore private string GetDisplayShortcut() { - var shortcut = this.Shortcut(); + var shortcut = this.Data.Value(); if (string.IsNullOrWhiteSpace(shortcut)) 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: return shortcut .Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl") @@ -80,8 +75,8 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore { var dialogParameters = new DialogParameters { - { x => x.InitialShortcut, this.Shortcut() }, - { x => x.ShortcutId, this.ShortcutId }, + { x => x.InitialShortcut, this.Data.Value() }, + { x => x.ShortcutId, this.Data.Id }, }; var dialogReference = await this.DialogService.ShowAsync( @@ -93,9 +88,17 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore if (dialogResult is null || dialogResult.Canceled) 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.InformAboutChange(); } diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcutData.cs b/app/MindWork AI Studio/Components/ConfigurationShortcutData.cs new file mode 100644 index 00000000..70541788 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigurationShortcutData.cs @@ -0,0 +1,44 @@ +using AIStudio.Tools.Rust; + +namespace AIStudio.Components; + +/// +/// UI binding data for a configurable keyboard shortcut. +/// +public sealed class ConfigurationShortcutData +{ + /// + /// Empty shortcut binding. + /// + public static ConfigurationShortcutData Empty { get; } = new(); + + /// + /// The name/identifier of the shortcut, used for conflict detection and registration. + /// + public Shortcut Id { get; init; } = Shortcut.NONE; + + /// + /// The current shortcut value. + /// + public Func Value { get; init; } = () => string.Empty; + + /// + /// An action that is called when the shortcut was changed. + /// + public Action ValueUpdate { get; init; } = _ => { }; + + /// + /// The optional user-facing shortcut label. + /// + public Func DisplayName { get; init; } = () => string.Empty; + + /// + /// The canonical shortcut value the optional user-facing label belongs to. + /// + public Func DisplaySource { get; init; } = () => string.Empty; + + /// + /// An action that is called when the user-facing shortcut label was changed. + /// + public Action DisplayUpdate { get; init; } = (_, _) => { }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index 33eb7d28..7d2b6801 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -37,7 +37,7 @@ @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) { - + } @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs index 9922291b..04022738 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs @@ -1,11 +1,22 @@ using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.Rust; namespace AIStudio.Components.Settings; 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() { var secret = EnterpriseEncryption.GenerateSecret(); @@ -93,6 +104,12 @@ public partial class SettingsPanelApp : SettingsPanelBase 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) { this.SettingsManager.ConfigurationData.App.LanguageBehavior = behavior; diff --git a/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs index 9809b818..b7872203 100644 --- a/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs @@ -34,6 +34,7 @@ public partial class ShortcutDialog : MSGComponentBase private string currentShortcut = string.Empty; private string originalShortcut = string.Empty; + private string currentDisplayName = string.Empty; private string validationMessage = string.Empty; private Severity validationSeverity = Severity.Info; private bool hasValidationError; @@ -115,6 +116,7 @@ public partial class ShortcutDialog : MSGComponentBase { this.UpdateModifiers(e); this.currentKey = null; + this.currentDisplayName = string.Empty; this.UpdateShortcutString(); return; } @@ -123,10 +125,12 @@ public partial class ShortcutDialog : MSGComponentBase // Get the key: this.currentKey = TranslateKeyCode(e.Code); + this.currentDisplayName = this.BuildDisplayShortcut(e.Key); // Validate: must have at least one modifier + a key 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.validationSeverity = Severity.Warning; this.hasValidationError = true; @@ -216,6 +220,9 @@ public partial class ShortcutDialog : MSGComponentBase private string GetDisplayShortcut() { + if (!string.IsNullOrWhiteSpace(this.currentDisplayName)) + return this.currentDisplayName; + // Convert internal format to display format: return this.currentShortcut .Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl") @@ -225,6 +232,7 @@ public partial class ShortcutDialog : MSGComponentBase private void ClearShortcut() { this.currentShortcut = string.Empty; + this.currentDisplayName = string.Empty; this.currentKey = null; this.hasCtrl = false; this.hasShift = false; @@ -237,7 +245,17 @@ public partial class ShortcutDialog : MSGComponentBase 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))); + } /// /// Checks if the key code represents a modifier key. @@ -377,6 +395,36 @@ public partial class ShortcutDialog : MSGComponentBase _ => code, }; + private string BuildDisplayShortcut(string? key) + { + var displayKey = GetDisplayKey(key); + if (string.IsNullOrWhiteSpace(displayKey)) + return string.Empty; + + var parts = new List(); + + 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() { // Re-focus the input field to keep capturing keys: diff --git a/app/MindWork AI Studio/Dialogs/ShortcutDialogResult.cs b/app/MindWork AI Studio/Dialogs/ShortcutDialogResult.cs new file mode 100644 index 00000000..c9b424c7 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/ShortcutDialogResult.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Dialogs; + +public readonly record struct ShortcutDialogResult(string Shortcut, string DisplayName, string DisplaySource); \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index 42bfdfac..c9352514 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -99,6 +99,16 @@ public sealed class DataApp(Expression>? configSelection = n /// public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty); + /// + /// The user-facing label for the voice recording shortcut, based on the user's keyboard layout. + /// + public string ShortcutVoiceRecordingDisplayName { get; set; } = string.Empty; + + /// + /// The canonical voice recording shortcut value this display label belongs to. + /// + public string ShortcutVoiceRecordingDisplaySource { get; set; } = string.Empty; + /// /// The HTTP timeout in seconds for external HTTP clients. /// diff --git a/app/MindWork AI Studio/Tools/Rust/Shortcut.cs b/app/MindWork AI Studio/Tools/Rust/Shortcut.cs index f8f783b3..d78ab8d5 100644 --- a/app/MindWork AI Studio/Tools/Rust/Shortcut.cs +++ b/app/MindWork AI Studio/Tools/Rust/Shortcut.cs @@ -14,4 +14,4 @@ public enum Shortcut /// Toggles voice recording on/off. /// VOICE_RECORDING_TOGGLE, -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md index 9742e02f..d3fd4a57 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md @@ -8,6 +8,7 @@ - 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 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. - 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. diff --git a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs index 5092d436..c4fe1392 100644 --- a/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs +++ b/app/SourceCodeRules/SourceCodeRules/UsageAnalyzers/EmptyStringAnalyzer.cs @@ -13,76 +13,94 @@ namespace SourceCodeRules.UsageAnalyzers; public sealed class EmptyStringAnalyzer : DiagnosticAnalyzer { private const string DIAGNOSTIC_ID = Identifier.EMPTY_STRING_ANALYZER; - + private static readonly string TITLE = """ Use string.Empty instead of "" """; - + private static readonly string MESSAGE_FORMAT = """ 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 static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION); - + public override ImmutableArray SupportedDiagnostics => [RULE]; - + public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); context.RegisterSyntaxNodeAction(AnalyzeEmptyStringLiteral, SyntaxKind.StringLiteralExpression); } - + private static void AnalyzeEmptyStringLiteral(SyntaxNodeAnalysisContext context) { var stringLiteral = (LiteralExpressionSyntax)context.Node; if (stringLiteral.Token.ValueText != string.Empty) return; - - if (IsInConstContext(stringLiteral)) + + if (RequiresCompileTimeConstant(stringLiteral)) return; - - if (IsInParameterDefaultValue(stringLiteral)) - return; - + var diagnostic = Diagnostic.Create(RULE, stringLiteral.GetLocation()); 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(); - if (variableDeclarator is null) + if (variableDeclarator?.Initializer is null || !ContainsNode(variableDeclarator.Initializer.Value, stringLiteral)) return false; - + var declaration = variableDeclarator.Parent?.Parent; return declaration switch { FieldDeclarationSyntax fieldDeclaration => fieldDeclaration.Modifiers.Any(SyntaxKind.ConstKeyword), LocalDeclarationStatementSyntax localDeclaration => localDeclaration.Modifiers.Any(SyntaxKind.ConstKeyword), - + _ => false }; } - + private static bool IsInParameterDefaultValue(LiteralExpressionSyntax stringLiteral) { - // Prüfen, ob das String-Literal Teil eines Parameter-Defaults ist var parameter = stringLiteral.FirstAncestorOrSelf(); - if (parameter is null) - return false; - - // Überprüfen, ob das String-Literal im Default-Wert des Parameters verwendet wird - if (parameter.Default is not null && - parameter.Default.Value == stringLiteral) - { - return true; - } - - return false; + return parameter?.Default is not null && ContainsNode(parameter.Default.Value, stringLiteral); + } + + private static bool IsInAttributeArgument(LiteralExpressionSyntax stringLiteral) + { + var attributeArgument = stringLiteral.FirstAncestorOrSelf(); + return attributeArgument is not null && ContainsNode(attributeArgument.Expression, stringLiteral); + } + + private static bool IsInSwitchCaseLabel(LiteralExpressionSyntax stringLiteral) + { + var caseSwitchLabel = stringLiteral.FirstAncestorOrSelf(); + return caseSwitchLabel is not null && ContainsNode(caseSwitchLabel.Value, stringLiteral); + } + + private static bool IsInConstantPattern(LiteralExpressionSyntax stringLiteral) + { + var constantPattern = stringLiteral.FirstAncestorOrSelf(); + 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; } } \ No newline at end of file