diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 4dc23996..6359609c 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -1,4 +1,5 @@ import { closeBrackets } from '@codemirror/autocomplete'; +import { toggleLineComment } from '@codemirror/commands'; export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands'; // import { search, highlightSelectionMatches } from '@codemirror/search'; import { indentWithTab } from '@codemirror/commands'; @@ -13,7 +14,7 @@ import { lineNumbers, drawSelection, } from '@codemirror/view'; -import { repl, registerControl } from '@strudel/core'; +import { repl, registerControl, logger } from '@strudel/core'; import { Drawer, cleanupDraw } from '@strudel/draw'; import { isAutoCompletionEnabled } from './autocomplete.mjs'; import { isTooltipEnabled } from './tooltip.mjs'; @@ -245,6 +246,32 @@ export class StrudelMirror { } }; document.addEventListener('start-repl', this.onStartRepl); + + // Handle global evaluation requests (e.g., from Vim :w) + this.onEvaluateRequest = (e) => { + try { + // Evaluate current editor on repl-evaluate + logger('[repl] evaluate via event'); + this.evaluate(); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-evaluate event', err); + } + }; + document.addEventListener('repl-evaluate', this.onEvaluateRequest); + document.addEventListener('repl-stop', this.onStopRequest); + + // Toggle comments requested from Vim (gc/gcc) + this.onToggleComment = (e) => { + try { + // Honor selections; toggleLineComment handles both selections and single line + toggleLineComment(this.editor); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-toggle-comment event', err); + } + }; + document.addEventListener('repl-toggle-comment', this.onToggleComment); } draw(haps, time, painters) { painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime)); @@ -271,6 +298,16 @@ export class StrudelMirror { async stop() { this.repl.scheduler.stop(); } + + // Listen for global stop requests (e.g., from Vim :q) + onStopRequest = (e) => { + try { + this.stop(); + e?.cancelable && e.preventDefault?.(); + } catch (err) { + console.error('Error handling repl-stop event', err); + } + }; async toggle() { if (this.repl.scheduler.started) { this.repl.stop(); @@ -351,6 +388,9 @@ export class StrudelMirror { } clear() { this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); + this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); + this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest); + this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment); } getCursorLocation() { return this.editor.state.selection.main.head; diff --git a/packages/codemirror/keybindings.mjs b/packages/codemirror/keybindings.mjs index ca5f34f4..7de2a723 100644 --- a/packages/codemirror/keybindings.mjs +++ b/packages/codemirror/keybindings.mjs @@ -1,32 +1,137 @@ -import { Prec } from '@codemirror/state'; -import { keymap, ViewPlugin } from '@codemirror/view'; +import {defaultKeymap} from '@codemirror/commands'; +import {Prec} from '@codemirror/state'; +import {keymap, ViewPlugin} from '@codemirror/view'; // import { searchKeymap } from '@codemirror/search'; -import { emacs } from '@replit/codemirror-emacs'; -import { vim } from '@replit/codemirror-vim'; +import {emacs} from '@replit/codemirror-emacs'; +import {vim, Vim} from '@replit/codemirror-vim'; +import { logger } from '@strudel/core'; // import { vim } from './vim_test.mjs'; -import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; -import { defaultKeymap } from '@codemirror/commands'; +import {vscodeKeymap} from '@replit/codemirror-vscode-keymap'; const vscodePlugin = ViewPlugin.fromClass( - class { - constructor() {} - }, - { - provide: () => { - return Prec.highest(keymap.of([...vscodeKeymap])); + class { + constructor() {} + }, + { + provide : () => { return Prec.highest(keymap.of([...vscodeKeymap ])); }, }, - }, ); const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); +// 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 +// appears in the Console panel. +try { + if (Vim && typeof Vim.defineEx === 'function') { + // Map gc/gcc to toggle line comments by dispatching a custom event that our + // CodeMirror integration listens to. This avoids depending on Vim's internal + // actions and works with current selections/visual mode. + try { + Vim.defineAction('strudelToggleComment', (cm) => { + const view = cm?.view || cm; + try { + const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true }); + document.dispatchEvent(ev); + } catch (e) { + console.error('strudelToggleComment dispatch failed', e); + } + }); + Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' }); + Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' }); + Vim.mapCommand('gcc', 'action', 'strudelToggleComment', {}, { context: 'normal' }); + } catch (e) { + console.error('Vim gc/gcc mapping failed', e); + } + + // :q to pause/stop + // :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 {} + 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); + } + }); + + // :w to evaluate + // :w to evaluate + Vim.defineEx('write', 'w', (cm) => { + const view = + cm?.view || + cm; // CM6 Vim passes either an object with view or the view itself + try { + view?.focus?.(); + // Let the app know this came from Vim :w + try { + logger('[vim] :w — evaluating code'); + } catch {} + // 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 {} + 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 :w evaluation event', e); + } + }); + } +} catch (e) { + console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e); +} + const keymaps = { - vim, + vim, + // Add extra Vim keymap for gc to toggle line comment + // We will include a Vim-specific keymap that calls the CM command + // respecting the current selection. emacs, - codemirror: () => keymap.of(defaultKeymap), - vscode: vscodeExtension, + codemirror : () => keymap.of(defaultKeymap), + vscode : vscodeExtension, }; export function keybindings(name) { const active = keymaps[name]; - return [active ? Prec.high(active()) : []]; + return [ active ? Prec.high(active()) : [] ]; } diff --git a/website/src/pages/technical-manual/repl.mdx b/website/src/pages/technical-manual/repl.mdx index 8c4287af..f74a4fb8 100644 --- a/website/src/pages/technical-manual/repl.mdx +++ b/website/src/pages/technical-manual/repl.mdx @@ -117,6 +117,10 @@ Here's an example AST for `c3 [e3 g3]` which translates to `seq(c3, seq(e3, g3))` +## Vim Keybindings + +See the separate page on Vim shortcuts for a quick reference: [/technical-manual/vim](/technical-manual/vim) + ## Scheduling Events After an instance of `Pattern` is obtained from the user code, diff --git a/website/src/pages/technical-manual/vim.mdx b/website/src/pages/technical-manual/vim.mdx new file mode 100644 index 00000000..ec284d0b --- /dev/null +++ b/website/src/pages/technical-manual/vim.mdx @@ -0,0 +1,36 @@ +--- +title: Vim Shortcuts +layout: ../../layouts/MainLayout.astro +--- + +# Vim Shortcuts in the REPL + +When the REPL editor (CodeMirror) is configured to use Vim keybindings, the following commands are available: + +- :w — Evaluate the current code + - Triggers the same evaluation as Ctrl+Enter / Alt+Enter + - You’ll see messages in the Console panel such as: + - [vim] :w — evaluating code + - [repl] evaluate via event + - [eval] code updated + +- :q — Stop/pause playback + - Triggers the same stop action as Alt+. + - Useful to quickly stop scheduling without leaving Vim mode + +- gc — Toggle line comments for the current selection(s) + - Works in normal and visual mode + - If there’s a selection, all selected lines are toggled + +- gcc — Toggle comment for the current line (normal mode) + +Notes + +- Behavior respects the current language mode in the editor for comment syntax. +- If multiple REPL editors are open, commands target the active editor. The implementation dispatches custom events handled by the editor. +- If you don’t see the Console panel, open the right panel in the REPL UI. + +Troubleshooting + +- If :w logs but evaluation doesn’t apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback. +- For :q / gc / gcc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.