Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor KeyCombination.ContainsKey() and .ContainsKeyPermissive() for better extensibility #6229

Merged
merged 17 commits into from
May 23, 2024

Conversation

Susko3
Copy link
Member

@Susko3 Susko3 commented Mar 27, 2024

Terminology

  • Physical key: physical key on a HID, such as a osuTK.Input.Key, MouseButton, JoystickButton and their direct mappings to InputKeys
  • Virtual key: a logical key, an abstraction of some physical key(s). Example:

Problems

KeyCombination.ContainsKey() is used for two different purposes

These two purposes have very different semantics:

  1. It's used to check that each InputKey from a candidate key combination is present in the pressedKey [sic] array.
    • In this case, the key param can be a virtual key.
  2. For KeyCombinationMatchingMode.{Exact,Modifiers}, it's used to check that there are no excess InputKeys from pressedKeys present in the combination.
    • In this case, the key param can only be a physical key and the switch is unreachable code.

This is evident from the xmldoc which explains 1. in <summary> and 2. in <param name=.../>

/// <summary>
/// Check whether a single key from a candidate binding is relevant to the currently pressed keys.
/// If the <paramref name="key"/> contains a left/right specific modifier, the <paramref name="candidate"/> must also for this to match.
/// </summary>
/// <param name="candidate">The candidate key binding to match against.</param>
/// <param name="key">The key which has been pressed by a user.</param>
/// <returns>Whether this is a match.</returns>
internal static bool ContainsKey(ImmutableArray<InputKey> candidate, InputKey key)

This is fixed in e05083a and invalid tests are removed in 3e021dd (check commit message as to why).

Adding additional virtual keys is complicated

Since definitions of virtual keys are spread across ContainsKeyPermissive() and ContainsKey(), it's hard to add new ones, and impossible to do it programmatically.

This is fixed by 0ab0adc without changing semantics. The added getVirtualKey() static method is subject to change and will need to have the InputState propagated to it to make it work for #5790.

I'll be doing some follow-up cleanup after this PR is merged.

Breaking changes

KeyCombination.IsPressed now takes in an InputState parameter

-public bool IsPressed(KeyCombination pressedKeys, KeyCombinationMatchingMode matchingMode)
+public bool IsPressed(KeyCombination pressedKeys, InputState inputState, KeyCombinationMatchingMode matchingMode)

These are invalid because they have the virtual `InputKey.Shift` as a pressed key,
which never happens in real usage. This is because `KeyCombination.FromKey` will
never return a virtual key like `Shift` or `Control`.
… concept of virtual keys

Where and how exactly `getVirtualKey()` works is subject to change. This is just a MVP.
@bdach
Copy link
Collaborator

bdach commented Mar 29, 2024

This is so difficult to make sense of that I am trying to approach this PR a second time and still not understanding any of it.

  • Why are the methods called ContainsKey() and ContainsKeyPermissive() if the "permissive" one doesn't look all that more or less permissive than the other? They both check the "physical" and the "virtual" key. The actual difference seems to be in the directionality of the comparison? I think?

    The methods need renames to not be borderline obfuscatory.

  • If I read in (2):

    In this case, the key param can only be a physical key and the switch is unreachable code"

    Where are the assertions for this? I want this to be asserted in code. So that determining that something breaks the invariant is so simple as seeing the game crash.

  • Later on:

    Since definitions of virtual keys are spread across ContainsKeyPermissive() and ContainsKey(), it's hard to add new ones, and impossible to do it programmatically

    Why do we want this again?

These may have been useful at some point, but not anymore.
Could be better but it's a start.
@Susko3
Copy link
Member Author

Susko3 commented Mar 29, 2024

Since definitions of virtual keys are spread across ContainsKeyPermissive() and ContainsKey(), it's hard to add new ones, and impossible to do it programmatically

Why do we want this again?

This is needed to unblock #5790. That PR currently takes a naive approach to adding virtual keys: it just adds them in KeyBindingsContainer.pressedInputKeys. This same mistake (but with virtual modifier keys) was fixed in 39032c9, but there still exists a lot of code that expects both physical and virtual keys to be in the user pressed keys array. I believe this may add to the confusion.

{
Debug.Assert(pressedKeys.All(k => k.IsPhysical()));
ImmutableArray<(InputKey Physical, InputKey? Virtual)> pressed = pressedKeys.Select(k => (k, getVirtualKey(k))).ToImmutableArray();
Copy link
Member Author

Choose a reason for hiding this comment

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

This is really bad for perf. Should be bubbled up to KeyBindingContainer.pressedInputKeys (currently a HashSet<InputKey>, but should be changed to a Dictionary<InputKey, InputKey?> that maps [physical key -> virtual key]).

Leaving for later, when the code makes it past the first round of review.

Copy link
Collaborator

@bdach bdach Apr 3, 2024

Choose a reason for hiding this comment

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

I don't get it, if that's a concern, why isn't there a static dictionary in this class then? Why involve KeyBindingContainer at all?

@Susko3
Copy link
Member Author

Susko3 commented Mar 29, 2024

Idk, it's really hard to explain and reason as the current code is broken in subtle ways and does things that don't make sense. But together it works out and the errors are not visible to the outside world.

Copy link
Collaborator

@bdach bdach left a comment

Choose a reason for hiding this comment

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

seems better than before but i still think it could be better yet

osu.Framework/Input/Bindings/KeyCombination.cs Outdated Show resolved Hide resolved
osu.Framework/Input/Bindings/KeyCombination.cs Outdated Show resolved Hide resolved
Per bdach's suggestion.
Comment on lines 141 to 144
Debug.Assert(pressedPhysicalKeys.All(k => k.IsPhysical()));
ImmutableArray<(InputKey Physical, InputKey? Virtual)> pressed = pressedPhysicalKeys.Select(k => (k, getVirtualKey(k))).ToImmutableArray();

Debug.Assert(pressed.All(k => k.Virtual == null || k.Virtual.Value.IsVirtual()));
Copy link
Contributor

@smoogipoo smoogipoo Apr 4, 2024

Choose a reason for hiding this comment

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

I suppose it's not a huge deal, but is there a reason this was structured to map physical/virtual keys in a separate array here rather than using getVirtualKey() at the points where it's required?

There are two minor concerns:

  1. Obviously this will allocate an array every time. It's a small and infrequent allocation though, so I'm willing to let this pass.
  2. The API of KeyBindingContains and IsPressed become a little bit weird now that the consumer is able to specify a separate physical and virtual key with what appears to be no validation. There's nothing stopping users from providing (Physical: A, Virtual: Shift) as an argument, for example, which would be an undefined operation.
    Maybe I'm misunderstanding something, but it sounds like given your commit message about the invalid test cases that the user should never be dealing with virtual keys outside of KeyCombination. The APIs I'd expect are:
    • KeyBindingContains(ImmutableArray<InputKey> candidateKeyBinding, InputKey physicalKey)
    • IsPressed(InputKey virtualOrPhysicalKey, InputKey candidateKey)

Again please let me know if I've missed something.

Copy link
Contributor

@smoogipoo smoogipoo Apr 4, 2024

Choose a reason for hiding this comment

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

Here's my attempt at refactoring this to better explain what I mean: https://gist.github.com/smoogipoo/af35edc6492de8d5ea47ca4d64b7fc42

I'm wondering if there's some part of the extensibility argument that I'm missing with this.

Copy link
Member Author

Choose a reason for hiding this comment

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

2. The API of KeyBindingContains and IsPressed become a little bit weird now that the consumer is able to specify a separate physical and virtual key with what appears to be no validation. There's nothing stopping users from providing (Physical: A, Virtual: Shift) as an argument, for example, which would be an undefined operation.

The methods you're talking about are internal (and can even be made private) so that's not part of the public API.

Here's my attempt at refactoring this to better explain what I mean: https://gist.github.com/smoogipoo/af35edc6492de8d5ea47ca4d64b7fc42

Yes, your refactor is perfectly fine if the virtual keys can be statically determined. But in #5790 that will not the the case. Eg. (Physical: Y, Virtual: KeycodeY) on a QWERTY, and (Physical: Y, Virtual: KeycodeZ) on a QWERTZ keyboard. The virtual key can only be inferred from InputState.Keyboard.

So the public API of KeyCombination should ultimately be changed from

public bool IsPressed(KeyCombination pressedKeys, KeyCombinationMatchingMode matchingMode)

to one of these options:

// Pass in InputState, so the (Physical, Virtual) key array is created each time
// [or getVirtualKey(InputKey physical, InputState state) is called when needed].
// Hard to test directly. But we can always test an internal method like how
// `KeyCombinationTest` tests `KeyCombination.ContainsAll`.
public bool IsPressed(KeyCombination pressedKeys, InputState inputState, KeyCombinationMatchingMode matchingMode)

// KeyBindingContainer builds a dictionary of (Physical, Virtual)
// and reuses the same instance for each checked KeyCombination.
// This ensures that there are no duplicate physical keys.
// But the meaning of <InputKey, InputKey?> is not apparent.
public bool IsPressed(ImmutableDictionary<InputKey, InputKey?> pressedPhysicalAndVirtualMapping, KeyCombinationMatchingMode matchingMode)

// KeyBindingContainer builds an array of (Physical, Virtual)
// and reuses the same instance for each checked KeyCombination.
// Undefined if someone passes in duplicate physical keys.
public bool IsPressed(ImmutableArray<(InputKey Physical, InputKey? Virtual)> pressedKeys, KeyCombinationMatchingMode matchingMode)

I think the first one makes the most sense, as we can have the getVirtualKey() logic contained inside KeyCombination. Your refactor works well with that.

Do we want to make the breaking change to KeyCombination.IsPressed() now or in #5790?

Copy link
Contributor

Choose a reason for hiding this comment

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

The methods you're talking about are internal

Oh, I didn't notice that.

I think the first one makes the most sense, as we can have the getVirtualKey() logic contained inside KeyCombination. Your refactor works well with that.

That one makes the most sense to me too for a public API. Maybe the breaking change should be in this PR because I don't know how it's going to be used - I'm only reviewing purely from a code-quality and isolation perspective because you definitely have a better vision for how this all works.

Unless the breaking change + implementation is significant :)

Susko3 and others added 2 commits April 22, 2024 14:23
return ContainsAll(Keys, pressedKeys.Keys, inputState, matchingMode);
}

private static InputKey? getVirtualKey(InputKey key, InputState inputState)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why does this receive the full InputState? Is the goal here to eventually read the Characters thing from #5790, then I'd rather just pass that in specifically when it exists.

The reason why I have an issue with this is that the InputState is propagated through methods multiple levels and when I look at the method signature of ContainsAll() I get very uneasy because there's three key collections there now - there's candidateKeyBinding and pressedPhysicalKeys which you are supposed to be checking, but then there is also InputState which really just allows you to read everything from everywhere and that feels like terrible layering. KeyBindingContains() is similar but not as egregious imo.

I'm fine with the IsPressed() signature fwiw, it's everything below that that I have an issue with.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah that's what I was also hoping to figure out - how the InputState is actually going to be used here.

Copy link
Member Author

Choose a reason for hiding this comment

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

The InputState will be used to read the Characters of the passed in InputKey. And if the character is [a-z], it'll return the corresponding InputKey.Keycode{A-Z}.

I'd rather just pass that in specifically when it exists.

Alright, I'll do that.

It's still there in `public` `IsPressed` for the breaking change.
bdach added 2 commits May 23, 2024 08:09
Sure, the tests may have been "invalid" due to specifying a pressed
virtual key, but the actual coverage they were trying to exercise is
useful, and can be recovered just by adjusting the test to actually
press both physical modifiers rather than just straight-up removing.
@bdach
Copy link
Collaborator

bdach commented May 23, 2024

I think I can live with this diff now, but do note I brought some of the tests removed in 3e021dd in an altered manner. See 8162563 for reasoning.

@bdach bdach requested a review from smoogipoo May 23, 2024 06:17
Copy link
Contributor

@smoogipoo smoogipoo left a comment

Choose a reason for hiding this comment

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

lgtm

@smoogipoo smoogipoo merged commit f6fd752 into ppy:master May 23, 2024
21 checks passed
bdach added a commit to bdach/osu-framework that referenced this pull request May 27, 2024
Found this while checking something else.

After ppy#6229,
`KeyCombination.IsPressed()` can trip on an assertion:

	System.AggregateException : One or more errors occurred. (: )
	  ----> NUnit.Framework.AssertionException : :
	   at osu.Framework.Testing.TestScene.checkForErrors() in /home/dachb/Documents/opensource/osu-framework/osu.Framework/Testing/TestScene.cs:line 502
	   at osu.Framework.Testing.TestScene.UseTestSceneRunnerAttribute.AfterTest(ITest test) in
	/home/dachb/Documents/opensource/osu-framework/osu.Framework/Testing/TestScene.cs:line 563
	   at NUnit.Framework.Internal.Commands.TestActionCommand.<>c__DisplayClass0_0.<.ctor>b__1(TestExecutionContext context)
	   at NUnit.Framework.Internal.Commands.BeforeAndAfterTestCommand.<>c__DisplayClass1_0.<Execute>b__1()
	   at NUnit.Framework.Internal.Commands.DelegatingTestCommand.RunTestMethodInThreadAbortSafeZone(TestExecutionContext context, Action action)
	--AssertionException
	   at osu.Framework.Logging.ThrowingTraceListener.Fail(String message1, String message2) in
	/home/dachb/Documents/opensource/osu-framework/osu.Framework/Logging/ThrowingTraceListener.cs:line 27
	   at System.Diagnostics.TraceInternal.Fail(String message, String detailMessage)
	   at System.Diagnostics.Debug.Fail(String message, String detailMessage)
	   at osu.Framework.Input.Bindings.KeyCombination.IsPressed(ImmutableArray`1 pressedPhysicalKeys, InputKey candidateKey) in
	/home/dachb/Documents/opensource/osu-framework/osu.Framework/Input/Bindings/KeyCombination.cs:line 206
	   at osu.Framework.Input.Bindings.KeyCombination.IsPressed(KeyCombination pressedKeys, InputState inputState, KeyCombinationMatchingMode matchingMode) in
	/home/dachb/Documents/opensource/osu-framework/osu.Framework/Input/Bindings/KeyCombination.cs:line 105
	   at osu.Framework.Input.Bindings.KeyBindingContainer`1.handleNewPressed(InputState state, InputKey newKey, Nullable`1 scrollDelta, Boolean isPrecise) in
	/home/dachb/Documents/opensource/osu-framework/osu.Framework/Input/Bindings/KeyBindingContainer.cs:line 227
	   at osu.Framework.Input.Bindings.KeyBindingContainer`1.Handle(UIEvent e) in
	/home/dachb/Documents/opensource/osu-framework/osu.Framework/Input/Bindings/KeyBindingContainer.cs:line 125
	   at osu.Framework.Graphics.Drawable.OnMouseDown(MouseDownEvent e) in /home/dachb/Documents/opensource/osu-framework/osu.Framework/Graphics/Drawable.cs:line 2157
	   at osu.Framework.Graphics.Drawable.TriggerEvent(UIEvent e) in /home/dachb/Documents/opensource/osu-framework/osu.Framework/Graphics/Drawable.cs:line 2033

This is happening game-side when a keybinding is vacated, at which point
it is represented by `KeyCombination.none` or equivalent, i.e. a
`KeyCombination` with its only key being `InputKey.None`.

`InputKey.None` is defined to be neither physical or virtual, which
trips the aforementioned assertion. To bypass that entire debacle
altogether, add an early return path that just returns false if the key
combination being tested for being pressed is equivalent to `none`.

(It cannot be a reference equality check, I checked against game.
Sequence equality is required. Something in game is probably
instantiating anew. Probably something to do with how keybindings are
materialised from realm.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants