diff --git a/Keybinding.Test/ChordConflictTests.cs b/Keybinding.Test/ChordConflictTests.cs index a6824eb..a6fe435 100644 --- a/Keybinding.Test/ChordConflictTests.cs +++ b/Keybinding.Test/ChordConflictTests.cs @@ -77,6 +77,84 @@ public void SetChords_TwoCommandsOneChord_KeepsOneBinding() Assert.AreEqual(1, manager.Profiles.GetActiveProfile()!.ChordCount); } + [TestMethod] + public void SetChords_TwoCommandsOneChord_CountsOnlyTheBindingThatRemains() + { + using KeybindingManager manager = new(_testDataDirectory); + manager.Commands.RegisterCommand(new Command("a", "A")); + manager.Commands.RegisterCommand(new Command("b", "B")); + manager.CreateDefaultProfile(); + + int count = manager.SetChords(new Dictionary { ["a"] = Chord.Parse("Ctrl+S"), ["b"] = Chord.Parse("Ctrl+S") }, out IReadOnlyList displaced); + + Assert.AreEqual(1, count); + Assert.AreEqual(manager.Profiles.GetActiveProfile()!.ChordCount, count); + Assert.HasCount(1, displaced); + Assert.AreEqual("a", displaced[0]); + Assert.AreEqual("b", manager.Keybindings.ExecuteChord(Chord.Parse("Ctrl+S"))); + } + + [TestMethod] + public void SetChords_TwoCommandsOneChord_WithoutOutParameter_ReturnsTheRemainingCount() + { + using KeybindingManager manager = new(_testDataDirectory); + manager.Commands.RegisterCommand(new Command("a", "A")); + manager.Commands.RegisterCommand(new Command("b", "B")); + manager.CreateDefaultProfile(); + + int count = manager.SetChords(new Dictionary { ["a"] = Chord.Parse("Ctrl+S"), ["b"] = Chord.Parse("Ctrl+S") }); + + Assert.AreEqual(1, count); + } + + [TestMethod] + public void SetChords_ChordHeldOutsideTheBatch_ReportsTheCommandThatLostIt() + { + using KeybindingManager manager = new(_testDataDirectory); + manager.Commands.RegisterCommand(new Command("save", "Save")); + manager.Commands.RegisterCommand(new Command("saveAll", "Save All")); + manager.Commands.RegisterCommand(new Command("open", "Open")); + manager.CreateDefaultProfile(); + manager.Keybindings.BindChord("save", Chord.Parse("Ctrl+S")); + + int count = manager.SetChords(new Dictionary { ["saveAll"] = Chord.Parse("Ctrl+S"), ["open"] = Chord.Parse("Ctrl+O") }, out IReadOnlyList displaced); + + Assert.AreEqual(2, count); + Assert.HasCount(1, displaced); + Assert.AreEqual("save", displaced[0]); + } + + [TestMethod] + public void SetChords_CommandRebindByTheSameBatch_IsNotReportedAsDisplaced() + { + using KeybindingManager manager = new(_testDataDirectory); + manager.Commands.RegisterCommand(new Command("a", "A")); + manager.Commands.RegisterCommand(new Command("b", "B")); + manager.CreateDefaultProfile(); + manager.Keybindings.BindChord("a", Chord.Parse("Ctrl+S")); + + // b takes Ctrl+S from a, and the same batch moves a to Ctrl+D: a swap, not a lost binding + int count = manager.SetChords(new Dictionary { ["b"] = Chord.Parse("Ctrl+S"), ["a"] = Chord.Parse("Ctrl+D") }, out IReadOnlyList displaced); + + Assert.AreEqual(2, count); + Assert.IsEmpty(displaced); + } + + [TestMethod] + public void SetChords_NoConflict_ReportsNothingDisplaced() + { + using KeybindingManager manager = new(_testDataDirectory); + manager.Commands.RegisterCommand(new Command("a", "A")); + manager.Commands.RegisterCommand(new Command("b", "B")); + manager.CreateDefaultProfile(); + manager.Keybindings.BindChord("a", Chord.Parse("Ctrl+A")); + + int count = manager.SetChords(new Dictionary { ["a"] = Chord.Parse("Ctrl+A"), ["b"] = Chord.Parse("Ctrl+B") }, out IReadOnlyList displaced); + + Assert.AreEqual(2, count); + Assert.IsEmpty(displaced); + } + [TestMethod] public void BindChord_SameCommandSameChord_KeepsTheBinding() { diff --git a/Keybinding/KeybindingManager.cs b/Keybinding/KeybindingManager.cs index 268a3bf..6b392ee 100644 --- a/Keybinding/KeybindingManager.cs +++ b/Keybinding/KeybindingManager.cs @@ -270,16 +270,58 @@ public int RegisterCommands(IEnumerable commands) /// Sets multiple chord bindings for the active profile /// /// Dictionary of command ID to chord mappings - /// Number of chord bindings successfully set - public int SetChords(IReadOnlyDictionary chords) + /// + /// Number of bindings from the batch that remain once it is applied. A chord runs only one command, so when + /// the batch gives one chord to two commands the later entry takes it and only that binding is counted. + /// Use to learn which + /// commands lost their chord. + /// + public int SetChords(IReadOnlyDictionary chords) => SetChords(chords, out _); + + /// + /// Sets multiple chord bindings for the active profile, and reports the commands that lost a chord to them + /// + /// Dictionary of command ID to chord mappings + /// + /// The IDs of the commands whose chord was taken by a later binding: commands outside the batch that held one + /// of its chords, and entries of the batch whose chord a later entry took. A command that the batch itself + /// rebinds, and that keeps its new binding, is not listed. Each ID appears once, in the order it was displaced. + /// + /// Number of bindings from the batch that remain once it is applied + public int SetChords(IReadOnlyDictionary chords, out IReadOnlyList displacedCommandIds) { DisposalHelper.ThrowIfDisposed(_disposed, this); Ensure.NotNull(chords); Profile activeProfile = Profiles.GetActiveProfile() ?? throw new InvalidOperationException("No active profile is set"); + List displaced = []; + Dictionary bound = []; + // Bind into the profile resolved above, so a concurrent SetActiveProfile cannot split the batch - return OperationHelper.ExecuteWithCount(chords, (commandId, chord) => Keybindings.BindChord(activeProfile.Id, commandId, chord)); + OperationHelper.ExecuteWithCount(chords, (commandId, chord) => + { + string[] holders = [.. activeProfile.GetAllChords().Where(b => b.Value.Equals(chord) && b.Key != commandId.Trim()).Select(b => b.Key)]; + if (!Keybindings.BindChord(activeProfile.Id, commandId, chord)) + { + return false; + } + + bound[commandId.Trim()] = chord; + foreach (string holder in holders) + { + if (!chord.Equals(activeProfile.GetChord(holder)) && !displaced.Contains(holder)) + { + displaced.Add(holder); + } + } + + return true; + }); + + // A command the batch rebinds after it lost its old chord is not displaced, as long as it keeps the new one + displacedCommandIds = [.. displaced.Where(id => !(bound.TryGetValue(id, out Chord? chord) && chord.Equals(activeProfile.GetChord(id))))]; + return bound.Count(b => b.Value.Equals(activeProfile.GetChord(b.Key))); } /// diff --git a/README.md b/README.md index 9a41ac3..6af60e1 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,10 @@ var chords = new Dictionary { "edit.paste", manager.Keybindings.ParseChord("Ctrl+V") } }; int set = manager.SetChords(chords); + +// A chord runs one command, so a later binding takes it from an earlier one. +// The count is the bindings that remain; the out overload names the commands that lost their chord. +int kept = manager.SetChords(chords, out IReadOnlyList displaced); ``` ### Profile Management