@f5-sales-demo/pi-tui 21.29.4 → 21.29.5

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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/tui.ts +57 -7
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-tui",
4
- "version": "21.29.4",
4
+ "version": "21.29.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@f5-sales-demo/pi-natives": "21.29.4",
41
- "@f5-sales-demo/pi-utils": "21.29.4",
40
+ "@f5-sales-demo/pi-natives": "21.29.5",
41
+ "@f5-sales-demo/pi-utils": "21.29.5",
42
42
  "marked": "18.0.11"
43
43
  },
44
44
  "devDependencies": {
package/src/tui.ts CHANGED
@@ -9,7 +9,7 @@ import { type MouseRoutable, routeSgrMouseInput } from "./mouse";
9
9
  import { setAlternateScreenActive, type Terminal } from "./terminal";
10
10
  import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
11
11
  import { isInsideTerminalMultiplexer } from "./terminal-multiplexer";
12
- import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils";
12
+ import { extractSegments, getSegmenter, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils";
13
13
 
14
14
  const SEGMENT_RESET = "\x1b[0m";
15
15
  const LINE_FIT_MIN_SOURCE_CODE_UNITS = 4096;
@@ -143,6 +143,60 @@ export function isFocusable(component: Component | null): component is Component
143
143
  */
144
144
  export const CURSOR_MARKER = "\x1b_pi:c\x07";
145
145
 
146
+ function insertCursorMarkerAtColumn(line: string, column: number): string {
147
+ if (column <= 0 || line.length === 0) return CURSOR_MARKER + line;
148
+
149
+ let index = 0;
150
+ let visualColumn = 0;
151
+ while (index < line.length && visualColumn < column) {
152
+ if (line.charCodeAt(index) === 0x1b) {
153
+ const end = ansiSequenceEnd(line, index);
154
+ if (end === -1) break;
155
+ const sequenceWidth = visibleWidth(line.slice(index, end));
156
+ if (visualColumn + sequenceWidth > column) break;
157
+ visualColumn += sequenceWidth;
158
+ index = end;
159
+ continue;
160
+ }
161
+
162
+ const escapeIndex = line.indexOf("\x1b", index);
163
+ const textEnd = escapeIndex === -1 ? line.length : escapeIndex;
164
+ const text = line.slice(index, textEnd);
165
+ const textStart = index;
166
+ for (const { segment, index: segmentIndex } of getSegmenter().segment(text)) {
167
+ const segmentStart = textStart + segmentIndex;
168
+ const segmentWidth = visibleWidth(segment);
169
+ if (visualColumn + segmentWidth > column) {
170
+ return line.slice(0, segmentStart) + CURSOR_MARKER + line.slice(segmentStart);
171
+ }
172
+ visualColumn += segmentWidth;
173
+ index = segmentStart + segment.length;
174
+ if (visualColumn === column) break;
175
+ }
176
+ }
177
+
178
+ return line.slice(0, index) + CURSOR_MARKER + line.slice(index);
179
+ }
180
+
181
+ function fitLinePreservingCursor(raw: string, width: number): string {
182
+ const markerIndex = raw.indexOf(CURSOR_MARKER);
183
+ const withoutCursor = markerIndex === -1 ? raw : raw.replaceAll(CURSOR_MARKER, "");
184
+ let fitted = lineFitSource(withoutCursor, width);
185
+ const sourceWasFitted = fitted !== withoutCursor;
186
+ const fittedWidth = visibleWidth(fitted);
187
+ const widthWasClamped = fittedWidth > width;
188
+ if (widthWasClamped) fitted = sliceByColumn(fitted, 0, width, true);
189
+
190
+ if (markerIndex === -1) return fitted;
191
+
192
+ const boundedBeforeCursor = lineFitSource(raw.slice(0, markerIndex), width);
193
+ const cursorColumn = visibleWidth(boundedBeforeCursor);
194
+ const maxCursorColumn = Math.max(0, Math.min(width - 1, visibleWidth(fitted)));
195
+ if (!sourceWasFitted && !widthWasClamped && cursorColumn <= maxCursorColumn) return raw;
196
+
197
+ return insertCursorMarkerAtColumn(fitted, Math.min(cursorColumn, maxCursorColumn));
198
+ }
199
+
146
200
  export { visibleWidth };
147
201
 
148
202
  /**
@@ -1191,14 +1245,10 @@ export class TUI extends Container {
1191
1245
 
1192
1246
  // Clamp any oversized lines before dirty-checking so the truncated form
1193
1247
  // matches what we store in #previousLines, preventing perpetual repaints.
1194
- // Strip CURSOR_MARKER before measuring — visibleWidth miscounts APC sequences
1195
- // as visible chars; the marker is extracted later in #extractCursorPosition.
1248
+ // Preserve CURSOR_MARKER's visual column while keeping width work bounded.
1196
1249
  for (let i = 0; i < newLines.length; i++) {
1197
1250
  if (TERMINAL.isImageLine(newLines[i])) continue;
1198
- const hadMarker = newLines[i].includes(CURSOR_MARKER);
1199
- let fitted = lineFitSource(newLines[i].replaceAll(CURSOR_MARKER, ""), width);
1200
- if (visibleWidth(fitted) > width) fitted = sliceByColumn(fitted, 0, width, true);
1201
- newLines[i] = fitted + (hadMarker ? CURSOR_MARKER : "");
1251
+ newLines[i] = fitLinePreservingCursor(newLines[i], width);
1202
1252
  }
1203
1253
 
1204
1254
  // Composite overlays into the rendered lines (before differential compare)