-
Notifications
You must be signed in to change notification settings - Fork 298
Input Management Architecture
Input management across editors has varied widely - from VimL
in Vim, to a custom text file format in Sublime, to a JSON-based format in VSCode. Each scheme has certainly been proven by usage, and each makes a particular set of tradeoffs - VSCode's JSON-based format, for example, allows for a rich UX to allow easy manipulation, but the action-filtering (determining which key bindings are available in a context) is a bit obtuse.
In general, all the formats allow for the binding between a key sequence (a key press or set of key presses) and a resultant action.
An interesting scheme is Emacs, in which each key bindings are programmatically defined in LISP, offering a wide range of customization. Keymaps (relationships between key sequences and actions) are grouped by mode, and there is a very wide-range of flexibility in definining these. However, the programmatic approach has the disadvantage that there is a higher learning curve to setting key bindings.
Oni's input model is heavily influenced by Emacs model - programmatic key bindings - because these offer the most flexibility and power to the user, and we are making the same set of trade-offs.
Given that Oni is a development tool, it stands to reason the users would be comfortable editing a customization. We do need to make this as intuitive and streamlined as possible - leveraging the TypeScript language service to immediately show errors and guide the user via intellisense (as well as have a quick list of configuration options) can go a long way.
With a programmatic model, we can also build additionial convenience layers on-top, if it makes sense - for example, it'd be easy to construct a JSON key sequence < - > command map.
In a programmatic model, it's straightforward to build non-trivial 'mini-plugins'. As an example of a quick key-binding I wanted the other day - I wanted to be able to press a key (like <C-enter>
) and take a screenshot, placed in a certain directory depending on the root folder I was working in. This sort of task is a good fit for a programmatic key-binding language, and makes it easy to then refactor out to create a generalized plugikn.
Oni uses two primary constructs for mapping input keys:
Oni.input.bind(keySequence: string, action: Action | string, filter: Filter)
Oni.input.unbind(keySequence: string)
The action can either be a function that executes a side-effect, or a string
corresponding to a registered command. The filter
is a function that returns a boolean
, and decides when the keybinding is active - for example, activating keybindings based on the current input mode. The filter
function is executed when we are processing the effect of the key-press.
I anticipate action
's coming from the following sources:
-
Plugins - Today plugins expose their actions via commands, but I'd like to move towards a model where plugins export functions, that could be called via input bindings, for example -
Oni.plugins.spotify.nextSong()
. In addition, inline lambdas and functions will be useful. -
Inline - Users quickly and easily declaring an action via a
lambda
I anticipate filter
's coming from the following sources:
-
Filters - I would like to expose a canonical set of filters (primarily filtering by mode) off of the Oni object - for example
Oni.filters.normalMode
, along with utilities for combine filters, likeOni.filters.and
andOni.filters.or
. - Inline - Users can quickly and easily define their own set of filters
Some examples of usage in our default keybindings, found in KeyBindings.ts.
A question that often comes up - why not just use Neovim's input management model?
There are a couple reasons:
- Primarily - Oni's vision goes beyond just a neovim editing experience. In particular, breaking free of terminal limitations means that we can bring some new experiences - like an embedded browser - that have no analog in terminal editing. Having a consistent experience in managing key-bindings across all these experiences - both the neovim-based and non-neovim-based, is critical.
- Prevent users from needing to modify their
init.vim
- if Oni's vision is successful, it means that users can fully customize Oni viaconfig.js
and have a more modern linting experience (and feedback, like code completion). - Performance - for some keys, if Oni handles the input management, it can prevent an additional round-trip to Neovim. An example is the
QuickOpen
menu.
My opinion (which may not be shared by Vim experts) is that the noremap
model is obtuse and confusing. In the spirit of lowering the barrier to entry, and because Oni's primary set of languages are web-based today (plus, JS + variants are the mostly widely used) - it makes sense to allow users to configure in a language they are comfortable with.
Following the previous point - how do I manage my Neovim keybindings?
As pointed out above - ideally, a user never has to go to their init.vim
, but they are always welcome to modify Neovim-facing key-bindings there via the oni.loadInitVim
configuration option. Indeed, for advanced users who have a carefully-curated VimL config that they wish to leverage in Oni, this may be the best way.
There are a couple of tools that we can use to help users in these cases:
- An ability to execute Neovim commands as an action, ie:
Oni.input.bind("<C-enter>", Oni.actions.neovimCommandAction(":wq"), Oni.filters.normalMode)
- An ability to execute Oni commands from Neovim via Viml, like:
:OniCommand("recorder.takeScreenshot")
. This does not allow the full flexibility of calling into functions, but it provides a stop-gap for many useful behaviors.
Once the input bindings are configured above, it's showtime.
The input handling happens in three phases - Initiation, Resolution, and Execution.
We receive a DOM KeyboardEvent
notifying us that a key was pressed. We do some handling here for IME (Input Method Editor) and dead keys, in terms of when to dispatch the event (ideally, we'd just hand-off the event with every keydown
, but that isn't the right behavior for a case like dead keys). Once we have the event, and realize we need to act on it, we pass it on to the Resolution phase.
The canonical input form that Oni uses is Vim-style, like <C-x>
, y
, <M-y>
, <A-b>
. The goal of the Resolution step is to go from the KeyboardEvent
-> Vim-style form. This seems like it would be trivial - just checking isCtrlKey
, isAltKey
, etc, and show the character. In actuality, when handling international cases, it's not so straighforward - for example, the AltGr
key in some keyboards should not be mapped to an <A-
prefix.
We also want to simply ignore some key-presses and allow them to pass-through, for example, changing audio volume.
The current set of resolvers can be found here: https://github.com/onivim/oni/blob/master/browser/src/Input/Keyboard/Resolvers.ts
Essentially, they are functions of the form (evt: KeyboardEvent: previousResolution: string | null): string | null
- they take a KeyboardEvent
, plus the result of previous resolvers, and pass the resolved string. A future goal is to open this up for extensibility, and allow custom resolvers in the pipeline.
Once the key press is resolved from a KeyboardEvent
to the canonical Vim format we expect, we can then get the set of bindings against that keymap (the bindings registered using Oni.input.bind
)
For each binding, we'll first execute the filter
function - if it is false
, we will not evaluate the binding
Next, if the filter
function passes, we'll execute the action
function. If the action
function doesn't return anything, we'll assume it was handled. However, the action
can also return false
to signify other bindings should be allowed to proceed - in that case, we'd continue to the next binding.
If no bindings pass the filter
/action
phase, we passed it on to the active IEditor
instance to handle. The only implementation of IEditor
we have at time of writing is the NeovimEditor
, and that simply passes the keyboard event to Neovim. In other words, if there are no bindings for a particular key, we'll end up passing it to Neovim to handle.
One outstanding issue we've come across with the current model is that it can be difficult to know how to specify a filter, or that a key binding should be available in a narrow set of constraints (ie, normal mode AND no menu open, etc).
My hope is that we can simplify this by having pre-set and ready-to-go filters available on Oni's API, like Oni.input.filters.isNormalMode
, Oni.input.filters.isOverlayOpen
, and then ways to easily combine these (ie, combine
). I anticipate novel approaches coming from users as well when we get to the point where users are leveraging this more extensively.
- Implementing actions / filters on the API
- Implementing 'chorded' key-presses
- Custom 'resolvers'