Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 76 additions & 13 deletions packages/core/src/lib/selection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Renderable } from "../Renderable.js"
import type { ViewportBounds } from "../types.js"
import { stringWidth } from "../platform/runtime.js"
import { coordinateToCharacterIndex, fonts } from "./ascii.font.js"

class SelectionAnchor {
Expand Down Expand Up @@ -111,8 +112,14 @@ export class Selection {
}

getSelectedText(): string {
const selectedTextsByLine = new Map<number, Array<{ x: number; text: string }>>()
const selectedRenderables = this._selectedRenderables
const selectedTextsByLine = new Map<number, Array<{ x: number; text: string; spanEndY: number }>>()
const anchor = this.anchor
const focus = this.focus
const selectionStart = anchor.y < focus.y || (anchor.y === focus.y && anchor.x <= focus.x) ? anchor : focus
let baselineX = Number.POSITIVE_INFINITY
let firstSelectedTextX: number | null = null
let preserveFirstLineIndentation = false
const selectedRenderables = [...this._selectedRenderables]
// Sort by reading order: top-to-bottom, then left-to-right
.sort((a, b) => {
const aY = a.y
Expand All @@ -128,23 +135,79 @@ export class Selection {
const text = renderable.getSelectedText()
if (!text) continue
const lines = text.split("\n")
const firstSelectedY = Math.max(renderable.y, selectionStart.y)
const visualEndY = renderable.y + renderable.height - 1

for (let index = 0; index < lines.length; index += 1) {
const y = renderable.y + index
const y = firstSelectedY + index
const selectedLine = lines[index]
// Some renderables include newline delimiter rows outside their visual
// bounds. Skip those boundary-only empties so we can synthesize gaps from
// actual vertical spacing instead of double-counting them.
if (selectedLine === "" && (y < renderable.y || y > visualEndY)) {
continue
}

const x = y === selectionStart.y ? Math.max(renderable.x, selectionStart.x) : renderable.x
const line = selectedTextsByLine.get(y) ?? []
line.push({ x: renderable.x, text: lines[index] })
line.push({ x, text: selectedLine, spanEndY: visualEndY })
selectedTextsByLine.set(y, line)

if (selectedLine !== "") {
if (firstSelectedTextX === null) {
firstSelectedTextX = x
preserveFirstLineIndentation = x === renderable.x
}

baselineX = Math.min(baselineX, x)
}
}
}

const sortedEntries = [...selectedTextsByLine.entries()].sort(([leftY], [rightY]) => leftY - rightY)
if (sortedEntries.length === 0) return ""

const lineStartX =
firstSelectedTextX === null
? 0
: preserveFirstLineIndentation && Number.isFinite(baselineX)
? baselineX
: firstSelectedTextX
const selectedLines: string[] = []
let previousSpanEndY: number | null = null

for (const [y, line] of sortedEntries) {
if (previousSpanEndY !== null && y > previousSpanEndY + 1) {
for (let gapY = previousSpanEndY + 1; gapY < y; gapY += 1) {
selectedLines.push("")
}
}

const currentSpanEndY = line.reduce((max, segment) => Math.max(max, segment.spanEndY), y)

if (line.every((segment) => segment.text === "")) {
selectedLines.push("")
previousSpanEndY = previousSpanEndY === null ? currentSpanEndY : Math.max(previousSpanEndY, currentSpanEndY)
continue
}

let cursorX = lineStartX
let selectedLine = ""

for (const segment of line.sort((left, right) => left.x - right.x)) {
if (segment.x > cursorX) {
selectedLine += " ".repeat(segment.x - cursorX)
}

selectedLine += segment.text
cursorX = Math.max(cursorX, segment.x + stringWidth(segment.text))
}

selectedLines.push(selectedLine)
previousSpanEndY = previousSpanEndY === null ? currentSpanEndY : Math.max(previousSpanEndY, currentSpanEndY)
}

return [...selectedTextsByLine.entries()]
.sort(([leftY], [rightY]) => leftY - rightY)
.map(([, line]) =>
line
.sort((left, right) => left.x - right.x)
.map((segment) => segment.text)
.join(""),
)
.join("\n")
return selectedLines.join("\n")
}
}

Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/renderables/__tests__/Markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1029,9 +1029,11 @@ Outro line below table.`

const selectedText = renderer.getSelection()?.getSelectedText() ?? ""

expect(selectedText).toContain("table.\n\nComponent")
expect(selectedText).toContain("Authentication")
expect(selectedText).toContain("Payments API")
expect(selectedText).toContain("Retry + idempotency")
expect(selectedText).toContain("Ranking + typo fix\n\nOutro line below table.")
})

// Code block tests
Expand Down Expand Up @@ -1356,6 +1358,49 @@ test("selection across top-level ordered list copies marker and text on same lin
expect(renderer.getSelection()?.getSelectedText()).toBe(" 9. Nine\n10. Ten")
})

test("selection across top-level ordered list does not add skipped leading indentation", async () => {
const md = createMarkdownRenderable({
id: "markdown-structured-ordered-list-selection-partial-indent",
content: `9. Nine
10. Ten`,
syntaxStyle,
internalBlockMode: "top-level",
})

renderer.root.add(md)
await renderMarkdownRenderable(md)

const list = md._blockStates[0]?.renderable
expect(list).toBeInstanceOf(BoxRenderable)

await mockMouse.drag(list!.x + 1, list!.y, list!.x + 20, list!.y + 1)
await renderer.idle()

expect(renderer.getSelection()?.getSelectedText()).toBe("9. Nine\n10. Ten")
})

test("selection across nested unordered list preserves visual indentation", async () => {
const md = createMarkdownRenderable({
id: "markdown-nested-structured-list-selection",
content: `- parent
- child
- grandchild`,
syntaxStyle,
internalBlockMode: "top-level",
})

renderer.root.add(md)
await renderMarkdownRenderable(md)

const list = md._blockStates[0]?.renderable
expect(list).toBeInstanceOf(BoxRenderable)

await mockMouse.drag(list!.x, list!.y, list!.x + 20, list!.y + 2)
await renderer.idle()

expect(renderer.getSelection()?.getSelectedText()).toBe("- parent\n - child\n - grandchild")
})

test("top-level structured lists align nested fenced code under nested content", async () => {
const md = createMarkdownRenderable({
id: "markdown-structured-list-code",
Expand Down
176 changes: 174 additions & 2 deletions packages/core/src/tests/renderer.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,151 @@ test("selected text joins same-row renderables without newlines", () => {
expect(renderer.getSelection()?.getSelectedText()).toBe("Hello World")
})

test("selected text keeps newlines between different rows", () => {
test("selected text preserves visual gaps between same-row renderables", () => {
const row = new BoxRenderable(renderer, {
flexDirection: "row",
width: 20,
height: 1,
})
const left = new TextRenderable(renderer, {
content: "Hello",
width: 5,
height: 1,
selectable: true,
})
const spacer = new BoxRenderable(renderer, {
width: 2,
height: 1,
})
const right = new TextRenderable(renderer, {
content: "World",
width: 5,
height: 1,
selectable: true,
})

row.add(left)
row.add(spacer)
row.add(right)
renderer.root.add(row)
renderOnce()

renderer.startSelection(left, left.x, left.y)
renderer.updateSelection(right, right.x + right.width, right.y, { finishDragging: true })

expect(renderer.getSelection()?.getSelectedText()).toBe("Hello World")
})

test("selected text joins adjacent rows with a single newline", () => {
const top = new TextRenderable(renderer, {
content: "First row",
left: 0,
top: 0,
width: 9,
height: 1,
selectable: true,
})
const bottom = new TextRenderable(renderer, {
content: "Second row",
left: 0,
top: 1,
width: 10,
height: 1,
selectable: true,
})

renderer.root.add(top)
renderer.root.add(bottom)
renderOnce()

renderer.startSelection(top, top.x, top.y)
renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true })

const gapLines = Math.max(bottom.y - top.y - 1, 0)
expect(renderer.getSelection()?.getSelectedText()).toBe(["First row", ...Array(gapLines).fill(""), "Second row"].join("\n"))
})

test("selected text preserves blank lines within multiline renderables", () => {
const text = new TextRenderable(renderer, {
content: "First\n\nSecond",
left: 0,
top: 0,
width: 10,
height: 3,
selectable: true,
})

renderer.root.add(text)
renderOnce()

renderer.startSelection(text, text.x, text.y)
renderer.updateSelection(text, text.x + 6, text.y + 2, { finishDragging: true })

expect(renderer.getSelection()?.getSelectedText()).toBe("First\n\nSecond")
})

test("selected text preserves vertical gaps between separated rows", () => {
const top = new TextRenderable(renderer, {
content: "First row",
left: 0,
top: 0,
width: 9,
height: 1,
selectable: true,
})
const bottom = new TextRenderable(renderer, {
content: "Second row",
left: 0,
top: 3,
width: 10,
height: 1,
selectable: true,
})

renderer.root.add(top)
renderer.root.add(bottom)
renderOnce()

renderer.startSelection(top, top.x, top.y)
renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true })

const gapLines = Math.max(bottom.y - top.y - 1, 0)
expect(renderer.getSelection()?.getSelectedText()).toBe(["First row", ...Array(gapLines).fill(""), "Second row"].join("\n"))
})

test("selected text preserves first-line indentation when selection starts at local column zero", () => {
const top = new TextRenderable(renderer, {
content: "First row",
left: 4,
top: 0,
width: 9,
height: 1,
selectable: true,
})
const bottom = new TextRenderable(renderer, {
content: "Second row",
left: 0,
top: 1,
width: 10,
height: 1,
selectable: true,
})

renderer.root.add(top)
renderer.root.add(bottom)
renderOnce()

renderer.startSelection(top, top.x, top.y)
renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true })

const gapLines = Math.max(bottom.y - top.y - 1, 0)
expect(renderer.getSelection()?.getSelectedText()).toBe([" First row", ...Array(gapLines).fill(""), "Second row"].join("\n"))
})

test("selected text does not synthesize skipped indentation on the first copied line", () => {
const top = new TextRenderable(renderer, {
content: "First row",
left: 4,
top: 0,
width: 9,
height: 1,
Expand All @@ -101,10 +242,41 @@ test("selected text keeps newlines between different rows", () => {
renderer.root.add(bottom)
renderOnce()

renderer.startSelection(top, top.x + 1, top.y)
renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true })

const gapLines = Math.max(bottom.y - top.y - 1, 0)
expect(renderer.getSelection()?.getSelectedText()).toBe(["irst row", ...Array(gapLines).fill(""), "Second row"].join("\n"))
})

test("selected text does not infer blank rows from renderable height", () => {
const top = new TextRenderable(renderer, {
content: "First row",
left: 0,
top: 0,
width: 9,
height: 3,
selectable: true,
})
const bottom = new TextRenderable(renderer, {
content: "Second row",
left: 0,
top: 4,
width: 10,
height: 1,
selectable: true,
})

renderer.root.add(top)
renderer.root.add(bottom)
renderOnce()

renderer.startSelection(top, top.x, top.y)
renderer.updateSelection(bottom, bottom.x + bottom.width, bottom.y, { finishDragging: true })

expect(renderer.getSelection()?.getSelectedText()).toBe("First row\nSecond row")
const topVisualEndY = top.y + top.height - 1
const gapLines = Math.max(bottom.y - topVisualEndY - 1, 0)
expect(renderer.getSelection()?.getSelectedText()).toBe(["First row", ...Array(gapLines).fill(""), "Second row"].join("\n"))
})

test("selected text merges multiline same-row renderables by visual row", () => {
Expand Down
Loading