This post is the second part in a series covering a series of improvements in the carnac codebase, specifically to improve the usage of Rx. The next class I will be rewriting is the MessageProvider.
Part 1 - Refactoring the InterceptKeys class
Part 2 - Refactoring the MessageProvider class
Part 3 - Introducing the MessageController class
Part 4 - Removing state mutation from the stream
As a bit of background, in carnac a KeyPress
is not directional and it also contains information about if modifiers were pressed at the same time. For Instance ctrl + r
would be a KeyPress. A Message
is what is shown on the screen.
The message provider as it is does the following:
- Is
IObserver<KeyPress>
- It aggregates multiple KeyPresses into logical messages with the following rules:
- Shortcuts are always shown in their own message
- If there has been more than a second between the last keypress a new message is created
- If the key presses were entered into different applications a new messsage is created
- Apply ‘Only show shortcuts’ filter
Here is what the code looked like before the refactor.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
|
The first thing you will notice is that most of the methods in this class access and manipulate the property CurrentMessage
. This makes this class really hard to follow and rationalise what is going on.
Removing IObservable<T>
Like in the previous class we refactored, the first thing we need to do is to stop implementing IObservable
Our subscribe method will change from IDisposable Subscribe(IObserver<Message> observer)
to IObservable<Message> GetMessageStream(IObservable<KeyPress> keyStream)
This means consumers of this class can simply pass an observable in and get a new feed. It also makes this class easy to test.
Visualising the requirements
After looking at the current behaviour I decided that it would make more sense to not show a partial shortcut on the screen until it had either been completed or broken. With that in mind, we will start off with a series of key presses. a, b, ctrl+r, ctrl+r, ctrl+r, a, ↓, ↓ (↓ is the down arrow key). For these examples imagine we have a single shortcut which is ctrl+r, ctrl+r.
If we draw an ascii marble diagram it will look like this
1
|
|
The first requirement is that we batch shortcuts into a single message.
1 2 |
|
In the above diagram we see that ctrl+r, ctrl+r is a completed shortcut so our second stream emits the completed shortcut. But when we have ctrl+r, ‘a’ that is not a shortcut. It is instead a broken shortcut so the second stream emits two messages, one directly after the other. a, b and the arrow keys emit a completed message right away because they are not part of any potential shortcuts.
The next step is to merge messages together which we want to display on the screen together. In the above example we want to see this on the screen:
1 2 3 4 |
|
Lets add that into our marble diagram. Items prefixed with * are new messages which will replace the messages it has been merged with
1 2 3 |
|
Now we have an idea visually of what our streams will look like we can turn this into Rx.
Writing the query
In Rx when we want to reduce the number of items we have where we need some sort of aggregation function. In this case we want to use the .Scan()
operator.
1 2 3 4 |
|
Scan calls your accumulation function for each item in the stream, but unlike Aggregate
it will emit the new aggregated value. In this case we start off with an empty ShortcutAccumulator
, then the keyStream
yields a value it is passed to the current ShortcutAccumulator
which returns either itself or a new ShortcutAccumulator
which .Scan
will yield.
The ShortcutAccumulator will check if that key press matches any shortcuts. If it doesn’t, or that key completes the shortcut, it sets the HasCompletedValue property to true and returns itself. When a completed accumulator is asked to process a key it will simply create a new ShortcutAccumulator
and get it to process the key and return that accumulator instead of itself.
Because .Scan
will emit the ShortcutAccumulator
after each key press, we can simply filter the accumulators which are not completed yet, then select many on each completed ShortcutAccumulator to get the messages it has accumulated. The reason for the select many is when a shortcut is broken we create a message for each accumulated key press. And our stream now matches the second line in our marble diagram.
To do the final line in our marble diagram we need another Scan
which merges Messages which need to be merged.
1 2 3 4 5 6 |
|
Whether a message needs to be merged or not is no longer the responsibility of this class, it has been moved into our MessageMerger
class. MergeIfNeeded
will check for all the merge conditions like the key presses being over a second apart, from different processes, either message being a shortcut etc and if they can be merged the new message will be merged into the previous. Ideally our entire stream would be immutable but that will have to be a separate task, we are refactoring existing code after all.
Finally we apply the Shortcut Only setting and filter our list if that option is set.
Summary
The end result of the MessageProvider
refactoring was a great improvement. The end result is a single method returning an Rx statement which forfills all of our requirements. The shortcut detection logic was moved into another class which has a single responsibility making it much easier to understand what is going on and follow.