diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c967ca..246e738 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] steps: - name: Checkout diff --git a/modify-keybinds.md b/modify-keybinds.md index e9af83c..1cead76 100644 --- a/modify-keybinds.md +++ b/modify-keybinds.md @@ -24,7 +24,8 @@ Jupyterlab commands don't know about the concept of vim modes so they will be ac `.jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode` -**Vim remappings** -For vim style remappings (`inoremap`, `imap`, `nmap`...) you can use the sibling extension [jupyterlab-vimrc](https://github.com/ianhi/jupyterlab-vimrc#jupyterlab-vimrc). +### Vim remappings -Beware that this does not work with everything you may have in your standard vimrc. Jupyterlab uses [Codemirror] for the editors in cells. This extension adds vim support by enabling the [vim](https://codemirror.net/demo/vim.html) emulation in codemirror. While this comes with partial support for remappings in your vimrc the support is incomplete. +Vim style remappings (`inoremap`, `imap`, `nmap`...) can be modified in the advanced settings editor under the `Notebook Vim` settings. Alternatively, you can use the sibling extension [jupyterlab-vimrc](https://github.com/ianhi/jupyterlab-vimrc#jupyterlab-vimrc) to do this. + +With either approach, Vim remappings do not work with everything you may have in your standard vimrc. Jupyterlab uses [Codemirror] for the editors in cells. This extension adds vim support by enabling the [vim](https://codemirror.net/demo/vim.html) emulation in codemirror. While this comes with partial support for remappings in your vimrc the support is incomplete. In particular, keybindings which use `noremap` don't work [unless the keybinding already exists in the default Codemirror Vim keybindings](https://github.com/codemirror/codemirror5/blob/b2d26b4ccb1d0994ae84d18ad8b84018de176da9/keymap/vim.js#L764-L766). If your keybinding doesn't work with `noremap`, try using `map`, and above all, avoid recursive remappings. diff --git a/schema/plugin.json b/schema/plugin.json index 21e93c9..42a82e9 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -2,261 +2,425 @@ "title": "Notebook Vim", "description": "Notebook Vim Settings", "type": "object", - "additionalProperties": true, + "additionalProperties": false, "jupyter.lab.shortcuts": [ { "command": "notebook:enter-command-mode", - "keys": ["Escape"], + "keys": [ + "Escape" + ], "selector": ".jp-Notebook.jp-mod-editMode", "disabled": true }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "U"], + "keys": [ + "Ctrl O", + "U" + ], "command": "notebook:undo-cell-action" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "-"], + "keys": [ + "Ctrl O", + "-" + ], "command": "notebook:split-cell-at-cursor" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "D"], + "keys": [ + "Ctrl O", + "D" + ], "command": "vim:cut-cell-and-edit" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "Y"], + "keys": [ + "Ctrl O", + "Y" + ], "command": "vim:copy-cell-and-edit" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "P"], + "keys": [ + "Ctrl O", + "P" + ], "command": "vim:paste-cell-and-edit" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl Shift J"], + "keys": [ + "Ctrl Shift J" + ], "command": "notebook:extend-marked-cells-below" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Ctrl Shift J"], + "keys": [ + "Ctrl Shift J" + ], "command": "notebook:extend-marked-cells-below" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl Shift K"], + "keys": [ + "Ctrl Shift K" + ], "command": "notebook:extend-marked-cells-above" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Ctrl Shift K"], + "keys": [ + "Ctrl Shift K" + ], "command": "notebook:extend-marked-cells-above" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "Shift O"], + "keys": [ + "Ctrl O", + "Shift O" + ], "command": "notebook:insert-cell-above" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "Ctrl O"], + "keys": [ + "Ctrl O", + "Ctrl O" + ], "command": "notebook:insert-cell-above" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "O"], + "keys": [ + "Ctrl O", + "O" + ], "command": "notebook:insert-cell-below" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl J"], + "keys": [ + "Ctrl J" + ], "command": "vim:select-below-execute-markdown" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl K"], + "keys": [ + "Ctrl K" + ], "command": "vim:select-above-execute-markdown" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Escape"], + "keys": [ + "Escape" + ], "command": "vim:leave-insert-mode" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl ["], + "keys": [ + "Ctrl [" + ], "command": "vim:leave-insert-mode" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Ctrl I"], + "keys": [ + "Ctrl I" + ], "command": "vim:enter-insert-mode" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl Enter"], + "keys": [ + "Ctrl Enter" + ], "command": "vim:run-cell-and-edit" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Shift Enter"], + "keys": [ + "Shift Enter" + ], "command": "vim:run-select-next-edit" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Shift Escape"], + "keys": [ + "Shift Escape" + ], "command": "notebook:enter-command-mode" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Shift M"], + "keys": [ + "Shift M" + ], "command": "vim:merge-and-edit" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Accel 1"], + "keys": [ + "Accel 1" + ], "command": "notebook:change-cell-to-code" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Accel 2"], + "keys": [ + "Accel 2" + ], "command": "notebook:change-cell-to-markdown" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Accel 3"], + "keys": [ + "Accel 3" + ], "command": "notebook:change-cell-to-raw" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "G"], + "keys": [ + "Ctrl O", + "G" + ], "command": "vim:select-first-cell" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "Ctrl G"], + "keys": [ + "Ctrl O", + "Ctrl G" + ], "command": "vim:select-last-cell" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["G", "G"], + "keys": [ + "G", + "G" + ], "command": "vim:select-first-cell" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Shift G"], + "keys": [ + "Shift G" + ], "command": "vim:select-last-cell" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Y", "Y"], + "keys": [ + "Y", + "Y" + ], "command": "notebook:copy-cell" }, { "command": "notebook:cut-cell", - "keys": ["D", "D"], + "keys": [ + "D", + "D" + ], "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Shift P"], + "keys": [ + "Shift P" + ], "command": "notebook:paste-cell-above" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["P"], + "keys": [ + "P" + ], "command": "notebook:paste-cell-below" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["O"], + "keys": [ + "O" + ], "command": "notebook:insert-cell-below" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Shift O"], + "keys": [ + "Shift O" + ], "command": "notebook:insert-cell-above" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["U"], + "keys": [ + "U" + ], "command": "notebook:undo-cell-action" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Ctrl E"], + "keys": [ + "Ctrl E" + ], "command": "notebook:move-cell-down" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Ctrl Y"], + "keys": [ + "Ctrl Y" + ], "command": "notebook:move-cell-up" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Z", "Z"], + "keys": [ + "Z", + "Z" + ], "command": "vim:center-cell" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Z", "C"], + "keys": [ + "Z", + "C" + ], "command": "notebook:hide-cell-code" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Z", "O"], + "keys": [ + "Z", + "O" + ], "command": "notebook:show-cell-code" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Z", "M"], + "keys": [ + "Z", + "M" + ], "command": "notebook:hide-all-cell-code" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook:focus", - "keys": ["Z", "R"], + "keys": [ + "Z", + "R" + ], "command": "notebook:show-all-cell-code" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode", - "keys": ["Ctrl O", "Z", "Z"], + "keys": [ + "Ctrl O", + "Z", + "Z" + ], "command": "vim:center-cell" }, { "selector": ".jp-NotebookPanel[data-jp-vim-mode='true'] .jp-Notebook.jp-mod-editMode .jp-InputArea-editor:not(.jp-mod-has-primary-selection)", - "keys": ["Ctrl G"], + "keys": [ + "Ctrl G" + ], "command": "tooltip:launch-notebook" } ], "jupyter.lab.menus": { - "main":[ - { - "id": "jp-mainmenu-settings", - "items": [ - { - "type": "separator", - "rank": 38 - }, - { - "command": "jupyterlab-vim:toggle", - "rank": 38 - }, - { - "type": "separator", - "rank": 38 - } - ] - } - ]}, + "main": [ + { + "id": "jp-mainmenu-settings", + "items": [ + { + "type": "separator", + "rank": 38 + }, + { + "command": "jupyterlab-vim:toggle", + "rank": 38 + }, + { + "type": "separator", + "rank": 38 + } + ] + } + ] + }, "properties": { "enabled": { "type": "boolean", "title": "Enabled", "description": "Enable/disable notebook vim (may require a page refresh)", "default": true + }, + "extraKeybindings": { + "type": "array", + "title": "Extra Vim Keybindings", + "items": { + "$ref": "#/definitions/shortcut" + }, + "default": [] + } + }, + "definitions": { + "shortcut": { + "properties": { + "command": { + "title": "Keybinding", + "description": "The new vim keybinding, or 'left hand side' of the keybinding, e.g. `M`", + "type": "string" + }, + "keys": { + "title": "The key sequence to execute", + "description": "The 'right hand side' of the keybinding to be executed, e.g. `:noh`", + "type": "string" + }, + "context": { + "title": "Mode", + "description": "Vim mode in which the keybinding applies", + "enum": [ + "normal", + "insert", + "visual" + ], + "default": "normal" + }, + "mapfn": { + "title": "Map function", + "description": "Vim map function to use", + "enum": [ + "map", + "noremap" + ], + "default": "map" + }, + "enabled": { + "description": "Whether this keybinding is enabled or not.", + "type": "boolean", + "default": true + } + }, + "required": [ + "command", + "keys" + ], + "type": "object" } } } diff --git a/src/codemirrorCommands.ts b/src/codemirrorCommands.ts index 9c5651a..524f2c3 100644 --- a/src/codemirrorCommands.ts +++ b/src/codemirrorCommands.ts @@ -8,16 +8,28 @@ import { CommandRegistry } from '@lumino/commands'; */ const IS_MAC = !!navigator.platform.match(/Mac/i); +export interface IKeybinding { + command: string; + keys: string; + context: string; + mapfn: string; + enabled: boolean; +} + +export interface IOptions { + commands: CommandRegistry; + cm: CodeMirrorEditor; + enabled: boolean; + userKeybindings: IKeybinding[]; +} + export class VimCellManager { - constructor( - commands: CommandRegistry, - cm: CodeMirrorEditor, - enabled: boolean - ) { + constructor({ commands, cm, enabled, userKeybindings }: IOptions) { this._commands = commands; this._cm = cm; this.enabled = enabled; this.lastActiveCell = null; + this.userKeybindings = userKeybindings ?? []; } onActiveCellChanged( @@ -52,6 +64,27 @@ export class VimCellManager { const lcm = this._cm as any; const lvim = lcm.Vim as any; + + // Clear existing user keybindings, then re-register in case they changed in the user settings + ['normal', 'visual', 'insert'].forEach(ctx => lvim.mapclear(ctx)); + this.userKeybindings.forEach( + ({ + command, + keys, + context, + mapfn, + enabled: keybindEnabled + }: IKeybinding) => { + if (keybindEnabled) { + if (mapfn === 'map') { + lvim.map(command, keys, context); + } else { + lvim.noremap(command, keys, context); + } + } + } + ); + lvim.defineEx('quit', 'q', (cm: any) => { this._commands.execute('notebook:enter-command-mode'); }); @@ -197,4 +230,5 @@ export class VimCellManager { private _cm: CodeMirrorEditor; public lastActiveCell: Cell | null; public enabled: boolean; + public userKeybindings: IKeybinding[]; } diff --git a/src/index.ts b/src/index.ts index fbca137..b8aaf87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { ICodeMirror, CodeMirrorEditor } from '@jupyterlab/codemirror'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { IDisposable } from '@lumino/disposable'; -import { VimCellManager } from './codemirrorCommands'; +import { VimCellManager, IKeybinding } from './codemirrorCommands'; import { addJLabCommands } from './labCommands'; const PLUGIN_NAME = '@axlair/jupyterlab_vim'; @@ -42,15 +42,23 @@ async function activateCellVim( }, isToggled: () => enabled }); + + const userKeybindings = (( + await settingRegistry.get(`${PLUGIN_NAME}:plugin`, 'extraKeybindings') + ).composite as unknown) as Array; + // eslint-disable-next-line prettier/prettier const globalCodeMirror = jlabCodeMirror.CodeMirror as unknown as CodeMirrorEditor; let cellManager: VimCellManager | null = null; let escBinding: IDisposable | null = null; - - let addedCommands: Array | null = null; let hasEverBeenEnabled = false; - cellManager = new VimCellManager(app.commands, globalCodeMirror, enabled); + cellManager = new VimCellManager({ + commands: app.commands, + cm: globalCodeMirror, + enabled, + userKeybindings + }); // it's ok to connect here because we will never reach the vim section unless // ensureVimKeyMap has been called due to the checks for enabled. // we need to have now in order to keep track of the last active cell @@ -59,13 +67,21 @@ async function activateCellVim( cellManager.onActiveCellChanged, cellManager ); + + addJLabCommands(app, tracker, globalCodeMirror); + async function updateSettings( settings: ISettingRegistry.ISettings ): Promise { + const userKeybindings = (( + await settingRegistry.get(`${PLUGIN_NAME}:plugin`, 'extraKeybindings') + ).composite as unknown) as Array; + enabled = settings.get('enabled').composite === true; app.commands.notifyCommandChanged(TOGGLE_ID); if (cellManager) { cellManager.enabled = enabled; + cellManager.userKeybindings = userKeybindings; } if (enabled) { escBinding?.dispose(); @@ -74,24 +90,19 @@ async function activateCellVim( await app.restored; await jlabCodeMirror.ensureVimKeymap(); } - addedCommands = addJLabCommands(app, tracker, globalCodeMirror); - cellManager?.modifyCell(cellManager.lastActiveCell); - tracker.forEach(notebook => { - notebook.node.dataset.jpVimMode = 'true'; - }); } else { - addedCommands?.forEach(command => command.dispose()); escBinding = app.commands.addKeyBinding({ command: 'notebook:enter-command-mode', keys: ['Escape'], selector: '.jp-Notebook.jp-mod-editMode' }); - cellManager?.modifyCell(cellManager.lastActiveCell); - tracker.forEach(notebook => { - notebook.node.dataset.jpVimMode = 'false'; - }); } + tracker.forEach(notebook => { + notebook.node.dataset.jpVimMode = `${enabled}`; + }); + cellManager?.modifyCell(cellManager.lastActiveCell); + // make sure our css selector is added to new notebooks tracker.widgetAdded.connect((sender, notebook) => { notebook.node.dataset.jpVimMode = `${enabled}`;