I only wanted to rebind some keys

Today, I wanted to remap some keys in Nyxt (actually it was yesterday but that’s not relevant).

And so, I told to myself: “Well, I don’t know anything about the Common Lisp that’s being used in the config files for Nyxt. Why not looking for the way the main contributors of Nyxt does it in their own dotfiles? It ought to be a simple, elegant way to do this, as it is something expected to be basic, coming from that kind of software, right?”

That’s why I was really surprised when I realized that they all does it in a seemingly very different way!

So, here are the relevant parts of the configs of
Ambrevar, jmercouris and aartaka:

(defvar *my-keymap* (make-keymap "my-map"))

; ...

(define-key *my-keymap* "C-M-p" 'copy-password)
(define-key *my-keymap* "C-m-u" 'copy-username)
; etc...

; ...

(define-mode my-mode ()
  "Dummy mode for the custom key bindings in `*my-keymap*'."
  ((keymap-scheme (keymap:make-scheme
                                  scheme:cua *my-keymap*
                                  scheme:emacs *my-keymap*
                                  scheme:vi-normal *my-keymap*))))

(define-configuration (buffer web-buffer nosave-buffer)
  ((default-modes (append '(my-mode vi-normal-mode) %slot-default%))))

It seems that we are defining a whole new scheme (is it a good idea to call a keymap a scheme in lisp…?), then it looks like that we sort of merge it with the base config in a new mode by calling make-scheme? And then we add this new mode to the default mode later in the config file?

(define-configuration buffer
  ((default-modes (append '(emacs-mode) %slot-default%))
   (override-map (let ((map (make-keymap "my-override-map")))
                   (define-key map
                     "C-o" 'execute-command
                     "C-q" 'quit
                     ; etc...)
                   map))))

OK, so it seems that here we’re actually overriding some keymap for the buffer configuration by making a temporary new keymap and using the override-map method in the current context. It is very similar to the above solution without the burden of creating a whole new mode for it?

(define-configuration nyxt/web-mode:web-mode
  ((nyxt/web-mode::keymap-scheme
    (nyxt::define-scheme (:name-prefix "web" :import %slot-default%)
      scheme:emacs
      (list
       "C-c p" 'copy-password
       "C-c y" 'autofill
       ; etc...)))))

And lastly, here it looks like we’re redefining the keymap-scheme in web-mode by importing the default slot and adding our own list of bindings to it. It seems to be the most low-level solution of all three?

I should note that this solution prints warning at start when we’re redefining some keys, unlike the firsts.
By the way, is it expected to prints almost a dozen times the “redefining key” warning when nyxt starts?
It looks to me like it’s loading the init-file numerous times…


Could you please explain what’s happening in each of these solutions, why and how do they work, the pros and the cons of each and why the heck each major devs of Nyxt is using its own different solution?

Please note that I don’t want to start a war on “what’s better?” and I don’t want to judge anyone about their way of configuring (I know this is something very personal).
It’s just that I really wonder about where does thoses differences come from and how it may or may not affect the community and accessibility of the software.

I guess (it’s a wild guess) redefining keymaps would be one of the first thing user would want to do when using Nyxt (maybe apart from changing the theme which seems to be easier in v3 adding blocker mode or that sort of stuff that the Common Settings can do IIRC).
I don’t think I found anything relevant in the documentation or the manual about this, and I think having “one official way of doing it” would be nice, as it gives a better accessibility to the features for newcomers and it would be easier to help people when we already know how they’re supposed to do it.

What do you all think?

No right way there

There obviously is one right way and it’s my way :smiley:

Jokes aside, Nyxt APIs (including the keymap one) changed over time, and that’s the main reason for different ways to configure keybindings that you see in configs of @Ambrevar, @jmercouris and myself.

The keymap config of @Ambrevar stood the test of time and seems to not change much since 2.0 or even earlier (1.5? 1.4?). I mean, this way to configure keybindings (create a new mode with a new keymap, and enable it by default) was the way when I joined the team.

The config of @jmercouris is also quite time-resistant, but it has some significant downsides not everyone will be fine with. More on that later.

Then, there’s another way to configure keybindings that you haven’t listed. I have this snippet in my config:

(define-configuration base-mode
  ((keymap-scheme
    (let ((scheme %slot-default%))
      (keymap:define-key (gethash scheme:cua scheme)
        "C-w" 'nothing)
      scheme))))

It relies on the internal structure of the keymap and is as low-level as you can possibly get. The internals of keymaps are pretty stable since 2.0, though. No harm in using it until it breaks :smiley:

And the most recent change that I also reflected in my config is the :import keyword for define-keymap. It was only introduced in 2.2.1 or around it, and it will be there in 3.0. It’s quite new compared to other ways of configuration, still. I’d actually recommend it, if you want to rebind/unbind some keys, as it preserves the keymap in its entirety, changing only the necessary keys.

Pros and cons

Ambrevar’s way

Pros

  • Stood the test of time – works since 1.4 and will likely work up until 4.0 or later :smiley:
  • Creates new keymaps and modes instead of modifying/redefining the old ones. If you’re into making your own huge keymaps instead of application-provided ones, this is the way.
  • Looks kind of Emacs-y, with lots of imperative-looking define-key all around.

Cons

  • No easy way to merge the new mode/keymap with the existing one.
    • Because of that, similar keys from different keymaps can conflict with unpredictable consequences.
  • Looks kind of Emacs-y, with lots of imperative-looking define-key all around.

jmercouris’ way

Pros

  • Stood the test of time – override-map is there at least since 2.0, and it’s there to stay.
  • Probably the clearest-looking and shortest solution there can be. Just create a map, populate it, and put it into override-map.
  • Works everywhere, in every mode and keymap you could ever use.
  • Reliably overrides any keybinding you don’t like.

Cons

  • Works everywhere, even in vi-insert-mode! Using override-map for keybindings is quite an easy way to shoot your leg off, as it overrides every other keybinding out there.
  • Reliably overrides any keybinding you don’t like. And every keybinding you like too.

aartaka’s low-level solution

Pros

  • No warnings in the REPL.
  • Probably the cleanest way to redefine keys in an existing keymap if you’re on a release before 2.2.1. Modifies only the keybindings you list, in the keymap and scheme you choose. Doesn’t conflict and override any other mode/keymap/scheme, unless you deliberately introduce a conflicting keybinding.
  • Requires no additional modes.

Cons

  • Bad if you’re on a release after 2.2.1. Use :import or more time-resistant ways instead.
  • Relies on the internal structure of the keymap and can break at any moment.
  • Looks ugly.
  • Requires quite some CL knowledge.

aartaka’s :import keymap

Pros

  • Doesn’t override anything and doesn’t conflict with anything, only adds keybindings you want to the keymap/scheme you want.
  • Cleanly isolates the mode keybinding configuration to one define-configuration form per mode.
  • Requires no additional modes to enable keybindings.
  • A hella easy way to alter the existing keymap preserving the rest of it intact.
  • Prints every redefined key to the shell, easy to see where things go wrong.

Cons

  • Clutters the shell.
  • Looks ugly-ish.
  • Only works after 2.2.1 (is that a problem?)
  • You don’t know about it unless you see my config or keymap documentation.

Functional configuration

We try to make Nyxt configuration as functional (in the sense of immutable structures and roll-back-able states), as possible. That’s why when you write a define-configuration for one of Nyxt classes, it inherits from the class you mentioned with all the slots you altered defined as taking the previous value and running your code with it bound to %slot-default%. This way, you can (although there’s no easy way to do that yet) cancel any piece of configuration you have and get back to the initial state Nyxt came with.

This is more complex than what Emacs does with stateful modification of variables, functions, and objects, but it allows for a cleaner and more controllable configuration when used right.

I am actually thinking of making a set of macros making Nyxt configuration to look a bit more imperative for those that like it. Right now the (conceptually) cleanest solution looks ugliest of those provided, which should be off-putting, I guess. A simple macro to bind keys “imperatively” would be

(defmacro ndefine-key (mode scheme-name key command)
  "A helper macro to re-/un-/define a KEY to COMMAND in a specific MODE.

KEY should be a string.

COMMAND can be a: symbol, command or nil (to unbind a key).

MODE should be a symbol with a proper package prefix. For example,
proper symbol for web-mode would be `nyxt/web-mode:web-mode', other
built-in modes should follow the same pattern of NYXT/NAME:NAME. For
extension-provided modes refer to extension documentation.

Only defines a key in a scheme named by SCHEME-NAME (like
`scheme:emacs', `scheme:vi-normal', `scheme:vi-instert', `scheme:cua'
etc.), without touching other schemes.

Evaluates KEY, SCHEME-NAME, COMMAND at least twice, so be careful about what you put there.

Examples:

;; Redefine a key.
\(ndefine-key nyxt/web-mode:web-mode scheme:emacs
             \"C-f\" 'nyxt/web-mode:history-forwards-maybe-query)

;; Undefine a key. Can use 'nothing too.
\(ndefine-key nyxt/auto-mode:auto-mode scheme:cua \"C-R\" nil)"
  `(progn
     (check-type ,scheme-name keymap:scheme-name)
     (check-type ,key string)
     (check-type ,command (or function nyxt:command symbol null))
     (define-configuration ,mode
       ((keymap-scheme
         (nyxt::define-scheme
             (:name-prefix ,(symbol-name mode) :import %slot-default%)
           ,scheme-name
           (list ,key ,command)))))))

I hope this help understanding where we come from with all these ways to configure keys :slight_smile:

3 Likes

Thank you a lot for your answer!

I know understand a lot better about the internals of keymaps in Nyxt and I’m actually quite amazed by how well each solution sold themselve, and how the majority of them stood the test of time although Nyxt being a software in constant evolution!

I suppose the snippet I missed in your config will hold as long as Nyxt is using hash as it’s internal structure for storing schemes, so it should be no problem either for quite some time, right?

By the way, the warning I got in the REPL was indeed due to the :import, and the fact that it printed multiple times wasn’t due to the init-file read many times, but rather having multiples buffers opening at start. I confirmed it by opening a new buffer and seeing the warning printed again.
I wonder now if all of the define-configuration are always executed for each new buffer or if it is specific to the keymap-scheme or nyxt::define-scheme calls.

Thank you for the demonstration macro, I personally enjoy using define-configuration everywhere, but I agree that those kind of interfaces are worth having as it is somewhat simpler…?

Anyway, thank you very much!
It would’ve took me ages if I wanted to understand all those things alone :sweat_smile: