Neovim
DEMOA character-grid-aligned terminal code editor mock modeled after Neovim (nvim),
complete with bufferline tab navigation, line number gutter, modal editing (NORMAL, INSERT, VISUAL, COMMAND), dynamic statusline, and
command-line execution (:w, :e <file>, :help).
Live Editor Demo (Click inside to interact)
NVIM v0.10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Press i for INSERT, : for commands (:w, :q), h/j/k/l to move
neovim.svelte
001 <script lang="ts">
002 import Input from '$lib/components/input.svelte';
003
004 // ── Buffer Files ───────────────────────────────────────
005 type FileBuffer = {
006 name: string;
007 filetype: string;
008 lines: string[];
009 modified: boolean;
010 };
011
012 const files: FileBuffer[] = [
013 {
014 name: 'src/lib/components/input.svelte',
015 filetype: 'svelte',
016 modified: false,
017 lines: [
018 '<script lang="ts" module>',
019 " import type { HTMLInputAttributes } from 'svelte/elements';",
020 '',
021 " export type InputProps = Omit<HTMLInputAttributes, 'type'> & {",
022 ' ref?: HTMLInputElement | null;',
023 ' caret?: string;',
024 " type?: 'text' | 'password';",
025 ' bordered?: boolean;',
026 ' };',
027 '<' + '/script>',
028 '',
029 '<script lang="ts">',
030 ' let {',
031 ' ref = $bindable(null),',
032 ' bordered = false,',
033 " value = $bindable(''),",
034 ' autofocus = false,',
035 ' ...restProps',
036 ' }: InputProps = $props();',
037 '<' + '/script>',
038 '',
039 '<div class="displayed" class:bordered>',
040 ' <span class="text">{value}</span>',
041 '</div>'
042 ]
043 },
044 {
045 name: 'src/app.css',
046 filetype: 'css',
047 modified: false,
048 lines: [
049 ':root {',
050 ' --cell: 1ch;',
051 ' --line: calc(var(--font-size) * var(--base-line-height));',
052 ' --surface-base: rgb(0, 0, 0);',
053 ' --text-primary: rgb(235, 235, 235);',
054 ' --focus-ring: rgb(239, 99, 0);',
055 '}',
056 '',
057 'body {',
058 ' font-family: var(--font-family-mono);',
059 ' line-height: var(--line);',
060 '}'
061 ]
062 },
063 {
064 name: 'src/lib/config/docs.ts',
065 filetype: 'typescript',
066 modified: false,
067 lines: [
068 "export const version = '1.1.13';",
069 '',
070 'export const components = [',
071 " 'input',",
072 " 'button',",
073 " 'card',",
074 " 'accordion',",
075 " 'divider'",
076 '];'
077 ]
078 }
079 ];
080
081 // ── State ─────────────────────────────────────────────
082 let activeFileIndex = $state(0);
083 let activeFile = $derived(files[activeFileIndex]);
084
085 type Mode = 'NORMAL' | 'INSERT' | 'VISUAL' | 'COMMAND';
086 let mode = $state<Mode>('NORMAL');
087
088 let cursorRow = $state(4);
089 let cursorCol = $state(9);
090
091 let commandInput = $state('');
092 let statusMessage = $state('Press i for INSERT, : for commands (:w, :q), h/j/k/l to move');
093 let isFocused = $state(false);
094
095 let containerEl: HTMLDivElement;
096
097 const visibleRows = Array.from({ length: 14 }, (_, i) => i);
098
099 function clamp(val: number, min: number, max: number) {
100 return Math.max(min, Math.min(max, val));
101 }
102
103 function handleContainerKey(e: KeyboardEvent) {
104 if (mode === 'COMMAND') return; // Handled by Input
105
106 if (mode === 'NORMAL') {
107 if (e.key === 'i') {
108 e.preventDefault();
109 mode = 'INSERT';
110 statusMessage = '-- INSERT --';
111 return;
112 }
113 if (e.key === 'v') {
114 e.preventDefault();
115 mode = 'VISUAL';
116 statusMessage = '-- VISUAL --';
117 return;
118 }
119 if (e.key === ':') {
120 e.preventDefault();
121 mode = 'COMMAND';
122 commandInput = ':';
123 statusMessage = '';
124 return;
125 }
126 if (e.key === 'h' || e.key === 'ArrowLeft') {
127 e.preventDefault();
128 cursorCol = clamp(cursorCol - 1, 0, (activeFile.lines[cursorRow]?.length ?? 1) - 1);
129 return;
130 }
131 if (e.key === 'l' || e.key === 'ArrowRight') {
132 e.preventDefault();
133 cursorCol = clamp(cursorCol + 1, 0, (activeFile.lines[cursorRow]?.length ?? 1) - 1);
134 return;
135 }
136 if (e.key === 'k' || e.key === 'ArrowUp') {
137 e.preventDefault();
138 cursorRow = clamp(cursorRow - 1, 0, activeFile.lines.length - 1);
139 cursorCol = clamp(
140 cursorCol,
141 0,
142 Math.max(0, (activeFile.lines[cursorRow]?.length ?? 1) - 1)
143 );
144 return;
145 }
146 if (e.key === 'j' || e.key === 'ArrowDown') {
147 e.preventDefault();
148 cursorRow = clamp(cursorRow + 1, 0, activeFile.lines.length - 1);
149 cursorCol = clamp(
150 cursorCol,
151 0,
152 Math.max(0, (activeFile.lines[cursorRow]?.length ?? 1) - 1)
153 );
154 return;
155 }
156 if (e.key === 'x') {
157 e.preventDefault();
158 const line = activeFile.lines[cursorRow];
159 if (line && cursorCol < line.length) {
160 activeFile.lines[cursorRow] = line.slice(0, cursorCol) + line.slice(cursorCol + 1);
161 activeFile.modified = true;
162 }
163 return;
164 }
165 } else if (mode === 'INSERT') {
166 if (e.key === 'Escape') {
167 e.preventDefault();
168 mode = 'NORMAL';
169 statusMessage = '';
170 return;
171 }
172 if (e.key === 'Enter') {
173 e.preventDefault();
174 const line = activeFile.lines[cursorRow];
175 const before = line.slice(0, cursorCol);
176 const after = line.slice(cursorCol);
177 activeFile.lines.splice(cursorRow, 1, before, after);
178 cursorRow++;
179 cursorCol = 0;
180 activeFile.modified = true;
181 return;
182 }
183 if (e.key === 'Backspace') {
184 e.preventDefault();
185 const line = activeFile.lines[cursorRow];
186 if (cursorCol > 0) {
187 activeFile.lines[cursorRow] = line.slice(0, cursorCol - 1) + line.slice(cursorCol);
188 cursorCol--;
189 activeFile.modified = true;
190 } else if (cursorRow > 0) {
191 const prevLine = activeFile.lines[cursorRow - 1];
192 cursorCol = prevLine.length;
193 activeFile.lines[cursorRow - 1] = prevLine + line;
194 activeFile.lines.splice(cursorRow, 1);
195 cursorRow--;
196 activeFile.modified = true;
197 }
198 return;
199 }
200 if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
201 e.preventDefault();
202 const line = activeFile.lines[cursorRow] ?? '';
203 activeFile.lines[cursorRow] = line.slice(0, cursorCol) + e.key + line.slice(cursorCol);
204 cursorCol++;
205 activeFile.modified = true;
206 }
207 } else if (mode === 'VISUAL') {
208 if (e.key === 'Escape') {
209 e.preventDefault();
210 mode = 'NORMAL';
211 statusMessage = '';
212 return;
213 }
214 }
215 }
216
217 function handleCommandKey(e: KeyboardEvent) {
218 if (e.key === 'Escape') {
219 e.preventDefault();
220 mode = 'NORMAL';
221 commandInput = '';
222 statusMessage = '';
223 containerEl?.focus();
224 return;
225 }
226 if (e.key === 'Enter') {
227 e.preventDefault();
228 executeCommand(commandInput.trim());
229 }
230 }
231
232 function executeCommand(cmd: string) {
233 mode = 'NORMAL';
234 commandInput = '';
235
236 if (cmd === ':w') {
237 activeFile.modified = false;
238 statusMessage = `"${activeFile.name}" ${activeFile.lines.length}L written`;
239 } else if (cmd === ':q' || cmd === ':q!') {
240 statusMessage = 'Quit buffer.';
241 } else if (cmd === ':wq') {
242 activeFile.modified = false;
243 statusMessage = `"${activeFile.name}" saved & quit`;
244 } else if (cmd.startsWith(':e ')) {
245 const target = cmd.slice(3).trim();
246 const idx = files.findIndex((f) => f.name.includes(target));
247 if (idx !== -1) {
248 activeFileIndex = idx;
249 cursorRow = 0;
250 cursorCol = 0;
251 statusMessage = `Switched to "${files[idx].name}"`;
252 } else {
253 statusMessage = `E484: Can't open file "${target}"`;
254 }
255 } else if (cmd === ':help') {
256 statusMessage = 'svtui Neovim Mock — :w (write), :e <file>, i (insert), v (visual), h/j/k/l';
257 } else {
258 statusMessage = `E492: Not an editor command: ${cmd.slice(1)}`;
259 }
260 containerEl?.focus();
261 }
262
263 function switchTab(index: number) {
264 activeFileIndex = index;
265 cursorRow = 0;
266 cursorCol = 0;
267 containerEl?.focus();
268 }
269 </script>
270
271 <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
272 <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
273 <div
274 class="nvim-window"
275 bind:this={containerEl}
276 tabindex="0"
277 onkeydown={handleContainerKey}
278 onfocus={() => (isFocused = true)}
279 onblur={() => (isFocused = false)}
280 role="region"
281 aria-label="Neovim editor interactive demo"
282 >
283 <!-- ── BUFFERLINE (TABLINE) ───────────────────── -->
284 <header class="tabline">
285 <div class="tabs">
286 {#each files as file, i (file.name)}
287 <button
288 class="tab"
289 class:active={i === activeFileIndex}
290 onclick={() => switchTab(i)}
291 tabindex="-1"
292 >
293 <span class="tab-idx">{i + 1}</span>
294 <span class="tab-name">{file.name.split('/').pop()}</span>
295 {#if file.modified}<span class="tab-mod">●</span>{/if}
296 </button>
297 {/each}
298 </div>
299 <div class="tabline-right">
300 <span class="dim">NVIM v0.10</span>
301 </div>
302 </header>
303
304 <!-- ── MAIN EDITOR VIEWPORT ──────────────────── -->
305 <div class="editor-body">
306 <!-- LINE NUMBER GUTTER -->
307 <div class="gutter">
308 {#each visibleRows as r (r)}
309 {@const lineNum = r + 1}
310 {@const hasLine = r < activeFile.lines.length}
311 <div class="gutter-line" class:active-gutter={r === cursorRow}>
312 {#if hasLine}
313 <span class="num">{lineNum}</span>
314 {:else}
315 <span class="tilde">~</span>
316 {/if}
317 </div>
318 {/each}
319 </div>
320
321 <!-- CODE BUFFER CONTENT -->
322 <div class="code-viewport">
323 {#each visibleRows as r (r)}
324 {@const line = activeFile.lines[r]}
325 {@const isCurrentLine = r === cursorRow}
326 <div class="code-line" class:current-line={isCurrentLine && isFocused}>
327 {#if line !== undefined}
328 {#if isCurrentLine && isFocused}
329 <!-- RENDER LINE WITH CURSOR -->
330 {@const before = line.slice(0, cursorCol)}
331 {@const charUnder = line[cursorCol] || ' '}
332 {@const after = line.slice(cursorCol + 1)}
333 <span class="text-seg">{before}</span><span
334 class="nvim-cursor"
335 class:insert-mode={mode === 'INSERT'}
336 class:visual-mode={mode === 'VISUAL'}>{charUnder}</span
337 ><span class="text-seg">{after}</span>
338 {:else}
339 <span class="text-seg">{line}</span>
340 {/if}
341 {:else}
342 <span class="empty-line"></span>
343 {/if}
344 </div>
345 {/each}
346 </div>
347 </div>
348
349 <!-- ── STATUSLINE ────────────────────────────── -->
350 <footer class="statusline">
351 <div class="status-left">
352 <span class="mode-pill mode-{mode.toLowerCase()}">{mode}</span>
353 <span class="status-branch">main</span>
354 <span class="status-file">
355 {activeFile.name.split('/').pop()}
356 {#if activeFile.modified}<span class="mod-flag">[+]</span>{/if}
357 </span>
358 </div>
359 <div class="status-right">
360 <span class="status-ft">{activeFile.filetype}</span>
361 <span class="status-pos">
362 {cursorRow + 1}:{cursorCol + 1}
363 </span>
364 <span class="status-pct">
365 {Math.round(((cursorRow + 1) / Math.max(1, activeFile.lines.length)) * 100)}%
366 </span>
367 </div>
368 </footer>
369
370 <!-- ── COMMAND / MESSAGE LINE ────────────────── -->
371 <div class="cmdline">
372 {#if mode === 'COMMAND'}
373 <div class="cmd-input-row">
374 <Input
375 id="nvim-cmd"
376 autofocus
377 bind:value={commandInput}
378 onkeydown={handleCommandKey}
379 class="cmd-field"
380 />
381 </div>
382 {:else}
383 <span class="status-msg">{statusMessage}</span>
384 {/if}
385 </div>
386 </div>
387
388 <style>
389 .nvim-window {
390 width: 100%;
391 box-sizing: border-box;
392 display: flex;
393 flex-direction: column;
394 background: var(--surface-base);
395 color: var(--text-primary);
396 font-family: var(--font-family-mono);
397 font-size: var(--font-size);
398 line-height: var(--line);
399 outline: 0;
400 box-shadow: inset 0 0 0 1px var(--border-default);
401 user-select: none;
402 }
403
404 .nvim-window:focus-visible {
405 box-shadow: inset 0 0 0 1px var(--focus-ring);
406 }
407
408 /* ── TABLINE ──────────────────────────────────── */
409 .tabline {
410 height: var(--line);
411 line-height: var(--line);
412 display: flex;
413 justify-content: space-between;
414 background: color-mix(in srgb, var(--surface-base) 80%, var(--border-default) 20%);
415 box-shadow: inset 0 -1px 0 0 var(--border-default);
416 }
417
418 .tabs {
419 display: flex;
420 }
421
422 .tab {
423 display: flex;
424 align-items: center;
425 gap: 1ch;
426 padding: 0 1.5ch;
427 height: var(--line);
428 line-height: var(--line);
429 background: transparent;
430 color: var(--button-muted);
431 border: 0;
432 cursor: pointer;
433 font-family: inherit;
434 font-size: inherit;
435 box-shadow: inset -1px 0 0 0 var(--border-default);
436 }
437
438 .tab:hover {
439 color: var(--text-primary);
440 }
441
442 .tab.active {
443 background: var(--surface-base);
444 color: var(--text-primary);
445 font-weight: bold;
446 box-shadow:
447 inset 0 2px 0 0 var(--focus-ring),
448 inset -1px 0 0 0 var(--border-default);
449 }
450
451 .tab-idx {
452 opacity: 0.5;
453 }
454
455 .tab-mod {
456 color: var(--focus-ring);
457 font-size: 0.8em;
458 }
459
460 .tabline-right {
461 padding: 0 1ch;
462 height: var(--line);
463 line-height: var(--line);
464 }
465
466 .dim {
467 opacity: 0.5;
468 }
469
470 /* ── MAIN BODY ────────────────────────────────── */
471 .editor-body {
472 display: flex;
473 height: calc(var(--line) * 14);
474 overflow: hidden;
475 }
476
477 .gutter {
478 width: 5ch;
479 flex-shrink: 0;
480 display: flex;
481 flex-direction: column;
482 background: color-mix(in srgb, var(--surface-base) 92%, var(--border-default) 8%);
483 box-shadow: inset -1px 0 0 0 var(--border-default);
484 text-align: right;
485 }
486
487 .gutter-line {
488 height: var(--line);
489 line-height: var(--line);
490 padding-right: 1ch;
491 color: var(--button-muted);
492 }
493
494 .gutter-line.active-gutter {
495 color: var(--text-primary);
496 font-weight: bold;
497 }
498
499 .tilde {
500 color: color-mix(in srgb, var(--focus-ring) 60%, transparent);
501 }
502
503 .code-viewport {
504 flex: 1;
505 min-width: 0;
506 display: flex;
507 flex-direction: column;
508 padding-left: 1ch;
509 overflow: hidden;
510 }
511
512 .code-line {
513 height: var(--line);
514 line-height: var(--line);
515 white-space: pre;
516 }
517
518 .code-line.current-line {
519 background: color-mix(in srgb, var(--text-primary) 5%, transparent);
520 }
521
522 .text-seg {
523 display: inline;
524 }
525
526 /* ── CURSORS ──────────────────────────────────── */
527 .nvim-cursor {
528 display: inline-block;
529 min-width: 1ch;
530 height: var(--line);
531 line-height: var(--line);
532 vertical-align: top;
533 background: var(--text-primary);
534 color: var(--surface-base);
535 }
536
537 .nvim-cursor.insert-mode {
538 box-shadow: inset 2px 0 0 0 var(--text-primary);
539 background: transparent;
540 color: inherit;
541 }
542
543 .nvim-cursor.visual-mode {
544 background: var(--focus-ring);
545 color: var(--surface-base);
546 }
547
548 /* ── STATUSLINE ───────────────────────────────── */
549 .statusline {
550 height: var(--line);
551 line-height: var(--line);
552 display: flex;
553 justify-content: space-between;
554 background: color-mix(in srgb, var(--surface-base) 80%, var(--border-default) 20%);
555 box-shadow: inset 0 1px 0 0 var(--border-default);
556 font-size: var(--font-size);
557 white-space: nowrap;
558 overflow: hidden;
559 }
560
561 .status-left,
562 .status-right {
563 display: flex;
564 align-items: center;
565 height: var(--line);
566 line-height: var(--line);
567 overflow: hidden;
568 }
569
570 .mode-pill {
571 padding: 0 1ch;
572 height: var(--line);
573 line-height: var(--line);
574 font-weight: bold;
575 }
576
577 .mode-normal {
578 background: var(--focus-ring);
579 color: var(--surface-base);
580 }
581
582 .mode-insert {
583 background: rgb(34, 197, 94);
584 color: rgb(0, 0, 0);
585 }
586
587 .mode-visual {
588 background: rgb(168, 85, 247);
589 color: rgb(0, 0, 0);
590 }
591
592 .mode-command {
593 background: rgb(234, 179, 8);
594 color: rgb(0, 0, 0);
595 }
596
597 .status-branch {
598 padding: 0 1.5ch;
599 color: var(--button-muted);
600 box-shadow: inset -1px 0 0 0 var(--border-default);
601 }
602
603 .status-file {
604 padding: 0 1.5ch;
605 }
606
607 .mod-flag {
608 color: var(--focus-ring);
609 }
610
611 .status-ft {
612 padding: 0 1.5ch;
613 opacity: 0.6;
614 box-shadow: inset 1px 0 0 0 var(--border-default);
615 }
616
617 .status-pos {
618 padding: 0 1.5ch;
619 box-shadow: inset 1px 0 0 0 var(--border-default);
620 }
621
622 .status-pct {
623 padding: 0 1.5ch;
624 background: color-mix(in srgb, var(--text-primary) 10%, transparent);
625 box-shadow: inset 1px 0 0 0 var(--border-default);
626 }
627
628 /* ── COMMAND LINE ─────────────────────────────── */
629 .cmdline {
630 height: var(--line);
631 line-height: var(--line);
632 padding: 0 1ch;
633 display: flex;
634 align-items: center;
635 background: var(--surface-base);
636 }
637
638 .cmd-input-row {
639 flex: 1;
640 height: var(--line);
641 line-height: var(--line);
642 }
643
644 .status-msg {
645 opacity: 0.6;
646 font-style: italic;
647 }
648 </style>
649