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