Merge pull request 'feat: Add Helix keybindings' (#1821) from space-shell/strudel:space-shell/helix-keybindings into main

Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1821
This commit is contained in:
froos
2026-02-05 10:12:58 +01:00
5 changed files with 213 additions and 56 deletions
+106 -55
View File
@@ -6,6 +6,7 @@ import { emacs } from '@replit/codemirror-emacs';
import { vim, Vim } from '@replit/codemirror-vim';
// import { vim } from './vim_test.mjs';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import { helix, commands } from 'codemirror-helix';
import { logger } from '@strudel/core';
const vscodePlugin = ViewPlugin.fromClass(
@@ -20,6 +21,70 @@ const vscodePlugin = ViewPlugin.fromClass(
);
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
function replEval(view) {
try {
// Dispatch a dedicated evaluate event first
let handled = false;
try {
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
} catch (e) {
console.error('Error dispatching repl-evaluate event', e);
}
if (handled) {
return;
}
// Try Ctrl+Enter first if not handled by custom event
const ctrlEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(ctrlEnter);
// If not handled (no handler called preventDefault), try Alt+Enter as
// fallback
if (!ctrlEnter.defaultPrevented) {
const altEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altEnter);
}
} catch (e) {
console.error('Error dispatching repl evaluation event', e);
}
}
function replStop(view) {
try {
// First try dispatching our custom stop event, then fallback to Alt+.
let handled = false;
try {
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false;
} catch (e) {
console.error('Error dispatching repl-stop event', e);
}
if (!handled) {
const altDot = new KeyboardEvent('keydown', {
key: '.',
code: 'Period',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altDot);
}
} catch (e) {
console.error('Error dispatching repl stop event', e);
}
}
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
// event 'repl-evaluate' that the editor listens for, and also simulate
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
@@ -47,29 +112,8 @@ try {
// :q to pause/stop
Vim.defineEx('quit', 'q', (cm) => {
try {
const view = cm?.view || cm;
// First try dispatching our custom stop event, then fallback to Alt+.
let handled = false;
try {
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false;
} catch (e) {
console.error('Error dispatching repl-stop event', e);
}
if (!handled) {
const altDot = new KeyboardEvent('keydown', {
key: '.',
code: 'Period',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altDot);
}
} catch (e) {
console.error('Error dispatching :q stop event', e);
}
const view = cm?.view || cm;
replStop(view);
});
// :w to evaluate
@@ -83,38 +127,7 @@ try {
} catch (e) {
console.error('Error logging Vim :w evaluation', e);
}
// Dispatch a dedicated evaluate event first
let handled = false;
try {
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
} catch (e) {
console.error('Error dispatching repl-evaluate event', e);
}
if (handled) {
return;
}
// Try Ctrl+Enter first if not handled by custom event
const ctrlEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(ctrlEnter);
// If not handled (no handler called preventDefault), try Alt+Enter as
// fallback
if (!ctrlEnter.defaultPrevented) {
const altEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altEnter);
}
replEval(view);
} catch (e) {
console.error('Error dispatching :w evaluation event', e);
}
@@ -124,11 +137,49 @@ try {
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
}
// Map Helix :w to trigger the same action as evaluation. We dispatch a custom
// event 'repl-evaluate' that the editor listens for, and also simulate
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
// appears in the Console panel.
const helixCommands = commands.of([
{
// :w to evaluate
name: 'write',
aliases: ['w'],
help: 'Repl-eval',
handler(view, args) {
try {
view?.focus?.(); // Let the app know this came from Helix :w
logger('[helix] :w — evaluating code');
replEval(view);
} catch (e) {
console.error('Error dispatching helix :w evaluation event', e);
}
},
},
{
// :q to pause/stop
name: 'quit',
aliases: ['q'],
help: 'Repl-stop',
handler(view, args) {
try {
view?.focus?.(); // Let the app know this came from Helix :q
logger('[helix] :q — stopping repl');
replStop(view);
} catch (e) {
console.error('Error dispatching helix :q stop event', e);
}
},
},
]);
const keymaps = {
vim,
emacs,
codemirror: () => keymap.of(defaultKeymap),
vscode: vscodeExtension,
helix: () => [helix(), helixCommands],
};
export { Vim } from '@replit/codemirror-vim';
+1
View File
@@ -44,6 +44,7 @@
"@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"codemirror-helix": "^0.5.0",
"@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*",
"@strudel/tonal": "workspace:*",
+20
View File
@@ -221,6 +221,9 @@ importers:
'@tonaljs/tonal':
specifier: ^4.10.0
version: 4.10.0
codemirror-helix:
specifier: ^0.5.0
version: 0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
nanostores:
specifier: ^0.11.3
version: 0.11.3
@@ -3551,6 +3554,15 @@ packages:
resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
codemirror-helix@0.5.0:
resolution: {integrity: sha512-hI56hf9VGz53H1YvL6H1GC7HtP6te8vX+MsIHaE9J7Q3PQ6KFapKtIRg6lqSH898ikHWpMCPu42r6HJN0IfVLA==}
peerDependencies:
'@codemirror/commands': ^6.0.0
'@codemirror/language': ^6.0.0
'@codemirror/search': ^6.0.0
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
@@ -11280,6 +11292,14 @@ snapshots:
cmd-shim@6.0.3: {}
codemirror-helix@0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2):
dependencies:
'@codemirror/commands': 6.8.0
'@codemirror/language': 6.10.8
'@codemirror/search': 6.5.8
'@codemirror/state': 6.5.1
'@codemirror/view': 6.36.2
collapse-white-space@2.1.0: {}
color-convert@2.0.1:
@@ -0,0 +1,85 @@
---
title: Helix Keybindings
layout: ../../layouts/MainLayout.astro
---
# Helix Keybindings in the REPL
The Strudel REPL supports [Helix editor](https://helix-editor.com/) keybindings through the `codemirror-helix` extension. Helix is a post-modern modal text editor with a focus on selection-first editing and multiple cursors.
## Enabling Helix Mode
1. Open the Settings panel in the REPL (click the settings icon or use the keyboard shortcut)
2. Find the "Keybindings" section
3. Select "Helix" from the available options
## Key Differences from Vim
Helix uses a selection-first approach, which means:
- **Selection → Action** (Helix) vs **Action → Motion** (Vim)
- For example, to delete a word in Helix: `w` (select word) → `d` (delete)
- In Vim, it would be: `dw` (delete word)
## Common Helix Commands
### Normal Mode
- `i` — Enter insert mode before selection
- `a` — Enter insert mode after selection
- `v` — Enter visual/select mode
- `w` — Select next word
- `e` — Select to end of word
- `b` — Select previous word
- `x` — Select/extend line
- `d` — Delete selection
- `c` — Change selection (delete and enter insert mode)
- `y` — Yank (copy) selection
- `p` — Paste after selection
- `u` — Undo
- `U` — Redo
- `/` — Search
- `n` — Select next search match
- `N` — Select previous search match
### Movement
- `h, j, k, l` — Move left, down, up, right
- `gg` — Go to start of document
- `ge` — Go to end of document
- `{` / `}` — Move to previous/next paragraph
- `%` — Match bracket
### Multi-cursor
- `C` — Duplicate cursor to line below
- `Alt-C` — Duplicate cursor to line above
- `,` — Remove primary cursor
- `Alt-,` — Remove all secondary cursors
## Strudel-Specific Features
Currently, Helix mode in Strudel provides standard Helix keybindings. Unlike Vim mode, there are no custom Strudel-specific commands (like `:w` to evaluate) yet.
To evaluate code while in Helix mode, use the standard shortcuts:
- `Ctrl+Enter` or `Alt+Enter` — Evaluate code
- `Alt+.` — Stop playback
## Resources
- [Helix Documentation](https://docs.helix-editor.com/)
- [Helix Keymap](https://docs.helix-editor.com/keymap.html)
- [codemirror-helix on npm](https://www.npmjs.com/package/codemirror-helix)
- [codemirror-helix on GitLab](https://gitlab.com/_rvidal/codemirror-helix)
## Notes
- The `codemirror-helix` extension is described as an "initial version" and may not implement all Helix features
- Behavior may differ slightly from the native Helix editor
- If you encounter issues, you can switch back to other keybinding modes in Settings
- The keybinding preference is saved in your browser's local storage
## Contributing
If you'd like to add Strudel-specific Helix commands (similar to Vim's `:w` for evaluate), contributions are welcome! See the implementation in `/packages/codemirror/keybindings.mjs` for reference.
@@ -246,7 +246,7 @@ export function SettingsTab({ started }) {
<ButtonGroup
value={keybindings}
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', helix: 'Helix', vscode: 'VSCode' }}
></ButtonGroup>
</FormItem>
<FormItem label="Panel Position">