diff --git a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml
index 0c9ea86..d46fd00 100644
--- a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml
+++ b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml
@@ -4,8 +4,10 @@
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
xmlns:chrome="clr-namespace:SwitchifyPc.App.Chrome"
Title="PC Switch Control profiles"
- Width="980"
- Height="720"
+ MinWidth="760"
+ MinHeight="560"
+ Width="900"
+ Height="690"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Icon="Assets/icon.ico"
@@ -501,11 +503,6 @@
MinWidth="84"
Style="{StaticResource PrimaryButton}"
Click="Save_Click" />
-
diff --git a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs
index cfe741c..bbb3058 100644
--- a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs
+++ b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs
@@ -14,19 +14,40 @@ public partial class SwitchControlProfileWindow : Window
{
private readonly ISwitchControlProfileStore store;
private readonly Func activeProfileId;
+ private readonly Func confirmUnsavedChanges;
private IReadOnlyList profiles = [];
private SwitchControlProfile? selected;
+ private ProfileEditSnapshot? cleanSnapshot;
+ private bool isEditable;
+ private bool isTransient;
+ private bool isDirty;
+ private bool suppressDirtyTracking;
+ private bool suppressSelectionChange;
private readonly BindingRowViewModel[] rows =
Enumerable.Range(1, 8).Select(id => new BindingRowViewModel(id)).ToArray();
public SwitchControlProfileWindow(
ISwitchControlProfileStore store,
Func activeProfileId)
+ : this(store, activeProfileId, ShowUnsavedChangesPrompt)
+ {
+ }
+
+ internal SwitchControlProfileWindow(
+ ISwitchControlProfileStore store,
+ Func activeProfileId,
+ Func confirmUnsavedChanges)
{
this.store = store;
this.activeProfileId = activeProfileId;
+ this.confirmUnsavedChanges = confirmUnsavedChanges;
InitializeComponent();
BindingRows.ItemsSource = rows;
+ ProfileName.TextChanged += (_, _) => RefreshDirtyState();
+ foreach (BindingRowViewModel row in rows)
+ {
+ row.PropertyChanged += (_, _) => RefreshDirtyState();
+ }
Reload();
}
@@ -34,33 +55,87 @@ private void Reload(string? selectId = null)
{
profiles = store.Load();
ProfilesList.ItemsSource = profiles;
- ProfilesList.SelectedItem = profiles.FirstOrDefault(profile => profile.Id == selectId) ?? profiles.FirstOrDefault();
+ SwitchControlProfile? profile =
+ profiles.FirstOrDefault(candidate => candidate.Id == selectId) ?? profiles.FirstOrDefault();
+ SelectAndLoad(profile);
}
private void ProfilesList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
- if (ProfilesList.SelectedItem is not SwitchControlProfile profile) return;
+ if (suppressSelectionChange ||
+ ProfilesList.SelectedItem is not SwitchControlProfile profile ||
+ profile.Id == selected?.Id)
+ {
+ return;
+ }
+
+ if (isDirty)
+ {
+ string targetId = profile.Id;
+ switch (confirmUnsavedChanges())
+ {
+ case MessageBoxResult.Yes:
+ if (!TrySave())
+ {
+ RestoreSelectedProfile();
+ return;
+ }
+ Reload(targetId);
+ return;
+ case MessageBoxResult.No:
+ Reload(targetId);
+ return;
+ default:
+ RestoreSelectedProfile();
+ return;
+ }
+ }
+
+ LoadProfile(profile);
+ }
+
+ protected override void OnClosing(CancelEventArgs e)
+ {
+ if (isDirty)
+ {
+ MessageBoxResult decision = confirmUnsavedChanges();
+ if (decision == MessageBoxResult.Cancel ||
+ decision == MessageBoxResult.Yes && !TrySave())
+ {
+ e.Cancel = true;
+ }
+ }
+
+ base.OnClosing(e);
+ }
+
+ private void LoadProfile(SwitchControlProfile profile, bool transient = false)
+ {
+ suppressDirtyTracking = true;
selected = profile;
- bool editable = !profile.IsBuiltIn && profile.Id != activeProfileId();
+ isEditable = !profile.IsBuiltIn && profile.Id != activeProfileId();
+ isTransient = transient;
ProfileName.Text = profile.Name;
- ProfileName.IsEnabled = editable;
+ ProfileName.IsEnabled = isEditable;
ReadOnlyMessage.Text = profile.IsBuiltIn
? "Built-in profiles are read-only. Duplicate this profile to customize it."
- : editable
+ : isEditable
? ""
: "This profile is active and cannot be changed until PC Switch Control stops.";
foreach ((BindingRowViewModel row, SwitchControlBinding binding) in rows.Zip(profile.Bindings))
{
- row.Load(binding, editable);
+ row.Load(binding, isEditable);
}
- SaveButton.IsEnabled = editable;
- CancelButton.IsEnabled = editable;
- DeleteButton.IsEnabled = editable;
+ cleanSnapshot = CaptureSnapshot();
+ suppressDirtyTracking = false;
+ DeleteButton.IsEnabled = isEditable;
ValidationMessage.Text = "";
+ RefreshDirtyState();
}
private void New_Click(object sender, RoutedEventArgs e)
{
+ if (!ResolvePendingChanges()) return;
EditUnsaved(new SwitchControlProfile(
Guid.NewGuid().ToString(),
1,
@@ -71,6 +146,7 @@ private void New_Click(object sender, RoutedEventArgs e)
private void Duplicate_Click(object sender, RoutedEventArgs e)
{
+ if (!ResolvePendingChanges()) return;
if (selected is null) return;
EditUnsaved(selected with
{
@@ -89,19 +165,24 @@ private void EditUnsaved(SwitchControlProfile profile)
{
profiles = [.. profiles, profile];
ProfilesList.ItemsSource = profiles;
+ suppressSelectionChange = true;
ProfilesList.SelectedItem = profile;
+ suppressSelectionChange = false;
+ LoadProfile(profile, transient: true);
ProfilesList.ScrollIntoView(profile);
}
- private void Save_Click(object sender, RoutedEventArgs e)
+ private void Save_Click(object sender, RoutedEventArgs e) => TrySave();
+
+ private bool TrySave()
{
- if (selected is null || selected.IsBuiltIn || selected.Id == activeProfileId()) return;
+ if (selected is null || !isEditable) return false;
BindingRowViewModel? invalidRow = rows.FirstOrDefault(row => !row.IsLocallyValid());
if (invalidRow is not null)
{
ValidationMessage.Text = $"Switch {invalidRow.SwitchId}: {invalidRow.ValueHelp}";
FocusBindingValue(invalidRow);
- return;
+ return false;
}
try
{
@@ -119,11 +200,13 @@ private void Save_Click(object sender, RoutedEventArgs e)
.ToArray();
store.Save(custom);
Reload(saved.Id);
+ return true;
}
catch (Exception error) when (error is InvalidDataException or IOException or UnauthorizedAccessException)
{
ValidationMessage.Text = error.Message;
ProfileName.Focus();
+ return false;
}
}
@@ -142,7 +225,64 @@ private void Delete_Click(object sender, RoutedEventArgs e)
Reload();
}
- private void Close_Click(object sender, RoutedEventArgs e) => Close();
+ private bool ResolvePendingChanges()
+ {
+ if (!isDirty) return true;
+ return confirmUnsavedChanges() switch
+ {
+ MessageBoxResult.Yes => TrySave(),
+ MessageBoxResult.No => DiscardPendingChanges(),
+ _ => false
+ };
+ }
+
+ private bool DiscardPendingChanges()
+ {
+ Reload(selected?.Id);
+ return true;
+ }
+
+ private void SelectAndLoad(SwitchControlProfile? profile)
+ {
+ suppressSelectionChange = true;
+ ProfilesList.SelectedItem = profile;
+ suppressSelectionChange = false;
+ if (profile is not null)
+ {
+ LoadProfile(profile);
+ }
+ }
+
+ private void RestoreSelectedProfile()
+ {
+ suppressSelectionChange = true;
+ ProfilesList.SelectedItem = selected;
+ suppressSelectionChange = false;
+ }
+
+ private void RefreshDirtyState()
+ {
+ if (suppressDirtyTracking || cleanSnapshot is null) return;
+ ProfileEditSnapshot current = CaptureSnapshot();
+ isDirty = isEditable &&
+ (isTransient ||
+ !string.Equals(current.Name, cleanSnapshot.Name, StringComparison.Ordinal) ||
+ !current.Bindings.SequenceEqual(cleanSnapshot.Bindings));
+ SaveButton.IsEnabled = isDirty;
+ CancelButton.IsEnabled = isDirty;
+ }
+
+ private ProfileEditSnapshot CaptureSnapshot() =>
+ new(
+ ProfileName.Text,
+ rows.Select(row => new BindingEditSnapshot(row.SelectedType, row.Value)).ToArray());
+
+ private static MessageBoxResult ShowUnsavedChangesPrompt() =>
+ WpfMessageBox.Show(
+ "Save changes to this profile before continuing?\n\nChoose No to discard them.",
+ "Unsaved PC Switch Control changes",
+ MessageBoxButton.YesNoCancel,
+ MessageBoxImage.Warning);
private string UniqueName(string proposed)
{
@@ -179,6 +319,14 @@ private static IEnumerable FindDescendants(DependencyObject parent)
}
}
+ private sealed record ProfileEditSnapshot(
+ string Name,
+ IReadOnlyList Bindings);
+
+ private sealed record BindingEditSnapshot(
+ SwitchBindingType Type,
+ string Value);
+
private sealed class BindingRowViewModel : INotifyPropertyChanged
{
private SwitchBindingType selectedType;
diff --git a/src/SwitchifyPc.App/SwitchifyPc.App.csproj b/src/SwitchifyPc.App/SwitchifyPc.App.csproj
index 345701f..de2963a 100644
--- a/src/SwitchifyPc.App/SwitchifyPc.App.csproj
+++ b/src/SwitchifyPc.App/SwitchifyPc.App.csproj
@@ -28,6 +28,7 @@
+
diff --git a/src/SwitchifyPc.Tests/SwitchControlProfileWindowTests.cs b/src/SwitchifyPc.Tests/SwitchControlProfileWindowTests.cs
index 0dca07d..23b01f9 100644
--- a/src/SwitchifyPc.Tests/SwitchControlProfileWindowTests.cs
+++ b/src/SwitchifyPc.Tests/SwitchControlProfileWindowTests.cs
@@ -9,6 +9,9 @@
using SwitchifyPc.Core.SwitchControl;
using WpfButton = System.Windows.Controls.Button;
using WpfColor = System.Windows.Media.Color;
+using WpfComboBox = System.Windows.Controls.ComboBox;
+using WpfListBox = System.Windows.Controls.ListBox;
+using WpfTextBox = System.Windows.Controls.TextBox;
namespace SwitchifyPc.Tests;
@@ -16,7 +19,7 @@ namespace SwitchifyPc.Tests;
public sealed class SwitchControlProfileWindowTests
{
[Fact]
- public void ProfileWindowUsesSwitchifyChromeAndFixedLayout()
+ public void ProfileWindowMatchesSettingsChromeAndLayout()
{
RunOnSta(() =>
{
@@ -29,8 +32,10 @@ public void ProfileWindowUsesSwitchifyChromeAndFixedLayout()
Assert.Equal(WindowStyle.None, window.WindowStyle);
Assert.Equal(ResizeMode.NoResize, window.ResizeMode);
- Assert.Equal(980, window.Width);
- Assert.Equal(720, window.Height);
+ Assert.Equal(900, window.Width);
+ Assert.Equal(690, window.Height);
+ Assert.Equal(760, window.MinWidth);
+ Assert.Equal(560, window.MinHeight);
Assert.NotNull(WindowChrome.GetWindowChrome(window));
Assert.Contains("PC Switch Control profiles", TextBlocks(window));
Assert.NotNull(ButtonByAutomationName(window, "Minimize"));
@@ -38,6 +43,7 @@ public void ProfileWindowUsesSwitchifyChromeAndFixedLayout()
WpfButton save = Assert.IsType(window.FindName("SaveButton"));
Assert.Same(window.FindResource("PrimaryButton"), save.Style);
+ Assert.DoesNotContain("Close", ButtonContent(window));
}
finally
{
@@ -46,6 +52,196 @@ public void ProfileWindowUsesSwitchifyChromeAndFixedLayout()
});
}
+ [Fact]
+ public void ProfileWindowEnablesEditActionsOnlyWhileDirty()
+ {
+ RunOnSta(() =>
+ {
+ MutableProfileStore store = new();
+ SwitchControlProfileWindow window = CreateWindow(store, () => MessageBoxResult.No);
+ try
+ {
+ window.Show();
+ window.UpdateLayout();
+ SelectCustomProfile(window);
+
+ WpfButton save = Assert.IsType(window.FindName("SaveButton"));
+ WpfButton cancel = Assert.IsType(window.FindName("CancelButton"));
+ WpfTextBox name = Assert.IsType(window.FindName("ProfileName"));
+ Assert.False(save.IsEnabled);
+ Assert.False(cancel.IsEnabled);
+
+ name.Text = "Edited profile";
+ Assert.True(save.IsEnabled);
+ Assert.True(cancel.IsEnabled);
+
+ name.Text = "Custom profile";
+ Assert.False(save.IsEnabled);
+ Assert.False(cancel.IsEnabled);
+
+ WpfComboBox action = Assert.IsType(
+ ControlByAutomationName(window, "Switch 1 action type"));
+ WpfComboBox value = Assert.IsType(
+ ControlByAutomationName(window, "Switch 1 action value"));
+ action.SelectedItem = SwitchBindingType.None;
+ Assert.True(save.IsEnabled);
+ action.SelectedItem = SwitchBindingType.Key;
+ value.Text = "A";
+ Assert.False(save.IsEnabled);
+
+ value.Text = "B";
+ Assert.True(save.IsEnabled);
+ value.Text = "A";
+ Assert.False(save.IsEnabled);
+ }
+ finally
+ {
+ window.Close();
+ }
+ });
+ }
+
+ [Fact]
+ public void NewAndDuplicateProfilesBeginDirty()
+ {
+ RunOnSta(() =>
+ {
+ MutableProfileStore store = new();
+ SwitchControlProfileWindow window = CreateWindow(store, () => MessageBoxResult.No);
+ try
+ {
+ window.Show();
+ window.UpdateLayout();
+
+ WpfButton save = Assert.IsType(window.FindName("SaveButton"));
+ WpfButton cancel = Assert.IsType(window.FindName("CancelButton"));
+ ButtonByContent(window, "New").RaiseEvent(new RoutedEventArgs(WpfButton.ClickEvent));
+ Assert.True(save.IsEnabled);
+ Assert.True(cancel.IsEnabled);
+
+ cancel.RaiseEvent(new RoutedEventArgs(WpfButton.ClickEvent));
+ SelectCustomProfile(window);
+ ButtonByContent(window, "Duplicate").RaiseEvent(new RoutedEventArgs(WpfButton.ClickEvent));
+ Assert.True(save.IsEnabled);
+ Assert.True(cancel.IsEnabled);
+ }
+ finally
+ {
+ window.Close();
+ }
+ });
+ }
+
+ [Theory]
+ [InlineData(MessageBoxResult.Yes, "Edited profile", "Grid 3")]
+ [InlineData(MessageBoxResult.No, "Custom profile", "Grid 3")]
+ [InlineData(MessageBoxResult.Cancel, "Custom profile", "Custom profile")]
+ public void ProfileSelectionResolvesUnsavedChanges(
+ MessageBoxResult decision,
+ string storedName,
+ string selectedName)
+ {
+ RunOnSta(() =>
+ {
+ MessageBoxResult promptDecision = decision;
+ MutableProfileStore store = new();
+ SwitchControlProfileWindow window = CreateWindow(store, () => promptDecision);
+ try
+ {
+ window.Show();
+ window.UpdateLayout();
+ SelectCustomProfile(window);
+ Assert.IsType(window.FindName("ProfileName")).Text = "Edited profile";
+
+ WpfListBox profiles = Assert.IsType(window.FindName("ProfilesList"));
+ profiles.SelectedItem = profiles.Items
+ .Cast()
+ .First(profile => profile.IsBuiltIn);
+
+ Assert.Equal(selectedName, Assert.IsType(profiles.SelectedItem).Name);
+ Assert.Equal(storedName, store.CustomProfiles.Single().Name);
+ }
+ finally
+ {
+ promptDecision = MessageBoxResult.No;
+ window.Close();
+ }
+ });
+ }
+
+ [Theory]
+ [InlineData(MessageBoxResult.Yes, false, "Edited profile")]
+ [InlineData(MessageBoxResult.No, false, "Custom profile")]
+ [InlineData(MessageBoxResult.Cancel, true, "Custom profile")]
+ public void ProfileWindowClosingResolvesUnsavedChanges(
+ MessageBoxResult decision,
+ bool remainsOpen,
+ string storedName)
+ {
+ RunOnSta(() =>
+ {
+ MessageBoxResult promptDecision = decision;
+ MutableProfileStore store = new();
+ SwitchControlProfileWindow window = CreateWindow(store, () => promptDecision);
+ window.Show();
+ window.UpdateLayout();
+ SelectCustomProfile(window);
+ Assert.IsType(window.FindName("ProfileName")).Text = "Edited profile";
+
+ window.Close();
+
+ Assert.Equal(remainsOpen, window.IsVisible);
+ Assert.Equal(storedName, store.CustomProfiles.Single().Name);
+ if (window.IsVisible)
+ {
+ promptDecision = MessageBoxResult.No;
+ window.Close();
+ }
+ });
+ }
+
+ [Fact]
+ public void InvalidProfilePreventsSelectionAndClosingWhenSaving()
+ {
+ RunOnSta(() =>
+ {
+ MessageBoxResult promptDecision = MessageBoxResult.Yes;
+ MutableProfileStore store = new();
+ SwitchControlProfileWindow window = CreateWindow(store, () => promptDecision);
+ 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.SelectedItem = SwitchBindingType.Key;
+ value.Text = "NotAKey";
+
+ WpfListBox profiles = Assert.IsType(window.FindName("ProfilesList"));
+ profiles.SelectedItem = profiles.Items
+ .Cast()
+ .First(profile => profile.IsBuiltIn);
+
+ Assert.Equal("Custom profile", Assert.IsType(profiles.SelectedItem).Name);
+ Assert.Contains(
+ "Switch 1",
+ Assert.IsType(window.FindName("ValidationMessage")).Text);
+
+ window.Close();
+ Assert.True(window.IsVisible);
+ }
+ finally
+ {
+ promptDecision = MessageBoxResult.No;
+ window.Close();
+ }
+ });
+ }
+
[Fact]
public void ProfileWindowLoadsWithDarkTheme()
{
@@ -73,6 +269,19 @@ public void ProfileWindowLoadsWithDarkTheme()
private static SwitchControlProfileWindow CreateWindow() =>
new(new StaticProfileStore(), () => null);
+ private static SwitchControlProfileWindow CreateWindow(
+ MutableProfileStore store,
+ Func confirmUnsavedChanges) =>
+ new(store, () => null, confirmUnsavedChanges);
+
+ private static void SelectCustomProfile(SwitchControlProfileWindow window)
+ {
+ WpfListBox profiles = Assert.IsType(window.FindName("ProfilesList"));
+ profiles.SelectedItem = profiles.Items
+ .Cast()
+ .Single(profile => !profile.IsBuiltIn);
+ }
+
private static void RunOnSta(Action action)
{
Exception? exception = null;
@@ -106,20 +315,47 @@ private static IReadOnlyList TextBlocks(DependencyObject root)
}
private static WpfButton? ButtonByAutomationName(DependencyObject root, string name)
+ => ControlByAutomationName(root, name);
+
+ private static T? ControlByAutomationName(DependencyObject root, string name)
+ where T : DependencyObject
{
- WpfButton? result = null;
+ T? result = null;
Collect(root, node =>
{
if (result is null &&
- node is WpfButton button &&
- AutomationProperties.GetName(button) == name)
+ node is T control &&
+ AutomationProperties.GetName(control) == name)
{
- result = button;
+ result = control;
}
});
return result;
}
+ private static IReadOnlyList ButtonContent(DependencyObject root)
+ {
+ List content = [];
+ Collect(root, node =>
+ {
+ if (node is WpfButton { Content: string text }) content.Add(text);
+ });
+ return content;
+ }
+
+ private static WpfButton ButtonByContent(DependencyObject root, string content)
+ {
+ WpfButton? result = null;
+ Collect(root, node =>
+ {
+ if (result is null && node is WpfButton { Content: string text } button && text == content)
+ {
+ result = button;
+ }
+ });
+ return Assert.IsType(result);
+ }
+
private static void Collect(DependencyObject node, Action visit)
{
visit(node);
@@ -136,4 +372,35 @@ private sealed class StaticProfileStore : ISwitchControlProfileStore
public IReadOnlyList Save(IReadOnlyList customProfiles) =>
[.. SwitchControlProfiles.BuiltIns, .. customProfiles];
}
+
+ private sealed class MutableProfileStore : ISwitchControlProfileStore
+ {
+ public MutableProfileStore()
+ {
+ CustomProfiles =
+ [
+ new SwitchControlProfile(
+ "custom-profile",
+ 1,
+ "Custom profile",
+ SwitchControlProviderKind.Mapped,
+ Enumerable.Range(1, 8)
+ .Select(id => id == 1
+ ? new SwitchControlBinding(id, SwitchBindingType.Key, "A")
+ : new SwitchControlBinding(id, SwitchBindingType.None))
+ .ToArray())
+ ];
+ }
+
+ public IReadOnlyList CustomProfiles { get; private set; }
+
+ public IReadOnlyList Load() =>
+ [.. SwitchControlProfiles.BuiltIns, .. CustomProfiles];
+
+ public IReadOnlyList Save(IReadOnlyList customProfiles)
+ {
+ CustomProfiles = customProfiles;
+ return Load();
+ }
+ }
}