@mariozechner/pi-tui 0.66.0 → 0.67.0

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.
@@ -200,6 +200,12 @@ export class Editor {
200
200
  jumpMode = null;
201
201
  // Preferred visual column for vertical cursor movement (sticky column)
202
202
  preferredVisualCol = null;
203
+ // When the cursor is snapped to the start of an atomic segment, e.g. a
204
+ // paste marker, cursorCol no longer reflects where the cursor would have
205
+ // landed. This field stores the pre-snap cursorCol so that the next
206
+ // vertical move can resolve it to a visual column on whatever VL it belongs
207
+ // to.
208
+ snappedFromCursorCol = null;
203
209
  // Undo support
204
210
  undoStack = new UndoStack();
205
211
  onSubmit;
@@ -1057,6 +1063,7 @@ export class Editor {
1057
1063
  setCursorCol(col) {
1058
1064
  this.state.cursorCol = col;
1059
1065
  this.preferredVisualCol = null;
1066
+ this.snappedFromCursorCol = null;
1060
1067
  }
1061
1068
  /**
1062
1069
  * Move cursor to a target visual line, applying sticky column logic.
@@ -1065,37 +1072,70 @@ export class Editor {
1065
1072
  moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
1066
1073
  const currentVL = visualLines[currentVisualLine];
1067
1074
  const targetVL = visualLines[targetVisualLine];
1068
- if (currentVL && targetVL) {
1069
- const currentVisualCol = this.state.cursorCol - currentVL.startCol;
1070
- // For non-last segments, clamp to length-1 to stay within the segment
1071
- const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1072
- visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1073
- const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1074
- const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1075
- visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1076
- const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1077
- const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1078
- // Set cursor position
1079
- this.state.cursorLine = targetVL.logicalLine;
1080
- const targetCol = targetVL.startCol + moveToVisualCol;
1081
- const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1082
- this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1083
- // Snap cursor to atomic segment boundary (e.g. paste markers)
1084
- // so the cursor never lands in the middle of a multi-grapheme unit.
1085
- // Single-grapheme segments don't need snapping.
1086
- const segments = [...this.segment(logicalLine)];
1087
- for (const seg of segments) {
1088
- if (seg.index > this.state.cursorCol)
1089
- break;
1090
- if (seg.segment.length <= 1)
1091
- continue;
1092
- if (this.state.cursorCol < seg.index + seg.segment.length) {
1093
- // jump to the start of the segment when moving up, to the end when moving down.
1094
- this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length;
1095
- break;
1075
+ if (!(currentVL && targetVL))
1076
+ return;
1077
+ // When the cursor was snapped to a segment start, resolve the pre-snap
1078
+ // position against the VL it belongs to. This gives the correct visual
1079
+ // column even after a resize reshuffles VLs.
1080
+ let currentVisualCol;
1081
+ if (this.snappedFromCursorCol !== null) {
1082
+ const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
1083
+ currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
1084
+ }
1085
+ else {
1086
+ currentVisualCol = this.state.cursorCol - currentVL.startCol;
1087
+ }
1088
+ // For non-last segments, clamp to length-1 to stay within the segment
1089
+ const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
1090
+ visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
1091
+ const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
1092
+ const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
1093
+ visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
1094
+ const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
1095
+ const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
1096
+ // Set cursor position
1097
+ this.state.cursorLine = targetVL.logicalLine;
1098
+ const targetCol = targetVL.startCol + moveToVisualCol;
1099
+ const logicalLine = this.state.lines[targetVL.logicalLine] || "";
1100
+ this.state.cursorCol = Math.min(targetCol, logicalLine.length);
1101
+ // Snap cursor to atomic segment boundary (e.g. paste markers)
1102
+ // so the cursor never lands in the middle of a multi-grapheme unit.
1103
+ // Single-grapheme segments don't need snapping.
1104
+ const segments = [...this.segment(logicalLine)];
1105
+ for (const seg of segments) {
1106
+ if (seg.index > this.state.cursorCol)
1107
+ break;
1108
+ if (seg.segment.length <= 1)
1109
+ continue;
1110
+ if (this.state.cursorCol < seg.index + seg.segment.length) {
1111
+ const isContinuation = seg.index < targetVL.startCol;
1112
+ const isMovingDown = targetVisualLine > currentVisualLine;
1113
+ if (isContinuation && isMovingDown) {
1114
+ // The segment started on a previous visual line, and we
1115
+ // already visited it on the way down. Skip all remaining
1116
+ // continuation VLs and land on the first VL past it.
1117
+ const segEnd = seg.index + seg.segment.length;
1118
+ let next = targetVisualLine + 1;
1119
+ while (next < visualLines.length &&
1120
+ visualLines[next].logicalLine === targetVL.logicalLine &&
1121
+ visualLines[next].startCol < segEnd) {
1122
+ next++;
1123
+ }
1124
+ if (next < visualLines.length) {
1125
+ this.moveToVisualLine(visualLines, currentVisualLine, next);
1126
+ return;
1127
+ }
1096
1128
  }
1129
+ // Snap to the start of the segment so it gets highlighted.
1130
+ // Store the pre-snap position so the next vertical move can
1131
+ // resolve it to the correct visual column.
1132
+ this.snappedFromCursorCol = this.state.cursorCol;
1133
+ this.state.cursorCol = seg.index;
1134
+ return;
1097
1135
  }
1098
1136
  }
1137
+ // No snap occurred – we moved out of the atomic segment.
1138
+ this.snappedFromCursorCol = null;
1099
1139
  }
1100
1140
  /**
1101
1141
  * Compute the target visual column for vertical cursor movement.
@@ -1349,26 +1389,29 @@ export class Editor {
1349
1389
  return visualLines;
1350
1390
  }
1351
1391
  /**
1352
- * Find the visual line index for the current cursor position.
1392
+ * Find the visual line index that contains the given logical position.
1353
1393
  */
1354
- findCurrentVisualLine(visualLines) {
1394
+ findVisualLineAt(visualLines, line, col) {
1355
1395
  for (let i = 0; i < visualLines.length; i++) {
1356
1396
  const vl = visualLines[i];
1357
- if (!vl)
1397
+ if (!vl || vl.logicalLine !== line)
1358
1398
  continue;
1359
- if (vl.logicalLine === this.state.cursorLine) {
1360
- const colInSegment = this.state.cursorCol - vl.startCol;
1361
- // Cursor is in this segment if it's within range
1362
- // For the last segment of a logical line, cursor can be at length (end position)
1363
- const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1364
- if (colInSegment >= 0 && (colInSegment < vl.length || (isLastSegmentOfLine && colInSegment <= vl.length))) {
1365
- return i;
1366
- }
1399
+ const offset = col - vl.startCol;
1400
+ // Cursor is in this segment if it's within range. For the last
1401
+ // segment of a logical line, cursor can be at length (end position)
1402
+ const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
1403
+ if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
1404
+ return i;
1367
1405
  }
1368
1406
  }
1369
- // Fallback: return last visual line
1370
1407
  return visualLines.length - 1;
1371
1408
  }
1409
+ /**
1410
+ * Find the visual line index for the current cursor position.
1411
+ */
1412
+ findCurrentVisualLine(visualLines) {
1413
+ return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
1414
+ }
1372
1415
  moveCursor(deltaLine, deltaCol) {
1373
1416
  this.lastAction = null;
1374
1417
  const visualLines = this.buildVisualLineMap(this.lastWidth);