Skip to content

Text Cursor Utilities ​

Pure functions for mapping between flat character offsets and visual (row, col) positions in word-wrapped text. Layer 0 of the text editing architecture.

Why This Exists ​

Terminal text editors face a fundamental alignment problem: the cursor position in the underlying text (a flat offset) doesn't correspond to what the user sees on screen after word wrapping. A character at offset 45 might be on visual line 3, column 5 -- but only if you use the same wrapping algorithm as the renderer.

The text-cursor module solves this by using wrapText() from unicode.ts internally -- the same function the Silvery rendering pipeline uses, with matching trim=true behavior. This guarantees cursor positions always match what's displayed on screen. The trim parameter is critical: the renderer trims trailing spaces at word-wrap break points and leading spaces on continuation lines. Without matching trim behavior, cursor offsets drift from visual positions on wrapped text.

Architecture ​

text-cursor is Layer 0 of a 4-layer text editing stack inspired by the web's EditContext API:

LayerModuleWhat It DoesStatus
0text-cursor.tsPure functions: offset ↔ visual positionAvailable
1useTextEdit()Hook: text state + cursor nav + stickyXPlanned
2EditableTextComponent: renders wrapped text with cursorPlanned
3TextAreaFull widget: EditableText + scroll + useInputExisting (will be refactored)

Each layer builds on the one below. Layer 0 has no state, no hooks, no React dependency -- just pure functions. This means you can use it with any state management approach (Slate, Zustand, custom hooks, etc.).

API Reference ​

All functions are exported from silvery:

ts
import {
  cursorToRowCol,
  getWrappedLines,
  rowColToCursor,
  cursorMoveUp,
  cursorMoveDown,
  countVisualLines,
} from "@silvery/ag-term"
import type { WrappedLine } from "@silvery/ag-term"

cursorToRowCol(text, cursor, wrapWidth) ​

Convert a flat cursor offset to a visual (row, col) position in word-wrapped text.

ts
function cursorToRowCol(
  text: string,
  cursor: number,
  wrapWidth: number,
): { row: number; col: number }
ts
// Single line, no wrapping needed
cursorToRowCol("hello world", 5, 80)
// { row: 0, col: 5 }

// Text wraps at width 8: "hello wo" / "rld"
cursorToRowCol("hello world", 9, 8)
// { row: 1, col: 1 } — "r" is col 1 on visual line 1

// Multi-line text
cursorToRowCol("first\nsecond", 8, 80)
// { row: 1, col: 2 } — "c" in "second"

getWrappedLines(text, wrapWidth) ​

Get all wrapped display lines with their starting character offsets.

ts
function getWrappedLines(text: string, wrapWidth: number): WrappedLine[]
ts
interface WrappedLine {
  line: string // Text content of this visual line
  startOffset: number // Offset in the original text where this line starts
}
ts
getWrappedLines("hello world", 8)
// [
//   { line: "hello wo", startOffset: 0 },
//   { line: "rld",      startOffset: 8 },
// ]

getWrappedLines("first\nsecond", 80)
// [
//   { line: "first",  startOffset: 0 },
//   { line: "second", startOffset: 6 },
// ]

The startOffset enables converting a (row, col) back to a flat offset: offset = lines[row].startOffset + col.

rowColToCursor(text, row, col, wrapWidth) ​

Convert a visual (row, col) to a flat cursor offset. Clamps col to the line length if it exceeds it (important for stickyX on short lines).

ts
function rowColToCursor(text: string, row: number, col: number, wrapWidth: number): number
ts
rowColToCursor("hello world", 0, 5, 80)
// 5

// Col exceeds line length — clamps to end
rowColToCursor("hi\nbye", 0, 10, 80)
// 2 (clamped to end of "hi")

cursorMoveUp(text, cursor, wrapWidth, stickyX?) ​

Move the cursor up one visual line. Returns the new cursor offset, or null if already on the first visual line.

ts
function cursorMoveUp(
  text: string,
  cursor: number,
  wrapWidth: number,
  stickyX?: number,
): number | null
ts
// Move up from second line to first
cursorMoveUp("hello\nworld", 8, 80)
// 2 — moved from col 2 in "world" to col 2 in "hello"

// Already on first line — returns null (boundary)
cursorMoveUp("hello\nworld", 2, 80)
// null

Returns null at the boundary to signal the caller should handle cross-block navigation (e.g., moving to the previous text block in a document editor).

cursorMoveDown(text, cursor, wrapWidth, stickyX?) ​

Move the cursor down one visual line. Returns the new cursor offset, or null if already on the last visual line.

ts
function cursorMoveDown(
  text: string,
  cursor: number,
  wrapWidth: number,
  stickyX?: number,
): number | null
ts
// Move down from first line to second
cursorMoveDown("hello\nworld", 3, 80)
// 9 — moved from col 3 in "hello" to col 3 in "world"

// Already on last line — returns null (boundary)
cursorMoveDown("hello\nworld", 8, 80)
// null

countVisualLines(text, wrapWidth) ​

Count the total number of visual lines after word wrapping.

ts
function countVisualLines(text: string, wrapWidth: number): number
ts
countVisualLines("hello world", 80)
// 1

countVisualLines("hello world", 8)
// 2 — wraps to "hello wo" / "rld"

countVisualLines("one\ntwo\nthree", 80)
// 3

stickyX Behavior ​

When navigating vertically through lines of different lengths, the cursor should "remember" its preferred column rather than drifting left on short lines.

Consider this text at width 20:

This is a long line..  <- cursor at col 18
short                  <- only 5 chars
Another long line...   <- want cursor at col 18 again

Without stickyX, pressing Down from col 18 would clamp to col 5 on "short", then pressing Down again would move to col 5 on the next line -- the cursor drifts left permanently.

With stickyX, the caller preserves the original column:

ts
const originalCol = 18
const stickyX = originalCol

// Down from "This is a long line.." col 18
const pos1 = cursorMoveDown(text, cursor, 20, stickyX)
// Lands at col 5 (clamped to "short" length), but stickyX=18 is preserved

// Down again from "short"
const pos2 = cursorMoveDown(text, pos1!, 20, stickyX)
// Lands at col 18 on "Another long line..." — stickyX restored

The text-cursor functions accept stickyX but don't store it -- the caller manages the sticky state. This keeps Layer 0 pure and stateless. Layer 1 (useTextEdit, planned) will manage stickyX automatically.

Common Patterns ​

External State Management ​

Use text-cursor functions with your own state (Slate, Zustand, or plain React state):

ts
import { cursorToRowCol, cursorMoveDown, cursorMoveUp, countVisualLines } from "@silvery/ag-term"

// In your state manager or hook:
function handleArrowDown(text: string, cursor: number, width: number, stickyX: number) {
  const newCursor = cursorMoveDown(text, cursor, width, stickyX)
  if (newCursor !== null) {
    return { cursor: newCursor, stickyX } // Stay in same block
  }
  // null = boundary — move to next block
  return moveToNextBlock(stickyX)
}

Cross-Block Navigation ​

The null return from cursorMoveUp / cursorMoveDown signals a boundary. In a block-based editor (like Slate or a markdown document), use this to transition between blocks:

ts
function handleVerticalMove(
  direction: "up" | "down",
  block: Block,
  cursor: number,
  width: number,
  stickyX: number,
) {
  const moveFn = direction === "up" ? cursorMoveUp : cursorMoveDown
  const newCursor = moveFn(block.text, cursor, width, stickyX)

  if (newCursor !== null) {
    // Stayed within the block
    return { blockId: block.id, cursor: newCursor }
  }

  // Crossed the boundary — move to adjacent block
  const adjacent = direction === "up" ? getPreviousBlock(block.id) : getNextBlock(block.id)

  if (!adjacent) return null // No adjacent block

  // Enter adjacent block at the correct visual position
  const lines = countVisualLines(adjacent.text, width)
  const targetRow = direction === "up" ? lines - 1 : 0
  const newOffset = rowColToCursor(adjacent.text, targetRow, stickyX, width)

  return { blockId: adjacent.id, cursor: newOffset }
}

Rendering a Cursor Indicator ​

Use cursorToRowCol to position a cursor overlay in your component:

tsx
function TextWithCursor({ text, cursor, width }: Props) {
  const { row, col } = cursorToRowCol(text, cursor, width)
  const lines = getWrappedLines(text, width)

  return (
    <Box flexDirection="column">
      {lines.map((wl, i) => (
        <Text key={i}>
          {i === row ? wl.line.slice(0, col) + "|" + wl.line.slice(col) : wl.line}
        </Text>
      ))}
    </Box>
  )
}