diff --git a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml index cec5a4e..c206bed 100644 --- a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml +++ b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml @@ -451,13 +451,17 @@ + Text="{Binding FeedbackText}" + AutomationProperties.LiveSetting="Polite"> + + + + diff --git a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs index 1c5a97a..a3207fe 100644 --- a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs +++ b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs @@ -333,8 +333,24 @@ private void RefreshDirtyState() (isTransient || !string.Equals(current.Name, cleanSnapshot.Name, StringComparison.Ordinal) || !current.Bindings.SequenceEqual(cleanSnapshot.Bindings)); - SaveButton.IsEnabled = isDirty; + bool isValid = IsProfileLocallyValid(); + SaveButton.IsEnabled = isDirty && isValid; CancelButton.IsEnabled = isDirty; + ValidationMessage.Text = IsProfileNameLocallyValid() + ? "" + : "Profile names must be unique and contain 1 to 50 characters."; + } + + private bool IsProfileLocallyValid() => + IsProfileNameLocallyValid() && rows.All(row => row.IsLocallyValid()); + + private bool IsProfileNameLocallyValid() + { + string name = ProfileName.Text.Trim(); + return name.Length is >= 1 and <= 50 && + profiles.All(profile => + profile.Id == selected?.Id || + !string.Equals(profile.Name, name, StringComparison.OrdinalIgnoreCase)); } private ProfileEditSnapshot CaptureSnapshot() => @@ -392,11 +408,20 @@ private sealed record BindingEditSnapshot( SwitchBindingType Type, string Value); + private sealed record BindingTypeOption( + SwitchBindingType Value, + string Label); + + private sealed record BindingValueOption( + string Value, + string Label); + private sealed class BindingRowViewModel : INotifyPropertyChanged { private SwitchBindingType selectedType; private string value = ""; private bool isEditable; + private bool isLoading; public BindingRowViewModel(int switchId) { @@ -408,40 +433,63 @@ public BindingRowViewModel(int switchId) public string SwitchLabel => $"Switch {SwitchId}"; public string TypeAutomationName => $"Switch {SwitchId} action type"; public string ValueAutomationName => $"Switch {SwitchId} action value"; - public IReadOnlyList Types { get; } = Enum.GetValues(); + public IReadOnlyList Types { get; } = BindingTypes; public SwitchBindingType SelectedType { get => selectedType; set { + if (selectedType == value) + { + return; + } + selectedType = value; + if (!isLoading) + { + SetRawValue(""); + } Changed(); Changed(nameof(ValueOptions)); Changed(nameof(ValueHelp)); + Changed(nameof(FeedbackText)); + Changed(nameof(HasValidationError)); } } public string Value { get => value; - set { this.value = value; Changed(); } + set => SetRawValue(value); + } + + public string ValueDisplay + { + get => DisplayValue(SelectedType, value); + set => SetRawValue(RawValue(SelectedType, value)); } public bool IsEditable { get => isEditable; - private set { isEditable = value; Changed(); } + private set + { + isEditable = value; + Changed(); + Changed(nameof(FeedbackText)); + Changed(nameof(HasValidationError)); + } } - public IReadOnlyList ValueOptions => SelectedType switch + public IReadOnlyList ValueOptions => SelectedType switch { - SwitchBindingType.Key => KeyValues, - SwitchBindingType.MouseButton => ["left", "right", "middle"], - SwitchBindingType.Shortcut => ["Ctrl + C", "Ctrl + V", "Alt + Tab", "Ctrl + Shift + Escape"], - SwitchBindingType.MouseClick => ["left", "left:2", "right", "right:2", "middle", "middle:2"], - SwitchBindingType.Scroll => ["up", "down", "left", "right"], - SwitchBindingType.Media => ["playPause", "nextTrack", "previousTrack", "volumeUp", "volumeDown", "mute"], + SwitchBindingType.Key => KeyOptions, + SwitchBindingType.MouseButton => MouseButtonOptions, + SwitchBindingType.Shortcut => ShortcutOptions, + SwitchBindingType.MouseClick => MouseClickOptions, + SwitchBindingType.Scroll => ScrollOptions, + SwitchBindingType.Media => MediaOptions, _ => [] }; @@ -449,14 +497,22 @@ public bool IsEditable { SwitchBindingType.None => "No value is required.", SwitchBindingType.Key => "Choose one keyboard key.", - SwitchBindingType.MouseButton => "Choose left, right, or middle.", + SwitchBindingType.MouseButton => "Choose a mouse button to hold while the switch is pressed.", SwitchBindingType.Shortcut => "Enter one to four unique keys separated by +, including a non-modifier.", - SwitchBindingType.MouseClick => "Choose a button, with :2 for a double click.", - SwitchBindingType.Scroll => "Choose up, down, left, or right.", + SwitchBindingType.MouseClick => "Choose a single or double mouse click.", + SwitchBindingType.Scroll => "Choose a scroll direction.", SwitchBindingType.Media => "Choose play/pause, track, volume, or mute.", _ => "Choose a valid value." }; + public bool HasValidationError => IsEditable && !IsLocallyValid(); + + public string FeedbackText => HasValidationError + ? string.IsNullOrWhiteSpace(Value) + ? "Choose a value for this action." + : $"The value is not valid. {ValueHelp}" + : ValueHelp; + public bool IsLocallyValid() { string trimmed = Value.Trim(); @@ -481,13 +537,21 @@ public bool IsLocallyValid() public void Load(SwitchControlBinding binding, bool editable) { - SelectedType = binding.Type; - Value = binding.Type == SwitchBindingType.Shortcut - ? string.Join(" + ", binding.Keys ?? []) - : binding.Type == SwitchBindingType.MouseClick - ? $"{binding.Value}:{binding.ClickCount}" - : binding.Value ?? ""; - IsEditable = editable; + isLoading = true; + try + { + SelectedType = binding.Type; + Value = binding.Type == SwitchBindingType.Shortcut + ? string.Join(" + ", binding.Keys ?? []) + : binding.Type == SwitchBindingType.MouseClick + ? $"{binding.Value}:{binding.ClickCount}" + : binding.Value ?? ""; + IsEditable = editable; + } + finally + { + isLoading = false; + } } public SwitchControlBinding ToBinding() @@ -508,6 +572,20 @@ public SwitchControlBinding ToBinding() return new(SwitchId, SelectedType, string.IsNullOrEmpty(trimmed) ? null : trimmed); } + private void SetRawValue(string newValue) + { + if (string.Equals(value, newValue, StringComparison.Ordinal)) + { + return; + } + + value = newValue; + Changed(nameof(Value)); + Changed(nameof(ValueDisplay)); + Changed(nameof(FeedbackText)); + Changed(nameof(HasValidationError)); + } + private void Changed([CallerMemberName] string? propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); @@ -522,6 +600,74 @@ private static bool IsValidShortcut(string value) keys.Any(key => !ModifierValues.Contains(key, StringComparer.OrdinalIgnoreCase)); } + private static string DisplayValue(SwitchBindingType type, string rawValue) + { + if (type == SwitchBindingType.Shortcut) + { + return string.Join( + " + ", + rawValue.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(key => OptionLabel(KeyOptions, key))); + } + + return OptionLabel(OptionsFor(type), rawValue); + } + + private static string RawValue(SwitchBindingType type, string displayValue) + { + if (type == SwitchBindingType.Shortcut) + { + return string.Join( + " + ", + displayValue.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(key => OptionValue(KeyOptions, key))); + } + + return OptionValue(OptionsFor(type), displayValue); + } + + private static IReadOnlyList OptionsFor(SwitchBindingType type) => type switch + { + SwitchBindingType.Key => KeyOptions, + SwitchBindingType.MouseButton => MouseButtonOptions, + SwitchBindingType.Shortcut => ShortcutOptions, + SwitchBindingType.MouseClick => MouseClickOptions, + SwitchBindingType.Scroll => ScrollOptions, + SwitchBindingType.Media => MediaOptions, + _ => [] + }; + + private static string OptionLabel(IReadOnlyList options, string value) => + options.FirstOrDefault(option => + string.Equals(option.Value, value, StringComparison.OrdinalIgnoreCase))?.Label ?? value; + + private static string OptionValue(IReadOnlyList options, string display) => + options.FirstOrDefault(option => + string.Equals(option.Label, display, StringComparison.OrdinalIgnoreCase) || + string.Equals(option.Value, display, StringComparison.OrdinalIgnoreCase))?.Value ?? display.Trim(); + + private static string KeyLabel(string value) => value switch + { + "ArrowUp" => "Up arrow", + "ArrowDown" => "Down arrow", + "ArrowLeft" => "Left arrow", + "ArrowRight" => "Right arrow", + "PageUp" => "Page up", + "PageDown" => "Page down", + "Meta" => "Windows key", + _ => value + }; + + private static readonly BindingTypeOption[] BindingTypes = + [ + new(SwitchBindingType.None, "Unassigned"), + new(SwitchBindingType.Key, "Keyboard key"), + new(SwitchBindingType.MouseButton, "Mouse button"), + new(SwitchBindingType.Shortcut, "Keyboard shortcut"), + new(SwitchBindingType.MouseClick, "Mouse click"), + new(SwitchBindingType.Scroll, "Scroll"), + new(SwitchBindingType.Media, "Media control") + ]; private static readonly string[] ModifierValues = ["Ctrl", "Alt", "Shift", "Meta"]; private static readonly string[] KeyValues = [ @@ -532,5 +678,45 @@ .. Enumerable.Range(1, 12).Select(value => $"F{value}"), "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Ctrl", "Alt", "Shift", "Meta" ]; + private static readonly BindingValueOption[] KeyOptions = + KeyValues.Select(value => new BindingValueOption(value, KeyLabel(value))).ToArray(); + private static readonly BindingValueOption[] MouseButtonOptions = + [ + new("left", "Left button"), + new("right", "Right button"), + new("middle", "Middle button") + ]; + private static readonly BindingValueOption[] ShortcutOptions = + [ + new("Ctrl + C", "Ctrl + C"), + new("Ctrl + V", "Ctrl + V"), + new("Alt + Tab", "Alt + Tab"), + new("Ctrl + Shift + Escape", "Ctrl + Shift + Escape") + ]; + private static readonly BindingValueOption[] MouseClickOptions = + [ + new("left:1", "Left click"), + new("left:2", "Double left click"), + new("right:1", "Right click"), + new("right:2", "Double right click"), + new("middle:1", "Middle click"), + new("middle:2", "Double middle click") + ]; + private static readonly BindingValueOption[] ScrollOptions = + [ + new("up", "Scroll up"), + new("down", "Scroll down"), + new("left", "Scroll left"), + new("right", "Scroll right") + ]; + private static readonly BindingValueOption[] MediaOptions = + [ + new("playPause", "Play / pause"), + new("nextTrack", "Next track"), + new("previousTrack", "Previous track"), + new("volumeUp", "Volume up"), + new("volumeDown", "Volume down"), + new("mute", "Mute") + ]; } } diff --git a/src/SwitchifyPc.Tests/SwitchControlProfileWindowTests.cs b/src/SwitchifyPc.Tests/SwitchControlProfileWindowTests.cs index 0ecf64e..7a4c84b 100644 --- a/src/SwitchifyPc.Tests/SwitchControlProfileWindowTests.cs +++ b/src/SwitchifyPc.Tests/SwitchControlProfileWindowTests.cs @@ -121,9 +121,9 @@ public void ProfileWindowEnablesEditActionsOnlyWhileDirty() ControlByAutomationName(window, "Switch 1 action type")); WpfComboBox value = Assert.IsType( ControlByAutomationName(window, "Switch 1 action value")); - action.SelectedItem = SwitchBindingType.None; + action.SelectedValue = SwitchBindingType.None; Assert.True(save.IsEnabled); - action.SelectedItem = SwitchBindingType.Key; + action.SelectedValue = SwitchBindingType.Key; value.Text = "A"; Assert.False(save.IsEnabled); @@ -256,7 +256,7 @@ public void InvalidProfilePreventsSelectionAndClosingWhenSaving() ControlByAutomationName(window, "Switch 1 action type")); WpfComboBox value = Assert.IsType( ControlByAutomationName(window, "Switch 1 action value")); - action.SelectedItem = SwitchBindingType.Key; + action.SelectedValue = SwitchBindingType.Key; value.Text = "NotAKey"; WpfListBox profiles = Assert.IsType(window.FindName("ProfilesList")); @@ -280,6 +280,140 @@ public void InvalidProfilePreventsSelectionAndClosingWhenSaving() }); } + [Fact] + public void ProfileWindowUsesFriendlyLabelsAndSavesCanonicalValues() + { + RunOnSta(() => + { + WpfTestApplication.ApplyTheme(AppTheme.Light); + MutableProfileStore store = new(); + SwitchControlProfileWindow window = CreateWindow(store, () => MessageBoxResult.No); + try + { + window.Show(); + window.UpdateLayout(); + SelectCustomProfile(window); + + WpfComboBox action = Assert.IsType( + ControlByAutomationName(window, "Switch 1 action type")); + WpfComboBox value = Assert.IsType( + ControlByAutomationName(window, "Switch 1 action value")); + WpfButton save = Assert.IsType(window.FindName("SaveButton")); + + Assert.Contains("Mouse button", ItemLabels(action)); + Assert.Contains("Media control", ItemLabels(action)); + action.SelectedValue = SwitchBindingType.MouseClick; + Assert.Empty(value.Text); + Assert.False(save.IsEnabled); + Assert.Contains("Choose a value for this action.", TextBlocks(window)); + Assert.Contains("Double left click", ItemLabels(value)); + + value.Text = "Double left click"; + Assert.True(save.IsEnabled); + save.RaiseEvent(new RoutedEventArgs(WpfButton.ClickEvent)); + + SwitchControlBinding saved = store.CustomProfiles.Single().Bindings[0]; + Assert.Equal(SwitchBindingType.MouseClick, saved.Type); + Assert.Equal("left", saved.Value); + Assert.Equal(2, saved.ClickCount); + } + finally + { + window.Close(); + } + }); + } + + [Fact] + public void ChangingEveryActionTypeClearsThePreviousValue() + { + RunOnSta(() => + { + WpfTestApplication.ApplyTheme(AppTheme.Light); + MutableProfileStore store = new(); + SwitchControlProfileWindow window = CreateWindow(store, () => MessageBoxResult.No); + try + { + window.Show(); + window.UpdateLayout(); + SelectCustomProfile(window); + + WpfComboBox action = Assert.IsType( + ControlByAutomationName(window, "Switch 1 action type")); + WpfComboBox value = Assert.IsType( + ControlByAutomationName(window, "Switch 1 action value")); + var displays = new Dictionary + { + [SwitchBindingType.Key] = "Up arrow", + [SwitchBindingType.MouseButton] = "Left button", + [SwitchBindingType.Shortcut] = "Ctrl + C", + [SwitchBindingType.MouseClick] = "Left click", + [SwitchBindingType.Scroll] = "Scroll down", + [SwitchBindingType.Media] = "Play / pause" + }; + + action.SelectedValue = SwitchBindingType.None; + Assert.Empty(value.Text); + foreach ((SwitchBindingType type, string display) in displays) + { + action.SelectedValue = type; + Assert.Empty(value.Text); + value.Text = display; + Assert.Equal(display, value.Text); + } + } + finally + { + window.Close(); + } + }); + } + + [Theory] + [InlineData(SwitchBindingType.Key, "Up arrow", "Key|ArrowUp|1|")] + [InlineData(SwitchBindingType.MouseButton, "Left button", "MouseButton|left|1|")] + [InlineData(SwitchBindingType.Shortcut, "Windows key + A", "Shortcut||1|Meta,A")] + [InlineData(SwitchBindingType.MouseClick, "Double right click", "MouseClick|right|2|")] + [InlineData(SwitchBindingType.Scroll, "Scroll up", "Scroll|up|1|")] + [InlineData(SwitchBindingType.Media, "Play / pause", "Media|playPause|1|")] + public void FriendlyValuesRoundTripToCanonicalBindings( + SwitchBindingType type, + string display, + string expected) + { + RunOnSta(() => + { + WpfTestApplication.ApplyTheme(AppTheme.Light); + MutableProfileStore store = new(); + SwitchControlProfileWindow window = CreateWindow(store, () => MessageBoxResult.No); + try + { + window.Show(); + window.UpdateLayout(); + SelectCustomProfile(window); + + WpfComboBox action = Assert.IsType( + ControlByAutomationName(window, "Switch 1 action type")); + WpfComboBox value = Assert.IsType( + ControlByAutomationName(window, "Switch 1 action value")); + action.SelectedValue = type; + value.Text = display; + Assert.True(Assert.IsType(window.FindName("SaveButton")).IsEnabled); + Assert.IsType(window.FindName("SaveButton")) + .RaiseEvent(new RoutedEventArgs(WpfButton.ClickEvent)); + + SwitchControlBinding binding = store.CustomProfiles.Single().Bindings[0]; + Assert.Equal( + expected, + $"{binding.Type}|{binding.Value}|{binding.ClickCount}|{string.Join(",", binding.Keys ?? [])}"); + } + finally + { + window.Close(); + } + }); + } + [Fact] public void ProfileWindowLoadsWithDarkTheme() { @@ -381,6 +515,13 @@ private static IReadOnlyList ButtonContent(DependencyObject root) return content; } + private static IReadOnlyList ItemLabels(ItemsControl items) => + items.Items.Cast() + .Select(item => item.GetType().GetProperty("Label")?.GetValue(item) as string) + .Where(label => label is not null) + .Cast() + .ToArray(); + private static WpfButton ButtonByContent(DependencyObject root, string content) { WpfButton? result = null;