ink-prompt 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/components/MultilineInput/KeyHandler.d.ts +16 -0
- package/dist/components/MultilineInput/KeyHandler.js +124 -0
- package/dist/components/MultilineInput/TextBuffer.d.ts +52 -0
- package/dist/components/MultilineInput/TextBuffer.js +312 -0
- package/dist/components/MultilineInput/TextRenderer.d.ts +24 -0
- package/dist/components/MultilineInput/TextRenderer.js +80 -0
- package/dist/components/MultilineInput/__tests__/KeyHandler.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/KeyHandler.test.js +124 -0
- package/dist/components/MultilineInput/__tests__/TextBuffer.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/TextBuffer.test.js +450 -0
- package/dist/components/MultilineInput/__tests__/TextRenderer.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/TextRenderer.test.js +185 -0
- package/dist/components/MultilineInput/__tests__/integration.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/integration.test.js +88 -0
- package/dist/components/MultilineInput/__tests__/useTextInput.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/useTextInput.test.js +172 -0
- package/dist/components/MultilineInput/index.d.ts +45 -0
- package/dist/components/MultilineInput/index.js +132 -0
- package/dist/components/MultilineInput/types.d.ts +55 -0
- package/dist/components/MultilineInput/types.js +1 -0
- package/dist/components/MultilineInput/useTextInput.d.ts +22 -0
- package/dist/components/MultilineInput/useTextInput.js +108 -0
- package/dist/hello.test.d.ts +1 -0
- package/dist/hello.test.js +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/utils/logger.d.ts +15 -0
- package/dist/utils/logger.js +42 -0
- package/package.json +57 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Wrap buffer lines to fit within a given width.
|
|
5
|
+
* Returns visual lines and maps cursor position to visual coordinates.
|
|
6
|
+
*/
|
|
7
|
+
export function wrapLines(buffer, cursor, width) {
|
|
8
|
+
const visualLines = [];
|
|
9
|
+
let cursorVisualRow = 0;
|
|
10
|
+
let cursorVisualCol = 0;
|
|
11
|
+
let visualRowIndex = 0;
|
|
12
|
+
for (let lineIndex = 0; lineIndex < buffer.lines.length; lineIndex++) {
|
|
13
|
+
const line = buffer.lines[lineIndex];
|
|
14
|
+
const isCursorLine = lineIndex === cursor.line;
|
|
15
|
+
if (line.length <= width) {
|
|
16
|
+
// Line fits, no wrapping needed
|
|
17
|
+
visualLines.push(line);
|
|
18
|
+
if (isCursorLine) {
|
|
19
|
+
cursorVisualRow = visualRowIndex;
|
|
20
|
+
cursorVisualCol = cursor.column;
|
|
21
|
+
}
|
|
22
|
+
visualRowIndex++;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// Line needs to be wrapped
|
|
26
|
+
let offset = 0;
|
|
27
|
+
while (offset < line.length) {
|
|
28
|
+
const chunk = line.slice(offset, offset + width);
|
|
29
|
+
visualLines.push(chunk);
|
|
30
|
+
if (isCursorLine) {
|
|
31
|
+
// Check if cursor falls within this chunk
|
|
32
|
+
if (cursor.column >= offset && cursor.column < offset + width) {
|
|
33
|
+
cursorVisualRow = visualRowIndex;
|
|
34
|
+
cursorVisualCol = cursor.column - offset;
|
|
35
|
+
}
|
|
36
|
+
else if (cursor.column === line.length && offset + chunk.length === line.length) {
|
|
37
|
+
// Cursor at end of line
|
|
38
|
+
cursorVisualRow = visualRowIndex;
|
|
39
|
+
cursorVisualCol = chunk.length;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
offset += width;
|
|
43
|
+
visualRowIndex++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { visualLines, cursorVisualRow, cursorVisualCol };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Render a line with cursor inserted at the specified position
|
|
51
|
+
*/
|
|
52
|
+
function renderLineWithCursor(line, cursorCol, showCursor) {
|
|
53
|
+
if (!showCursor) {
|
|
54
|
+
// Empty lines need a space to be rendered by Ink
|
|
55
|
+
return _jsx(Text, { children: line || ' ' });
|
|
56
|
+
}
|
|
57
|
+
// For empty lines with cursor, we need special handling
|
|
58
|
+
if (line.length === 0) {
|
|
59
|
+
return _jsx(Text, { inverse: true, children: " " });
|
|
60
|
+
}
|
|
61
|
+
const before = line.slice(0, cursorCol);
|
|
62
|
+
const charUnderCursor = cursorCol < line.length ? line[cursorCol] : ' ';
|
|
63
|
+
const after = line.slice(cursorCol + 1);
|
|
64
|
+
// Render the cursor using Ink's Text with inverse colors for high visibility.
|
|
65
|
+
// We show the actual character under the cursor (or a space at line end)
|
|
66
|
+
// with inverted colors to make it stand out in any terminal color scheme.
|
|
67
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: charUnderCursor }), _jsx(Text, { children: after })] }));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* TextRenderer component for displaying buffer content with cursor
|
|
71
|
+
*/
|
|
72
|
+
export function TextRenderer({ buffer, cursor, width = 80, showCursor = true, }) {
|
|
73
|
+
const { visualLines, cursorVisualRow, cursorVisualCol } = wrapLines(buffer, cursor, width);
|
|
74
|
+
return (_jsx(Box, { flexDirection: "column", children: visualLines.map((line, index) => {
|
|
75
|
+
const isCursorRow = index === cursorVisualRow;
|
|
76
|
+
return (_jsx(Box, { children: isCursorRow ? (
|
|
77
|
+
// renderLineWithCursor returns a ReactNode composed of Text
|
|
78
|
+
renderLineWithCursor(line, cursorVisualCol, showCursor)) : (_jsx(Text, { children: line || ' ' })) }, index));
|
|
79
|
+
}) }));
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { handleKey } from '../KeyHandler.js';
|
|
3
|
+
describe('KeyHandler', () => {
|
|
4
|
+
let actions;
|
|
5
|
+
let buffer;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
actions = {
|
|
8
|
+
insert: vi.fn(),
|
|
9
|
+
delete: vi.fn(),
|
|
10
|
+
deleteForward: vi.fn(),
|
|
11
|
+
newLine: vi.fn(),
|
|
12
|
+
deleteAndNewLine: vi.fn(),
|
|
13
|
+
moveCursor: vi.fn(),
|
|
14
|
+
undo: vi.fn(),
|
|
15
|
+
redo: vi.fn(),
|
|
16
|
+
setText: vi.fn(),
|
|
17
|
+
submit: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
buffer = { lines: [''] };
|
|
20
|
+
});
|
|
21
|
+
describe('Navigation', () => {
|
|
22
|
+
it('handles ArrowUp', () => {
|
|
23
|
+
handleKey({ upArrow: true }, 'up', buffer, actions);
|
|
24
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('up');
|
|
25
|
+
});
|
|
26
|
+
it('handles ArrowDown', () => {
|
|
27
|
+
handleKey({ downArrow: true }, 'down', buffer, actions);
|
|
28
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('down');
|
|
29
|
+
});
|
|
30
|
+
it('handles ArrowLeft', () => {
|
|
31
|
+
handleKey({ leftArrow: true }, 'left', buffer, actions);
|
|
32
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('left');
|
|
33
|
+
});
|
|
34
|
+
it('handles ArrowRight', () => {
|
|
35
|
+
handleKey({ rightArrow: true }, 'right', buffer, actions);
|
|
36
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('right');
|
|
37
|
+
});
|
|
38
|
+
it('handles Home key via escape sequence', () => {
|
|
39
|
+
// Home key comes as escape sequence, not key.home
|
|
40
|
+
handleKey({}, '', buffer, actions, undefined, '\x1b[H');
|
|
41
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('lineStart');
|
|
42
|
+
});
|
|
43
|
+
it('handles Home key via alternative escape sequence', () => {
|
|
44
|
+
handleKey({}, '', buffer, actions, undefined, '\x1bOH');
|
|
45
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('lineStart');
|
|
46
|
+
});
|
|
47
|
+
it('handles Ctrl+A as Home alternative', () => {
|
|
48
|
+
handleKey({ ctrl: true }, 'a', buffer, actions);
|
|
49
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('lineStart');
|
|
50
|
+
});
|
|
51
|
+
it('handles End key via escape sequence', () => {
|
|
52
|
+
// End key comes as escape sequence, not key.end
|
|
53
|
+
handleKey({}, '', buffer, actions, undefined, '\x1b[F');
|
|
54
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('lineEnd');
|
|
55
|
+
});
|
|
56
|
+
it('handles End key via alternative escape sequence', () => {
|
|
57
|
+
handleKey({}, '', buffer, actions, undefined, '\x1bOF');
|
|
58
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('lineEnd');
|
|
59
|
+
});
|
|
60
|
+
it('handles Ctrl+E as End alternative', () => {
|
|
61
|
+
handleKey({ ctrl: true }, 'e', buffer, actions);
|
|
62
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('lineEnd');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('Editing', () => {
|
|
66
|
+
it('handles Backspace', () => {
|
|
67
|
+
handleKey({ backspace: true }, 'backspace', buffer, actions);
|
|
68
|
+
expect(actions.delete).toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
it('handles Delete (forward delete)', () => {
|
|
71
|
+
handleKey({ delete: true }, '', buffer, actions);
|
|
72
|
+
expect(actions.deleteForward).toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
it('handles Ctrl+J (NewLine)', () => {
|
|
75
|
+
handleKey({ ctrl: true }, 'j', buffer, actions);
|
|
76
|
+
expect(actions.newLine).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
it('handles regular text insertion', () => {
|
|
79
|
+
handleKey({}, 'a', buffer, actions);
|
|
80
|
+
expect(actions.insert).toHaveBeenCalledWith('a');
|
|
81
|
+
});
|
|
82
|
+
it('ignores control keys without text', () => {
|
|
83
|
+
handleKey({ ctrl: true }, '', buffer, actions);
|
|
84
|
+
expect(actions.insert).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('History', () => {
|
|
88
|
+
it('handles Ctrl+Z (Undo)', () => {
|
|
89
|
+
handleKey({ ctrl: true }, 'z', buffer, actions);
|
|
90
|
+
expect(actions.undo).toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
it('handles Ctrl+Y (Redo)', () => {
|
|
93
|
+
handleKey({ ctrl: true }, 'y', buffer, actions);
|
|
94
|
+
expect(actions.redo).toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('Submission', () => {
|
|
98
|
+
it('handles Enter as submit by default', () => {
|
|
99
|
+
buffer = { lines: ['hello'] };
|
|
100
|
+
handleKey({ return: true }, 'return', buffer, actions);
|
|
101
|
+
expect(actions.submit).toHaveBeenCalled();
|
|
102
|
+
expect(actions.newLine).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
it('handles Enter as newline if line ends with backslash', () => {
|
|
105
|
+
buffer = { lines: ['hello\\'] };
|
|
106
|
+
const cursor = { line: 0, column: 6 };
|
|
107
|
+
handleKey({ return: true }, 'return', buffer, actions, cursor);
|
|
108
|
+
// It should use the combined deleteAndNewLine action
|
|
109
|
+
expect(actions.deleteAndNewLine).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(actions.delete).not.toHaveBeenCalled();
|
|
111
|
+
expect(actions.newLine).not.toHaveBeenCalled();
|
|
112
|
+
expect(actions.submit).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
it('handles Enter as newline if line ends with backslash (multiple lines)', () => {
|
|
115
|
+
const cursor = { line: 1, column: 7 };
|
|
116
|
+
buffer = { lines: ['first', 'second\\'] };
|
|
117
|
+
handleKey({ return: true }, 'return', buffer, actions, cursor);
|
|
118
|
+
expect(actions.deleteAndNewLine).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(actions.delete).not.toHaveBeenCalled();
|
|
120
|
+
expect(actions.newLine).not.toHaveBeenCalled();
|
|
121
|
+
expect(actions.submit).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createBuffer, insertText, deleteChar, deleteCharForward, insertNewLine, moveCursor, getTextContent, } from '../TextBuffer.js';
|
|
3
|
+
describe('TextBuffer', () => {
|
|
4
|
+
describe('createBuffer', () => {
|
|
5
|
+
it('creates empty buffer with single empty line', () => {
|
|
6
|
+
const buffer = createBuffer();
|
|
7
|
+
expect(buffer).toEqual({ lines: [''] });
|
|
8
|
+
});
|
|
9
|
+
it('creates buffer from single line text', () => {
|
|
10
|
+
const buffer = createBuffer('hello');
|
|
11
|
+
expect(buffer).toEqual({ lines: ['hello'] });
|
|
12
|
+
});
|
|
13
|
+
it('creates buffer from multi-line text', () => {
|
|
14
|
+
const buffer = createBuffer('line1\nline2\nline3');
|
|
15
|
+
expect(buffer).toEqual({ lines: ['line1', 'line2', 'line3'] });
|
|
16
|
+
});
|
|
17
|
+
it('handles text ending with newline', () => {
|
|
18
|
+
const buffer = createBuffer('hello\n');
|
|
19
|
+
expect(buffer).toEqual({ lines: ['hello', ''] });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('insertText', () => {
|
|
23
|
+
it('inserts character into empty buffer', () => {
|
|
24
|
+
const buffer = createBuffer();
|
|
25
|
+
const cursor = { line: 0, column: 0 };
|
|
26
|
+
const result = insertText(buffer, cursor, 'a');
|
|
27
|
+
expect(result.buffer).toEqual({ lines: ['a'] });
|
|
28
|
+
expect(result.cursor).toEqual({ line: 0, column: 1 });
|
|
29
|
+
});
|
|
30
|
+
it('inserts character at end of line', () => {
|
|
31
|
+
const buffer = createBuffer('hello');
|
|
32
|
+
const cursor = { line: 0, column: 5 };
|
|
33
|
+
const result = insertText(buffer, cursor, '!');
|
|
34
|
+
expect(result.buffer).toEqual({ lines: ['hello!'] });
|
|
35
|
+
expect(result.cursor).toEqual({ line: 0, column: 6 });
|
|
36
|
+
});
|
|
37
|
+
it('inserts character in middle of line', () => {
|
|
38
|
+
const buffer = createBuffer('hllo');
|
|
39
|
+
const cursor = { line: 0, column: 1 };
|
|
40
|
+
const result = insertText(buffer, cursor, 'e');
|
|
41
|
+
expect(result.buffer).toEqual({ lines: ['hello'] });
|
|
42
|
+
expect(result.cursor).toEqual({ line: 0, column: 2 });
|
|
43
|
+
});
|
|
44
|
+
it('inserts character at line start', () => {
|
|
45
|
+
const buffer = createBuffer('ello');
|
|
46
|
+
const cursor = { line: 0, column: 0 };
|
|
47
|
+
const result = insertText(buffer, cursor, 'h');
|
|
48
|
+
expect(result.buffer).toEqual({ lines: ['hello'] });
|
|
49
|
+
expect(result.cursor).toEqual({ line: 0, column: 1 });
|
|
50
|
+
});
|
|
51
|
+
it('inserts into specific line of multi-line buffer', () => {
|
|
52
|
+
const buffer = createBuffer('line1\nline2\nline3');
|
|
53
|
+
const cursor = { line: 1, column: 4 };
|
|
54
|
+
const result = insertText(buffer, cursor, 'X');
|
|
55
|
+
expect(result.buffer).toEqual({ lines: ['line1', 'lineX2', 'line3'] });
|
|
56
|
+
expect(result.cursor).toEqual({ line: 1, column: 5 });
|
|
57
|
+
});
|
|
58
|
+
describe('multi-character insertion', () => {
|
|
59
|
+
it('inserts multiple characters at once', () => {
|
|
60
|
+
const buffer = createBuffer('world');
|
|
61
|
+
const cursor = { line: 0, column: 0 };
|
|
62
|
+
const result = insertText(buffer, cursor, 'hello ');
|
|
63
|
+
expect(result.buffer).toEqual({ lines: ['hello world'] });
|
|
64
|
+
expect(result.cursor).toEqual({ line: 0, column: 6 });
|
|
65
|
+
});
|
|
66
|
+
it('inserts multi-char string in middle of line', () => {
|
|
67
|
+
const buffer = createBuffer('helloworld');
|
|
68
|
+
const cursor = { line: 0, column: 5 };
|
|
69
|
+
const result = insertText(buffer, cursor, ', beautiful ');
|
|
70
|
+
expect(result.buffer).toEqual({ lines: ['hello, beautiful world'] });
|
|
71
|
+
expect(result.cursor).toEqual({ line: 0, column: 17 });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('multi-line insertion', () => {
|
|
75
|
+
it('handles text with single newline', () => {
|
|
76
|
+
const buffer = createBuffer('');
|
|
77
|
+
const cursor = { line: 0, column: 0 };
|
|
78
|
+
const result = insertText(buffer, cursor, 'hello\nworld');
|
|
79
|
+
expect(result.buffer).toEqual({ lines: ['hello', 'world'] });
|
|
80
|
+
expect(result.cursor).toEqual({ line: 1, column: 5 });
|
|
81
|
+
});
|
|
82
|
+
it('handles text with multiple newlines', () => {
|
|
83
|
+
const buffer = createBuffer('');
|
|
84
|
+
const cursor = { line: 0, column: 0 };
|
|
85
|
+
const result = insertText(buffer, cursor, 'line1\nline2\nline3');
|
|
86
|
+
expect(result.buffer).toEqual({ lines: ['line1', 'line2', 'line3'] });
|
|
87
|
+
expect(result.cursor).toEqual({ line: 2, column: 5 });
|
|
88
|
+
});
|
|
89
|
+
it('inserts multi-line text in middle of line', () => {
|
|
90
|
+
const buffer = createBuffer('helloworld');
|
|
91
|
+
const cursor = { line: 0, column: 5 };
|
|
92
|
+
const result = insertText(buffer, cursor, '\nthere\n');
|
|
93
|
+
expect(result.buffer).toEqual({ lines: ['hello', 'there', 'world'] });
|
|
94
|
+
expect(result.cursor).toEqual({ line: 2, column: 0 });
|
|
95
|
+
});
|
|
96
|
+
it('handles paste with trailing content', () => {
|
|
97
|
+
const buffer = createBuffer('start end');
|
|
98
|
+
const cursor = { line: 0, column: 6 };
|
|
99
|
+
const result = insertText(buffer, cursor, 'mid1\nmid2');
|
|
100
|
+
expect(result.buffer).toEqual({ lines: ['start mid1', 'mid2end'] });
|
|
101
|
+
expect(result.cursor).toEqual({ line: 1, column: 4 });
|
|
102
|
+
});
|
|
103
|
+
it('handles empty lines from consecutive newlines', () => {
|
|
104
|
+
const buffer = createBuffer('hello');
|
|
105
|
+
const cursor = { line: 0, column: 5 };
|
|
106
|
+
const result = insertText(buffer, cursor, '\n\nworld');
|
|
107
|
+
expect(result.buffer).toEqual({ lines: ['hello', '', 'world'] });
|
|
108
|
+
expect(result.cursor).toEqual({ line: 2, column: 5 });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('deleteChar (backspace)', () => {
|
|
113
|
+
it('does nothing when cursor at buffer start', () => {
|
|
114
|
+
const buffer = createBuffer('hello');
|
|
115
|
+
const cursor = { line: 0, column: 0 };
|
|
116
|
+
const result = deleteChar(buffer, cursor);
|
|
117
|
+
expect(result.buffer).toEqual({ lines: ['hello'] });
|
|
118
|
+
expect(result.cursor).toEqual({ line: 0, column: 0 });
|
|
119
|
+
});
|
|
120
|
+
it('deletes character before cursor', () => {
|
|
121
|
+
const buffer = createBuffer('hello');
|
|
122
|
+
const cursor = { line: 0, column: 5 };
|
|
123
|
+
const result = deleteChar(buffer, cursor);
|
|
124
|
+
expect(result.buffer).toEqual({ lines: ['hell'] });
|
|
125
|
+
expect(result.cursor).toEqual({ line: 0, column: 4 });
|
|
126
|
+
});
|
|
127
|
+
it('deletes character in middle of line', () => {
|
|
128
|
+
const buffer = createBuffer('heello');
|
|
129
|
+
const cursor = { line: 0, column: 3 };
|
|
130
|
+
const result = deleteChar(buffer, cursor);
|
|
131
|
+
expect(result.buffer).toEqual({ lines: ['hello'] });
|
|
132
|
+
expect(result.cursor).toEqual({ line: 0, column: 2 });
|
|
133
|
+
});
|
|
134
|
+
it('merges with previous line when at line start', () => {
|
|
135
|
+
const buffer = createBuffer('hello\nworld');
|
|
136
|
+
const cursor = { line: 1, column: 0 };
|
|
137
|
+
const result = deleteChar(buffer, cursor);
|
|
138
|
+
expect(result.buffer).toEqual({ lines: ['helloworld'] });
|
|
139
|
+
expect(result.cursor).toEqual({ line: 0, column: 5 });
|
|
140
|
+
});
|
|
141
|
+
it('merges empty line with previous', () => {
|
|
142
|
+
const buffer = createBuffer('hello\n');
|
|
143
|
+
const cursor = { line: 1, column: 0 };
|
|
144
|
+
const result = deleteChar(buffer, cursor);
|
|
145
|
+
expect(result.buffer).toEqual({ lines: ['hello'] });
|
|
146
|
+
expect(result.cursor).toEqual({ line: 0, column: 5 });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('deleteCharForward (delete key)', () => {
|
|
150
|
+
it('does nothing when cursor at buffer end', () => {
|
|
151
|
+
const buffer = createBuffer('hello');
|
|
152
|
+
const cursor = { line: 0, column: 5 };
|
|
153
|
+
const result = deleteCharForward(buffer, cursor);
|
|
154
|
+
expect(result.buffer).toEqual({ lines: ['hello'] });
|
|
155
|
+
expect(result.cursor).toEqual({ line: 0, column: 5 });
|
|
156
|
+
});
|
|
157
|
+
it('deletes character after cursor', () => {
|
|
158
|
+
const buffer = createBuffer('hello');
|
|
159
|
+
const cursor = { line: 0, column: 0 };
|
|
160
|
+
const result = deleteCharForward(buffer, cursor);
|
|
161
|
+
expect(result.buffer).toEqual({ lines: ['ello'] });
|
|
162
|
+
expect(result.cursor).toEqual({ line: 0, column: 0 });
|
|
163
|
+
});
|
|
164
|
+
it('deletes character in middle of line', () => {
|
|
165
|
+
const buffer = createBuffer('heello');
|
|
166
|
+
const cursor = { line: 0, column: 2 };
|
|
167
|
+
const result = deleteCharForward(buffer, cursor);
|
|
168
|
+
expect(result.buffer).toEqual({ lines: ['hello'] });
|
|
169
|
+
expect(result.cursor).toEqual({ line: 0, column: 2 });
|
|
170
|
+
});
|
|
171
|
+
it('merges with next line when at line end', () => {
|
|
172
|
+
const buffer = createBuffer('hello\nworld');
|
|
173
|
+
const cursor = { line: 0, column: 5 };
|
|
174
|
+
const result = deleteCharForward(buffer, cursor);
|
|
175
|
+
expect(result.buffer).toEqual({ lines: ['helloworld'] });
|
|
176
|
+
expect(result.cursor).toEqual({ line: 0, column: 5 });
|
|
177
|
+
});
|
|
178
|
+
it('merges next empty line with current', () => {
|
|
179
|
+
const buffer = createBuffer('hello\n');
|
|
180
|
+
const cursor = { line: 0, column: 5 };
|
|
181
|
+
const result = deleteCharForward(buffer, cursor);
|
|
182
|
+
expect(result.buffer).toEqual({ lines: ['hello'] });
|
|
183
|
+
expect(result.cursor).toEqual({ line: 0, column: 5 });
|
|
184
|
+
});
|
|
185
|
+
it('does nothing on last line at end', () => {
|
|
186
|
+
const buffer = createBuffer('hello\nworld');
|
|
187
|
+
const cursor = { line: 1, column: 5 };
|
|
188
|
+
const result = deleteCharForward(buffer, cursor);
|
|
189
|
+
expect(result.buffer).toEqual({ lines: ['hello', 'world'] });
|
|
190
|
+
expect(result.cursor).toEqual({ line: 1, column: 5 });
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('insertNewLine', () => {
|
|
194
|
+
it('splits line at cursor position', () => {
|
|
195
|
+
const buffer = createBuffer('helloworld');
|
|
196
|
+
const cursor = { line: 0, column: 5 };
|
|
197
|
+
const result = insertNewLine(buffer, cursor);
|
|
198
|
+
expect(result.buffer).toEqual({ lines: ['hello', 'world'] });
|
|
199
|
+
expect(result.cursor).toEqual({ line: 1, column: 0 });
|
|
200
|
+
});
|
|
201
|
+
it('creates new line at line start', () => {
|
|
202
|
+
const buffer = createBuffer('hello');
|
|
203
|
+
const cursor = { line: 0, column: 0 };
|
|
204
|
+
const result = insertNewLine(buffer, cursor);
|
|
205
|
+
expect(result.buffer).toEqual({ lines: ['', 'hello'] });
|
|
206
|
+
expect(result.cursor).toEqual({ line: 1, column: 0 });
|
|
207
|
+
});
|
|
208
|
+
it('creates new line at line end', () => {
|
|
209
|
+
const buffer = createBuffer('hello');
|
|
210
|
+
const cursor = { line: 0, column: 5 };
|
|
211
|
+
const result = insertNewLine(buffer, cursor);
|
|
212
|
+
expect(result.buffer).toEqual({ lines: ['hello', ''] });
|
|
213
|
+
expect(result.cursor).toEqual({ line: 1, column: 0 });
|
|
214
|
+
});
|
|
215
|
+
it('inserts new line in middle of multi-line buffer', () => {
|
|
216
|
+
const buffer = createBuffer('line1\nline2\nline3');
|
|
217
|
+
const cursor = { line: 1, column: 2 };
|
|
218
|
+
const result = insertNewLine(buffer, cursor);
|
|
219
|
+
expect(result.buffer).toEqual({ lines: ['line1', 'li', 'ne2', 'line3'] });
|
|
220
|
+
expect(result.cursor).toEqual({ line: 2, column: 0 });
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
describe('moveCursor', () => {
|
|
224
|
+
describe('left', () => {
|
|
225
|
+
it('moves left within line', () => {
|
|
226
|
+
const buffer = createBuffer('hello');
|
|
227
|
+
const cursor = { line: 0, column: 3 };
|
|
228
|
+
const result = moveCursor(buffer, cursor, 'left');
|
|
229
|
+
expect(result).toEqual({ line: 0, column: 2 });
|
|
230
|
+
});
|
|
231
|
+
it('wraps to end of previous line', () => {
|
|
232
|
+
const buffer = createBuffer('hello\nworld');
|
|
233
|
+
const cursor = { line: 1, column: 0 };
|
|
234
|
+
const result = moveCursor(buffer, cursor, 'left');
|
|
235
|
+
expect(result).toEqual({ line: 0, column: 5 });
|
|
236
|
+
});
|
|
237
|
+
it('stays at buffer start', () => {
|
|
238
|
+
const buffer = createBuffer('hello');
|
|
239
|
+
const cursor = { line: 0, column: 0 };
|
|
240
|
+
const result = moveCursor(buffer, cursor, 'left');
|
|
241
|
+
expect(result).toEqual({ line: 0, column: 0 });
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
describe('right', () => {
|
|
245
|
+
it('moves right within line', () => {
|
|
246
|
+
const buffer = createBuffer('hello');
|
|
247
|
+
const cursor = { line: 0, column: 2 };
|
|
248
|
+
const result = moveCursor(buffer, cursor, 'right');
|
|
249
|
+
expect(result).toEqual({ line: 0, column: 3 });
|
|
250
|
+
});
|
|
251
|
+
it('wraps to start of next line', () => {
|
|
252
|
+
const buffer = createBuffer('hello\nworld');
|
|
253
|
+
const cursor = { line: 0, column: 5 };
|
|
254
|
+
const result = moveCursor(buffer, cursor, 'right');
|
|
255
|
+
expect(result).toEqual({ line: 1, column: 0 });
|
|
256
|
+
});
|
|
257
|
+
it('stays at buffer end', () => {
|
|
258
|
+
const buffer = createBuffer('hello');
|
|
259
|
+
const cursor = { line: 0, column: 5 };
|
|
260
|
+
const result = moveCursor(buffer, cursor, 'right');
|
|
261
|
+
expect(result).toEqual({ line: 0, column: 5 });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
describe('up', () => {
|
|
265
|
+
it('moves up maintaining column', () => {
|
|
266
|
+
const buffer = createBuffer('hello\nworld');
|
|
267
|
+
const cursor = { line: 1, column: 3 };
|
|
268
|
+
const result = moveCursor(buffer, cursor, 'up');
|
|
269
|
+
expect(result).toEqual({ line: 0, column: 3 });
|
|
270
|
+
});
|
|
271
|
+
it('clamps column to shorter line', () => {
|
|
272
|
+
const buffer = createBuffer('hi\nhello');
|
|
273
|
+
const cursor = { line: 1, column: 4 };
|
|
274
|
+
const result = moveCursor(buffer, cursor, 'up');
|
|
275
|
+
expect(result).toEqual({ line: 0, column: 2 });
|
|
276
|
+
});
|
|
277
|
+
it('stays on first line', () => {
|
|
278
|
+
const buffer = createBuffer('hello');
|
|
279
|
+
const cursor = { line: 0, column: 2 };
|
|
280
|
+
const result = moveCursor(buffer, cursor, 'up');
|
|
281
|
+
expect(result).toEqual({ line: 0, column: 2 });
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
describe('down', () => {
|
|
285
|
+
it('moves down maintaining column', () => {
|
|
286
|
+
const buffer = createBuffer('hello\nworld');
|
|
287
|
+
const cursor = { line: 0, column: 3 };
|
|
288
|
+
const result = moveCursor(buffer, cursor, 'down');
|
|
289
|
+
expect(result).toEqual({ line: 1, column: 3 });
|
|
290
|
+
});
|
|
291
|
+
it('clamps column to shorter line', () => {
|
|
292
|
+
const buffer = createBuffer('hello\nhi');
|
|
293
|
+
const cursor = { line: 0, column: 4 };
|
|
294
|
+
const result = moveCursor(buffer, cursor, 'down');
|
|
295
|
+
expect(result).toEqual({ line: 1, column: 2 });
|
|
296
|
+
});
|
|
297
|
+
it('stays on last line', () => {
|
|
298
|
+
const buffer = createBuffer('hello');
|
|
299
|
+
const cursor = { line: 0, column: 2 };
|
|
300
|
+
const result = moveCursor(buffer, cursor, 'down');
|
|
301
|
+
expect(result).toEqual({ line: 0, column: 2 });
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
describe('visual navigation with wrapping', () => {
|
|
305
|
+
// A 25-char line wraps into 3 visual lines with width=10:
|
|
306
|
+
// Visual 0: "0123456789" (chars 0-9)
|
|
307
|
+
// Visual 1: "0123456789" (chars 10-19)
|
|
308
|
+
// Visual 2: "01234" (chars 20-24)
|
|
309
|
+
const longLine = '0123456789012345678901234'; // 25 chars
|
|
310
|
+
describe('down with width (visual)', () => {
|
|
311
|
+
it('moves down within wrapped line', () => {
|
|
312
|
+
const buffer = createBuffer(longLine);
|
|
313
|
+
const cursor = { line: 0, column: 5 }; // visual row 0, col 5
|
|
314
|
+
const result = moveCursor(buffer, cursor, 'down', 10);
|
|
315
|
+
// Should move to visual row 1, col 5 → buffer column 15
|
|
316
|
+
expect(result).toEqual({ line: 0, column: 15 });
|
|
317
|
+
});
|
|
318
|
+
it('moves from second visual row to third', () => {
|
|
319
|
+
const buffer = createBuffer(longLine);
|
|
320
|
+
const cursor = { line: 0, column: 12 }; // visual row 1, col 2
|
|
321
|
+
const result = moveCursor(buffer, cursor, 'down', 10);
|
|
322
|
+
// Should move to visual row 2, col 2 → buffer column 22
|
|
323
|
+
expect(result).toEqual({ line: 0, column: 22 });
|
|
324
|
+
});
|
|
325
|
+
it('clamps column when moving to shorter visual row', () => {
|
|
326
|
+
const buffer = createBuffer(longLine);
|
|
327
|
+
const cursor = { line: 0, column: 17 }; // visual row 1, col 7
|
|
328
|
+
const result = moveCursor(buffer, cursor, 'down', 10);
|
|
329
|
+
// Visual row 2 only has 5 chars (0-4), so clamp to end of line position (25)
|
|
330
|
+
// The cursor can be positioned at the end of the line (after the last char)
|
|
331
|
+
expect(result).toEqual({ line: 0, column: 25 });
|
|
332
|
+
});
|
|
333
|
+
it('moves from last visual row of one line to first visual row of next line', () => {
|
|
334
|
+
const buffer = createBuffer(longLine + '\nhello');
|
|
335
|
+
const cursor = { line: 0, column: 22 }; // visual row 2, col 2
|
|
336
|
+
const result = moveCursor(buffer, cursor, 'down', 10);
|
|
337
|
+
// Should move to line 1, visual row 0, col 2 → line 1, column 2
|
|
338
|
+
expect(result).toEqual({ line: 1, column: 2 });
|
|
339
|
+
});
|
|
340
|
+
it('stays at last visual row of last line', () => {
|
|
341
|
+
const buffer = createBuffer(longLine);
|
|
342
|
+
const cursor = { line: 0, column: 22 }; // visual row 2, col 2
|
|
343
|
+
const result = moveCursor(buffer, cursor, 'down', 10);
|
|
344
|
+
// No more visual rows, stay where we are
|
|
345
|
+
expect(result).toEqual({ line: 0, column: 22 });
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
describe('up with width (visual)', () => {
|
|
349
|
+
it('moves up within wrapped line', () => {
|
|
350
|
+
const buffer = createBuffer(longLine);
|
|
351
|
+
const cursor = { line: 0, column: 15 }; // visual row 1, col 5
|
|
352
|
+
const result = moveCursor(buffer, cursor, 'up', 10);
|
|
353
|
+
// Should move to visual row 0, col 5 → buffer column 5
|
|
354
|
+
expect(result).toEqual({ line: 0, column: 5 });
|
|
355
|
+
});
|
|
356
|
+
it('moves from third visual row to second', () => {
|
|
357
|
+
const buffer = createBuffer(longLine);
|
|
358
|
+
const cursor = { line: 0, column: 22 }; // visual row 2, col 2
|
|
359
|
+
const result = moveCursor(buffer, cursor, 'up', 10);
|
|
360
|
+
// Should move to visual row 1, col 2 → buffer column 12
|
|
361
|
+
expect(result).toEqual({ line: 0, column: 12 });
|
|
362
|
+
});
|
|
363
|
+
it('moves from first visual row of line to last visual row of previous line', () => {
|
|
364
|
+
const buffer = createBuffer(longLine + '\nhello');
|
|
365
|
+
const cursor = { line: 1, column: 2 }; // line 1, visual row 0, col 2
|
|
366
|
+
const result = moveCursor(buffer, cursor, 'up', 10);
|
|
367
|
+
// Should move to line 0, visual row 2, col 2 → line 0, column 22
|
|
368
|
+
expect(result).toEqual({ line: 0, column: 22 });
|
|
369
|
+
});
|
|
370
|
+
it('clamps column when moving to shorter visual row above', () => {
|
|
371
|
+
const buffer = createBuffer(longLine + '\nhello');
|
|
372
|
+
const cursor = { line: 1, column: 4 }; // line 1, col 4
|
|
373
|
+
const result = moveCursor(buffer, cursor, 'up', 10);
|
|
374
|
+
// Move to line 0, visual row 2 (only 5 chars: 0-4)
|
|
375
|
+
// col 4 is valid, so buffer column = 20 + 4 = 24
|
|
376
|
+
expect(result).toEqual({ line: 0, column: 24 });
|
|
377
|
+
});
|
|
378
|
+
it('stays at first visual row of first line', () => {
|
|
379
|
+
const buffer = createBuffer(longLine);
|
|
380
|
+
const cursor = { line: 0, column: 5 }; // visual row 0, col 5
|
|
381
|
+
const result = moveCursor(buffer, cursor, 'up', 10);
|
|
382
|
+
// No more visual rows above, stay where we are
|
|
383
|
+
expect(result).toEqual({ line: 0, column: 5 });
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
describe('backward compatibility without width', () => {
|
|
387
|
+
it('up still works with buffer lines when width not provided', () => {
|
|
388
|
+
const buffer = createBuffer(longLine + '\nhello');
|
|
389
|
+
const cursor = { line: 1, column: 3 };
|
|
390
|
+
const result = moveCursor(buffer, cursor, 'up');
|
|
391
|
+
// Without width, should move to previous buffer line
|
|
392
|
+
expect(result).toEqual({ line: 0, column: 3 });
|
|
393
|
+
});
|
|
394
|
+
it('down still works with buffer lines when width not provided', () => {
|
|
395
|
+
const buffer = createBuffer('hello\n' + longLine);
|
|
396
|
+
const cursor = { line: 0, column: 3 };
|
|
397
|
+
const result = moveCursor(buffer, cursor, 'down');
|
|
398
|
+
// Without width, should move to next buffer line
|
|
399
|
+
expect(result).toEqual({ line: 1, column: 3 });
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
describe('lineStart', () => {
|
|
404
|
+
it('moves to start of line', () => {
|
|
405
|
+
const buffer = createBuffer('hello');
|
|
406
|
+
const cursor = { line: 0, column: 3 };
|
|
407
|
+
const result = moveCursor(buffer, cursor, 'lineStart');
|
|
408
|
+
expect(result).toEqual({ line: 0, column: 0 });
|
|
409
|
+
});
|
|
410
|
+
it('stays at start if already there', () => {
|
|
411
|
+
const buffer = createBuffer('hello');
|
|
412
|
+
const cursor = { line: 0, column: 0 };
|
|
413
|
+
const result = moveCursor(buffer, cursor, 'lineStart');
|
|
414
|
+
expect(result).toEqual({ line: 0, column: 0 });
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
describe('lineEnd', () => {
|
|
418
|
+
it('moves to end of line', () => {
|
|
419
|
+
const buffer = createBuffer('hello');
|
|
420
|
+
const cursor = { line: 0, column: 2 };
|
|
421
|
+
const result = moveCursor(buffer, cursor, 'lineEnd');
|
|
422
|
+
expect(result).toEqual({ line: 0, column: 5 });
|
|
423
|
+
});
|
|
424
|
+
it('stays at end if already there', () => {
|
|
425
|
+
const buffer = createBuffer('hello');
|
|
426
|
+
const cursor = { line: 0, column: 5 };
|
|
427
|
+
const result = moveCursor(buffer, cursor, 'lineEnd');
|
|
428
|
+
expect(result).toEqual({ line: 0, column: 5 });
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
describe('getTextContent', () => {
|
|
433
|
+
it('returns empty string for empty buffer', () => {
|
|
434
|
+
const buffer = createBuffer();
|
|
435
|
+
expect(getTextContent(buffer)).toBe('');
|
|
436
|
+
});
|
|
437
|
+
it('returns single line content', () => {
|
|
438
|
+
const buffer = createBuffer('hello');
|
|
439
|
+
expect(getTextContent(buffer)).toBe('hello');
|
|
440
|
+
});
|
|
441
|
+
it('joins multiple lines with newlines', () => {
|
|
442
|
+
const buffer = createBuffer('line1\nline2\nline3');
|
|
443
|
+
expect(getTextContent(buffer)).toBe('line1\nline2\nline3');
|
|
444
|
+
});
|
|
445
|
+
it('preserves empty lines', () => {
|
|
446
|
+
const buffer = createBuffer('hello\n\nworld');
|
|
447
|
+
expect(getTextContent(buffer)).toBe('hello\n\nworld');
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|