* Move cursor to a target visual line, applying sticky column logic. * Shared by moveCursor() and pageScroll().
(
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
currentVisualLine: number,
targetVisualLine: number,
)
| 1439 | * Shared by moveCursor() and pageScroll(). |
| 1440 | */ |
| 1441 | private moveToVisualLine( |
| 1442 | visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, |
| 1443 | currentVisualLine: number, |
| 1444 | targetVisualLine: number, |
| 1445 | ): void { |
| 1446 | const currentVL = visualLines[currentVisualLine]; |
| 1447 | const targetVL = visualLines[targetVisualLine]; |
| 1448 | if (!(currentVL && targetVL)) return; |
| 1449 | |
| 1450 | // When the cursor was snapped to a segment start, resolve the pre-snap |
| 1451 | // position against the VL it belongs to. This gives the correct visual |
| 1452 | // column even after a resize reshuffles VLs. |
| 1453 | let currentVisualCol: number; |
| 1454 | if (this.snappedFromCursorCol !== null) { |
| 1455 | const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol); |
| 1456 | currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex]!.startCol; |
| 1457 | } else { |
| 1458 | currentVisualCol = this.state.cursorCol - currentVL.startCol; |
| 1459 | } |
| 1460 | |
| 1461 | // For non-last segments, clamp to length-1 to stay within the segment |
| 1462 | const isLastSourceSegment = |
| 1463 | currentVisualLine === visualLines.length - 1 || |
| 1464 | visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine; |
| 1465 | const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1); |
| 1466 | |
| 1467 | const isLastTargetSegment = |
| 1468 | targetVisualLine === visualLines.length - 1 || |
| 1469 | visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine; |
| 1470 | const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1); |
| 1471 | |
| 1472 | const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol); |
| 1473 | |
| 1474 | // Set cursor position |
| 1475 | this.state.cursorLine = targetVL.logicalLine; |
| 1476 | const targetCol = targetVL.startCol + moveToVisualCol; |
| 1477 | const logicalLine = this.state.lines[targetVL.logicalLine] || ""; |
| 1478 | this.state.cursorCol = Math.min(targetCol, logicalLine.length); |
| 1479 | |
| 1480 | // Snap cursor to atomic segment boundary (e.g. paste markers) |
| 1481 | // so the cursor never lands in the middle of a multi-grapheme unit. |
| 1482 | // Single-grapheme segments don't need snapping. |
| 1483 | const segments = [...this.segment(logicalLine, "grapheme")]; |
| 1484 | for (const seg of segments) { |
| 1485 | if (seg.index > this.state.cursorCol) break; |
| 1486 | if (seg.segment.length <= 1) continue; |
| 1487 | if (this.state.cursorCol < seg.index + seg.segment.length) { |
| 1488 | const isContinuation = seg.index < targetVL.startCol; |
| 1489 | const isMovingDown = targetVisualLine > currentVisualLine; |
| 1490 | |
| 1491 | if (isContinuation && isMovingDown) { |
| 1492 | // The segment started on a previous visual line, and we |
| 1493 | // already visited it on the way down. Skip all remaining |
| 1494 | // continuation VLs and land on the first VL past it. |
| 1495 | const segEnd = seg.index + seg.segment.length; |
| 1496 | let next = targetVisualLine + 1; |
| 1497 | while ( |
| 1498 | next < visualLines.length && |
no test coverage detected