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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Duc Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# ink-prompt
|
|
2
|
+
|
|
3
|
+
A React Ink component library focused on terminal-friendly prompts. The first
|
|
4
|
+
export is `MultilineInput`, an Ink component for collecting multi-line text in
|
|
5
|
+
CLIs.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install ink-prompt
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import React from 'react';
|
|
17
|
+
import { render, Box, Text } from 'ink';
|
|
18
|
+
import { MultilineInput } from 'ink-prompt';
|
|
19
|
+
|
|
20
|
+
const App = () => {
|
|
21
|
+
return (
|
|
22
|
+
<Box flexDirection="column">
|
|
23
|
+
<Text>Describe your change (press Enter to submit):</Text>
|
|
24
|
+
<MultilineInput
|
|
25
|
+
onSubmit={(value) => console.log(value)}
|
|
26
|
+
width={80}
|
|
27
|
+
/>
|
|
28
|
+
</Box>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
render(<App />);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`MultilineInput` supports typical editing controls:
|
|
36
|
+
|
|
37
|
+
- Arrow keys for navigation
|
|
38
|
+
- `Ctrl+J` or typing `\` before Enter to add a newline
|
|
39
|
+
- `Ctrl+Z`/`Ctrl+Y` for undo/redo
|
|
40
|
+
- Enter submits the current buffer
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Install dependencies
|
|
46
|
+
npm install
|
|
47
|
+
|
|
48
|
+
# Build the project
|
|
49
|
+
npm run build
|
|
50
|
+
|
|
51
|
+
# Watch for changes
|
|
52
|
+
npm run dev
|
|
53
|
+
|
|
54
|
+
# Type check
|
|
55
|
+
npm run type-check
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Key, type Buffer, type Cursor } from './types.js';
|
|
2
|
+
import { type UseTextInputResult } from './useTextInput.js';
|
|
3
|
+
export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor' | 'cursorOffset' | 'setCursorOffset'> {
|
|
4
|
+
submit: () => void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Handles keyboard input and maps it to text input actions.
|
|
8
|
+
*
|
|
9
|
+
* @param key - The Ink key object
|
|
10
|
+
* @param input - The input string (if any)
|
|
11
|
+
* @param buffer - The current text buffer
|
|
12
|
+
* @param actions - The actions available to modify the state
|
|
13
|
+
* @param cursor - The current cursor position (optional, but required for some logic like backslash check)
|
|
14
|
+
* @param rawInput - The raw input sequence (optional, used for detecting Home/End keys)
|
|
15
|
+
*/
|
|
16
|
+
export declare function handleKey(key: Partial<Key>, input: string, buffer: Buffer, actions: KeyHandlerActions, cursor?: Cursor, rawInput?: string): void;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { log } from '../../utils/logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Escape sequences for Home key (various terminal emulators)
|
|
4
|
+
*/
|
|
5
|
+
const HOME_SEQUENCES = [
|
|
6
|
+
'\x1b[H', // CSI H (xterm)
|
|
7
|
+
'\x1b[1~', // CSI 1~ (linux console)
|
|
8
|
+
'\x1bOH', // SS3 H (xterm application mode)
|
|
9
|
+
'\x1b[7~', // CSI 7~ (rxvt)
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Escape sequences for End key (various terminal emulators)
|
|
13
|
+
*/
|
|
14
|
+
const END_SEQUENCES = [
|
|
15
|
+
'\x1b[F', // CSI F (xterm)
|
|
16
|
+
'\x1b[4~', // CSI 4~ (linux console)
|
|
17
|
+
'\x1bOF', // SS3 F (xterm application mode)
|
|
18
|
+
'\x1b[8~', // CSI 8~ (rxvt)
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Handles keyboard input and maps it to text input actions.
|
|
22
|
+
*
|
|
23
|
+
* @param key - The Ink key object
|
|
24
|
+
* @param input - The input string (if any)
|
|
25
|
+
* @param buffer - The current text buffer
|
|
26
|
+
* @param actions - The actions available to modify the state
|
|
27
|
+
* @param cursor - The current cursor position (optional, but required for some logic like backslash check)
|
|
28
|
+
* @param rawInput - The raw input sequence (optional, used for detecting Home/End keys)
|
|
29
|
+
*/
|
|
30
|
+
export function handleKey(key, input, buffer, actions, cursor, rawInput) {
|
|
31
|
+
// Navigation
|
|
32
|
+
if (key.upArrow) {
|
|
33
|
+
actions.moveCursor('up');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (key.downArrow) {
|
|
37
|
+
actions.moveCursor('down');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (key.leftArrow) {
|
|
41
|
+
actions.moveCursor('left');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (key.rightArrow) {
|
|
45
|
+
actions.moveCursor('right');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Home/End key detection
|
|
49
|
+
// Ink doesn't expose key.home/key.end, so we check:
|
|
50
|
+
// 1. Raw escape sequences if available
|
|
51
|
+
// 2. Ctrl+A (home) and Ctrl+E (end) - common terminal shortcuts
|
|
52
|
+
if (rawInput && HOME_SEQUENCES.includes(rawInput)) {
|
|
53
|
+
actions.moveCursor('lineStart');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (rawInput && END_SEQUENCES.includes(rawInput)) {
|
|
57
|
+
actions.moveCursor('lineEnd');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Ctrl+A for line start (common in bash/readline)
|
|
61
|
+
if (key.ctrl && input === 'a') {
|
|
62
|
+
actions.moveCursor('lineStart');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Ctrl+E for line end (common in bash/readline)
|
|
66
|
+
if (key.ctrl && input === 'e') {
|
|
67
|
+
actions.moveCursor('lineEnd');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// History
|
|
71
|
+
if (key.ctrl) {
|
|
72
|
+
if (input === 'z') {
|
|
73
|
+
actions.undo();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (input === 'y') {
|
|
77
|
+
actions.redo();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (input === 'j') {
|
|
81
|
+
actions.newLine();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Editing
|
|
86
|
+
if (key.backspace) {
|
|
87
|
+
actions.delete();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (key.delete) {
|
|
91
|
+
// Delete key - forward delete (delete character after cursor)
|
|
92
|
+
actions.deleteForward();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Submission / New Line
|
|
96
|
+
if (key.return) {
|
|
97
|
+
log(`[KEYHANDLER] return key, cursor=${JSON.stringify(cursor)}, currentLine="${(cursor ? buffer.lines[cursor.line || 0] : 'no cursor').replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" endsWithBackslash=${cursor ? buffer.lines[cursor.line || 0].endsWith('\\') : false}`);
|
|
98
|
+
if (cursor) {
|
|
99
|
+
const currentLine = buffer.lines[cursor.line]; // Check if line ends with backslash AND cursor is at the end (or we just check the line content?)
|
|
100
|
+
// Requirement: "Line ending with \ + Enter continues to next line"
|
|
101
|
+
// Usually this implies the user typed '\' then Enter.
|
|
102
|
+
// We should probably check if the character *before* the cursor is '\' if we want to be precise,
|
|
103
|
+
// or just if the line ends with '\'.
|
|
104
|
+
// Let's assume "line ends with \" means the last char of the line is '\'.
|
|
105
|
+
if (currentLine.endsWith('\\')) {
|
|
106
|
+
// Use combined action to ensure both operations happen with correct state
|
|
107
|
+
actions.deleteAndNewLine();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
log(`[KEYHANDLER] submit value lines=${buffer.lines.length} lastLine="${buffer.lines[buffer.lines.length - 1]?.replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}"`);
|
|
112
|
+
actions.submit();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Text Insertion
|
|
116
|
+
// Ignore control keys if they don't have a specific handler above
|
|
117
|
+
if (key.ctrl || key.meta) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (input) {
|
|
121
|
+
log(`[KEYHANDLER] insert input="${input.replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" len=${input.length} cursor=${JSON.stringify(cursor || {})} bufferLines=${buffer.lines.length}`);
|
|
122
|
+
actions.insert(input);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Buffer, Cursor, Direction } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Create a new buffer from optional initial text
|
|
4
|
+
*/
|
|
5
|
+
export declare function createBuffer(text?: string): Buffer;
|
|
6
|
+
/**
|
|
7
|
+
* Insert text at the cursor position.
|
|
8
|
+
* Handles both single-line and multi-line text (containing \n).
|
|
9
|
+
*/
|
|
10
|
+
export declare function insertText(buffer: Buffer, cursor: Cursor, text: string): {
|
|
11
|
+
buffer: Buffer;
|
|
12
|
+
cursor: Cursor;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Delete character before cursor (backspace)
|
|
16
|
+
*/
|
|
17
|
+
export declare function deleteChar(buffer: Buffer, cursor: Cursor): {
|
|
18
|
+
buffer: Buffer;
|
|
19
|
+
cursor: Cursor;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Delete character after cursor (forward delete / Delete key)
|
|
23
|
+
*/
|
|
24
|
+
export declare function deleteCharForward(buffer: Buffer, cursor: Cursor): {
|
|
25
|
+
buffer: Buffer;
|
|
26
|
+
cursor: Cursor;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Insert a new line at cursor position (splits current line)
|
|
30
|
+
*/
|
|
31
|
+
export declare function insertNewLine(buffer: Buffer, cursor: Cursor): {
|
|
32
|
+
buffer: Buffer;
|
|
33
|
+
cursor: Cursor;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Move cursor in specified direction with bounds checking.
|
|
37
|
+
* When width is provided, up/down movement is based on visual lines (accounting for wrapping).
|
|
38
|
+
* When width is not provided, up/down movement is based on buffer lines.
|
|
39
|
+
*/
|
|
40
|
+
export declare function moveCursor(buffer: Buffer, cursor: Cursor, direction: Direction, width?: number): Cursor;
|
|
41
|
+
/**
|
|
42
|
+
* Get the full text content from buffer (lines joined with newlines)
|
|
43
|
+
*/
|
|
44
|
+
export declare function getTextContent(buffer: Buffer): string;
|
|
45
|
+
/**
|
|
46
|
+
* Get the flat offset (index) from a cursor position.
|
|
47
|
+
*/
|
|
48
|
+
export declare function getOffset(buffer: Buffer, cursor: Cursor): number;
|
|
49
|
+
/**
|
|
50
|
+
* Get the cursor position from a flat offset.
|
|
51
|
+
*/
|
|
52
|
+
export declare function getCursor(buffer: Buffer, offset: number): Cursor;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a new buffer from optional initial text
|
|
3
|
+
*/
|
|
4
|
+
export function createBuffer(text) {
|
|
5
|
+
if (!text) {
|
|
6
|
+
return { lines: [''] };
|
|
7
|
+
}
|
|
8
|
+
return { lines: text.split('\n') };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Insert text at the cursor position.
|
|
12
|
+
* Handles both single-line and multi-line text (containing \n).
|
|
13
|
+
*/
|
|
14
|
+
export function insertText(buffer, cursor, text) {
|
|
15
|
+
if (!text)
|
|
16
|
+
return { buffer, cursor };
|
|
17
|
+
const { line, column } = cursor;
|
|
18
|
+
const currentLine = buffer.lines[line];
|
|
19
|
+
// Insert text and handle newlines
|
|
20
|
+
const beforeCursor = currentLine.slice(0, column);
|
|
21
|
+
const afterCursor = currentLine.slice(column);
|
|
22
|
+
const fullText = beforeCursor + text + afterCursor;
|
|
23
|
+
// Split into lines
|
|
24
|
+
const newLines = fullText.split('\n');
|
|
25
|
+
// Calculate new cursor position
|
|
26
|
+
const textLines = text.split('\n');
|
|
27
|
+
const cursorLine = line + (textLines.length - 1);
|
|
28
|
+
const cursorColumn = textLines.length === 1
|
|
29
|
+
? column + text.length
|
|
30
|
+
: textLines[textLines.length - 1].length;
|
|
31
|
+
// Rebuild buffer
|
|
32
|
+
const resultLines = [
|
|
33
|
+
...buffer.lines.slice(0, line),
|
|
34
|
+
...newLines,
|
|
35
|
+
...buffer.lines.slice(line + 1),
|
|
36
|
+
];
|
|
37
|
+
return {
|
|
38
|
+
buffer: { lines: resultLines },
|
|
39
|
+
cursor: { line: cursorLine, column: cursorColumn },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Delete character before cursor (backspace)
|
|
44
|
+
*/
|
|
45
|
+
export function deleteChar(buffer, cursor) {
|
|
46
|
+
const { line, column } = cursor;
|
|
47
|
+
// At the very start of the buffer - nothing to delete
|
|
48
|
+
if (line === 0 && column === 0) {
|
|
49
|
+
return { buffer, cursor };
|
|
50
|
+
}
|
|
51
|
+
// At the start of a line - merge with previous line
|
|
52
|
+
if (column === 0) {
|
|
53
|
+
const previousLine = buffer.lines[line - 1];
|
|
54
|
+
const currentLine = buffer.lines[line];
|
|
55
|
+
const mergedLine = previousLine + currentLine;
|
|
56
|
+
const newLines = [...buffer.lines];
|
|
57
|
+
newLines[line - 1] = mergedLine;
|
|
58
|
+
newLines.splice(line, 1);
|
|
59
|
+
return {
|
|
60
|
+
buffer: { lines: newLines },
|
|
61
|
+
cursor: { line: line - 1, column: previousLine.length },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Delete character within the line
|
|
65
|
+
const currentLine = buffer.lines[line];
|
|
66
|
+
const newLine = currentLine.slice(0, column - 1) + currentLine.slice(column);
|
|
67
|
+
const newLines = [...buffer.lines];
|
|
68
|
+
newLines[line] = newLine;
|
|
69
|
+
return {
|
|
70
|
+
buffer: { lines: newLines },
|
|
71
|
+
cursor: { line, column: column - 1 },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Delete character after cursor (forward delete / Delete key)
|
|
76
|
+
*/
|
|
77
|
+
export function deleteCharForward(buffer, cursor) {
|
|
78
|
+
const { line, column } = cursor;
|
|
79
|
+
const currentLine = buffer.lines[line];
|
|
80
|
+
const lineCount = buffer.lines.length;
|
|
81
|
+
// At the very end of the buffer - nothing to delete
|
|
82
|
+
if (line === lineCount - 1 && column >= currentLine.length) {
|
|
83
|
+
return { buffer, cursor };
|
|
84
|
+
}
|
|
85
|
+
// At the end of a line - merge with next line
|
|
86
|
+
if (column >= currentLine.length) {
|
|
87
|
+
const nextLine = buffer.lines[line + 1];
|
|
88
|
+
const mergedLine = currentLine + nextLine;
|
|
89
|
+
const newLines = [...buffer.lines];
|
|
90
|
+
newLines[line] = mergedLine;
|
|
91
|
+
newLines.splice(line + 1, 1);
|
|
92
|
+
return {
|
|
93
|
+
buffer: { lines: newLines },
|
|
94
|
+
cursor, // Cursor stays in place
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// Delete character after cursor within the line
|
|
98
|
+
const newLine = currentLine.slice(0, column) + currentLine.slice(column + 1);
|
|
99
|
+
const newLines = [...buffer.lines];
|
|
100
|
+
newLines[line] = newLine;
|
|
101
|
+
return {
|
|
102
|
+
buffer: { lines: newLines },
|
|
103
|
+
cursor, // Cursor stays in place
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Insert a new line at cursor position (splits current line)
|
|
108
|
+
*/
|
|
109
|
+
export function insertNewLine(buffer, cursor) {
|
|
110
|
+
const { line, column } = cursor;
|
|
111
|
+
const currentLine = buffer.lines[line];
|
|
112
|
+
const beforeCursor = currentLine.slice(0, column);
|
|
113
|
+
const afterCursor = currentLine.slice(column);
|
|
114
|
+
const newLines = [...buffer.lines];
|
|
115
|
+
newLines[line] = beforeCursor;
|
|
116
|
+
newLines.splice(line + 1, 0, afterCursor);
|
|
117
|
+
return {
|
|
118
|
+
buffer: { lines: newLines },
|
|
119
|
+
cursor: { line: line + 1, column: 0 },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Calculate which visual row (within a buffer line) the cursor is on,
|
|
124
|
+
* and the column within that visual row.
|
|
125
|
+
*/
|
|
126
|
+
function getVisualPosition(bufferColumn, lineLength, width) {
|
|
127
|
+
if (lineLength <= width) {
|
|
128
|
+
return { visualRow: 0, visualCol: bufferColumn };
|
|
129
|
+
}
|
|
130
|
+
const visualRow = Math.floor(bufferColumn / width);
|
|
131
|
+
const visualCol = bufferColumn % width;
|
|
132
|
+
return { visualRow, visualCol };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Calculate how many visual rows a buffer line takes.
|
|
136
|
+
*/
|
|
137
|
+
function getVisualRowCount(lineLength, width) {
|
|
138
|
+
if (lineLength === 0)
|
|
139
|
+
return 1;
|
|
140
|
+
return Math.ceil(lineLength / width);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Convert visual position back to buffer column.
|
|
144
|
+
*/
|
|
145
|
+
function visualToBufferColumn(visualRow, visualCol, lineLength, width) {
|
|
146
|
+
const bufferColumn = visualRow * width + visualCol;
|
|
147
|
+
return Math.min(bufferColumn, lineLength);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Get the length of a specific visual row within a buffer line.
|
|
151
|
+
*/
|
|
152
|
+
function getVisualRowLength(lineLength, visualRow, width) {
|
|
153
|
+
const totalVisualRows = getVisualRowCount(lineLength, width);
|
|
154
|
+
if (visualRow >= totalVisualRows)
|
|
155
|
+
return 0;
|
|
156
|
+
if (visualRow === totalVisualRows - 1) {
|
|
157
|
+
// Last visual row - might be shorter
|
|
158
|
+
const remaining = lineLength - visualRow * width;
|
|
159
|
+
return remaining;
|
|
160
|
+
}
|
|
161
|
+
return width;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Move cursor in specified direction with bounds checking.
|
|
165
|
+
* When width is provided, up/down movement is based on visual lines (accounting for wrapping).
|
|
166
|
+
* When width is not provided, up/down movement is based on buffer lines.
|
|
167
|
+
*/
|
|
168
|
+
export function moveCursor(buffer, cursor, direction, width) {
|
|
169
|
+
const { line, column } = cursor;
|
|
170
|
+
const currentLine = buffer.lines[line];
|
|
171
|
+
const lineCount = buffer.lines.length;
|
|
172
|
+
switch (direction) {
|
|
173
|
+
case 'left':
|
|
174
|
+
if (column > 0) {
|
|
175
|
+
return { line, column: column - 1 };
|
|
176
|
+
}
|
|
177
|
+
// Wrap to end of previous line
|
|
178
|
+
if (line > 0) {
|
|
179
|
+
return { line: line - 1, column: buffer.lines[line - 1].length };
|
|
180
|
+
}
|
|
181
|
+
return cursor;
|
|
182
|
+
case 'right':
|
|
183
|
+
if (column < currentLine.length) {
|
|
184
|
+
return { line, column: column + 1 };
|
|
185
|
+
}
|
|
186
|
+
// Wrap to start of next line
|
|
187
|
+
if (line < lineCount - 1) {
|
|
188
|
+
return { line: line + 1, column: 0 };
|
|
189
|
+
}
|
|
190
|
+
return cursor;
|
|
191
|
+
case 'up':
|
|
192
|
+
if (width !== undefined) {
|
|
193
|
+
// Visual-aware movement
|
|
194
|
+
const { visualRow, visualCol } = getVisualPosition(column, currentLine.length, width);
|
|
195
|
+
if (visualRow > 0) {
|
|
196
|
+
// Move to previous visual row within the same buffer line
|
|
197
|
+
const targetVisualRow = visualRow - 1;
|
|
198
|
+
const targetVisualRowLength = getVisualRowLength(currentLine.length, targetVisualRow, width);
|
|
199
|
+
const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
|
|
200
|
+
return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine.length, width) };
|
|
201
|
+
}
|
|
202
|
+
// At first visual row of current line - move to last visual row of previous buffer line
|
|
203
|
+
if (line > 0) {
|
|
204
|
+
const prevLine = buffer.lines[line - 1];
|
|
205
|
+
const prevLineVisualRows = getVisualRowCount(prevLine.length, width);
|
|
206
|
+
const targetVisualRow = prevLineVisualRows - 1;
|
|
207
|
+
const targetVisualRowLength = getVisualRowLength(prevLine.length, targetVisualRow, width);
|
|
208
|
+
const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
|
|
209
|
+
return { line: line - 1, column: visualToBufferColumn(targetVisualRow, targetVisualCol, prevLine.length, width) };
|
|
210
|
+
}
|
|
211
|
+
return cursor;
|
|
212
|
+
}
|
|
213
|
+
// Buffer-line movement (no width provided)
|
|
214
|
+
if (line > 0) {
|
|
215
|
+
const targetLine = buffer.lines[line - 1];
|
|
216
|
+
return { line: line - 1, column: Math.min(column, targetLine.length) };
|
|
217
|
+
}
|
|
218
|
+
return cursor;
|
|
219
|
+
case 'down':
|
|
220
|
+
if (width !== undefined) {
|
|
221
|
+
// Visual-aware movement
|
|
222
|
+
const { visualRow, visualCol } = getVisualPosition(column, currentLine.length, width);
|
|
223
|
+
const currentLineVisualRows = getVisualRowCount(currentLine.length, width);
|
|
224
|
+
if (visualRow < currentLineVisualRows - 1) {
|
|
225
|
+
// Move to next visual row within the same buffer line
|
|
226
|
+
const targetVisualRow = visualRow + 1;
|
|
227
|
+
const targetVisualRowLength = getVisualRowLength(currentLine.length, targetVisualRow, width);
|
|
228
|
+
const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
|
|
229
|
+
return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine.length, width) };
|
|
230
|
+
}
|
|
231
|
+
// At last visual row of current line - move to first visual row of next buffer line
|
|
232
|
+
if (line < lineCount - 1) {
|
|
233
|
+
const nextLine = buffer.lines[line + 1];
|
|
234
|
+
const targetVisualRowLength = getVisualRowLength(nextLine.length, 0, width);
|
|
235
|
+
const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
|
|
236
|
+
return { line: line + 1, column: Math.min(targetVisualCol, nextLine.length) };
|
|
237
|
+
}
|
|
238
|
+
return cursor;
|
|
239
|
+
}
|
|
240
|
+
// Buffer-line movement (no width provided)
|
|
241
|
+
if (line < lineCount - 1) {
|
|
242
|
+
const targetLine = buffer.lines[line + 1];
|
|
243
|
+
return { line: line + 1, column: Math.min(column, targetLine.length) };
|
|
244
|
+
}
|
|
245
|
+
return cursor;
|
|
246
|
+
case 'lineStart':
|
|
247
|
+
return { line, column: 0 };
|
|
248
|
+
case 'lineEnd':
|
|
249
|
+
return { line, column: currentLine.length };
|
|
250
|
+
default:
|
|
251
|
+
return cursor;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Get the full text content from buffer (lines joined with newlines)
|
|
256
|
+
*/
|
|
257
|
+
export function getTextContent(buffer) {
|
|
258
|
+
// Single empty line is considered empty buffer
|
|
259
|
+
if (buffer.lines.length === 1 && buffer.lines[0] === '') {
|
|
260
|
+
return '';
|
|
261
|
+
}
|
|
262
|
+
return buffer.lines.join('\n');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Get the flat offset (index) from a cursor position.
|
|
266
|
+
*/
|
|
267
|
+
export function getOffset(buffer, cursor) {
|
|
268
|
+
let offset = 0;
|
|
269
|
+
for (let i = 0; i < cursor.line; i++) {
|
|
270
|
+
offset += buffer.lines[i].length + 1; // +1 for the newline
|
|
271
|
+
}
|
|
272
|
+
offset += cursor.column;
|
|
273
|
+
return offset;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Get the cursor position from a flat offset.
|
|
277
|
+
*/
|
|
278
|
+
export function getCursor(buffer, offset) {
|
|
279
|
+
let currentOffset = 0;
|
|
280
|
+
for (let i = 0; i < buffer.lines.length; i++) {
|
|
281
|
+
const lineLength = buffer.lines[i].length;
|
|
282
|
+
// Check if the offset falls within this line (including the newline character unless it's the last line)
|
|
283
|
+
// We treat the position *after* the newline as the start of the next line.
|
|
284
|
+
// Position *at* the end of line (before newline) is valid cursor column.
|
|
285
|
+
// If we are on the last line, we accept up to lineLength
|
|
286
|
+
if (i === buffer.lines.length - 1) {
|
|
287
|
+
if (offset <= currentOffset + lineLength) {
|
|
288
|
+
return { line: i, column: Math.max(0, offset - currentOffset) };
|
|
289
|
+
}
|
|
290
|
+
// If offset is beyond the content, clamp to end
|
|
291
|
+
return { line: i, column: lineLength };
|
|
292
|
+
}
|
|
293
|
+
// For non-last lines, we account for newline character (+1)
|
|
294
|
+
if (offset <= currentOffset + lineLength) {
|
|
295
|
+
return { line: i, column: Math.max(0, offset - currentOffset) };
|
|
296
|
+
}
|
|
297
|
+
// If offset is exactly at the newline character, it depends on interpretation.
|
|
298
|
+
// Usually, cursor at the newline means it's at the end of the line.
|
|
299
|
+
// But if we are "past" the newline, we are on the start of next line.
|
|
300
|
+
// Logic:
|
|
301
|
+
// Line 0: "abc" (len 3). Newline at index 3.
|
|
302
|
+
// Index 0='a', 1='b', 2='c', 3='\n'.
|
|
303
|
+
// If target offset is 3: That's end of line 0.
|
|
304
|
+
// If target offset is 4: That's start of line 1.
|
|
305
|
+
// currentOffset + lineLength is index of newline.
|
|
306
|
+
// If offset == currentOffset + lineLength + 1, we move to next loop
|
|
307
|
+
currentOffset += lineLength + 1;
|
|
308
|
+
}
|
|
309
|
+
// Fallback (should have returned in loop)
|
|
310
|
+
const lastLineIdx = buffer.lines.length - 1;
|
|
311
|
+
return { line: lastLineIdx, column: buffer.lines[lastLineIdx].length };
|
|
312
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Buffer, Cursor, WrapResult } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Props for the TextRenderer component
|
|
5
|
+
*/
|
|
6
|
+
export interface TextRendererProps {
|
|
7
|
+
/** Text buffer to render */
|
|
8
|
+
buffer: Buffer;
|
|
9
|
+
/** Current cursor position */
|
|
10
|
+
cursor: Cursor;
|
|
11
|
+
/** Terminal width for word wrapping (defaults to 80) */
|
|
12
|
+
width?: number;
|
|
13
|
+
/** Whether to show the cursor (defaults to true) */
|
|
14
|
+
showCursor?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Wrap buffer lines to fit within a given width.
|
|
18
|
+
* Returns visual lines and maps cursor position to visual coordinates.
|
|
19
|
+
*/
|
|
20
|
+
export declare function wrapLines(buffer: Buffer, cursor: Cursor, width: number): WrapResult;
|
|
21
|
+
/**
|
|
22
|
+
* TextRenderer component for displaying buffer content with cursor
|
|
23
|
+
*/
|
|
24
|
+
export declare function TextRenderer({ buffer, cursor, width, showCursor, }: TextRendererProps): React.ReactElement;
|