|
1 | 1 | import { useCursor, useBoxMetrics } from "ink"; |
| 2 | +import stringWidth from "string-width"; |
| 3 | +import wrapAnsi from "wrap-ansi"; |
2 | 4 | import { useLayoutEffect, useState } from "react"; |
3 | 5 | import type { RefObject } from "react"; |
4 | 6 | import type { DOMElement } from "ink"; |
@@ -52,88 +54,65 @@ export function getPromptCursorPlacement( |
52 | 54 | screenWidth: number, |
53 | 55 | initialColumn = 0 |
54 | 56 | ): CursorPlacement { |
55 | | - const width = Math.max(1, screenWidth); |
56 | | - const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); |
57 | | - const beforeCursor = state.text.slice(0, cursor); |
58 | | - const cursorPosition = measureTextPosition(beforeCursor, width, initialColumn); |
59 | | - return { row: cursorPosition.row, column: cursorPosition.column }; |
| 57 | + const { row, column } = measurePromptCursor(state, screenWidth, initialColumn); |
| 58 | + return { row, column }; |
60 | 59 | } |
61 | 60 |
|
62 | 61 | export function isPromptCursorAtWrapBoundary(state: PromptBufferState, screenWidth: number): boolean { |
63 | | - const width = Math.max(1, screenWidth); |
64 | | - const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); |
65 | | - const currentLineStart = state.text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; |
66 | | - const currentLineBeforeCursor = state.text.slice(currentLineStart, cursor); |
67 | | - return measureTextPosition(currentLineBeforeCursor, width, 0).row > 0; |
| 62 | + return measurePromptCursor(state, screenWidth).softWrapped; |
68 | 63 | } |
69 | 64 |
|
70 | | -function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } { |
| 65 | +const graphemes = new Intl.Segmenter(); |
| 66 | + |
| 67 | +function measurePromptCursor(state: PromptBufferState, screenWidth: number, initialColumn = 0) { |
| 68 | + const width = Math.max(1, screenWidth); |
| 69 | + const indent = Math.max(0, Math.min(initialColumn, width - 1)); |
| 70 | + const contentWidth = width - indent; |
| 71 | + const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); |
| 72 | + let sourceStart = 0; |
71 | 73 | let row = 0; |
72 | | - let column = Math.min(initialColumn, width - 1); |
73 | | - let pendingWrap = false; |
74 | | - |
75 | | - for (const char of Array.from(text)) { |
76 | | - if (char === "\n") { |
77 | | - row++; |
78 | | - column = Math.min(initialColumn, width - 1); |
79 | | - pendingWrap = false; |
80 | | - continue; |
81 | | - } |
82 | 74 |
|
83 | | - if (pendingWrap) { |
84 | | - row++; |
85 | | - column = Math.min(initialColumn, width - 1); |
86 | | - pendingWrap = false; |
| 75 | + for (const line of state.text.split("\n")) { |
| 76 | + // wrap-ansi normalizes Unicode and expands tabs. Keep the cursor's source |
| 77 | + // offset while applying those transformations, snapping inside a grapheme |
| 78 | + // to its first cell rather than measuring partial emoji or accents. |
| 79 | + let normalized = ""; |
| 80 | + let offset = 0; |
| 81 | + for (const { segment, index } of graphemes.segment(line)) { |
| 82 | + const value = segment === "\t" ? " ".repeat(8 - (stringWidth(normalized) % 8)) : segment.normalize(); |
| 83 | + normalized += value; |
| 84 | + if (sourceStart + index + segment.length <= cursor) { |
| 85 | + offset = normalized.length; |
| 86 | + } |
87 | 87 | } |
88 | 88 |
|
89 | | - const charColumns = textWidth(char); |
90 | | - if (column + charColumns > width) { |
91 | | - row++; |
92 | | - column = Math.min(initialColumn, width - 1); |
93 | | - } |
94 | | - column += charColumns; |
95 | | - if (column >= width) { |
96 | | - column = width; |
97 | | - pendingWrap = true; |
| 89 | + // Match Ink's Text wrap="wrap", including preserved spaces and hard |
| 90 | + // breaks for oversized words. The suffix matters even before the cursor: |
| 91 | + // it can move the entire word containing that cursor onto the next row. |
| 92 | + const rows = wrapAnsi(normalized, contentWidth, { trim: false, hard: true }).split("\n"); |
| 93 | + if (cursor > sourceStart + line.length) { |
| 94 | + row += rows.length; |
| 95 | + sourceStart += line.length + 1; |
| 96 | + continue; |
98 | 97 | } |
99 | | - } |
100 | | - |
101 | | - if (pendingWrap) { |
102 | | - return { row: row + 1, column: Math.min(initialColumn, width - 1) }; |
103 | | - } |
104 | | - |
105 | | - return { row, column }; |
106 | | -} |
107 | 98 |
|
108 | | -function textWidth(value: string): number { |
109 | | - let width = 0; |
110 | | - for (const char of Array.from(value.normalize())) { |
111 | | - width += characterWidth(char); |
| 99 | + let wrappedRow = 0; |
| 100 | + for (; wrappedRow < rows.length - 1; wrappedRow++) { |
| 101 | + const length = rows[wrappedRow]!.length; |
| 102 | + if (offset < length) break; |
| 103 | + offset -= length; |
| 104 | + } |
| 105 | + const column = stringWidth(rows[wrappedRow]!.slice(0, offset)); |
| 106 | + // An inline cursor reserves the next row at an exact-width line end. |
| 107 | + const pendingWrap = column >= contentWidth; |
| 108 | + return { |
| 109 | + row: row + wrappedRow + (pendingWrap ? 1 : 0), |
| 110 | + column: indent + (pendingWrap ? 0 : column), |
| 111 | + softWrapped: wrappedRow > 0 || pendingWrap, |
| 112 | + }; |
112 | 113 | } |
113 | | - return width; |
114 | | -} |
115 | 114 |
|
116 | | -function characterWidth(char: string): number { |
117 | | - const codePoint = char.codePointAt(0) ?? 0; |
118 | | - if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) { |
119 | | - return 0; |
120 | | - } |
121 | | - if (codePoint >= 0x300 && codePoint <= 0x36f) { |
122 | | - return 0; |
123 | | - } |
124 | | - if ( |
125 | | - (codePoint >= 0x1100 && codePoint <= 0x115f) || |
126 | | - (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || |
127 | | - (codePoint >= 0xac00 && codePoint <= 0xd7a3) || |
128 | | - (codePoint >= 0xf900 && codePoint <= 0xfaff) || |
129 | | - (codePoint >= 0xfe10 && codePoint <= 0xfe19) || |
130 | | - (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || |
131 | | - (codePoint >= 0xff00 && codePoint <= 0xff60) || |
132 | | - (codePoint >= 0xffe0 && codePoint <= 0xffe6) |
133 | | - ) { |
134 | | - return 2; |
135 | | - } |
136 | | - return 1; |
| 115 | + return { row, column: indent, softWrapped: false }; |
137 | 116 | } |
138 | 117 |
|
139 | 118 | export function usePromptTerminalCursor( |
|
0 commit comments