Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 37 additions & 14 deletions Terminal.Gui/Core/View.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ internal Direction FocusDirection {
/// <summary>
/// Configurable keybindings supported by the control
/// </summary>
private Dictionary<Key, Command> KeyBindings { get; set; } = new Dictionary<Key, Command> ();
private Dictionary<Key, Command []> KeyBindings { get; set; } = new Dictionary<Key, Command []> ();
private Dictionary<Command, Func<bool?>> CommandImplementations { get; set; } = new Dictionary<Command, Func<bool?>> ();

/// <summary>
Expand Down Expand Up @@ -1716,17 +1716,32 @@ public override bool ProcessKey (KeyEvent keyEvent)
/// <param name="keyEvent">The key event passed.</param>
protected bool? InvokeKeybindings (KeyEvent keyEvent)
{
bool? toReturn = null;

if (KeyBindings.ContainsKey (keyEvent.Key)) {
var command = KeyBindings [keyEvent.Key];

if (!CommandImplementations.ContainsKey (command)) {
throw new NotSupportedException ($"A KeyBinding was set up for the command {command} ({keyEvent.Key}) but that command is not supported by this View ({GetType ().Name})");
}
foreach (var command in KeyBindings [keyEvent.Key]) {

if (!CommandImplementations.ContainsKey (command)) {
throw new NotSupportedException ($"A KeyBinding was set up for the command {command} ({keyEvent.Key}) but that command is not supported by this View ({GetType ().Name})");
}

// each command has its own return value
var thisReturn = CommandImplementations [command] ();

// if we haven't got anything yet, the current command result should be used
if (toReturn == null) {
toReturn = thisReturn;
}

return CommandImplementations [command] ();
// if ever see a true then that's what we will return
if (thisReturn ?? false) {
toReturn = thisReturn.Value;
}
}
}

return null;
return toReturn;
}


Expand All @@ -1736,11 +1751,19 @@ public override bool ProcessKey (KeyEvent keyEvent)
/// </para>
/// <para>If the key is already bound to a different <see cref="Command"/> it will be
/// rebound to this one</para>
/// <remarks>Commands are only ever applied to the current <see cref="View"/>(i.e. this feature
/// cannot be used to switch focus to another view and perform multiple commands there)</remarks>
/// </summary>
/// <param name="key"></param>
/// <param name="command"></param>
public void AddKeyBinding (Key key, Command command)
/// <param name="command">The command(s) to run on the <see cref="View"/> when <paramref name="key"/> is pressed.
/// When specifying multiple, all commands will be applied in sequence. The bound <paramref name="key"/> strike
/// will be consumed if any took effect.</param>
public void AddKeyBinding (Key key, params Command [] command)
{
if (command.Length == 0) {
throw new ArgumentException ("At least one command must be specified", nameof (command));
}

if (KeyBindings.ContainsKey (key)) {
KeyBindings [key] = command;
} else {
Expand All @@ -1756,7 +1779,7 @@ public void AddKeyBinding (Key key, Command command)
protected void ReplaceKeyBinding (Key fromKey, Key toKey)
{
if (KeyBindings.ContainsKey (fromKey)) {
Command value = KeyBindings [fromKey];
var value = KeyBindings [fromKey];
KeyBindings.Remove (fromKey);
KeyBindings [toKey] = value;
}
Expand Down Expand Up @@ -1795,9 +1818,9 @@ public void ClearKeybinding (Key key)
/// keys bound to the same command and this method will clear all of them.
/// </summary>
/// <param name="command"></param>
public void ClearKeybinding (Command command)
public void ClearKeybinding (params Command [] command)
{
foreach (var kvp in KeyBindings.Where (kvp => kvp.Value == command).ToArray ()) {
foreach (var kvp in KeyBindings.Where (kvp => kvp.Value.SequenceEqual (command))) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was just looking again at this code and see that we lost the .ToArray(). I thought this would cause the old 'Collection modified during enumeration' error but the test passes which is wierd.

I looked into why and it seems maybe the behaviour changed as of .netcore3.1. I will open a quick PR to put it back incase this would break when running on an older runtime.

Ideally, it is not advisable to do it. The GetEnumerator() that is called while iterating to the next item will throw an InvalidOperationException. However, from .netcore 3.1 onwards, this will work when you do a dictionary.Remove() or dictionary.Clear() from inside the loop.

https://stackoverflow.com/a/67650242/4824531

KeyBindings.Remove (kvp.Key);
}
}
Expand Down Expand Up @@ -1837,9 +1860,9 @@ public IEnumerable<Command> GetSupportedCommands ()
/// </summary>
/// <param name="command">The command to search.</param>
/// <returns>The <see cref="Key"/> used by a <see cref="Command"/></returns>
public Key GetKeyFromCommand (Command command)
public Key GetKeyFromCommand (params Command [] command)
{
return KeyBindings.First (x => x.Value == command).Key;
return KeyBindings.First (x => x.Value.SequenceEqual (command)).Key;
}

/// <inheritdoc/>
Expand Down
91 changes: 91 additions & 0 deletions UnitTests/ListViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,97 @@ public void Constructors_Defaults ()
Assert.Equal (new Rect (0, 1, 10, 20), lv.Frame);
}

[Fact]
public void ListViewSelectThenDown ()
{
var lv = new ListView (new List<string> () { "One", "Two", "Three" });
lv.AllowsMarking = true;

Assert.NotNull (lv.Source);

// first item should be selected by default
Assert.Equal (0, lv.SelectedItem);

// nothing is ticked
Assert.False (lv.Source.IsMarked (0));
Assert.False (lv.Source.IsMarked (1));
Assert.False (lv.Source.IsMarked (2));

lv.AddKeyBinding (Key.Space | Key.ShiftMask, Command.ToggleChecked, Command.LineDown);

var ev = new KeyEvent (Key.Space | Key.ShiftMask, new KeyModifiers () { Shift = true });

// view should indicate that it has accepted and consumed the event
Assert.True (lv.ProcessKey (ev));

// second item should now be selected
Assert.Equal (1, lv.SelectedItem);

// first item only should be ticked
Assert.True (lv.Source.IsMarked (0));
Assert.False (lv.Source.IsMarked (1));
Assert.False (lv.Source.IsMarked (2));

// Press key combo again
Assert.True (lv.ProcessKey (ev));
Assert.Equal (2, lv.SelectedItem);
Assert.True (lv.Source.IsMarked (0));
Assert.True (lv.Source.IsMarked (1));
Assert.False (lv.Source.IsMarked (2));

// Press key combo again
Assert.True (lv.ProcessKey (ev));
Assert.Equal (2, lv.SelectedItem); // cannot move down any further
Assert.True (lv.Source.IsMarked (0));
Assert.True (lv.Source.IsMarked (1));
Assert.True (lv.Source.IsMarked (2)); // but can toggle marked

// Press key combo again
Assert.True (lv.ProcessKey (ev));
Assert.Equal (2, lv.SelectedItem); // cannot move down any further
Assert.True (lv.Source.IsMarked (0));
Assert.True (lv.Source.IsMarked (1));
Assert.False (lv.Source.IsMarked (2)); // untoggle toggle marked
}
[Fact]
public void SettingEmptyKeybindingThrows ()
{
var lv = new ListView (new List<string> () { "One", "Two", "Three" });
Assert.Throws<ArgumentException> (() => lv.AddKeyBinding (Key.Space));
}


/// <summary>
/// Tests that when none of the Commands in a chained keybinding are possible
/// the <see cref="View.ProcessKey(KeyEvent)"/> returns the appropriate result
/// </summary>
[Fact]
public void ListViewProcessKeyReturnValue_WithMultipleCommands ()
{
var lv = new ListView (new List<string> () { "One", "Two", "Three", "Four" });

Assert.NotNull (lv.Source);

// first item should be selected by default
Assert.Equal (0, lv.SelectedItem);

// bind shift down to move down twice in control
lv.AddKeyBinding (Key.CursorDown | Key.ShiftMask, Command.LineDown, Command.LineDown);

var ev = new KeyEvent (Key.CursorDown | Key.ShiftMask, new KeyModifiers () { Shift = true });

Assert.True (lv.ProcessKey (ev), "The first time we move down 2 it should be possible");

// After moving down twice from One we should be at 'Three'
Assert.Equal (2, lv.SelectedItem);

// clear the items
lv.SetSource (null);

// Press key combo again - return should be false this time as none of the Commands are allowable
Assert.False (lv.ProcessKey (ev), "We cannot move down so will not respond to this");
}

private class NewListDataSource : IListDataSource {
public int Count => throw new NotImplementedException ();

Expand Down