mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
core: add configurable selection occupancy (#1393)
Selection combines half-open forward ranges with an inclusive backward workaround. Replaying a local selection can change its range, and extending by one display column can split a wide grapheme. Add an explicit occupancy model. Cell occupancy includes both endpoint graphemes for Vim-style behavior. Boundary occupancy uses a half-open range between insertion points. This behavior matches conventional thin cursors. Keep occupancy independent of cursor paint. Normalize endpoints to whole graphemes. This keeps highlighting, copied text, deletion, cursor sync, and wrapped-line navigation consistent in both directions. A press without movement keeps the selection empty. Preserve half-open offset selections. A cursor at the end of a line does not occupy the newline, but crossing the line boundary selects it. Supersedes the boundary-semantics direction of PR https://github.com/anomalyco/opentui/pull/1389.
This commit is contained in:
@@ -221,9 +221,10 @@ describe("EditorView", () => {
|
||||
it("should update local selection focus position", () => {
|
||||
buffer.setText("Hello World")
|
||||
|
||||
// Inclusive selection: the cell under the focus (5, the space) is selected too.
|
||||
const changed1 = view.setLocalSelection(0, 0, 5, 0)
|
||||
expect(changed1).toBe(true)
|
||||
expect(view.getSelectedText()).toBe("Hello")
|
||||
expect(view.getSelectedText()).toBe("Hello ")
|
||||
|
||||
const changed2 = view.updateLocalSelection(0, 0, 11, 0)
|
||||
expect(changed2).toBe(true)
|
||||
@@ -249,20 +250,20 @@ describe("EditorView", () => {
|
||||
const changed = view.updateLocalSelection(0, 0, 5, 0)
|
||||
expect(changed).toBe(true)
|
||||
expect(view.hasSelection()).toBe(true)
|
||||
expect(view.getSelectedText()).toBe("Hello")
|
||||
expect(view.getSelectedText()).toBe("Hello ")
|
||||
})
|
||||
|
||||
it("should preserve anchor when updating local selection", () => {
|
||||
buffer.setText("Hello World")
|
||||
|
||||
view.setLocalSelection(0, 0, 5, 0)
|
||||
expect(view.getSelectedText()).toBe("Hello")
|
||||
expect(view.getSelectedText()).toBe("Hello ")
|
||||
|
||||
view.updateLocalSelection(0, 0, 11, 0)
|
||||
expect(view.getSelectedText()).toBe("Hello World")
|
||||
|
||||
view.updateLocalSelection(0, 0, 3, 0)
|
||||
expect(view.getSelectedText()).toBe("Hel")
|
||||
expect(view.getSelectedText()).toBe("Hell")
|
||||
})
|
||||
|
||||
it("should handle backward selection with updateLocalSelection", () => {
|
||||
@@ -285,7 +286,7 @@ describe("EditorView", () => {
|
||||
|
||||
const changed = view.updateLocalSelection(0, 0, 5, 1)
|
||||
expect(changed).toBe(true)
|
||||
expect(view.getSelectedText()).toBe("ABCDEFGHIJKLMNO")
|
||||
expect(view.getSelectedText()).toBe("ABCDEFGHIJKLMNOP")
|
||||
})
|
||||
|
||||
it("should return null bytes for zero-length selected-text output buffer", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "./zig.js"
|
||||
import type { EditBuffer } from "./edit-buffer.js"
|
||||
import { createExtmarksController } from "./lib/index.js"
|
||||
import type { SelectionOccupancy } from "./types.js"
|
||||
|
||||
export interface Viewport {
|
||||
offsetY: number
|
||||
@@ -161,6 +162,27 @@ export class EditorView {
|
||||
this.lib.editorViewResetLocalSelection(this.viewPtr)
|
||||
}
|
||||
|
||||
public setSelectionOccupancy(occupancy: SelectionOccupancy): void {
|
||||
this.guard()
|
||||
this.lib.editorViewSetSelectionOccupancy(this.viewPtr, occupancy)
|
||||
}
|
||||
|
||||
public getSelectionOccupancy(): SelectionOccupancy {
|
||||
this.guard()
|
||||
this._textBufferViewPtr ??= this.lib.editorViewGetTextBufferView(this.viewPtr)
|
||||
return this.lib.textBufferViewGetSelectionOccupancy(this._textBufferViewPtr)
|
||||
}
|
||||
|
||||
public setSelectionInclusive(start: number, end: number, bgColor?: RGBA, fgColor?: RGBA): void {
|
||||
this.guard()
|
||||
this.lib.editorViewSetSelectionInclusive(this.viewPtr, start, end, bgColor || null, fgColor || null)
|
||||
}
|
||||
|
||||
public setSelectionColors(bgColor?: RGBA, fgColor?: RGBA): void {
|
||||
this.guard()
|
||||
this.lib.editorViewSetSelectionColors(this.viewPtr, bgColor || null, fgColor || null)
|
||||
}
|
||||
|
||||
public getSelectedText(): string {
|
||||
this.guard()
|
||||
// TODO: native can stack alloc all the text and decode will alloc as js string then
|
||||
@@ -235,6 +257,11 @@ export class EditorView {
|
||||
return this.lib.editorViewGetVisualEOL(this.viewPtr)
|
||||
}
|
||||
|
||||
public gotoVisualLineEnd(): void {
|
||||
this.guard()
|
||||
this.lib.editorViewGotoVisualLineEnd(this.viewPtr)
|
||||
}
|
||||
|
||||
public getLineInfo(): LineInfo {
|
||||
this.guard()
|
||||
return this.lib.editorViewGetLineInfo(this.viewPtr)
|
||||
@@ -270,9 +297,7 @@ export class EditorView {
|
||||
|
||||
public measureForDimensions(width: number, height: number): { lineCount: number; widthColsMax: number } | null {
|
||||
this.guard()
|
||||
if (!this._textBufferViewPtr) {
|
||||
this._textBufferViewPtr = this.lib.editorViewGetTextBufferView(this.viewPtr)
|
||||
}
|
||||
this._textBufferViewPtr ??= this.lib.editorViewGetTextBufferView(this.viewPtr)
|
||||
return this.lib.textBufferViewMeasureForDimensions(this._textBufferViewPtr, width, height)
|
||||
}
|
||||
|
||||
|
||||
@@ -1615,7 +1615,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 0
|
||||
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
// Inclusive selection: 3 shift+right presses select 4 cells ("hell").
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
@@ -1642,7 +1642,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 0
|
||||
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
// Inclusive selection: 3 shift+right presses select 4 cells ("hell").
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
@@ -1669,7 +1669,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 0
|
||||
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
// Inclusive selection: 4 shift+right presses select 5 cells ("hello").
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
@@ -1696,7 +1696,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 0
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
for (let i = 0; i < 11; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
@@ -1721,7 +1721,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 7
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
@@ -1729,12 +1729,14 @@ Press ESC to return to main menu.`
|
||||
|
||||
currentMockInput.pressBackspace()
|
||||
|
||||
expect(textarea.plainText).toBe("Line 1\nLine 3\nLine 4")
|
||||
// Charwise selection at EOL does not include the newline (Vim v$),
|
||||
// so the emptied line remains.
|
||||
expect(textarea.plainText).toBe("Line 1\n\nLine 3\nLine 4")
|
||||
|
||||
const extmark = extmarks.get(id)
|
||||
expect(extmark).not.toBeNull()
|
||||
expect(extmark?.start).toBe(14)
|
||||
expect(extmark?.end).toBe(20)
|
||||
expect(extmark?.start).toBe(15)
|
||||
expect(extmark?.end).toBe(21)
|
||||
})
|
||||
|
||||
it("should adjust multiple extmarks after deleting multiline selection", async () => {
|
||||
@@ -1753,7 +1755,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 0
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
@@ -1761,18 +1763,19 @@ Press ESC to return to main menu.`
|
||||
|
||||
currentMockInput.pressBackspace()
|
||||
|
||||
expect(textarea.plainText).toBe("CCC\nDDD")
|
||||
// Charwise selection at EOL does not include the trailing newline.
|
||||
expect(textarea.plainText).toBe("\nCCC\nDDD")
|
||||
|
||||
const extmark1 = extmarks.get(id1)
|
||||
expect(extmark1).not.toBeNull()
|
||||
expect(extmark1?.start).toBe(0)
|
||||
expect(extmark1?.end).toBe(3)
|
||||
expect(extmark1?.start).toBe(1)
|
||||
expect(extmark1?.end).toBe(4)
|
||||
expect(textarea.plainText.substring(extmark1!.start, extmark1!.end)).toBe("CCC")
|
||||
|
||||
const extmark2 = extmarks.get(id2)
|
||||
expect(extmark2).not.toBeNull()
|
||||
expect(extmark2?.start).toBe(4)
|
||||
expect(extmark2?.end).toBe(7)
|
||||
expect(extmark2?.start).toBe(5)
|
||||
expect(extmark2?.end).toBe(8)
|
||||
expect(textarea.plainText.substring(extmark2!.start, extmark2!.end)).toBe("DDD")
|
||||
})
|
||||
|
||||
@@ -1787,7 +1790,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 0
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
@@ -1795,12 +1798,13 @@ Press ESC to return to main menu.`
|
||||
|
||||
currentMockInput.pressBackspace()
|
||||
|
||||
expect(textarea.plainText).toBe("CCC\nDDD\nEEE")
|
||||
// Charwise selection at EOL does not include the trailing newline.
|
||||
expect(textarea.plainText).toBe("\nCCC\nDDD\nEEE")
|
||||
|
||||
const extmark = extmarks.get(id)
|
||||
expect(extmark).not.toBeNull()
|
||||
expect(extmark?.start).toBe(4)
|
||||
expect(extmark?.end).toBe(11)
|
||||
expect(extmark?.start).toBe(5)
|
||||
expect(extmark?.end).toBe(12)
|
||||
expect(textarea.plainText.substring(extmark!.start, extmark!.end)).toBe("DDD\nEEE")
|
||||
})
|
||||
|
||||
@@ -1815,7 +1819,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 4
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
@@ -1847,7 +1851,7 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 0
|
||||
|
||||
for (let i = 0; i < 18; i++) {
|
||||
for (let i = 0; i < 17; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
@@ -1855,18 +1859,19 @@ Press ESC to return to main menu.`
|
||||
|
||||
currentMockInput.pressBackspace()
|
||||
|
||||
expect(textarea.plainText).toBe("Line4\nLine5")
|
||||
// Charwise selection at EOL does not include the trailing newline.
|
||||
expect(textarea.plainText).toBe("\nLine4\nLine5")
|
||||
|
||||
const extmark1 = extmarks.get(id1)
|
||||
expect(extmark1).not.toBeNull()
|
||||
expect(extmark1?.start).toBe(0)
|
||||
expect(extmark1?.end).toBe(5)
|
||||
expect(extmark1?.start).toBe(1)
|
||||
expect(extmark1?.end).toBe(6)
|
||||
expect(textarea.plainText.substring(extmark1!.start, extmark1!.end)).toBe("Line4")
|
||||
|
||||
const extmark2 = extmarks.get(id2)
|
||||
expect(extmark2).not.toBeNull()
|
||||
expect(extmark2?.start).toBe(6)
|
||||
expect(extmark2?.end).toBe(11)
|
||||
expect(extmark2?.start).toBe(7)
|
||||
expect(extmark2?.end).toBe(12)
|
||||
expect(textarea.plainText.substring(extmark2!.start, extmark2!.end)).toBe("Line5")
|
||||
})
|
||||
})
|
||||
@@ -2772,7 +2777,8 @@ Press ESC to return to main menu.`
|
||||
textarea.focus()
|
||||
textarea.cursorOffset = 0
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// Inclusive selection: 4 shift+right presses select 5 cells ("Hello").
|
||||
for (let i = 0; i < 4; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { ASCIIFontRenderable } from "../renderables/ASCIIFont.js"
|
||||
import { createTestRenderer, type TestRenderer } from "../testing/test-renderer.js"
|
||||
import { ASCIIFontSelectionHelper, type LocalSelectionBounds } from "./selection.js"
|
||||
|
||||
function helper() {
|
||||
return new ASCIIFontSelectionHelper(
|
||||
() => "HI",
|
||||
() => "tiny",
|
||||
)
|
||||
}
|
||||
|
||||
function bounds(anchorX: number, focusX: number, anchorY = 0, focusY = 0): LocalSelectionBounds {
|
||||
return {
|
||||
anchorX,
|
||||
anchorY,
|
||||
focusX,
|
||||
focusY,
|
||||
isActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
describe("ASCIIFontSelectionHelper", () => {
|
||||
// tiny "H" is 3 cols, letterspace 1, "I" is 1 col. Positions: H@0, I@4.
|
||||
const hStart = 0
|
||||
const iStart = 4
|
||||
const height = 2
|
||||
const width = 5
|
||||
|
||||
it.each([
|
||||
["within H", hStart, 1, { start: 0, end: 1 }],
|
||||
["at the right edge of H", hStart, 2, { start: 0, end: 1 }],
|
||||
["from H to I", hStart, iStart, { start: 0, end: 2 }],
|
||||
["from I to H", iStart, hStart, { start: 0, end: 2 }],
|
||||
] as const)("selects occupied characters %s", (_name, anchorX, focusX, expected) => {
|
||||
const sel = helper()
|
||||
sel.onLocalSelectionChanged(bounds(anchorX, focusX), width, height)
|
||||
|
||||
expect(sel.getSelection()).toEqual(expected)
|
||||
})
|
||||
|
||||
it("stays empty on a press without drag", () => {
|
||||
const sel = helper()
|
||||
const changed = sel.onLocalSelectionChanged(bounds(hStart, hStart), width, height)
|
||||
|
||||
expect(sel.getSelection()).toBe(null)
|
||||
expect(changed).toBe(false)
|
||||
})
|
||||
|
||||
it("clears when the local selection is inactive", () => {
|
||||
const sel = helper()
|
||||
sel.onLocalSelectionChanged(bounds(hStart, iStart), width, height)
|
||||
expect(sel.hasSelection()).toBe(true)
|
||||
|
||||
sel.onLocalSelectionChanged(null, width, height)
|
||||
expect(sel.hasSelection()).toBe(false)
|
||||
})
|
||||
|
||||
it("uses vertical reading order outside the glyph row", () => {
|
||||
const sel = helper()
|
||||
sel.onLocalSelectionChanged(bounds(width, -1, -1, height), width, height)
|
||||
|
||||
expect(sel.getSelection()).toEqual({ start: 0, end: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("ASCIIFontRenderable selection", () => {
|
||||
let renderer: TestRenderer | undefined
|
||||
|
||||
afterEach(() => renderer?.destroy())
|
||||
|
||||
it("keeps unchanged active selections in renderer copy state", async () => {
|
||||
const test = await createTestRenderer({ width: 20, height: 4 })
|
||||
renderer = test.renderer
|
||||
const font = new ASCIIFontRenderable(renderer, { text: "HI", font: "tiny" })
|
||||
renderer.root.add(font)
|
||||
await test.renderOnce()
|
||||
|
||||
renderer.startSelection(font, font.x, font.y)
|
||||
renderer.updateSelection(font, font.x + 4, font.y)
|
||||
renderer.updateSelection(font, font.x + 4, font.y, { finishDragging: true })
|
||||
|
||||
expect(renderer.getSelection()?.getSelectedText()).toBe("HI")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Renderable } from "../Renderable.js"
|
||||
import type { ViewportBounds } from "../types.js"
|
||||
import { coordinateToCharacterIndex, fonts } from "./ascii.font.js"
|
||||
import { coordinateToCharacterIndex, fonts, getCharacterPositions } from "./ascii.font.js"
|
||||
|
||||
class SelectionAnchor {
|
||||
private relativeX: number
|
||||
@@ -212,46 +212,38 @@ export class ASCIIFontSelectionHelper {
|
||||
|
||||
const text = this.getText()
|
||||
const font = this.getFont()
|
||||
const positions = getCharacterPositions(text, font)
|
||||
const minY = Math.min(localSelection.anchorY, localSelection.focusY)
|
||||
const maxY = Math.max(localSelection.anchorY, localSelection.focusY)
|
||||
|
||||
const selStart = { x: localSelection.anchorX, y: localSelection.anchorY }
|
||||
const selEnd = { x: localSelection.focusX, y: localSelection.focusY }
|
||||
|
||||
if (height - 1 < selStart.y || 0 > selEnd.y) {
|
||||
// Completely above or below this glyph row: not selected.
|
||||
if (maxY < 0 || minY > height - 1) {
|
||||
this.localSelection = null
|
||||
return previousSelection !== null
|
||||
}
|
||||
|
||||
let startCharIndex = 0
|
||||
let endCharIndex = text.length
|
||||
|
||||
if (selStart.y > height - 1) {
|
||||
// Selection starts below us - we're not selected
|
||||
this.localSelection = null
|
||||
return previousSelection !== null
|
||||
} else if (selStart.y >= 0 && selStart.y <= height - 1) {
|
||||
// Selection starts within our Y range - use the actual start X coordinate
|
||||
if (selStart.x > 0) {
|
||||
startCharIndex = coordinateToCharacterIndex(selStart.x, text, font)
|
||||
const indexAt = (x: number, y: number): number => {
|
||||
if (y < 0) return 0
|
||||
if (y > height - 1) return text.length
|
||||
if (x < 0) return 0
|
||||
if (x >= width) return text.length
|
||||
for (let index = 1; index < positions.length; index += 1) {
|
||||
if (x < positions[index]) return index - 1
|
||||
}
|
||||
return text.length
|
||||
}
|
||||
|
||||
if (selEnd.y < 0) {
|
||||
// Selection ends above us - we're not selected
|
||||
const anchorIndex = indexAt(localSelection.anchorX, localSelection.anchorY)
|
||||
const focusIndex = indexAt(localSelection.focusX, localSelection.focusY)
|
||||
const start = Math.min(anchorIndex, focusIndex)
|
||||
const end = Math.min(Math.max(anchorIndex, focusIndex) + 1, text.length)
|
||||
const samePoint =
|
||||
localSelection.anchorX === localSelection.focusX && localSelection.anchorY === localSelection.focusY
|
||||
|
||||
if (samePoint || start >= end) {
|
||||
this.localSelection = null
|
||||
return previousSelection !== null
|
||||
} else if (selEnd.y >= 0 && selEnd.y <= height - 1) {
|
||||
// Selection ends within our Y range - use the actual end X coordinate
|
||||
if (selEnd.x >= 0) {
|
||||
endCharIndex = coordinateToCharacterIndex(selEnd.x, text, font)
|
||||
} else {
|
||||
endCharIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (startCharIndex < endCharIndex && startCharIndex >= 0 && endCharIndex <= text.length) {
|
||||
this.localSelection = { start: startCharIndex, end: endCharIndex }
|
||||
} else {
|
||||
this.localSelection = null
|
||||
this.localSelection = { start, end }
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -151,7 +151,7 @@ export class ASCIIFontRenderable extends FrameBufferRenderable {
|
||||
this.renderFontToBuffer()
|
||||
this.requestRender()
|
||||
}
|
||||
return changed
|
||||
return this.selectionHelper.hasSelection()
|
||||
}
|
||||
|
||||
getSelectedText(): string {
|
||||
|
||||
@@ -206,8 +206,10 @@ describe("EditBufferRenderable", () => {
|
||||
textarea.cursorOffset = 2
|
||||
textarea.moveCursorRight({ select: true })
|
||||
|
||||
expect(textarea.getSelection()).toEqual({ start: 2, end: 3 })
|
||||
expect(textarea.getSelectedText()).toBe("c")
|
||||
// Inclusive selection: the anchor cell and the cell under the moved
|
||||
// cursor are both selected (Vim v+l).
|
||||
expect(textarea.getSelection()).toEqual({ start: 2, end: 4 })
|
||||
expect(textarea.getSelectedText()).toBe("cd")
|
||||
})
|
||||
|
||||
test("sets cursor through renderable api", async () => {
|
||||
@@ -274,15 +276,91 @@ describe("EditBufferRenderable", () => {
|
||||
const textarea = new TextareaRenderable(renderer, {
|
||||
width: 20,
|
||||
height: 3,
|
||||
initialValue: "abcdefg",
|
||||
initialValue: "ab你cd",
|
||||
})
|
||||
|
||||
renderer.root.add(textarea)
|
||||
await renderOnce()
|
||||
|
||||
textarea.setSelectionInclusive(2, 3)
|
||||
textarea.setSelectionInclusive(2, 2)
|
||||
|
||||
expect(textarea.getSelection()).toEqual({ start: 2, end: 4 })
|
||||
expect(textarea.getSelectedText()).toBe("cd")
|
||||
expect(textarea.getSelectedText()).toBe("你")
|
||||
})
|
||||
|
||||
test("setSelectionInclusive does not extend under boundary occupancy", async () => {
|
||||
const textarea = new TextareaRenderable(renderer, {
|
||||
width: 20,
|
||||
height: 3,
|
||||
initialValue: "ab你cd",
|
||||
selectionOccupancy: "boundary",
|
||||
})
|
||||
|
||||
renderer.root.add(textarea)
|
||||
await renderOnce()
|
||||
|
||||
textarea.setSelectionInclusive(3, 4)
|
||||
|
||||
expect(textarea.getSelection()).toEqual({ start: 2, end: 4 })
|
||||
expect(textarea.getSelectedText()).toBe("你")
|
||||
textarea.deleteSelection()
|
||||
expect(textarea.plainText).toBe("abcd")
|
||||
})
|
||||
|
||||
test("setSelectionInclusive uses current text bounds", async () => {
|
||||
const textarea = new TextareaRenderable(renderer, {
|
||||
width: 20,
|
||||
height: 3,
|
||||
initialValue: "abc",
|
||||
})
|
||||
|
||||
renderer.root.add(textarea)
|
||||
await renderOnce()
|
||||
|
||||
textarea.setSelection(0, 3)
|
||||
textarea.replaceText("abcdefghij")
|
||||
textarea.setSelectionInclusive(8, 8)
|
||||
|
||||
expect(textarea.getSelection()).toEqual({ start: 8, end: 9 })
|
||||
expect(textarea.getSelectedText()).toBe("i")
|
||||
textarea.deleteSelection()
|
||||
expect(textarea.plainText).toBe("abcdefghj")
|
||||
|
||||
textarea.setText("abc")
|
||||
textarea.setSelectionInclusive(0, 99)
|
||||
expect(textarea.getSelection()).toEqual({ start: 0, end: 3 })
|
||||
textarea.deleteSelection()
|
||||
expect(textarea.plainText).toBe("")
|
||||
})
|
||||
|
||||
test("reads selection occupancy from the editor view", async () => {
|
||||
const textarea = new TextareaRenderable(renderer, { width: 20, height: 3 })
|
||||
|
||||
renderer.root.add(textarea)
|
||||
await renderOnce()
|
||||
|
||||
textarea.editorView.setSelectionOccupancy("boundary")
|
||||
expect(textarea.selectionOccupancy).toBe("boundary")
|
||||
|
||||
textarea.selectionOccupancy = "cell"
|
||||
expect(textarea.editorView.getSelectionOccupancy()).toBe("cell")
|
||||
|
||||
textarea.selectionOccupancy = undefined
|
||||
expect(textarea.selectionOccupancy).toBe("cell")
|
||||
})
|
||||
|
||||
test("does not move the cursor when occupancy changes an offset selection", async () => {
|
||||
const textarea = new TextareaRenderable(renderer, { width: 20, height: 3, initialValue: "ab你cd" })
|
||||
renderer.root.add(textarea)
|
||||
await renderOnce()
|
||||
|
||||
textarea.cursorOffset = 4
|
||||
textarea.setSelection(0, 2)
|
||||
textarea.selectionOccupancy = "boundary"
|
||||
expect(textarea.cursorOffset).toBe(4)
|
||||
|
||||
textarea.setSelection(3, 3)
|
||||
textarea.selectionOccupancy = "cell"
|
||||
expect(textarea.cursorOffset).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,14 @@ import { convertGlobalToLocalSelection, Selection, type LocalSelectionBounds } f
|
||||
import { EditBuffer, type LogicalCursor } from "../edit-buffer.js"
|
||||
import { EditorView, type VisualCursor } from "../editor-view.js"
|
||||
import { RGBA, parseColor } from "../lib/RGBA.js"
|
||||
import type { RenderContext, Highlight, CursorStyleOptions, LineInfoProvider, LineInfo } from "../types.js"
|
||||
import type {
|
||||
RenderContext,
|
||||
Highlight,
|
||||
CursorStyleOptions,
|
||||
LineInfoProvider,
|
||||
LineInfo,
|
||||
SelectionOccupancy,
|
||||
} from "../types.js"
|
||||
import type { OptimizedBuffer } from "../buffer.js"
|
||||
import type { SyntaxStyle } from "../syntax-style.js"
|
||||
import { NativeMeasureTargetKind, resolveRenderLib, type NativeRenderableHandle } from "../zig.js"
|
||||
@@ -59,6 +66,7 @@ export interface EditBufferOptions extends RenderableOptions<EditBufferRenderabl
|
||||
showCursor?: boolean
|
||||
cursorColor?: string | RGBA
|
||||
cursorStyle?: CursorStyleOptions
|
||||
selectionOccupancy?: SelectionOccupancy
|
||||
syntaxStyle?: SyntaxStyle
|
||||
tabIndicator?: string | number
|
||||
tabIndicatorColor?: string | RGBA
|
||||
@@ -143,6 +151,9 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
this.editorView.setWrapMode(this._wrapMode)
|
||||
this.editorView.setScrollMargin(this._scrollMargin)
|
||||
if (options.selectionOccupancy === "boundary") {
|
||||
this.editorView.setSelectionOccupancy("boundary")
|
||||
}
|
||||
|
||||
this.editBuffer.setDefaultFg(this._textColor)
|
||||
this.editBuffer.setDefaultBg(this._backgroundColor)
|
||||
@@ -220,6 +231,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
}
|
||||
|
||||
set cursorOffset(offset: number) {
|
||||
this.clearSelection()
|
||||
this.editorView.setCursorByOffset(offset)
|
||||
this.requestRender()
|
||||
}
|
||||
@@ -372,6 +384,17 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
}
|
||||
}
|
||||
|
||||
get selectionOccupancy(): SelectionOccupancy {
|
||||
return this.editorView.getSelectionOccupancy()
|
||||
}
|
||||
|
||||
set selectionOccupancy(value: SelectionOccupancy | null | undefined) {
|
||||
const occupancy = value ?? "cell"
|
||||
if (this.selectionOccupancy === occupancy) return
|
||||
this.editorView.setSelectionOccupancy(occupancy)
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
get tabIndicator(): string | number | undefined {
|
||||
return this._tabIndicator
|
||||
}
|
||||
@@ -582,14 +605,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
}
|
||||
|
||||
private refreshSelectionStyle(): void {
|
||||
if (this.lastLocalSelection) {
|
||||
this.updateLocalSelection(this.lastLocalSelection)
|
||||
return
|
||||
}
|
||||
|
||||
const selection = this.getSelection()
|
||||
if (!selection) return
|
||||
this.editorView.setSelection(selection.start, selection.end, this._selectionBg, this._selectionFg)
|
||||
this.editorView.setSelectionColors(this._selectionBg, this._selectionFg)
|
||||
}
|
||||
|
||||
private deleteSelectedText(): void {
|
||||
@@ -607,13 +623,16 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
}
|
||||
|
||||
setSelectionInclusive(start: number, end: number): void {
|
||||
this.setSelection(Math.min(start, end), Math.max(start, end) + 1)
|
||||
this.lastLocalSelection = null
|
||||
this.editorView.resetLocalSelection()
|
||||
this._ctx.clearSelection()
|
||||
this.editorView.setSelectionInclusive(start, end, this._selectionBg, this._selectionFg)
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
clearSelection(): boolean {
|
||||
const had = this.hasSelection()
|
||||
this.lastLocalSelection = null
|
||||
this.editorView.resetSelection()
|
||||
this.editorView.resetLocalSelection()
|
||||
this._ctx.clearSelection()
|
||||
if (had) {
|
||||
@@ -631,6 +650,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
}
|
||||
|
||||
setCursor(row: number, col: number): void {
|
||||
this.clearSelection()
|
||||
this.editBuffer.setCursor(row, col)
|
||||
this.requestRender()
|
||||
}
|
||||
@@ -691,16 +711,18 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
return true
|
||||
}
|
||||
|
||||
// Horizontal movement collapses to an edge without taking another step.
|
||||
private collapseSelectionToEdge(edge: "start" | "end"): boolean {
|
||||
const selection = this.getSelection()
|
||||
if (!selection) return false
|
||||
this.editBuffer.setCursorByOffset(edge === "start" ? selection.start : selection.end)
|
||||
this.clearSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
public moveCursorLeft(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
|
||||
if (!select && this.hasSelection()) {
|
||||
const selection = this.getSelection()!
|
||||
this.editBuffer.setCursorByOffset(selection.start)
|
||||
this._ctx.clearSelection()
|
||||
this.requestRender()
|
||||
return true
|
||||
}
|
||||
if (!select && this.collapseSelectionToEdge("start")) return true
|
||||
|
||||
this.updateSelectionForMovement(select, true)
|
||||
this.editBuffer.moveCursorLeft()
|
||||
@@ -711,15 +733,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public moveCursorRight(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
|
||||
if (!select && this.hasSelection()) {
|
||||
const selection = this.getSelection()!
|
||||
const targetOffset = this.cursorOffset === selection.start ? selection.end - 1 : selection.end
|
||||
this.editBuffer.setCursorByOffset(targetOffset)
|
||||
this._ctx.clearSelection()
|
||||
this.requestRender()
|
||||
return true
|
||||
}
|
||||
if (!select && this.collapseSelectionToEdge("end")) return true
|
||||
|
||||
this.updateSelectionForMovement(select, true)
|
||||
this.editBuffer.moveCursorRight()
|
||||
@@ -747,6 +761,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
}
|
||||
|
||||
public gotoLine(line: number): void {
|
||||
this.clearSelection()
|
||||
this.editBuffer.gotoLine(line)
|
||||
this.requestRender()
|
||||
}
|
||||
@@ -762,6 +777,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public gotoLineHome(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
if (!select && this.collapseSelectionToEdge("start")) return true
|
||||
this.updateSelectionForMovement(select, true)
|
||||
const cursor = this.editorView.getCursor()
|
||||
if (cursor.col === 0 && cursor.row > 0) {
|
||||
@@ -779,6 +795,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public gotoLineEnd(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
if (!select && this.collapseSelectionToEdge("end")) return true
|
||||
this.updateSelectionForMovement(select, true)
|
||||
const cursor = this.editorView.getCursor()
|
||||
const eol = this.editBuffer.getEOL()
|
||||
@@ -796,6 +813,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public gotoVisualLineHome(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
if (!select && this.collapseSelectionToEdge("start")) return true
|
||||
this.updateSelectionForMovement(select, true)
|
||||
const sol = this.editorView.getVisualSOL()
|
||||
this.editBuffer.setCursor(sol.logicalRow, sol.logicalCol)
|
||||
@@ -806,9 +824,9 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public gotoVisualLineEnd(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
if (!select && this.collapseSelectionToEdge("end")) return true
|
||||
this.updateSelectionForMovement(select, true)
|
||||
const eol = this.editorView.getVisualEOL()
|
||||
this.editBuffer.setCursor(eol.logicalRow, eol.logicalCol)
|
||||
this.editorView.gotoVisualLineEnd()
|
||||
this.updateSelectionForMovement(select, false)
|
||||
this.requestRender()
|
||||
return true
|
||||
@@ -816,6 +834,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public gotoBufferHome(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
if (!select && this.collapseSelectionToEdge("start")) return true
|
||||
this.updateSelectionForMovement(select, true)
|
||||
this.editBuffer.setCursor(0, 0)
|
||||
this.updateSelectionForMovement(select, false)
|
||||
@@ -825,6 +844,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public gotoBufferEnd(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
if (!select && this.collapseSelectionToEdge("end")) return true
|
||||
this.updateSelectionForMovement(select, true)
|
||||
this.editBuffer.gotoLine(999999)
|
||||
this.updateSelectionForMovement(select, false)
|
||||
@@ -879,6 +899,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public moveWordForward(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
if (!select && this.collapseSelectionToEdge("end")) return true
|
||||
this.updateSelectionForMovement(select, true)
|
||||
const nextWord = this.editBuffer.getNextWordBoundary()
|
||||
this.editBuffer.setCursorByOffset(nextWord.offset)
|
||||
@@ -889,6 +910,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
|
||||
public moveWordBackward(options?: { select?: boolean }): boolean {
|
||||
const select = options?.select ?? false
|
||||
if (!select && this.collapseSelectionToEdge("start")) return true
|
||||
this.updateSelectionForMovement(select, true)
|
||||
const prevWord = this.editBuffer.getPrevWordBoundary()
|
||||
this.editBuffer.setCursorByOffset(prevWord.offset)
|
||||
@@ -1126,14 +1148,14 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
}
|
||||
|
||||
protected updateSelectionForMovement(shiftPressed: boolean, isBeforeMovement: boolean): void {
|
||||
if (!this.selectable) return
|
||||
|
||||
if (!shiftPressed) {
|
||||
this._keyboardSelectionActive = false
|
||||
this._ctx.clearSelection()
|
||||
this.clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.selectable) return
|
||||
|
||||
this._keyboardSelectionActive = true
|
||||
|
||||
const visualCursor = this.editorView.getVisualCursor()
|
||||
@@ -1141,7 +1163,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
|
||||
const cursorY = this.y + visualCursor.visualRow
|
||||
|
||||
if (isBeforeMovement) {
|
||||
if (!this._ctx.hasSelection) {
|
||||
if (!this._ctx.hasSelection || !this.hasSelection()) {
|
||||
this._ctx.startSelection(this, cursorX, cursorY)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("TextRenderable Selection - Buffer Validation", () => {
|
||||
expect(text3.hasSelection()).toBe(false)
|
||||
|
||||
expect(text1.getSelectedText()).toBe("This is a paragraph in the first box.")
|
||||
expect(text2.getSelectedText()).toBe("It contain")
|
||||
expect(text2.getSelectedText()).toBe("It contains")
|
||||
|
||||
const buffers = currentRenderer.currentRenderBuffer.buffers
|
||||
const width = currentRenderer.currentRenderBuffer.width
|
||||
@@ -97,7 +97,7 @@ describe("TextRenderable Selection - Buffer Validation", () => {
|
||||
expect(bgMatches).toBe(true)
|
||||
}
|
||||
|
||||
for (let col = text2.x; col < text2.x + 10; col++) {
|
||||
for (let col = text2.x; col < text2.x + 11; col++) {
|
||||
const bg = getBgAt(col, text2.y)
|
||||
const bgMatches =
|
||||
Math.abs(bg.r - expectedBg.r) < 0.01 &&
|
||||
@@ -106,7 +106,7 @@ describe("TextRenderable Selection - Buffer Validation", () => {
|
||||
expect(bgMatches).toBe(true)
|
||||
}
|
||||
|
||||
for (let col = text2.x + 10; col < text2.x + text2.plainText.length; col++) {
|
||||
for (let col = text2.x + 11; col < text2.x + text2.plainText.length; col++) {
|
||||
const bg = getBgAt(col, text2.y)
|
||||
const bgMatches =
|
||||
Math.abs(bg.r - expectedBg.r) < 0.01 &&
|
||||
|
||||
@@ -31,11 +31,12 @@ describe("TextRenderable Selection", () => {
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
// Inclusive selection: the cell under the pointer (5) is selected too.
|
||||
await currentMouse.drag(text.x, text.y, text.x + 5, text.y)
|
||||
await renderOnce()
|
||||
|
||||
const selectedText = text.getSelectedText()
|
||||
expect(selectedText).toBe("Hello")
|
||||
expect(selectedText).toBe("Hello ")
|
||||
})
|
||||
|
||||
it("should handle graphemes correctly", async () => {
|
||||
@@ -125,10 +126,11 @@ describe("TextRenderable Selection", () => {
|
||||
// With newline-aware offsets: Line 0 (0-5) + newline (6) + Line 1 starts at 7
|
||||
// Position "n" in "Line 2" is at 7 + 2 = 9
|
||||
expect(selection!.start).toBe(9)
|
||||
// Line 2 starts at 14, position after "Line" is 14 + 4 = 18
|
||||
expect(selection!.end).toBe(18)
|
||||
// Line 2 starts at 14; the cell under the pointer (14 + 4, the ' ' of
|
||||
// "Line 3") is included, so the end is 19.
|
||||
expect(selection!.end).toBe(19)
|
||||
|
||||
expect(text.getSelectedText()).toBe("ne 2\nLine")
|
||||
expect(text.getSelectedText()).toBe("ne 2\nLine ")
|
||||
})
|
||||
|
||||
it("should handle selection across empty lines", async () => {
|
||||
@@ -372,13 +374,13 @@ describe("TextRenderable Selection", () => {
|
||||
|
||||
await currentMouse.drag(text.x + 0, text.y, text.x + 5, text.y)
|
||||
await renderOnce()
|
||||
expect(text.getSelectedText()).toBe("Hello")
|
||||
expect(text.getSelection()).toEqual({ start: 0, end: 5 })
|
||||
expect(text.getSelectedText()).toBe("Hello ")
|
||||
expect(text.getSelection()).toEqual({ start: 0, end: 6 })
|
||||
|
||||
await currentMouse.drag(text.x + 6, text.y, text.x + 11, text.y)
|
||||
await renderOnce()
|
||||
expect(text.getSelectedText()).toBe("World")
|
||||
expect(text.getSelection()).toEqual({ start: 6, end: 11 })
|
||||
expect(text.getSelectedText()).toBe("World ")
|
||||
expect(text.getSelection()).toEqual({ start: 6, end: 12 })
|
||||
|
||||
await currentMouse.drag(text.x + 12, text.y, text.x + 16, text.y)
|
||||
await renderOnce()
|
||||
@@ -1240,7 +1242,7 @@ describe("TextRenderable Selection", () => {
|
||||
await currentMouse.drag(text.x + 4, text.y, text.x + 9, text.y)
|
||||
await renderOnce()
|
||||
|
||||
expect(text.getSelectedText()).toBe("Green")
|
||||
expect(text.getSelectedText()).toBe("Green ")
|
||||
})
|
||||
|
||||
it("should handle StyledText with TextNodeRenderable children", async () => {
|
||||
|
||||
@@ -945,7 +945,7 @@ describe("TextTableRenderable", () => {
|
||||
await mockMouse.drag(anchor.x + 3, anchor.y, anchor.x + 5, anchor.y)
|
||||
await renderOnce()
|
||||
|
||||
expect(table.getSelectedText()).toBe("ha")
|
||||
expect(table.getSelectedText()).toBe("hab")
|
||||
})
|
||||
|
||||
test("selects the full anchor cell once focus leaves that cell", async () => {
|
||||
|
||||
@@ -82,6 +82,6 @@ describe("Multi-Renderable Selection Tests", () => {
|
||||
expect(selectedTextareaText).toContain("Line 10")
|
||||
|
||||
// Verify selection in Text renderable
|
||||
expect(selectedTextText).toBe("Text ")
|
||||
expect(selectedTextText).toBe("Text B")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -917,10 +917,10 @@ describe("Textarea - Editing Tests", () => {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
currentMockInput.pressKey("BACKSPACE", { shift: true })
|
||||
expect(editor.plainText).toBe(" World")
|
||||
expect(editor.plainText).toBe("World")
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1100,10 +1100,10 @@ describe("Textarea - Editing Tests", () => {
|
||||
kittyMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
expect(textarea.hasSelection()).toBe(true)
|
||||
expect(textarea.getSelectedText()).toBe("Hello")
|
||||
expect(textarea.getSelectedText()).toBe("Hello ")
|
||||
|
||||
kittyMockInput.pressKey("BACKSPACE", { shift: true })
|
||||
expect(textarea.plainText).toBe(" World")
|
||||
expect(textarea.plainText).toBe("World")
|
||||
expect(textarea.hasSelection()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1680,7 +1680,7 @@ describe("Textarea - Editing Tests", () => {
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
|
||||
currentMockInput.pressKey("d", { meta: true })
|
||||
expect(editor.plainText).toBe("lo world foo")
|
||||
expect(editor.plainText).toBe("o world foo")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("Textarea - Event Handlers Tests", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(contentChangeCount).toBeGreaterThan(beforeDelete)
|
||||
expect(editor.plainText).toBe(" World")
|
||||
expect(editor.plainText).toBe("World")
|
||||
})
|
||||
|
||||
it("should update event handler when set dynamically", async () => {
|
||||
|
||||
@@ -676,10 +676,11 @@ describe("Textarea - Keybinding Tests", () => {
|
||||
|
||||
currentMockInput.pressKey("L", { shift: true })
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("H")
|
||||
// Inclusive selection: anchor cell plus the cell under the moved cursor.
|
||||
expect(editor.getSelectedText()).toBe("He")
|
||||
|
||||
currentMockInput.pressKey("L", { shift: true })
|
||||
expect(editor.getSelectedText()).toBe("He")
|
||||
expect(editor.getSelectedText()).toBe("Hel")
|
||||
})
|
||||
|
||||
it("should support custom keybindings with alt modifier", async () => {
|
||||
@@ -2430,7 +2431,9 @@ describe("Textarea - Keybinding Tests", () => {
|
||||
|
||||
currentMockInput.pressArrow("right", { meta: true, shift: true })
|
||||
expect(editor.logicalCursor.col).toBe(6)
|
||||
expect(editor.getSelectedText()).toBe("日本語")
|
||||
// Inclusive selection (Vim v+w): the first cell of the next word is
|
||||
// selected because the cursor lands on it.
|
||||
expect(editor.getSelectedText()).toBe("日本語a")
|
||||
|
||||
currentMockInput.pressArrow("right", { meta: true, shift: true })
|
||||
expect(editor.logicalCursor.col).toBe(9)
|
||||
@@ -2438,7 +2441,7 @@ describe("Textarea - Keybinding Tests", () => {
|
||||
|
||||
currentMockInput.pressArrow("left", { meta: true, shift: true })
|
||||
expect(editor.logicalCursor.col).toBe(6)
|
||||
expect(editor.getSelectedText()).toBe("日本語")
|
||||
expect(editor.getSelectedText()).toBe("日本語a")
|
||||
|
||||
currentMockInput.pressArrow("left", { meta: true, shift: true })
|
||||
expect(editor.logicalCursor.col).toBe(0)
|
||||
@@ -2457,7 +2460,9 @@ describe("Textarea - Keybinding Tests", () => {
|
||||
|
||||
currentMockInput.pressArrow("right", { meta: true, shift: true })
|
||||
expect(editor.logicalCursor.col).toBe(2)
|
||||
expect(editor.getSelectedText()).toBe("丽")
|
||||
// Inclusive selection (Vim v+w): the first cell of the next word is
|
||||
// selected because the cursor lands on it.
|
||||
expect(editor.getSelectedText()).toBe("丽a")
|
||||
|
||||
currentMockInput.pressArrow("right", { meta: true, shift: true })
|
||||
expect(editor.logicalCursor.col).toBe(5)
|
||||
@@ -2465,7 +2470,7 @@ describe("Textarea - Keybinding Tests", () => {
|
||||
|
||||
currentMockInput.pressArrow("left", { meta: true, shift: true })
|
||||
expect(editor.logicalCursor.col).toBe(2)
|
||||
expect(editor.getSelectedText()).toBe("丽")
|
||||
expect(editor.getSelectedText()).toBe("丽a")
|
||||
|
||||
currentMockInput.pressArrow("left", { meta: true, shift: true })
|
||||
expect(editor.logicalCursor.col).toBe(0)
|
||||
@@ -3026,9 +3031,10 @@ describe("Textarea - Keybinding Tests", () => {
|
||||
// At end of line 1
|
||||
editor.editBuffer.setCursor(0, 6)
|
||||
|
||||
// First ctrl+shift+a from EOL selects through the line break at EOL
|
||||
// ctrl+shift+a from EOL selects the line up to the anchor: there is no
|
||||
// cell under an EOL cursor, so the newline is not included.
|
||||
kittyMockInput.pressKey("a", { ctrl: true, shift: true })
|
||||
expect(editor.getSelectedText()).toBe("Line 1\n")
|
||||
expect(editor.getSelectedText()).toBe("Line 1")
|
||||
|
||||
// Reset
|
||||
editor.editBuffer.setCursor(0, 0)
|
||||
@@ -3223,7 +3229,7 @@ describe("Textarea - Keybinding Tests", () => {
|
||||
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
const selectedText = editor.getSelectedText()
|
||||
expect(selectedText).toBe("KLMNOPQRS")
|
||||
expect(selectedText).toBe("KLMNOPQRST")
|
||||
})
|
||||
|
||||
it("should work without wrapping (same as logical)", async () => {
|
||||
|
||||
@@ -102,14 +102,16 @@ describe("Textarea - Paste Tests", () => {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
// Inclusive selection: 5 shift+right presses select "Hello" plus the
|
||||
// space under the cursor.
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
// Paste to replace selection
|
||||
await currentMockInput.pasteBracketedText("Goodbye")
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.plainText).toBe("Goodbye World")
|
||||
expect(editor.plainText).toBe("GoodbyeWorld")
|
||||
})
|
||||
|
||||
it("should replace multi-line selection when pasting", async () => {
|
||||
@@ -122,7 +124,8 @@ describe("Textarea - Paste Tests", () => {
|
||||
|
||||
editor.focus()
|
||||
|
||||
// Select from start through "Line 1\nLi"
|
||||
// Select from start through "Line 1\nLine" (inclusive of the cell
|
||||
// under the cursor)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
@@ -133,7 +136,7 @@ describe("Textarea - Paste Tests", () => {
|
||||
await currentMockInput.pasteBracketedText("New")
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.plainText).toBe("Newe 2\nLine 3")
|
||||
expect(editor.plainText).toBe("New 2\nLine 3")
|
||||
})
|
||||
|
||||
it("should replace selected text with multi-line paste", async () => {
|
||||
@@ -151,13 +154,13 @@ describe("Textarea - Paste Tests", () => {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
// Paste multi-line text to replace selection
|
||||
await currentMockInput.pasteBracketedText("Line 1\nLine 2")
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.plainText).toBe("Line 1\nLine 2 World")
|
||||
expect(editor.plainText).toBe("Line 1\nLine 2World")
|
||||
})
|
||||
|
||||
it("should paste empty string without error", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ let currentRenderer: TestRenderer
|
||||
let renderOnce: () => Promise<void>
|
||||
let currentMouse: MockMouse
|
||||
let currentMockInput: MockInput
|
||||
type SelectionTestEditor = Awaited<ReturnType<typeof createTextareaRenderable>>["textarea"]
|
||||
|
||||
describe("Textarea - Selection Tests", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -38,6 +39,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
|
||||
// Inclusive selection: the cell under the pointer (5, the space) is selected too.
|
||||
await currentMouse.drag(editor.x, editor.y, editor.x + 5, editor.y)
|
||||
await renderOnce()
|
||||
|
||||
@@ -46,9 +48,9 @@ describe("Textarea - Selection Tests", () => {
|
||||
const sel = editor.getSelection()
|
||||
expect(sel).not.toBe(null)
|
||||
expect(sel!.start).toBe(0)
|
||||
expect(sel!.end).toBe(5)
|
||||
expect(sel!.end).toBe(6)
|
||||
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
})
|
||||
|
||||
it("should return selected text from multi-line content", async () => {
|
||||
@@ -63,7 +65,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
await renderOnce()
|
||||
|
||||
const selectedText = editor.getSelectedText()
|
||||
expect(selectedText).toBe("AA\nBBBB\nCC")
|
||||
expect(selectedText).toBe("AA\nBBBB\nCCC")
|
||||
})
|
||||
|
||||
it("should handle selection with viewport scrolling", async () => {
|
||||
@@ -167,7 +169,25 @@ describe("Textarea - Selection Tests", () => {
|
||||
const sel = editor.getSelection()
|
||||
expect(sel).not.toBe(null)
|
||||
expect(sel!.start).toBe(2)
|
||||
expect(sel!.end).toBe(13)
|
||||
expect(sel!.end).toBe(14)
|
||||
})
|
||||
|
||||
it("should not select the next wrapped line when dragging into wrap padding", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "hello my good friend",
|
||||
width: 18,
|
||||
height: 10,
|
||||
wrapMode: "word",
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
expect(editor.editorView.getVirtualLineCount()).toBe(2)
|
||||
|
||||
// First visual line is 14 cols; columns 14..17 are empty wrap padding.
|
||||
await currentMouse.drag(editor.x, editor.y, editor.x + 17, editor.y)
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.getSelectedText()).toBe("hello my good ")
|
||||
})
|
||||
|
||||
it("should handle reverse selection (drag from end to start)", async () => {
|
||||
@@ -189,6 +209,95 @@ describe("Textarea - Selection Tests", () => {
|
||||
expect(editor.getSelectedText()).toBe("World")
|
||||
})
|
||||
|
||||
it("should keep the anchor cell selected when dragging backward", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello World",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
// Press on cell 9 ('l'), drag left to cell 6 ('W'): cells 6..9 selected.
|
||||
await currentMouse.drag(editor.x + 9, editor.y, editor.x + 6, editor.y)
|
||||
await renderOnce()
|
||||
|
||||
const sel = editor.getSelection()
|
||||
expect(sel).not.toBe(null)
|
||||
expect(sel!.start).toBe(6)
|
||||
expect(sel!.end).toBe(10)
|
||||
expect(editor.getSelectedText()).toBe("Worl")
|
||||
})
|
||||
|
||||
it("should keep the same selection when selection colors change after a backward drag", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello World",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
selectionBg: RGBA.fromValues(0, 0, 1, 1),
|
||||
})
|
||||
|
||||
await currentMouse.drag(editor.x + 9, editor.y, editor.x + 6, editor.y)
|
||||
await renderOnce()
|
||||
|
||||
const before = editor.getSelectedText()
|
||||
expect(before).toBe("Worl")
|
||||
|
||||
// Style changes replay the stored anchor/focus through setLocalSelection;
|
||||
// set and update must agree or the selection shifts under the user.
|
||||
editor.selectionBg = RGBA.fromValues(1, 0, 0, 1)
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.getSelectedText()).toBe(before)
|
||||
})
|
||||
|
||||
it("should collapse to the selection edges with plain arrow keys", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello World",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
await currentMouse.drag(editor.x + 9, editor.y, editor.x + 6, editor.y)
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.getSelectedText()).toBe("Worl")
|
||||
|
||||
// Right arrow collapses to the boundary after the last selected cell.
|
||||
currentMockInput.pressArrow("right")
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.logicalCursor.col).toBe(10)
|
||||
|
||||
await currentMouse.drag(editor.x + 9, editor.y, editor.x + 6, editor.y)
|
||||
await renderOnce()
|
||||
|
||||
// Left arrow collapses to the first selected cell.
|
||||
currentMockInput.pressArrow("left")
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.logicalCursor.col).toBe(6)
|
||||
})
|
||||
|
||||
it("should select the anchor cell and the crossed cell with shift+left", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello World",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
editor.editBuffer.setCursorToLineCol(0, 5)
|
||||
|
||||
// Vim v+h: the cell under the anchor cursor (the space at 5) stays
|
||||
// selected together with the cell the cursor moved onto (the 'o' at 4).
|
||||
currentMockInput.pressArrow("left", { shift: true })
|
||||
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("o ")
|
||||
})
|
||||
|
||||
it("should render selection properly when drawing to buffer", async () => {
|
||||
const buffer = OptimizedBuffer.create(80, 24, "wcwidth")
|
||||
|
||||
@@ -205,7 +314,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
buffer.clear(RGBA.fromValues(0, 0, 0, 1))
|
||||
buffer.drawEditorView(editor.editorView, editor.x, editor.y)
|
||||
@@ -213,7 +322,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
const sel = editor.getSelection()
|
||||
expect(sel).not.toBe(null)
|
||||
expect(sel!.start).toBe(0)
|
||||
expect(sel!.end).toBe(5)
|
||||
expect(sel!.end).toBe(6)
|
||||
|
||||
buffer.destroy()
|
||||
})
|
||||
@@ -300,7 +409,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
const selectedText = editor.getSelectedText()
|
||||
|
||||
expect(selectedText).toBe("A".repeat(10))
|
||||
expect(selectedText).toBe("A".repeat(11))
|
||||
|
||||
const sel = editor.getSelection()
|
||||
expect(sel).not.toBe(null)
|
||||
@@ -416,11 +525,12 @@ describe("Textarea - Selection Tests", () => {
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
const selectedText = editor.getSelectedText()
|
||||
|
||||
expect(selectedText).toBe("Line1")
|
||||
// Inclusive selection: 5 shift+right presses select 6 cells.
|
||||
expect(selectedText).toBe("Line15")
|
||||
|
||||
const sel = editor.getSelection()
|
||||
expect(sel).not.toBe(null)
|
||||
expect(sel!.end - sel!.start).toBe(5)
|
||||
expect(sel!.end - sel!.start).toBe(6)
|
||||
})
|
||||
|
||||
it("should handle mouse drag selection with scrolled viewport using correct offset", async () => {
|
||||
@@ -445,10 +555,10 @@ describe("Textarea - Selection Tests", () => {
|
||||
const selectedText = editor.getSelectedText()
|
||||
|
||||
expect(selectedText).not.toContain("AAAA0")
|
||||
expect(selectedText).not.toContain("AAAA1")
|
||||
|
||||
// Inclusive selection: dragging over cells 0..4 selects 5 cells.
|
||||
const firstVisibleLineIdx = viewport.offsetY
|
||||
const expectedText = `AAAA${firstVisibleLineIdx}`.substring(0, 4)
|
||||
const expectedText = `AAAA${firstVisibleLineIdx}`.substring(0, 5)
|
||||
expect(selectedText).toBe(expectedText)
|
||||
})
|
||||
|
||||
@@ -500,8 +610,10 @@ describe("Textarea - Selection Tests", () => {
|
||||
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
|
||||
// Inclusive selection: the anchor cell and the cell under the moved
|
||||
// cursor are both selected (Vim v+l).
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("H")
|
||||
expect(editor.getSelectedText()).toBe("He")
|
||||
})
|
||||
|
||||
it("should extend selection with shift+right", async () => {
|
||||
@@ -519,7 +631,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
}
|
||||
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
})
|
||||
|
||||
it("should extend a mouse selection with shift+right", async () => {
|
||||
@@ -536,12 +648,12 @@ describe("Textarea - Selection Tests", () => {
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
expect(editor.getSelectedText()).toBe("Hello W")
|
||||
})
|
||||
|
||||
it("should handle shift+left selection", async () => {
|
||||
@@ -576,9 +688,11 @@ describe("Textarea - Selection Tests", () => {
|
||||
|
||||
currentMockInput.pressArrow("down", { shift: true })
|
||||
|
||||
// Inclusive selection: the cell under the moved cursor (the 'L' of
|
||||
// "Line 2") is selected too.
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
const selectedText = editor.getSelectedText()
|
||||
expect(selectedText).toBe("Line 1\n")
|
||||
expect(selectedText).toBe("Line 1\nL")
|
||||
})
|
||||
|
||||
it("should select with shift+up", async () => {
|
||||
@@ -669,13 +783,13 @@ describe("Textarea - Selection Tests", () => {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
expect(editor.plainText).toBe("Hello World")
|
||||
|
||||
currentMockInput.pressBackspace()
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.plainText).toBe(" World")
|
||||
expect(editor.plainText).toBe("World")
|
||||
expect(editor.logicalCursor.col).toBe(0)
|
||||
})
|
||||
|
||||
@@ -725,7 +839,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
currentMockInput.pressBackspace()
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.plainText).toBe("e 2\nLine 3")
|
||||
expect(editor.plainText).toBe(" 2\nLine 3")
|
||||
expect(editor.logicalCursor.col).toBe(0)
|
||||
expect(editor.logicalCursor.row).toBe(0)
|
||||
})
|
||||
@@ -743,13 +857,15 @@ describe("Textarea - Selection Tests", () => {
|
||||
|
||||
currentMockInput.pressArrow("down", { shift: true })
|
||||
|
||||
// Inclusive selection: the cell under the moved cursor (the 'L' of
|
||||
// "Line 3") is selected and deleted too.
|
||||
const selectedText = editor.getSelectedText()
|
||||
expect(selectedText).toBe("Line 2\n")
|
||||
expect(selectedText).toBe("Line 2\nL")
|
||||
|
||||
currentMockInput.pressKey("DELETE")
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.plainText).toBe("Line 1\nLine 3")
|
||||
expect(editor.plainText).toBe("Line 1\nine 3")
|
||||
expect(editor.logicalCursor.row).toBe(1)
|
||||
})
|
||||
|
||||
@@ -767,13 +883,13 @@ describe("Textarea - Selection Tests", () => {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
currentMockInput.pressKey("H")
|
||||
currentMockInput.pressKey("i")
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.plainText).toBe("Hi World")
|
||||
expect(editor.plainText).toBe("HiWorld")
|
||||
})
|
||||
|
||||
it("should delete selected text via native deleteSelectedText API", async () => {
|
||||
@@ -790,13 +906,13 @@ describe("Textarea - Selection Tests", () => {
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
editor.editorView.deleteSelectedText()
|
||||
currentRenderer.clearSelection()
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.plainText).toBe(" World")
|
||||
expect(editor.plainText).toBe("World")
|
||||
expect(editor.logicalCursor.row).toBe(0)
|
||||
expect(editor.logicalCursor.col).toBe(0)
|
||||
expect(editor.editorView.hasSelection()).toBe(false)
|
||||
@@ -859,7 +975,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
expect(editor.getSelectedText()).toBe("")
|
||||
|
||||
expect(textBelow.hasSelection()).toBe(true)
|
||||
expect(textBelow.getSelectedText()).toBe("This is te")
|
||||
expect(textBelow.getSelectedText()).toBe("This is tex")
|
||||
|
||||
textBelow.destroy()
|
||||
})
|
||||
@@ -983,7 +1099,8 @@ describe("Textarea - Selection Tests", () => {
|
||||
expect(codeText2.hasSelection()).toBe(true)
|
||||
const codeText2Selected = codeText2.getSelectedText()
|
||||
const codeText2Content = " const selected = getText()"
|
||||
expect(codeText2Selected).toBe(codeText2Content.substring(0, 15))
|
||||
// Inclusive selection: the cell under the pointer (15) is selected too.
|
||||
expect(codeText2Selected).toBe(codeText2Content.substring(0, 16))
|
||||
|
||||
bottomText.destroy()
|
||||
rightBox.destroy()
|
||||
@@ -1091,8 +1208,10 @@ describe("Textarea - Selection Tests", () => {
|
||||
const selectedTextBefore = editor.getSelectedText()
|
||||
const selectionBefore = editor.getSelection()
|
||||
|
||||
// Inclusive selection: cells 6..17 = "BBBBB CCCCC" plus the space under
|
||||
// the pointer at cell 17.
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(selectedTextBefore).toBe("BBBBB CCCCC")
|
||||
expect(selectedTextBefore).toBe("BBBBB CCCCC ")
|
||||
|
||||
editor.width = 15
|
||||
editor.height = 15
|
||||
@@ -1103,7 +1222,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
const selectionAfterNarrow = editor.getSelection()
|
||||
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(selectedTextAfterNarrow).toBe("BBBBB CCCCC")
|
||||
expect(selectedTextAfterNarrow).toBe("BBBBB CCCCC ")
|
||||
expect(selectionAfterNarrow?.start).toBe(selectionBefore?.start)
|
||||
expect(selectionAfterNarrow?.end).toBe(selectionBefore?.end)
|
||||
|
||||
@@ -1125,7 +1244,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(selectedCellsNarrow).toBe(11)
|
||||
expect(selectedCellsNarrow).toBe(12)
|
||||
|
||||
editor.width = 50
|
||||
editor.height = 10
|
||||
@@ -1136,7 +1255,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
const selectionAfterWide = editor.getSelection()
|
||||
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(selectedTextAfterWide).toBe("BBBBB CCCCC")
|
||||
expect(selectedTextAfterWide).toBe("BBBBB CCCCC ")
|
||||
expect(selectionAfterWide?.start).toBe(selectionBefore?.start)
|
||||
expect(selectionAfterWide?.end).toBe(selectionBefore?.end)
|
||||
|
||||
@@ -1157,7 +1276,7 @@ describe("Textarea - Selection Tests", () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(selectedCellsWide).toBe(11)
|
||||
expect(selectedCellsWide).toBe(12)
|
||||
|
||||
buffer.destroy()
|
||||
editor.destroy()
|
||||
@@ -1563,6 +1682,363 @@ describe("Textarea - Selection Tests", () => {
|
||||
expect(selectedText).toContain("Line 14")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Occupancy contract", () => {
|
||||
it("selects one cell on first shift+right in boundary occupancy", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
selectionOccupancy: "boundary",
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
|
||||
expect(editor.getSelectedText()).toBe("H")
|
||||
expect(editor.getSelection()).toEqual({ start: 0, end: 1 })
|
||||
currentMockInput.pressBackspace()
|
||||
expect(editor.plainText).toBe("ello")
|
||||
})
|
||||
|
||||
it("selects b when shifting left from ab|cd in boundary occupancy", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "abcd",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
selectionOccupancy: "boundary",
|
||||
})
|
||||
editor.focus()
|
||||
editor.editBuffer.setCursorToLineCol(0, 2)
|
||||
editor.moveCursorLeft({ select: true })
|
||||
expect(editor.getSelectedText()).toBe("b")
|
||||
})
|
||||
|
||||
it("never copies half a wide glyph in either occupancy", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "ab你cd",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
editor.cursorOffset = 0
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
expect(editor.getSelectedText()).toBe("ab你")
|
||||
|
||||
editor.selectionOccupancy = "boundary"
|
||||
expect(editor.getSelectedText()).toBe("ab")
|
||||
|
||||
editor.selectionOccupancy = "cell"
|
||||
expect(editor.getSelectedText()).toBe("ab你")
|
||||
})
|
||||
|
||||
it("keeps boundary copy and deletion aligned on a wide-glyph continuation cell", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "ab你cd",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
selectionOccupancy: "boundary",
|
||||
})
|
||||
|
||||
await currentMouse.drag(editor.x, editor.y, editor.x + 3, editor.y)
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.getSelection()).toEqual({ start: 0, end: 4 })
|
||||
expect(editor.getSelectedText()).toBe("ab你")
|
||||
expect(editor.cursorOffset).toBe(4)
|
||||
editor.deleteSelection()
|
||||
expect(editor.plainText).toBe("cd")
|
||||
})
|
||||
|
||||
it("restarts shift selection from a normalized continuation-cell click", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "ab你cd",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
selectionOccupancy: "boundary",
|
||||
})
|
||||
editor.focus()
|
||||
|
||||
await currentMouse.click(editor.x + 3, editor.y)
|
||||
editor.moveCursorLeft({ select: true })
|
||||
expect(editor.getSelectedText()).toBe("b")
|
||||
|
||||
editor.moveCursorRight({ select: true })
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
})
|
||||
|
||||
it("resynchronizes a normalized caret when occupancy changes", async () => {
|
||||
let cursorChanges = 0
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "ab你cd",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
onCursorChange: () => cursorChanges++,
|
||||
})
|
||||
|
||||
await currentMouse.drag(editor.x, editor.y, editor.x + 3, editor.y)
|
||||
expect(editor.cursorOffset).toBe(2)
|
||||
|
||||
cursorChanges = 0
|
||||
editor.selectionOccupancy = "boundary"
|
||||
await renderOnce()
|
||||
expect(editor.cursorOffset).toBe(4)
|
||||
expect(cursorChanges).toBe(1)
|
||||
editor.moveCursorRight({ select: true })
|
||||
expect(editor.getSelectedText()).toBe("ab你c")
|
||||
})
|
||||
|
||||
it("keeps a press without drag empty", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
await currentMouse.pressDown(editor.x + 2, editor.y)
|
||||
await currentMouse.release(editor.x + 2, editor.y)
|
||||
await renderOnce()
|
||||
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.getSelectedText()).toBe("")
|
||||
})
|
||||
|
||||
it("does not grab a bare newline at EOL", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello\nWorld",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
editor.gotoLineEnd({ select: true })
|
||||
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
})
|
||||
|
||||
it("does not change selected text when cursorStyle changes", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
expect(editor.getSelectedText()).toBe("He")
|
||||
|
||||
editor.cursorStyle = { style: "line", blinking: true }
|
||||
expect(editor.getSelectedText()).toBe("He")
|
||||
expect(editor.selectionOccupancy).toBe("cell")
|
||||
|
||||
editor.cursorStyle = { style: "block", blinking: false }
|
||||
expect(editor.getSelectedText()).toBe("He")
|
||||
})
|
||||
|
||||
it("does not replay viewport-relative endpoints when selection colors change", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: Array.from({ length: 12 }, (_, index) => `Line ${index}`).join("\n"),
|
||||
width: 20,
|
||||
height: 3,
|
||||
selectable: true,
|
||||
})
|
||||
editor.gotoLine(6)
|
||||
await renderOnce()
|
||||
await currentMouse.drag(editor.x, editor.y, editor.x + 4, editor.y + 1)
|
||||
const before = editor.getSelectedText()
|
||||
|
||||
const viewport = editor.editorView.getViewport()
|
||||
editor.editorView.setViewport(viewport.offsetX, viewport.offsetY + 2, viewport.width, viewport.height, false)
|
||||
editor.selectionBg = "#ff0000"
|
||||
|
||||
expect(editor.getSelectedText()).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Non-shift collapse", () => {
|
||||
async function selectedHelloSpace() {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello World",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
editor.focus()
|
||||
editor.cursorOffset = 5
|
||||
editor.setSelection(0, 6)
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
return editor
|
||||
}
|
||||
|
||||
const clearCases: Array<[string, (editor: SelectionTestEditor) => void, number]> = [
|
||||
["left", (editor: SelectionTestEditor) => editor.moveCursorLeft(), 0],
|
||||
["right", (editor: SelectionTestEditor) => editor.moveCursorRight(), 6],
|
||||
["home", (editor: SelectionTestEditor) => editor.gotoLineHome(), 0],
|
||||
["end", (editor: SelectionTestEditor) => editor.gotoLineEnd(), 6],
|
||||
["visual-home", (editor: SelectionTestEditor) => editor.gotoVisualLineHome(), 0],
|
||||
["visual-end", (editor: SelectionTestEditor) => editor.gotoVisualLineEnd(), 6],
|
||||
["buffer-home", (editor: SelectionTestEditor) => editor.gotoBufferHome(), 0],
|
||||
["buffer-end", (editor: SelectionTestEditor) => editor.gotoBufferEnd(), 6],
|
||||
["word-back", (editor: SelectionTestEditor) => editor.moveWordBackward(), 0],
|
||||
["word-forward", (editor: SelectionTestEditor) => editor.moveWordForward(), 6],
|
||||
["cursor offset", (editor: SelectionTestEditor) => (editor.cursorOffset = 3), 3],
|
||||
["set cursor", (editor: SelectionTestEditor) => editor.setCursor(0, 4), 4],
|
||||
["line", (editor: SelectionTestEditor) => editor.gotoLine(0), 0],
|
||||
["exact line start", (editor: SelectionTestEditor) => editor.gotoLineStart(), 0],
|
||||
["exact line end", (editor: SelectionTestEditor) => editor.gotoLineTextEnd(), 11],
|
||||
]
|
||||
|
||||
it.each(clearCases)("clears the selection on %s", async (_name, move, offset) => {
|
||||
const editor = await selectedHelloSpace()
|
||||
move(editor)
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.cursorOffset).toBe(offset)
|
||||
})
|
||||
|
||||
it("clears an explicit selection before vertical movement", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello World\nSecond line",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
editor.focus()
|
||||
editor.cursorOffset = 5
|
||||
editor.setSelection(0, 6)
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
editor.selectable = false
|
||||
editor.moveCursorDown()
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
expect(editor.logicalCursor.row).toBe(1)
|
||||
expect(editor.logicalCursor.col).toBe(5)
|
||||
})
|
||||
|
||||
it("starts a new shift selection when only an explicit range exists", async () => {
|
||||
const editor = await selectedHelloSpace()
|
||||
|
||||
editor.moveCursorRight({ select: true })
|
||||
|
||||
expect(editor.getSelectedText()).toBe(" W")
|
||||
expect(editor.cursorOffset).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Horizontal wrapping", () => {
|
||||
it("includes newline when shift+right wraps from EOL onto the next line", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "Hello\nWorld",
|
||||
width: 40,
|
||||
height: 10,
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
editor.cursorOffset = 5
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
|
||||
expect(editor.getSelectedText()).toBe("\nW")
|
||||
})
|
||||
|
||||
it("includes the last cell at a wrapped visual-line end in boundary occupancy", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "hello my good friend",
|
||||
width: 18,
|
||||
height: 4,
|
||||
selectable: true,
|
||||
selectionOccupancy: "boundary",
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
editor.gotoVisualLineEnd({ select: true })
|
||||
expect(editor.getSelectedText()).toBe("hello my good ")
|
||||
expect(editor.visualCursor.visualRow).toBe(0)
|
||||
expect(editor.visualCursor.visualCol).toBe(14)
|
||||
|
||||
editor.height = 5
|
||||
await renderOnce()
|
||||
expect(editor.visualCursor.visualRow).toBe(0)
|
||||
expect(editor.visualCursor.visualCol).toBe(14)
|
||||
|
||||
editor.clearSelection()
|
||||
editor.moveCursorDown()
|
||||
expect(editor.logicalCursor.col).toBe(20)
|
||||
|
||||
editor.cursorOffset = 0
|
||||
await currentMouse.drag(editor.x, editor.y, editor.x + 17, editor.y)
|
||||
await renderOnce()
|
||||
expect(editor.getSelectedText()).toBe("hello my good ")
|
||||
expect(editor.visualCursor.visualRow).toBe(0)
|
||||
expect(editor.visualCursor.visualCol).toBe(14)
|
||||
|
||||
editor.selectionOccupancy = "cell"
|
||||
expect(editor.getSelectedText()).toBe("hello my good f")
|
||||
expect(editor.visualCursor.visualRow).toBe(1)
|
||||
expect(editor.visualCursor.visualCol).toBe(0)
|
||||
})
|
||||
|
||||
it("invalidates soft-wrap affinity after content changes", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "hello my good friend",
|
||||
width: 18,
|
||||
height: 4,
|
||||
selectable: true,
|
||||
selectionOccupancy: "boundary",
|
||||
})
|
||||
|
||||
await currentMouse.drag(editor.x + 17, editor.y + 1, editor.x, editor.y + 1)
|
||||
editor.deleteSelection()
|
||||
|
||||
expect(editor.plainText).toBe("hello my good ")
|
||||
expect(editor.visualCursor.visualRow).toBe(0)
|
||||
expect(editor.visualCursor.visualCol).toBe(14)
|
||||
})
|
||||
|
||||
it("keeps cell visual End on a grapheme boundary", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "ab你cd",
|
||||
width: 4,
|
||||
height: 4,
|
||||
wrapMode: "char",
|
||||
selectable: true,
|
||||
})
|
||||
|
||||
editor.focus()
|
||||
editor.gotoVisualLineEnd()
|
||||
expect(editor.cursorOffset).toBe(2)
|
||||
|
||||
editor.insertText("X")
|
||||
editor.deleteCharBackward()
|
||||
expect(editor.plainText).toBe("ab你cd")
|
||||
})
|
||||
|
||||
it("preserves boundary EOL affinity during vertical movement", async () => {
|
||||
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
|
||||
initialValue: "abcdefghijklmnopqr",
|
||||
width: 6,
|
||||
height: 4,
|
||||
wrapMode: "char",
|
||||
selectionOccupancy: "boundary",
|
||||
})
|
||||
|
||||
editor.gotoVisualLineEnd()
|
||||
editor.moveCursorDown()
|
||||
expect(editor.visualCursor.visualRow).toBe(1)
|
||||
expect(editor.visualCursor.visualCol).toBe(6)
|
||||
expect(editor.cursorOffset).toBe(12)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function countSelectedCells(
|
||||
|
||||
@@ -38,11 +38,12 @@ describe("Textarea - Undo/Redo Tests", () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
// Inclusive selection: each range extends through the cell under the cursor.
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe("Hello")
|
||||
expect(editor.getSelectedText()).toBe("Hello ")
|
||||
|
||||
currentMockInput.pressBackspace()
|
||||
expect(editor.plainText).toBe(" World Test")
|
||||
expect(editor.plainText).toBe("World Test")
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
|
||||
editor.editBuffer.setCursor(0, 0)
|
||||
@@ -50,10 +51,10 @@ describe("Textarea - Undo/Redo Tests", () => {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe(" World")
|
||||
expect(editor.getSelectedText()).toBe("World T")
|
||||
|
||||
currentMockInput.pressKey("DELETE")
|
||||
expect(editor.plainText).toBe(" Test")
|
||||
expect(editor.plainText).toBe("est")
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
|
||||
editor.editBuffer.setCursor(0, 0)
|
||||
@@ -61,17 +62,17 @@ describe("Textarea - Undo/Redo Tests", () => {
|
||||
currentMockInput.pressArrow("right", { shift: true })
|
||||
}
|
||||
expect(editor.hasSelection()).toBe(true)
|
||||
expect(editor.getSelectedText()).toBe(" Test")
|
||||
expect(editor.getSelectedText()).toBe("est")
|
||||
|
||||
currentMockInput.pressBackspace()
|
||||
expect(editor.plainText).toBe("")
|
||||
expect(editor.hasSelection()).toBe(false)
|
||||
|
||||
currentMockInput.pressKey("-", { ctrl: true })
|
||||
expect(editor.plainText).toBe(" Test")
|
||||
expect(editor.plainText).toBe("est")
|
||||
|
||||
currentMockInput.pressKey("-", { ctrl: true })
|
||||
expect(editor.plainText).toBe(" World Test")
|
||||
expect(editor.plainText).toBe("World Test")
|
||||
|
||||
currentMockInput.pressKey("-", { ctrl: true })
|
||||
expect(editor.plainText).toBe(initialText)
|
||||
|
||||
@@ -192,8 +192,8 @@ describe("TextareaRenderable - Visual Line Navigation", () => {
|
||||
textarea.gotoVisualLineEnd({ select: true })
|
||||
|
||||
const selectedText = textarea.getSelectedText()
|
||||
expect(selectedText).toBe("KLMNOPQRS")
|
||||
expect(selectedText.length).toBe(9)
|
||||
expect(selectedText).toBe("KLMNOPQRST")
|
||||
expect(selectedText.length).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -308,9 +308,10 @@ describe("TextBufferView", () => {
|
||||
const styledText = stringToStyledText("Hello World")
|
||||
buffer.setStyledText(styledText)
|
||||
|
||||
// Inclusive selection: the cell under the focus (5, the space) is selected too.
|
||||
const changed1 = view.setLocalSelection(0, 0, 5, 0)
|
||||
expect(changed1).toBe(true)
|
||||
expect(view.getSelectedText()).toBe("Hello")
|
||||
expect(view.getSelectedText()).toBe("Hello ")
|
||||
|
||||
const changed2 = view.updateLocalSelection(0, 0, 11, 0)
|
||||
expect(changed2).toBe(true)
|
||||
@@ -338,7 +339,7 @@ describe("TextBufferView", () => {
|
||||
const changed = view.updateLocalSelection(0, 0, 5, 0)
|
||||
expect(changed).toBe(true)
|
||||
expect(view.hasSelection()).toBe(true)
|
||||
expect(view.getSelectedText()).toBe("Hello")
|
||||
expect(view.getSelectedText()).toBe("Hello ")
|
||||
})
|
||||
|
||||
it("should preserve anchor when updating local selection", () => {
|
||||
@@ -346,16 +347,16 @@ describe("TextBufferView", () => {
|
||||
buffer.setStyledText(styledText)
|
||||
|
||||
view.setLocalSelection(0, 0, 5, 0)
|
||||
expect(view.getSelectedText()).toBe("Hello")
|
||||
expect(view.getSelectedText()).toBe("Hello ")
|
||||
|
||||
view.updateLocalSelection(0, 0, 6, 0)
|
||||
expect(view.getSelectedText()).toBe("Hello ")
|
||||
expect(view.getSelectedText()).toBe("Hello W")
|
||||
|
||||
view.updateLocalSelection(0, 0, 11, 0)
|
||||
expect(view.getSelectedText()).toBe("Hello World")
|
||||
|
||||
view.updateLocalSelection(0, 0, 3, 0)
|
||||
expect(view.getSelectedText()).toBe("Hel")
|
||||
expect(view.getSelectedText()).toBe("Hell")
|
||||
})
|
||||
|
||||
it("should handle backward selection with updateLocalSelection", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type TextBufferViewHandle,
|
||||
} from "./zig.js"
|
||||
import type { TextBuffer } from "./text-buffer.js"
|
||||
import type { SelectionOccupancy } from "./types.js"
|
||||
|
||||
export class TextBufferView {
|
||||
private lib: RenderLib
|
||||
@@ -106,6 +107,16 @@ export class TextBufferView {
|
||||
this.lib.textBufferViewResetLocalSelection(this.viewPtr)
|
||||
}
|
||||
|
||||
public setSelectionOccupancy(occupancy: SelectionOccupancy): void {
|
||||
this.guard()
|
||||
this.lib.textBufferViewSetSelectionOccupancy(this.viewPtr, occupancy)
|
||||
}
|
||||
|
||||
public getSelectionOccupancy(): SelectionOccupancy {
|
||||
this.guard()
|
||||
return this.lib.textBufferViewGetSelectionOccupancy(this.viewPtr)
|
||||
}
|
||||
|
||||
public setWrapWidth(width: number | null): void {
|
||||
this.guard()
|
||||
this.lib.textBufferViewSetWrapWidth(this.viewPtr, width ?? 0)
|
||||
|
||||
@@ -33,6 +33,12 @@ export type ThemeMode = "dark" | "light"
|
||||
|
||||
export type CursorStyle = "block" | "line" | "underline" | "default"
|
||||
|
||||
/** How a selection occupies cells between stored anchor and focus offsets.
|
||||
* Independent of `cursorStyle` (CSI `q` is paint). Default `cell` includes
|
||||
* the grapheme under the max endpoint. `boundary` is the half-open insert
|
||||
* range `[min, max)`. */
|
||||
export type SelectionOccupancy = "cell" | "boundary"
|
||||
|
||||
export type MousePointerStyle = "default" | "pointer" | "text" | "crosshair" | "move" | "not-allowed"
|
||||
|
||||
export interface CursorStyleOptions {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EventEmitter } from "events"
|
||||
import {
|
||||
type CursorStyle,
|
||||
type CursorStyleOptions,
|
||||
type SelectionOccupancy,
|
||||
type TargetChannel,
|
||||
type DebugOverlayCorner,
|
||||
type WidthMethod,
|
||||
@@ -1123,6 +1124,14 @@ function getOpenTUILib(libPath?: string) {
|
||||
args: ["u32"],
|
||||
returns: "void",
|
||||
},
|
||||
textBufferViewSetSelectionOccupancy: {
|
||||
args: ["u32", "u8"],
|
||||
returns: "void",
|
||||
},
|
||||
textBufferViewGetSelectionOccupancy: {
|
||||
args: ["u32"],
|
||||
returns: "u8",
|
||||
},
|
||||
textBufferViewSetWrapWidth: {
|
||||
args: ["u32", "u32"],
|
||||
returns: "void",
|
||||
@@ -1429,6 +1438,18 @@ function getOpenTUILib(libPath?: string) {
|
||||
args: ["u32"],
|
||||
returns: "void",
|
||||
},
|
||||
editorViewSetSelectionOccupancy: {
|
||||
args: ["u32", "u8"],
|
||||
returns: "void",
|
||||
},
|
||||
editorViewSetSelectionInclusive: {
|
||||
args: ["u32", "u32", "u32", "ptr", "ptr"],
|
||||
returns: "void",
|
||||
},
|
||||
editorViewSetSelectionColors: {
|
||||
args: ["u32", "ptr", "ptr"],
|
||||
returns: "void",
|
||||
},
|
||||
editorViewGetSelectedTextBytes: {
|
||||
args: ["u32", "ptr", "u32"],
|
||||
returns: "u32",
|
||||
@@ -1484,6 +1505,10 @@ function getOpenTUILib(libPath?: string) {
|
||||
args: ["u32", "ptr"],
|
||||
returns: "void",
|
||||
},
|
||||
editorViewGotoVisualLineEnd: {
|
||||
args: ["u32"],
|
||||
returns: "void",
|
||||
},
|
||||
editorViewSetPlaceholderStyledText: {
|
||||
args: ["u32", "ptr", "u32"],
|
||||
returns: "void",
|
||||
@@ -2812,6 +2837,8 @@ export interface RenderLib extends AudioEngineLib {
|
||||
fgColor: RGBA | null,
|
||||
) => boolean
|
||||
textBufferViewResetLocalSelection: (view: TextBufferViewHandle) => void
|
||||
textBufferViewSetSelectionOccupancy: (view: TextBufferViewHandle, occupancy: SelectionOccupancy) => void
|
||||
textBufferViewGetSelectionOccupancy: (view: TextBufferViewHandle) => SelectionOccupancy
|
||||
textBufferViewSetWrapWidth: (view: TextBufferViewHandle, width: number) => void
|
||||
textBufferViewSetWrapMode: (view: TextBufferViewHandle, mode: "none" | "char" | "word") => void
|
||||
textBufferViewSetFirstLineOffset: (view: TextBufferViewHandle, offset: number) => void
|
||||
@@ -2952,6 +2979,15 @@ export interface RenderLib extends AudioEngineLib {
|
||||
) => boolean
|
||||
|
||||
editorViewResetLocalSelection: (view: EditorViewHandle) => void
|
||||
editorViewSetSelectionOccupancy: (view: EditorViewHandle, occupancy: SelectionOccupancy) => void
|
||||
editorViewSetSelectionInclusive: (
|
||||
view: EditorViewHandle,
|
||||
start: number,
|
||||
end: number,
|
||||
bgColor: RGBA | null,
|
||||
fgColor: RGBA | null,
|
||||
) => void
|
||||
editorViewSetSelectionColors: (view: EditorViewHandle, bgColor: RGBA | null, fgColor: RGBA | null) => void
|
||||
editorViewGetSelectedTextBytes: (view: EditorViewHandle, maxLength: number) => Uint8Array | null
|
||||
editorViewGetCursor: (view: EditorViewHandle) => { row: number; col: number }
|
||||
editorViewGetText: (view: EditorViewHandle, maxLength: number) => Uint8Array | null
|
||||
@@ -2965,6 +3001,7 @@ export interface RenderLib extends AudioEngineLib {
|
||||
editorViewGetEOL: (view: EditorViewHandle) => VisualCursor
|
||||
editorViewGetVisualSOL: (view: EditorViewHandle) => VisualCursor
|
||||
editorViewGetVisualEOL: (view: EditorViewHandle) => VisualCursor
|
||||
editorViewGotoVisualLineEnd: (view: EditorViewHandle) => void
|
||||
editorViewGetLineInfo: (view: EditorViewHandle) => LineInfo
|
||||
editorViewGetLogicalLineInfo: (view: EditorViewHandle) => LineInfo
|
||||
editorViewSetPlaceholderStyledText: (
|
||||
@@ -5114,6 +5151,14 @@ class FFIRenderLib implements RenderLib {
|
||||
this.opentui.symbols.textBufferViewResetLocalSelection(view)
|
||||
}
|
||||
|
||||
public textBufferViewSetSelectionOccupancy(view: Pointer, occupancy: SelectionOccupancy): void {
|
||||
this.opentui.symbols.textBufferViewSetSelectionOccupancy(view, occupancy === "boundary" ? 1 : 0)
|
||||
}
|
||||
|
||||
public textBufferViewGetSelectionOccupancy(view: Pointer): SelectionOccupancy {
|
||||
return this.opentui.symbols.textBufferViewGetSelectionOccupancy(view) === 1 ? "boundary" : "cell"
|
||||
}
|
||||
|
||||
public textBufferViewSetWrapWidth(view: Pointer, width: number): void {
|
||||
this.opentui.symbols.textBufferViewSetWrapWidth(view, width)
|
||||
}
|
||||
@@ -5765,6 +5810,28 @@ class FFIRenderLib implements RenderLib {
|
||||
this.opentui.symbols.editorViewResetLocalSelection(view)
|
||||
}
|
||||
|
||||
public editorViewSetSelectionOccupancy(view: Pointer, occupancy: SelectionOccupancy): void {
|
||||
this.opentui.symbols.editorViewSetSelectionOccupancy(view, occupancy === "boundary" ? 1 : 0)
|
||||
}
|
||||
|
||||
public editorViewSetSelectionInclusive(
|
||||
view: Pointer,
|
||||
start: number,
|
||||
end: number,
|
||||
bgColor: RGBA | null,
|
||||
fgColor: RGBA | null,
|
||||
): void {
|
||||
const bg = optionalRgbaBuffer(bgColor)
|
||||
const fg = optionalRgbaBuffer(fgColor)
|
||||
this.opentui.symbols.editorViewSetSelectionInclusive(view, start, end, bg, fg)
|
||||
}
|
||||
|
||||
public editorViewSetSelectionColors(view: Pointer, bgColor: RGBA | null, fgColor: RGBA | null): void {
|
||||
const bg = optionalRgbaBuffer(bgColor)
|
||||
const fg = optionalRgbaBuffer(fgColor)
|
||||
this.opentui.symbols.editorViewSetSelectionColors(view, bg, fg)
|
||||
}
|
||||
|
||||
public editorViewGetSelectedTextBytes(view: Pointer, maxLength: number): Uint8Array | null {
|
||||
const outBuffer = new Uint8Array(maxLength)
|
||||
const actualLen = this.opentui.symbols.editorViewGetSelectedTextBytes(view, viewOrNull(outBuffer), maxLength)
|
||||
@@ -5846,6 +5913,10 @@ class FFIRenderLib implements RenderLib {
|
||||
return { ...cursor }
|
||||
}
|
||||
|
||||
public editorViewGotoVisualLineEnd(view: Pointer): void {
|
||||
this.opentui.symbols.editorViewGotoVisualLineEnd(view)
|
||||
}
|
||||
|
||||
public bufferPushScissorRect(buffer: Pointer, x: number, y: number, width: number, height: number): void {
|
||||
this.opentui.symbols.bufferPushScissorRect(buffer, x, y, width, height)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import {
|
||||
CliRenderer,
|
||||
CliRenderEvents,
|
||||
createCliRenderer,
|
||||
createClipboard,
|
||||
createHostClipboard,
|
||||
createRendererClipboardAdapter,
|
||||
TextareaRenderable,
|
||||
BoxRenderable,
|
||||
TextRenderable,
|
||||
LineNumberRenderable,
|
||||
KeyEvent,
|
||||
MouseButton,
|
||||
PasteEvent,
|
||||
t,
|
||||
bold,
|
||||
cyan,
|
||||
fg,
|
||||
type ClipboardSelection,
|
||||
type ClipboardService,
|
||||
type HostClipboardService,
|
||||
type Selection,
|
||||
} from "@opentui/core"
|
||||
import { setupCommonDemoKeys } from "./lib/standalone-keys.js"
|
||||
|
||||
@@ -41,6 +51,13 @@ SELECTION:
|
||||
• Alt+Shift+Left/Right to select word forward/backward
|
||||
• Alt+Shift+A/E to select to visual line start/end
|
||||
|
||||
CLIPBOARD:
|
||||
• Ctrl+Y to copy selected text
|
||||
• Ctrl+V to paste from the system clipboard
|
||||
• Terminal paste shortcuts also insert text
|
||||
• Linux: selecting text updates the primary selection
|
||||
• Linux: middle-click to paste the primary selection
|
||||
|
||||
EDITING:
|
||||
• Type any text to insert
|
||||
• Backspace/Delete to remove text
|
||||
@@ -60,8 +77,10 @@ UNDO/REDO:
|
||||
VIEW:
|
||||
• Shift+W to toggle wrap mode (word/char/none)
|
||||
• Shift+L to toggle line numbers
|
||||
• Shift+S to toggle selection occupancy (cell/boundary)
|
||||
• Shift+H to toggle diff highlights (colors + +/- signs)
|
||||
• Shift+D to toggle diagnostics (error/warning/info emojis)
|
||||
• Shift+C to toggle cursor style (block/line)
|
||||
• Ctrl+] to increase scroll speed
|
||||
• Ctrl+[ to decrease scroll speed
|
||||
|
||||
@@ -81,11 +100,127 @@ let parentContainer: BoxRenderable | null = null
|
||||
let editor: TextareaRenderable | null = null
|
||||
let editorWithLines: LineNumberRenderable | null = null
|
||||
let statusText: TextRenderable | null = null
|
||||
let highlightsEnabled: boolean = false
|
||||
let diagnosticsEnabled: boolean = false
|
||||
let clipboard: ClipboardService | null = null
|
||||
let keyHandler: ((key: KeyEvent) => void) | null = null
|
||||
let selectionHandler: ((selection: Selection) => void) | null = null
|
||||
let rendererDestroyHandler: (() => void) | null = null
|
||||
let destroyPromise: Promise<void> | null = null
|
||||
let clipboardStatus = "Clipboard ready"
|
||||
let highlightsEnabled: boolean = true
|
||||
let diagnosticsEnabled: boolean = true
|
||||
|
||||
export async function run(rendererInstance: CliRenderer): Promise<void> {
|
||||
const IS_LINUX = process.platform === "linux"
|
||||
const INHERITED_WAYLAND_ONLY =
|
||||
IS_LINUX && Boolean(process.env.WAYLAND_SOCKET) && !process.env.WAYLAND_DISPLAY && !process.env.DISPLAY
|
||||
let inheritedWaylandServiceCreated = false
|
||||
const MAX_QUEUED_CLIPBOARD_OPERATIONS = 16
|
||||
type ClipboardOperationQueue = { tail: Promise<void>; pending: number }
|
||||
const clipboardOperationQueues: Record<ClipboardSelection, ClipboardOperationQueue> = {
|
||||
clipboard: { tail: Promise.resolve(), pending: 0 },
|
||||
primary: { tail: Promise.resolve(), pending: 0 },
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
function matchesControlKey(key: KeyEvent, name: string): boolean {
|
||||
const baseCode = name.charCodeAt(0)
|
||||
const matchesName = key.name === name || key.baseCode === baseCode || key.baseCode === baseCode - 32
|
||||
return matchesName && key.ctrl && !key.shift && !key.meta && !key.super && !key.hyper
|
||||
}
|
||||
|
||||
function queueClipboardOperation(selection: ClipboardSelection, operation: () => Promise<void>): void {
|
||||
const queue = clipboardOperationQueues[selection]
|
||||
if (queue.pending >= MAX_QUEUED_CLIPBOARD_OPERATIONS) {
|
||||
clipboardStatus = "Clipboard busy"
|
||||
return
|
||||
}
|
||||
|
||||
queue.pending += 1
|
||||
const queued = queue.tail.then(operation)
|
||||
queue.tail = queued.catch((error) => {
|
||||
clipboardStatus = `Clipboard failed: ${errorMessage(error)}`
|
||||
})
|
||||
void queue.tail.then(() => {
|
||||
queue.pending -= 1
|
||||
})
|
||||
}
|
||||
|
||||
async function writeClipboardText(text: string, selection: ClipboardSelection, successStatus: string): Promise<void> {
|
||||
const service = clipboard
|
||||
if (!service) {
|
||||
clipboardStatus = "Clipboard unavailable"
|
||||
return
|
||||
}
|
||||
|
||||
clipboardStatus = selection === "primary" ? "Updating primary selection..." : "Copying selection..."
|
||||
try {
|
||||
const result = await service.writeText(text, {
|
||||
destination: selection === "primary" ? "host-only" : "best-available",
|
||||
selection,
|
||||
allowRemoteHost: selection === "primary",
|
||||
})
|
||||
if (service !== clipboard) return
|
||||
|
||||
if (result.host.status === "written") {
|
||||
clipboardStatus = successStatus
|
||||
} else if (result.terminal.status === "attempted") {
|
||||
clipboardStatus = `${successStatus} (terminal request sent)`
|
||||
} else if (result.host.status === "failed") {
|
||||
clipboardStatus = `Copy failed: ${errorMessage(result.host.error)}`
|
||||
} else {
|
||||
clipboardStatus = `Copy failed: host ${result.host.status}, terminal ${result.terminal.status}`
|
||||
}
|
||||
} catch (error) {
|
||||
if (service === clipboard) clipboardStatus = `Copy failed: ${errorMessage(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
function queueClipboardWrite(text: string, selection: ClipboardSelection, successStatus: string): void {
|
||||
queueClipboardOperation(selection, () => writeClipboardText(text, selection, successStatus))
|
||||
}
|
||||
|
||||
function pasteClipboardText(selection: ClipboardSelection): void {
|
||||
queueClipboardOperation(selection, async () => {
|
||||
const service = clipboard
|
||||
const target = editor
|
||||
if (!service || !target || target.isDestroyed) {
|
||||
clipboardStatus = "Clipboard unavailable"
|
||||
return
|
||||
}
|
||||
|
||||
const source = selection === "primary" ? "primary selection" : "clipboard"
|
||||
clipboardStatus = `Reading ${source}...`
|
||||
try {
|
||||
const result = await service.read({ preferredTypes: ["text/plain"], selection })
|
||||
if (service !== clipboard || target !== editor || target.isDestroyed) return
|
||||
|
||||
if (result.status !== "read") {
|
||||
const detail = result.status === "failed" ? `: ${errorMessage(result.error)}` : ""
|
||||
clipboardStatus = `Paste failed: ${result.status}${detail}`
|
||||
return
|
||||
}
|
||||
|
||||
if (result.representation.bytes.length === 0) {
|
||||
clipboardStatus = `${source} is empty`
|
||||
return
|
||||
}
|
||||
|
||||
target.handlePaste(new PasteEvent(result.representation.bytes, { mimeType: "text/plain", kind: "text" }))
|
||||
clipboardStatus = `Pasted from ${source}`
|
||||
} catch (error) {
|
||||
if (service === clipboard) clipboardStatus = `Paste failed: ${errorMessage(error)}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupEditor(rendererInstance: CliRenderer): void {
|
||||
renderer = rendererInstance
|
||||
rendererDestroyHandler = () => {
|
||||
void destroy(rendererInstance).catch((error) => console.error("Failed to clean up editor:", error))
|
||||
}
|
||||
rendererInstance.once(CliRenderEvents.DESTROY, rendererDestroyHandler)
|
||||
renderer.setBackgroundColor("#0D1117")
|
||||
|
||||
parentContainer = new BoxRenderable(renderer, {
|
||||
@@ -130,6 +265,14 @@ export async function run(rendererInstance: CliRenderer): Promise<void> {
|
||||
bg: "#161b22", // Slightly darker than editor background for distinction
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
onMouseDown: (event) => {
|
||||
if (!IS_LINUX || event.button !== MouseButton.MIDDLE) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
editor?.focus()
|
||||
pasteClipboardText("primary")
|
||||
},
|
||||
})
|
||||
|
||||
editorBox.add(editorWithLines)
|
||||
@@ -143,6 +286,7 @@ export async function run(rendererInstance: CliRenderer): Promise<void> {
|
||||
parentContainer.add(statusText)
|
||||
|
||||
editor.focus()
|
||||
if (rendererInstance.isDestroyed) throw new Error("Renderer was destroyed during editor setup")
|
||||
|
||||
rendererInstance.setFrameCallback(async () => {
|
||||
if (statusText && editor && !editor.isDestroyed) {
|
||||
@@ -151,15 +295,35 @@ export async function run(rendererInstance: CliRenderer): Promise<void> {
|
||||
const wrap = editor.wrapMode !== "none" ? "ON" : "OFF"
|
||||
const highlights = highlightsEnabled ? "ON" : "OFF"
|
||||
const diagnostics = diagnosticsEnabled ? "ON" : "OFF"
|
||||
const selectionOccupancy = editor.selectionOccupancy.toUpperCase()
|
||||
const cursorStyle = (editor.cursorStyle.style ?? "block").toUpperCase()
|
||||
const scrollSpeed = editor.scrollSpeed
|
||||
statusText.content = `Line ${cursor.row + 1}, Col ${cursor.col + 1} | Wrap: ${wrap} | Diff: ${highlights} | Diag: ${diagnostics} | Scroll: ${scrollSpeed} lines/s`
|
||||
statusText.content = `Line ${cursor.row + 1}, Col ${cursor.col + 1} | ${clipboardStatus} | Wrap: ${wrap} | Selection: ${selectionOccupancy} | Diff: ${highlights} | Diag: ${diagnostics} | Cursor: ${cursorStyle} | Scroll: ${scrollSpeed} lines/s`
|
||||
} catch (error) {
|
||||
// Ignore errors during shutdown
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
rendererInstance.keyInput.on("keypress", (key: KeyEvent) => {
|
||||
keyHandler = (key: KeyEvent) => {
|
||||
if (editor?.focused && matchesControlKey(key, "v")) {
|
||||
key.preventDefault()
|
||||
key.stopPropagation()
|
||||
pasteClipboardText("clipboard")
|
||||
return
|
||||
}
|
||||
if (editor?.focused && matchesControlKey(key, "y")) {
|
||||
key.preventDefault()
|
||||
key.stopPropagation()
|
||||
const selectedText = rendererInstance.getSelection()?.getSelectedText() || editor.getSelectedText()
|
||||
if (selectedText) {
|
||||
queueClipboardWrite(selectedText, "clipboard", "Selection copied")
|
||||
} else {
|
||||
clipboardStatus = "Nothing selected"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.shift && key.name === "l") {
|
||||
key.preventDefault()
|
||||
if (editorWithLines && !editorWithLines.isDestroyed) {
|
||||
@@ -174,6 +338,19 @@ export async function run(rendererInstance: CliRenderer): Promise<void> {
|
||||
editor.wrapMode = nextMode
|
||||
}
|
||||
}
|
||||
if (key.shift && key.name === "s") {
|
||||
key.preventDefault()
|
||||
if (editor && !editor.isDestroyed) {
|
||||
editor.selectionOccupancy = editor.selectionOccupancy === "cell" ? "boundary" : "cell"
|
||||
}
|
||||
}
|
||||
if (key.shift && key.name === "c") {
|
||||
key.preventDefault()
|
||||
if (editor && !editor.isDestroyed) {
|
||||
const thin = editor.cursorStyle.style === "line"
|
||||
editor.cursorStyle = thin ? { style: "block", blinking: true } : { style: "line", blinking: false }
|
||||
}
|
||||
}
|
||||
if (key.shift && key.name === "h") {
|
||||
key.preventDefault()
|
||||
if (editorWithLines && !editorWithLines.isDestroyed) {
|
||||
@@ -299,24 +476,109 @@ export async function run(rendererInstance: CliRenderer): Promise<void> {
|
||||
editor.scrollSpeed = Math.max(4, editor.scrollSpeed - 4)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
rendererInstance.keyInput.on("keypress", keyHandler)
|
||||
|
||||
if (IS_LINUX) {
|
||||
selectionHandler = (selection) => {
|
||||
const selectedText = selection.getSelectedText()
|
||||
if (selectedText) queueClipboardWrite(selectedText, "primary", "Primary selection updated")
|
||||
}
|
||||
rendererInstance.on(CliRenderEvents.SELECTION, selectionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
export function destroy(rendererInstance: CliRenderer): void {
|
||||
rendererInstance.clearFrameCallbacks()
|
||||
parentContainer?.destroy()
|
||||
parentContainer = null
|
||||
editorWithLines = null
|
||||
editor = null
|
||||
statusText = null
|
||||
renderer = null
|
||||
export async function run(rendererInstance: CliRenderer): Promise<void> {
|
||||
if (destroyPromise) await destroyPromise
|
||||
if (renderer) throw new Error("Editor demo is already running")
|
||||
if (rendererInstance.isDestroyed) throw new Error("Cannot run editor demo with a destroyed renderer")
|
||||
|
||||
destroyPromise = null
|
||||
clipboardStatus = "Clipboard ready"
|
||||
let host: HostClipboardService | null = null
|
||||
|
||||
if (INHERITED_WAYLAND_ONLY && inheritedWaylandServiceCreated) {
|
||||
clipboardStatus = "Host clipboard unavailable: inherited Wayland socket already used"
|
||||
setupEditor(rendererInstance)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
host = createHostClipboard()
|
||||
clipboard = createClipboard({
|
||||
host,
|
||||
terminal: createRendererClipboardAdapter(rendererInstance),
|
||||
})
|
||||
host = null
|
||||
if (INHERITED_WAYLAND_ONLY) inheritedWaylandServiceCreated = true
|
||||
setupEditor(rendererInstance)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (clipboard) await destroy(rendererInstance)
|
||||
else if (host) await host.dispose()
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to clean up editor clipboard:", cleanupError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function destroy(rendererInstance: CliRenderer): Promise<void> {
|
||||
destroyPromise ??= Promise.resolve().then(async () => {
|
||||
const service = clipboard
|
||||
const activeClipboardOperations = Object.values(clipboardOperationQueues).map((queue) => queue.tail)
|
||||
const activeKeyHandler = keyHandler
|
||||
const activeSelectionHandler = selectionHandler
|
||||
const activeRendererDestroyHandler = rendererDestroyHandler
|
||||
const container = parentContainer
|
||||
clipboard = null
|
||||
keyHandler = null
|
||||
selectionHandler = null
|
||||
rendererDestroyHandler = null
|
||||
parentContainer = null
|
||||
editorWithLines = null
|
||||
editor = null
|
||||
statusText = null
|
||||
renderer = null
|
||||
|
||||
try {
|
||||
if (activeKeyHandler) rendererInstance.keyInput.off("keypress", activeKeyHandler)
|
||||
if (activeSelectionHandler) rendererInstance.off(CliRenderEvents.SELECTION, activeSelectionHandler)
|
||||
if (activeRendererDestroyHandler) rendererInstance.off(CliRenderEvents.DESTROY, activeRendererDestroyHandler)
|
||||
|
||||
rendererInstance.clearFrameCallbacks()
|
||||
rendererInstance.clearSelection()
|
||||
container?.destroyRecursively()
|
||||
} finally {
|
||||
try {
|
||||
await service?.dispose()
|
||||
} finally {
|
||||
await Promise.all(activeClipboardOperations)
|
||||
}
|
||||
}
|
||||
})
|
||||
return destroyPromise
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: true,
|
||||
exitOnCtrlC: false,
|
||||
targetFps: 60,
|
||||
})
|
||||
run(renderer)
|
||||
try {
|
||||
await run(renderer)
|
||||
} catch (error) {
|
||||
renderer.destroy()
|
||||
throw error
|
||||
}
|
||||
setupCommonDemoKeys(renderer)
|
||||
renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
||||
if (matchesControlKey(key, "c") || matchesControlKey(key, "q")) {
|
||||
key.preventDefault()
|
||||
key.stopPropagation()
|
||||
void destroy(renderer)
|
||||
.catch((error) => console.error("Failed to clean up editor:", error))
|
||||
.finally(() => renderer.destroy())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ pub const VisualCursor = struct {
|
||||
offset: u32, // Global display-width offset from buffer start
|
||||
};
|
||||
|
||||
const CursorVisualAffinity = struct {
|
||||
offset: u32,
|
||||
visual_row: u32,
|
||||
visual_col: u32,
|
||||
};
|
||||
|
||||
/// EditorView wraps a TextBufferView and manages viewport state for efficient rendering
|
||||
/// It also holds a reference to an EditBuffer for cursor/editing operations
|
||||
pub const EditorView = struct {
|
||||
@@ -37,6 +43,8 @@ pub const EditorView = struct {
|
||||
edit_buffer: *EditBuffer, // Reference to the EditBuffer (not owned)
|
||||
scroll_margin: f32, // Fraction of viewport height (0.0-0.5) to keep cursor away from edges
|
||||
desired_visual_col: ?u32, // Preserved visual column for visual up/down navigation
|
||||
cursor_visual_affinity: ?CursorVisualAffinity,
|
||||
selection_updates_cursor: bool,
|
||||
selection_follow_cursor: bool, // Keep viewport synced during selection
|
||||
cursor_changed_listener: event_emitter.EventEmitter(eb.EditBufferEvent).Listener,
|
||||
|
||||
@@ -52,10 +60,14 @@ pub const EditorView = struct {
|
||||
self.desired_visual_col = null;
|
||||
self.updatePlaceholderVisibility();
|
||||
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
if (self.cursor_visual_affinity) |affinity| {
|
||||
if (affinity.offset != cursor.offset) self.cursor_visual_affinity = null;
|
||||
}
|
||||
|
||||
const has_selection = self.text_buffer_view.selection != null;
|
||||
if (!has_selection or self.selection_follow_cursor) {
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
self.ensureCursorVisible(vcursor.visual_row);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +85,8 @@ pub const EditorView = struct {
|
||||
.edit_buffer = edit_buffer,
|
||||
.scroll_margin = 0.15, // Default 15% margin
|
||||
.desired_visual_col = null,
|
||||
.cursor_visual_affinity = null,
|
||||
.selection_updates_cursor = false,
|
||||
.selection_follow_cursor = false,
|
||||
.cursor_changed_listener = .{
|
||||
.ctx = undefined, // Will be set below
|
||||
@@ -120,7 +134,11 @@ pub const EditorView = struct {
|
||||
/// this will trigger a reflow by updating the TextBufferView's wrap width.
|
||||
/// moveCursor: if true, moves cursor to stay within viewport bounds (prevents viewport reset)
|
||||
pub fn setViewport(self: *EditorView, vp: ?tbv.Viewport, moveCursor: bool) void {
|
||||
const old_viewport = self.text_buffer_view.getViewport();
|
||||
self.text_buffer_view.setViewport(vp);
|
||||
if (old_viewport == null or vp == null or old_viewport.?.width != vp.?.width) {
|
||||
self.cursor_visual_affinity = null;
|
||||
}
|
||||
|
||||
if (moveCursor) {
|
||||
self.makeCursorVisible();
|
||||
@@ -137,7 +155,7 @@ pub const EditorView = struct {
|
||||
pub fn makeCursorVisible(self: *EditorView) void {
|
||||
const vp = self.text_buffer_view.getViewport() orelse return;
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
|
||||
const viewport_height = vp.height;
|
||||
const margin_lines = @max(1, @as(u32, @intFromFloat(@as(f32, @floatFromInt(viewport_height)) * self.scroll_margin)));
|
||||
@@ -252,8 +270,7 @@ pub const EditorView = struct {
|
||||
const has_selection = self.text_buffer_view.selection != null;
|
||||
|
||||
if (!has_selection or self.selection_follow_cursor) {
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
self.ensureCursorVisible(vcursor.visual_row);
|
||||
}
|
||||
}
|
||||
@@ -298,55 +315,101 @@ pub const EditorView = struct {
|
||||
}
|
||||
|
||||
pub fn setSelection(self: *EditorView, start: u32, end: u32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA) void {
|
||||
self.selection_updates_cursor = false;
|
||||
self.text_buffer_view.setSelection(start, end, bgColor, fgColor);
|
||||
}
|
||||
|
||||
pub fn updateSelection(self: *EditorView, end: u32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA) void {
|
||||
self.selection_updates_cursor = false;
|
||||
self.text_buffer_view.updateSelection(end, bgColor, fgColor);
|
||||
}
|
||||
|
||||
pub fn setSelectionInclusive(self: *EditorView, start: u32, end: u32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA) void {
|
||||
self.selection_updates_cursor = false;
|
||||
self.text_buffer_view.setSelectionInclusiveStyle(start, end, tbv.SelectionStyle.rgb(bgColor, fgColor));
|
||||
}
|
||||
|
||||
pub fn resetSelection(self: *EditorView) void {
|
||||
self.selection_updates_cursor = false;
|
||||
self.text_buffer_view.resetSelection();
|
||||
}
|
||||
|
||||
pub fn setLocalSelection(self: *EditorView, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA, updateCursor: bool) bool {
|
||||
const changed = self.text_buffer_view.setLocalSelection(anchorX, anchorY, focusX, focusY, bgColor, fgColor);
|
||||
self.selection_updates_cursor = updateCursor;
|
||||
|
||||
if (changed and updateCursor) {
|
||||
if (updateCursor and self.text_buffer_view.selection_endpoints != null) {
|
||||
self.syncCursorToSelectionFocus();
|
||||
self.setCursorAffinityForLocalRow(focusY);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
pub fn updateLocalSelection(self: *EditorView, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA, updateCursor: bool) bool {
|
||||
const had_endpoints = self.text_buffer_view.selection_endpoints != null;
|
||||
const changed = self.text_buffer_view.updateLocalSelection(anchorX, anchorY, focusX, focusY, bgColor, fgColor);
|
||||
if (!had_endpoints) {
|
||||
self.selection_updates_cursor = updateCursor;
|
||||
} else if (updateCursor) {
|
||||
self.selection_updates_cursor = true;
|
||||
}
|
||||
|
||||
if (changed and updateCursor) {
|
||||
if (updateCursor and self.text_buffer_view.selection_endpoints != null) {
|
||||
self.syncCursorToSelectionFocus();
|
||||
self.setCursorAffinityForLocalRow(focusY);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
pub fn resetLocalSelection(self: *EditorView) void {
|
||||
self.selection_updates_cursor = false;
|
||||
self.text_buffer_view.resetLocalSelection();
|
||||
}
|
||||
|
||||
pub fn setSelectionOccupancy(self: *EditorView, occupancy: tbv.SelectionOccupancy) void {
|
||||
const visual_row = if (self.cursor_visual_affinity) |affinity| affinity.visual_row else null;
|
||||
const selection = self.text_buffer_view.getSelection();
|
||||
const has_local_selection = self.selection_updates_cursor and self.text_buffer_view.selection_endpoints != null and selection != null and
|
||||
selection.?.start != selection.?.end;
|
||||
const cursor_offset_before = self.edit_buffer.getPrimaryCursor().offset;
|
||||
self.text_buffer_view.setSelectionOccupancy(occupancy);
|
||||
if (has_local_selection) {
|
||||
self.syncCursorToSelectionFocus();
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
if (cursor.offset != cursor_offset_before) {
|
||||
self.edit_buffer.setCursor(cursor.row, cursor.col) catch {};
|
||||
}
|
||||
}
|
||||
if (visual_row) |row| self.setCursorAffinityForAbsoluteRow(row);
|
||||
}
|
||||
|
||||
pub fn getSelectionOccupancy(self: *const EditorView) tbv.SelectionOccupancy {
|
||||
return self.text_buffer_view.getSelectionOccupancy();
|
||||
}
|
||||
|
||||
/// Updates the cursor position to match the selection focus position.
|
||||
/// Does NOT trigger viewport scrolling - TypeScript layer handles that.
|
||||
pub fn syncCursorToSelectionFocus(self: *EditorView) void {
|
||||
const selection = self.text_buffer_view.getSelection() orelse return;
|
||||
|
||||
const focus_offset = if (self.text_buffer_view.selection_anchor_offset) |anchor| blk: {
|
||||
if (anchor == selection.start) {
|
||||
break :blk selection.end;
|
||||
} else {
|
||||
break :blk selection.start;
|
||||
// The stored focus offset is authoritative: with cell occupancy,
|
||||
// `selection.end` extends past the focus grapheme, so the focus cannot
|
||||
// be inferred from the normalized start/end pair. Offset APIs clear
|
||||
// stored focus; falling back to `selection.end` is then correct
|
||||
// because those ranges are already exclusive-end.
|
||||
var focus_offset = selection.end;
|
||||
if (self.text_buffer_view.selection_endpoints) |endpoints| {
|
||||
focus_offset = endpoints.focus;
|
||||
if (self.text_buffer_view.getSelectionOccupancy() == .boundary and focus_offset < endpoints.anchor) {
|
||||
focus_offset = selection.start;
|
||||
} else if (self.text_buffer_view.getSelectionOccupancy() == .boundary and focus_offset > endpoints.anchor) {
|
||||
focus_offset = selection.end;
|
||||
} else if (self.edit_buffer.tb.cursorUnitBoundsAtOffset(focus_offset)) |bounds| {
|
||||
focus_offset = bounds.start;
|
||||
}
|
||||
} else blk: {
|
||||
break :blk selection.end;
|
||||
};
|
||||
}
|
||||
|
||||
const focus_coords = iter_mod.offsetToCoords(self.edit_buffer.tb.rope(), focus_offset) orelse return;
|
||||
|
||||
@@ -378,6 +441,8 @@ pub const EditorView = struct {
|
||||
/// This is a convenience method that preserves existing offset
|
||||
/// After resize, ensures cursor is visible and clamps viewport offset to valid range
|
||||
pub fn setViewportSize(self: *EditorView, width: u32, height: u32) void {
|
||||
const old_width = if (self.text_buffer_view.getViewport()) |viewport| viewport.width else 0;
|
||||
if (old_width != width) self.cursor_visual_affinity = null;
|
||||
self.text_buffer_view.setViewportSize(width, height);
|
||||
|
||||
const vp = self.text_buffer_view.getViewport() orelse return;
|
||||
@@ -402,12 +467,12 @@ pub const EditorView = struct {
|
||||
});
|
||||
}
|
||||
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
self.ensureCursorVisible(vcursor.visual_row);
|
||||
}
|
||||
|
||||
pub fn setWrapMode(self: *EditorView, mode: tb.WrapMode) void {
|
||||
self.cursor_visual_affinity = null;
|
||||
self.text_buffer_view.setWrapMode(mode);
|
||||
}
|
||||
|
||||
@@ -432,11 +497,66 @@ pub const EditorView = struct {
|
||||
// VisualCursor - Wrapping-aware cursor translation
|
||||
// ============================================================================
|
||||
|
||||
fn getPrimaryVisualCursorAbsolute(self: *EditorView) VisualCursor {
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
if (self.cursor_visual_affinity) |affinity| {
|
||||
if (affinity.offset == cursor.offset) {
|
||||
self.setCursorAffinityForAbsoluteRow(affinity.visual_row);
|
||||
if (self.cursor_visual_affinity) |validated| {
|
||||
return .{
|
||||
.visual_row = validated.visual_row,
|
||||
.visual_col = validated.visual_col,
|
||||
.logical_row = cursor.row,
|
||||
.logical_col = cursor.col,
|
||||
.offset = cursor.offset,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
}
|
||||
|
||||
fn setCursorAffinityForAbsoluteRow(self: *EditorView, visual_row: u32) void {
|
||||
self.text_buffer_view.updateVirtualLines();
|
||||
const vlines = self.text_buffer_view.virtual_lines.items;
|
||||
if (visual_row >= vlines.len) {
|
||||
self.cursor_visual_affinity = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vline = &vlines[visual_row];
|
||||
if (vline.source_line != cursor.row or cursor.offset < vline.col_offset) {
|
||||
self.cursor_visual_affinity = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const visual_col = cursor.offset - vline.col_offset;
|
||||
var visual_col_max = vline.width_cols;
|
||||
if (self.text_buffer_view.getSelectionOccupancy() == .cell and vline.width_cols > 0 and visual_row + 1 < vlines.len) {
|
||||
const next_vline = &vlines[visual_row + 1];
|
||||
if (next_vline.source_line == vline.source_line) visual_col_max -= 1;
|
||||
}
|
||||
if (visual_col > visual_col_max) {
|
||||
self.cursor_visual_affinity = null;
|
||||
return;
|
||||
}
|
||||
self.cursor_visual_affinity = .{ .offset = cursor.offset, .visual_row = visual_row, .visual_col = visual_col };
|
||||
}
|
||||
|
||||
fn setCursorAffinityForLocalRow(self: *EditorView, visual_row: i32) void {
|
||||
self.text_buffer_view.updateVirtualLines();
|
||||
const line_count: i64 = @intCast(self.text_buffer_view.virtual_lines.items.len);
|
||||
if (line_count == 0) return;
|
||||
const viewport_y: i64 = if (self.text_buffer_view.getViewport()) |viewport| viewport.y else 0;
|
||||
const absolute_row: u32 = @intCast(@max(0, @min(@as(i64, visual_row) + viewport_y, line_count - 1)));
|
||||
self.setCursorAffinityForAbsoluteRow(absolute_row);
|
||||
}
|
||||
|
||||
/// Returns viewport-relative visual coordinates for external API consumers
|
||||
pub fn getVisualCursor(self: *EditorView) VisualCursor {
|
||||
self.updateBeforeRender();
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
|
||||
// Convert absolute visual coordinates to viewport-relative for the API
|
||||
const vp = self.text_buffer_view.getViewport() orelse return vcursor;
|
||||
@@ -544,8 +664,7 @@ pub const EditorView = struct {
|
||||
}
|
||||
|
||||
pub fn moveUpVisual(self: *EditorView) void {
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
|
||||
if (vcursor.visual_row == 0) {
|
||||
return;
|
||||
@@ -559,9 +678,11 @@ pub const EditorView = struct {
|
||||
}
|
||||
const desired_visual_col = self.desired_visual_col.?;
|
||||
|
||||
// logicalToVisualCursor refreshed this snapshot; keep navigation on it.
|
||||
const vlines = self.text_buffer_view.virtual_lines.items;
|
||||
const target_visual_col = clampVisualColToStayOnVisualRow(vlines, target_visual_row, desired_visual_col);
|
||||
const target_visual_col = if (self.text_buffer_view.getSelectionOccupancy() == .boundary)
|
||||
@min(desired_visual_col, vlines[target_visual_row].width_cols)
|
||||
else
|
||||
clampVisualColToStayOnVisualRow(vlines, target_visual_row, desired_visual_col);
|
||||
|
||||
if (self.visualToLogicalCursor(target_visual_row, target_visual_col)) |new_vcursor| {
|
||||
if (self.edit_buffer.cursors.items.len > 0) {
|
||||
@@ -571,6 +692,10 @@ pub const EditorView = struct {
|
||||
.desired_col = new_vcursor.logical_col,
|
||||
.offset = new_vcursor.offset,
|
||||
};
|
||||
self.cursor_visual_affinity = null;
|
||||
if (self.text_buffer_view.getSelectionOccupancy() == .boundary) {
|
||||
self.setCursorAffinityForAbsoluteRow(target_visual_row);
|
||||
}
|
||||
self.ensureCursorVisible(new_vcursor.visual_row);
|
||||
|
||||
// Restore desired_visual_col after the cursor change event resets it
|
||||
@@ -580,10 +705,8 @@ pub const EditorView = struct {
|
||||
}
|
||||
|
||||
pub fn moveDownVisual(self: *EditorView) void {
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
|
||||
// logicalToVisualCursor refreshed this snapshot; keep navigation on it.
|
||||
const vlines = self.text_buffer_view.virtual_lines.items;
|
||||
|
||||
if (vcursor.visual_row + 1 >= vlines.len) {
|
||||
@@ -597,7 +720,10 @@ pub const EditorView = struct {
|
||||
self.desired_visual_col = vcursor.visual_col;
|
||||
}
|
||||
const desired_visual_col = self.desired_visual_col.?;
|
||||
const target_visual_col = clampVisualColToStayOnVisualRow(vlines, target_visual_row, desired_visual_col);
|
||||
const target_visual_col = if (self.text_buffer_view.getSelectionOccupancy() == .boundary)
|
||||
@min(desired_visual_col, vlines[target_visual_row].width_cols)
|
||||
else
|
||||
clampVisualColToStayOnVisualRow(vlines, target_visual_row, desired_visual_col);
|
||||
|
||||
if (self.visualToLogicalCursor(target_visual_row, target_visual_col)) |new_vcursor| {
|
||||
if (self.edit_buffer.cursors.items.len > 0) {
|
||||
@@ -607,6 +733,10 @@ pub const EditorView = struct {
|
||||
.desired_col = new_vcursor.logical_col,
|
||||
.offset = new_vcursor.offset,
|
||||
};
|
||||
self.cursor_visual_affinity = null;
|
||||
if (self.text_buffer_view.getSelectionOccupancy() == .boundary) {
|
||||
self.setCursorAffinityForAbsoluteRow(target_visual_row);
|
||||
}
|
||||
self.ensureCursorVisible(new_vcursor.visual_row);
|
||||
|
||||
// Restore desired_visual_col after the cursor change event resets it
|
||||
@@ -639,7 +769,7 @@ pub const EditorView = struct {
|
||||
};
|
||||
|
||||
try self.edit_buffer.deleteRange(start_cursor, end_cursor);
|
||||
self.text_buffer_view.resetLocalSelection();
|
||||
self.resetLocalSelection();
|
||||
self.updateBeforeRender();
|
||||
}
|
||||
|
||||
@@ -667,9 +797,8 @@ pub const EditorView = struct {
|
||||
/// Returns a cursor at column 0 of the current visual line
|
||||
pub fn getVisualSOL(self: *EditorView) VisualCursor {
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
|
||||
// logicalToVisualCursor refreshed this snapshot; keep SOL on the same row.
|
||||
const vlines = self.text_buffer_view.virtual_lines.items;
|
||||
|
||||
if (vcursor.visual_row >= vlines.len) {
|
||||
@@ -698,15 +827,12 @@ pub const EditorView = struct {
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the end of the current visual line (EOL = End Of Line)
|
||||
/// Returns a cursor at the last position of the current visual line
|
||||
/// For wrapped lines, this is the position just before the wrap boundary to ensure
|
||||
/// the cursor stays on the current visual line when used with setCursor()
|
||||
/// Get the end of the current visual line (EOL = End Of Line).
|
||||
/// Cell occupancy targets the last cell; boundary occupancy targets the
|
||||
/// insertion gap after it. The latter needs visual affinity at soft wraps.
|
||||
pub fn getVisualEOL(self: *EditorView) VisualCursor {
|
||||
const cursor = self.edit_buffer.getPrimaryCursor();
|
||||
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
|
||||
const vcursor = self.getPrimaryVisualCursorAbsolute();
|
||||
|
||||
// logicalToVisualCursor refreshed this snapshot; keep EOL on the same row.
|
||||
const vlines = self.text_buffer_view.virtual_lines.items;
|
||||
|
||||
if (vcursor.visual_row >= vlines.len) {
|
||||
@@ -716,7 +842,19 @@ pub const EditorView = struct {
|
||||
}
|
||||
|
||||
const vline = &vlines[vcursor.visual_row];
|
||||
const target_visual_col = clampVisualColToStayOnVisualRow(vlines, vcursor.visual_row, vline.width_cols);
|
||||
var target_visual_col = if (self.text_buffer_view.getSelectionOccupancy() == .boundary)
|
||||
vline.width_cols
|
||||
else
|
||||
clampVisualColToStayOnVisualRow(vlines, vcursor.visual_row, vline.width_cols);
|
||||
if (self.text_buffer_view.getSelectionOccupancy() == .cell) {
|
||||
const target_offset = vline.col_offset + target_visual_col;
|
||||
if (self.edit_buffer.tb.cursorUnitBoundsAtOffset(target_offset)) |bounds| {
|
||||
if (bounds.start < target_offset) {
|
||||
const target = if (bounds.start >= vline.col_offset) bounds.start else bounds.end;
|
||||
target_visual_col = @min(target -| vline.col_offset, vline.width_cols);
|
||||
}
|
||||
}
|
||||
}
|
||||
const logical_row = @as(u32, @intCast(vline.source_line));
|
||||
const logical_col = vline.source_col_offset + target_visual_col;
|
||||
const offset = iter_mod.coordsToOffset(self.edit_buffer.tb.rope(), logical_row, logical_col) orelse 0;
|
||||
@@ -730,6 +868,15 @@ pub const EditorView = struct {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn gotoVisualLineEnd(self: *EditorView) void {
|
||||
const eol = self.getVisualEOL();
|
||||
self.cursor_visual_affinity = .{ .offset = eol.offset, .visual_row = eol.visual_row, .visual_col = eol.visual_col };
|
||||
self.edit_buffer.setCursor(eol.logical_row, eol.logical_col) catch {
|
||||
self.cursor_visual_affinity = null;
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Placeholder - Visual Only
|
||||
// ============================================================================
|
||||
|
||||
+40
-14
@@ -194,6 +194,10 @@ inline fn selectionStyle(bg: ?RGBA, fg: ?RGBA) text_buffer_view.SelectionStyle {
|
||||
};
|
||||
}
|
||||
|
||||
inline fn selectionOccupancy(value: u8) text_buffer_view.SelectionOccupancy {
|
||||
return if (value == 1) .boundary else .cell;
|
||||
}
|
||||
|
||||
comptime {
|
||||
std.debug.assert(@sizeOf(ExternalEmbeddedTerminalCursor) == 14);
|
||||
std.debug.assert(@sizeOf(ExternalEmbeddedTerminalKeyOptions) == 12);
|
||||
@@ -2281,6 +2285,16 @@ export fn textBufferViewResetLocalSelection(view_handle: NativeHandle) void {
|
||||
object_ptr.resetLocalSelection();
|
||||
}
|
||||
|
||||
export fn textBufferViewSetSelectionOccupancy(view_handle: NativeHandle, occupancy: u8) void {
|
||||
const object_ptr = acquireTextBufferView(view_handle) orelse return;
|
||||
object_ptr.setSelectionOccupancy(selectionOccupancy(occupancy));
|
||||
}
|
||||
|
||||
export fn textBufferViewGetSelectionOccupancy(view_handle: NativeHandle) u8 {
|
||||
const object_ptr = acquireTextBufferView(view_handle) orelse return 0;
|
||||
return @intFromEnum(object_ptr.getSelectionOccupancy());
|
||||
}
|
||||
|
||||
export fn textBufferViewSetWrapWidth(view_handle: NativeHandle, width: u32) void {
|
||||
const object_ptr = acquireTextBufferView(view_handle) orelse return;
|
||||
object_ptr.setWrapWidth(if (width == 0) null else width);
|
||||
@@ -2866,12 +2880,12 @@ export fn editorViewSetWrapMode(view_handle: NativeHandle, mode: u8) void {
|
||||
// EditorView selection methods - delegate to TextBufferView
|
||||
export fn editorViewSetSelection(view_handle: NativeHandle, start: u32, end: u32, bgColor: ?[*]const u16, fgColor: ?[*]const u16) void {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return;
|
||||
object_ptr.text_buffer_view.setSelectionStyle(start, end, selectionStyle(optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor)));
|
||||
object_ptr.setSelection(start, end, optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor));
|
||||
}
|
||||
|
||||
export fn editorViewResetSelection(view_handle: NativeHandle) void {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return;
|
||||
object_ptr.text_buffer_view.resetSelection();
|
||||
object_ptr.resetSelection();
|
||||
}
|
||||
|
||||
export fn editorViewGetSelection(view_handle: NativeHandle) u64 {
|
||||
@@ -2882,32 +2896,44 @@ export fn editorViewGetSelection(view_handle: NativeHandle) u64 {
|
||||
export fn editorViewSetLocalSelection(view_handle: NativeHandle, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?[*]const u16, fgColor: ?[*]const u16, updateCursor: bool, followCursor: bool) bool {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return false;
|
||||
object_ptr.setSelectionFollowCursor(followCursor);
|
||||
const changed = object_ptr.text_buffer_view.setLocalSelectionStyle(anchorX, anchorY, focusX, focusY, selectionStyle(optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor)));
|
||||
if (changed and updateCursor) {
|
||||
object_ptr.syncCursorToSelectionFocus();
|
||||
}
|
||||
return changed;
|
||||
return object_ptr.setLocalSelection(anchorX, anchorY, focusX, focusY, optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor), updateCursor);
|
||||
}
|
||||
|
||||
export fn editorViewUpdateSelection(view_handle: NativeHandle, end: u32, bgColor: ?[*]const u16, fgColor: ?[*]const u16) void {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return;
|
||||
object_ptr.text_buffer_view.updateSelectionStyle(end, selectionStyle(optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor)));
|
||||
object_ptr.updateSelection(end, optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor));
|
||||
}
|
||||
|
||||
export fn editorViewUpdateLocalSelection(view_handle: NativeHandle, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?[*]const u16, fgColor: ?[*]const u16, updateCursor: bool, followCursor: bool) bool {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return false;
|
||||
object_ptr.setSelectionFollowCursor(followCursor);
|
||||
const changed = object_ptr.text_buffer_view.updateLocalSelectionStyle(anchorX, anchorY, focusX, focusY, selectionStyle(optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor)));
|
||||
if (changed and updateCursor) {
|
||||
object_ptr.syncCursorToSelectionFocus();
|
||||
}
|
||||
return changed;
|
||||
return object_ptr.updateLocalSelection(anchorX, anchorY, focusX, focusY, optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor), updateCursor);
|
||||
}
|
||||
|
||||
export fn editorViewResetLocalSelection(view_handle: NativeHandle) void {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return;
|
||||
object_ptr.setSelectionFollowCursor(false);
|
||||
object_ptr.text_buffer_view.resetLocalSelection();
|
||||
object_ptr.resetLocalSelection();
|
||||
}
|
||||
|
||||
export fn editorViewSetSelectionOccupancy(view_handle: NativeHandle, occupancy: u8) void {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return;
|
||||
object_ptr.setSelectionOccupancy(selectionOccupancy(occupancy));
|
||||
}
|
||||
|
||||
export fn editorViewGotoVisualLineEnd(view_handle: NativeHandle) void {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return;
|
||||
object_ptr.gotoVisualLineEnd();
|
||||
}
|
||||
|
||||
export fn editorViewSetSelectionInclusive(view_handle: NativeHandle, start: u32, end: u32, bgColor: ?[*]const u16, fgColor: ?[*]const u16) void {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return;
|
||||
object_ptr.setSelectionInclusive(start, end, optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor));
|
||||
}
|
||||
|
||||
export fn editorViewSetSelectionColors(view_handle: NativeHandle, bgColor: ?[*]const u16, fgColor: ?[*]const u16) void {
|
||||
const object_ptr = acquireEditorView(view_handle) orelse return;
|
||||
object_ptr.text_buffer_view.setSelectionColors(optionalPtrToRGBA(bgColor), optionalPtrToRGBA(fgColor));
|
||||
}
|
||||
|
||||
export fn editorViewGetSelectedTextBytes(view_handle: NativeHandle, outPtr: ?[*]u8, maxLen: u32) u32 {
|
||||
|
||||
@@ -3451,3 +3451,152 @@ test "EditorView - mouse selection focus outside buffer bounds clamps correctly"
|
||||
// Cursor should be clamped to last line (line 9)
|
||||
try std.testing.expectEqual(@as(u32, 9), cursor.row);
|
||||
}
|
||||
|
||||
test "EditorView - cursor syncs to focus not selection end for inclusive forward selection" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var eb = try EditBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth, null);
|
||||
defer eb.deinit();
|
||||
|
||||
var ev = try EditorView.init(std.testing.allocator, eb, 80, 24);
|
||||
defer ev.deinit();
|
||||
|
||||
try eb.insertText("Hello World");
|
||||
try eb.setCursor(0, 0);
|
||||
_ = ev.getVirtualLines();
|
||||
|
||||
// Forward selection from cell 0 to cell 5: inclusive end is 6, but the
|
||||
// cursor must land on the focus (5), not the extended end.
|
||||
_ = ev.setLocalSelection(0, 0, 5, 0, null, null, true);
|
||||
|
||||
const cursor = ev.getPrimaryCursor();
|
||||
try std.testing.expectEqual(@as(u32, 0), cursor.row);
|
||||
try std.testing.expectEqual(@as(u32, 5), cursor.col);
|
||||
}
|
||||
|
||||
test "EditorView - backward selection keeps anchor cell and cursor at focus" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var eb = try EditBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth, null);
|
||||
defer eb.deinit();
|
||||
|
||||
var ev = try EditorView.init(std.testing.allocator, eb, 80, 24);
|
||||
defer ev.deinit();
|
||||
|
||||
try eb.insertText("Hello World");
|
||||
try eb.setCursor(0, 0);
|
||||
_ = ev.getVirtualLines();
|
||||
|
||||
// Press at cell 8, drag left to cell 6: cells 6..8 selected = "Wor",
|
||||
// cursor at the focus cell 6.
|
||||
_ = ev.setLocalSelection(8, 0, 8, 0, null, null, true);
|
||||
_ = ev.updateLocalSelection(8, 0, 6, 0, null, null, true);
|
||||
|
||||
var out_buffer: [100]u8 = undefined;
|
||||
const len = ev.getSelectedTextIntoBuffer(&out_buffer);
|
||||
try std.testing.expectEqualStrings("Wor", out_buffer[0..len]);
|
||||
|
||||
const cursor = ev.getPrimaryCursor();
|
||||
try std.testing.expectEqual(@as(u32, 0), cursor.row);
|
||||
try std.testing.expectEqual(@as(u32, 6), cursor.col);
|
||||
}
|
||||
|
||||
test "occupancy - EditorView forwards occupancy and replays stored endpoints" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var eb = try EditBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth, null);
|
||||
defer eb.deinit();
|
||||
|
||||
var ev = try EditorView.init(std.testing.allocator, eb, 80, 24);
|
||||
defer ev.deinit();
|
||||
|
||||
try eb.insertText("Hello");
|
||||
try eb.setCursor(0, 0);
|
||||
_ = ev.getVirtualLines();
|
||||
|
||||
try std.testing.expectEqual(text_buffer_view.SelectionOccupancy.cell, ev.getSelectionOccupancy());
|
||||
|
||||
_ = ev.setLocalSelection(0, 0, 1, 0, null, null, true);
|
||||
var out: [100]u8 = undefined;
|
||||
var len = ev.getSelectedTextIntoBuffer(&out);
|
||||
try std.testing.expectEqualStrings("He", out[0..len]);
|
||||
|
||||
const cursor_before = ev.getPrimaryCursor();
|
||||
try std.testing.expectEqual(@as(u32, 1), cursor_before.col);
|
||||
|
||||
ev.setSelectionOccupancy(.boundary);
|
||||
try std.testing.expectEqual(text_buffer_view.SelectionOccupancy.boundary, ev.getSelectionOccupancy());
|
||||
len = ev.getSelectedTextIntoBuffer(&out);
|
||||
try std.testing.expectEqualStrings("H", out[0..len]);
|
||||
|
||||
// Occupancy replay must not move the stored focus.
|
||||
const cursor_after = ev.getPrimaryCursor();
|
||||
try std.testing.expectEqual(@as(u32, 1), cursor_after.col);
|
||||
|
||||
try eb.setText("\u{1F44B}\u{1F3FF}X");
|
||||
ev.resetLocalSelection();
|
||||
ev.setSelectionOccupancy(.cell);
|
||||
_ = ev.setLocalSelection(0, 0, 2, 0, null, null, true);
|
||||
try std.testing.expectEqual(@as(u32, 2), ev.getPrimaryCursor().col);
|
||||
len = ev.getSelectedTextIntoBuffer(&out);
|
||||
try std.testing.expectEqualStrings("\u{1F44B}\u{1F3FF}", out[0..len]);
|
||||
|
||||
ev.resetLocalSelection();
|
||||
_ = ev.setLocalSelection(4, 0, 2, 0, null, null, true);
|
||||
try std.testing.expectEqual(@as(u32, 2), ev.getPrimaryCursor().col);
|
||||
|
||||
ev.resetLocalSelection();
|
||||
_ = ev.setLocalSelection(0, 0, 2, 0, null, null, true);
|
||||
|
||||
try ev.deleteSelectedText();
|
||||
len = ev.getText(&out);
|
||||
try std.testing.expectEqualStrings("X", out[0..len]);
|
||||
|
||||
try eb.setText("Hello");
|
||||
try eb.setCursor(0, 4);
|
||||
_ = ev.updateLocalSelection(0, 0, 2, 0, null, null, false);
|
||||
ev.setSelectionOccupancy(.boundary);
|
||||
try std.testing.expectEqual(@as(u32, 4), ev.getPrimaryCursor().col);
|
||||
|
||||
try eb.setText("abcdefgh");
|
||||
ev.resetLocalSelection();
|
||||
ev.setSelectionOccupancy(.boundary);
|
||||
_ = ev.setLocalSelection(0, 0, 8, 0, null, null, true);
|
||||
try eb.setText("abcde");
|
||||
ev.setSelectionOccupancy(.cell);
|
||||
try std.testing.expectEqual(@as(u32, 5), ev.getPrimaryCursor().col);
|
||||
|
||||
ev.resetLocalSelection();
|
||||
try eb.setCursor(0, 3);
|
||||
_ = ev.setLocalSelection(0, -1, 0, -1, null, null, true);
|
||||
try std.testing.expectEqual(@as(u32, 3), ev.getPrimaryCursor().col);
|
||||
|
||||
try eb.setText("\u{1F44B}\u{1F3FF}X");
|
||||
ev.setSelectionOccupancy(.cell);
|
||||
ev.setWrapMode(.char);
|
||||
ev.setViewportSize(2, 24);
|
||||
try eb.setCursor(0, 2);
|
||||
ev.gotoVisualLineEnd();
|
||||
try std.testing.expectEqual(@as(u32, 2), ev.getPrimaryCursor().col);
|
||||
|
||||
try eb.setText("\u{4F60}a");
|
||||
ev.setWrapMode(.word);
|
||||
ev.setViewportSize(1, 24);
|
||||
ev.setSelectionOccupancy(.boundary);
|
||||
try eb.setCursor(0, 0);
|
||||
ev.gotoVisualLineEnd();
|
||||
try std.testing.expectEqual(@as(u32, 1), ev.getPrimaryCursor().offset);
|
||||
|
||||
ev.setSelectionOccupancy(.cell);
|
||||
ev.gotoVisualLineEnd();
|
||||
try std.testing.expectEqual(@as(u32, 2), ev.getPrimaryCursor().offset);
|
||||
}
|
||||
|
||||
@@ -667,7 +667,11 @@ test "getGraphemeWidthAt - middle of wide character" {
|
||||
|
||||
try testing.expectEqual(@as(u32, 2), iter_mod.getGraphemeWidthAt(tb.rope(), tb.memRegistry(), 0, 0, tb.tabWidth(), tb.widthMethod()));
|
||||
const result = iter_mod.getGraphemeWidthAt(tb.rope(), tb.memRegistry(), 0, 1, tb.tabWidth(), tb.widthMethod());
|
||||
_ = result;
|
||||
try testing.expectEqual(@as(u32, 1), result);
|
||||
|
||||
const bounds = iter_mod.getGraphemeBoundsAt(tb.rope(), tb.memRegistry(), 0, 1, tb.tabWidth(), tb.widthMethod()).?;
|
||||
try testing.expectEqual(@as(u32, 0), bounds.start);
|
||||
try testing.expectEqual(@as(u32, 2), bounds.end);
|
||||
}
|
||||
|
||||
test "getGraphemeWidthAt - invalid row" {
|
||||
|
||||
@@ -22,6 +22,7 @@ test "Selection - basic selection without wrap" {
|
||||
|
||||
try tb.setText("Hello World");
|
||||
|
||||
// Inclusive selection: the cell under the focus (7) is selected too.
|
||||
_ = view.setLocalSelection(2, 0, 7, 0, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
@@ -30,7 +31,7 @@ test "Selection - basic selection without wrap" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 2), start);
|
||||
try std.testing.expectEqual(@as(u32, 7), end);
|
||||
try std.testing.expectEqual(@as(u32, 8), end);
|
||||
}
|
||||
|
||||
test "Selection - with wrapped lines" {
|
||||
@@ -52,6 +53,7 @@ test "Selection - with wrapped lines" {
|
||||
|
||||
try std.testing.expectEqual(@as(u32, 2), view.getVirtualLineCount());
|
||||
|
||||
// Inclusive selection: focus cell (5,1) = offset 15 is selected too.
|
||||
_ = view.setLocalSelection(5, 0, 5, 1, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
@@ -60,7 +62,7 @@ test "Selection - with wrapped lines" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 5), start);
|
||||
try std.testing.expectEqual(@as(u32, 15), end);
|
||||
try std.testing.expectEqual(@as(u32, 16), end);
|
||||
}
|
||||
|
||||
test "Selection - no selection returns all bits set" {
|
||||
@@ -299,6 +301,10 @@ test "Selection - zero-width selection" {
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
try std.testing.expectEqual(@as(u64, 0xFFFFFFFF_FFFFFFFF), packed_info);
|
||||
|
||||
view.setSelectionOccupancy(.boundary);
|
||||
_ = view.setLocalSelection(5, 0, 5, 0, null, null);
|
||||
try std.testing.expectEqual(@as(u64, 0xFFFFFFFF_FFFFFFFF), view.packSelectionInfo());
|
||||
}
|
||||
|
||||
test "Selection - beyond text bounds" {
|
||||
@@ -366,6 +372,7 @@ test "Selection - at wrap boundary" {
|
||||
view.setWrapMode(.char);
|
||||
view.setWrapWidth(10);
|
||||
|
||||
// Inclusive selection: focus cell (1,1) = offset 11 is selected too.
|
||||
_ = view.setLocalSelection(9, 0, 1, 1, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
@@ -374,7 +381,7 @@ test "Selection - at wrap boundary" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 9), start);
|
||||
try std.testing.expectEqual(@as(u32, 11), end);
|
||||
try std.testing.expectEqual(@as(u32, 12), end);
|
||||
}
|
||||
|
||||
test "Selection - spanning multiple wrapped lines" {
|
||||
@@ -395,6 +402,7 @@ test "Selection - spanning multiple wrapped lines" {
|
||||
view.setWrapWidth(10);
|
||||
try std.testing.expectEqual(@as(u32, 3), view.getVirtualLineCount());
|
||||
|
||||
// Inclusive selection: focus cell (8,2) = offset 28 is selected too.
|
||||
_ = view.setLocalSelection(2, 0, 8, 2, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
@@ -403,7 +411,7 @@ test "Selection - spanning multiple wrapped lines" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 2), start);
|
||||
try std.testing.expectEqual(@as(u32, 28), end);
|
||||
try std.testing.expectEqual(@as(u32, 29), end);
|
||||
}
|
||||
|
||||
test "Selection - changes when wrap width changes" {
|
||||
@@ -428,7 +436,7 @@ test "Selection - changes when wrap width changes" {
|
||||
var start = @as(u32, @intCast(packed_info >> 32));
|
||||
var end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 5), start);
|
||||
try std.testing.expectEqual(@as(u32, 15), end);
|
||||
try std.testing.expectEqual(@as(u32, 16), end);
|
||||
|
||||
view.setWrapWidth(5);
|
||||
_ = view.setLocalSelection(5, 0, 5, 1, null, null);
|
||||
@@ -813,14 +821,15 @@ test "Selection - updateLocalSelection extends focus position" {
|
||||
|
||||
try tb.setText("Hello World");
|
||||
|
||||
// Set initial local selection from (0,0) to (5,0)
|
||||
// Set initial local selection from (0,0) to (5,0); the focus cell at
|
||||
// offset 5 is included, so the end is 6.
|
||||
_ = view.setLocalSelection(0, 0, 5, 0, null, null);
|
||||
|
||||
var packed_info = view.packSelectionInfo();
|
||||
var start = @as(u32, @intCast(packed_info >> 32));
|
||||
var end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 0), start);
|
||||
try std.testing.expectEqual(@as(u32, 5), end);
|
||||
try std.testing.expectEqual(@as(u32, 6), end);
|
||||
|
||||
// Update focus to (11,0) - should keep anchor at (0,0)
|
||||
const changed = view.updateLocalSelection(0, 0, 11, 0, null, null);
|
||||
@@ -860,7 +869,7 @@ test "Selection - updateLocalSelection with no existing selection falls back to
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 0), start);
|
||||
try std.testing.expectEqual(@as(u32, 5), end);
|
||||
try std.testing.expectEqual(@as(u32, 6), end);
|
||||
}
|
||||
|
||||
test "Selection - updateLocalSelection can shrink selection" {
|
||||
@@ -879,7 +888,7 @@ test "Selection - updateLocalSelection can shrink selection" {
|
||||
|
||||
_ = view.setLocalSelection(0, 0, 11, 0, null, null);
|
||||
|
||||
// Shrink focus to 5
|
||||
// Shrink focus to 5; the focus cell (the space) stays included.
|
||||
const changed = view.updateLocalSelection(0, 0, 5, 0, null, null);
|
||||
try std.testing.expect(changed);
|
||||
|
||||
@@ -887,12 +896,12 @@ test "Selection - updateLocalSelection can shrink selection" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 0), start);
|
||||
try std.testing.expectEqual(@as(u32, 5), end);
|
||||
try std.testing.expectEqual(@as(u32, 6), end);
|
||||
|
||||
var out_buffer: [100]u8 = undefined;
|
||||
const len = view.getSelectedTextIntoBuffer(&out_buffer);
|
||||
const text = out_buffer[0..len];
|
||||
try std.testing.expectEqualStrings("Hello", text);
|
||||
try std.testing.expectEqualStrings("Hello ", text);
|
||||
}
|
||||
|
||||
test "Selection - updateLocalSelection across multiple lines" {
|
||||
@@ -943,7 +952,7 @@ test "Selection - updateLocalSelection backward selection" {
|
||||
_ = view.setLocalSelection(11, 0, 11, 0, null, null);
|
||||
|
||||
// Move focus backward to (6, 0) - start of "World"
|
||||
// Backward selection adds +1 to make it inclusive, so [6, 12) = "World!"
|
||||
// Inclusive selection keeps the anchor cell selected, so [6, 12) = "World!"
|
||||
const changed = view.updateLocalSelection(11, 0, 6, 0, null, null);
|
||||
try std.testing.expect(changed);
|
||||
|
||||
@@ -981,7 +990,7 @@ test "Selection - updateLocalSelection with wrapped lines" {
|
||||
// Start at (0, 0)
|
||||
_ = view.setLocalSelection(0, 0, 0, 0, null, null);
|
||||
|
||||
// Extend to second wrapped line (5, 1)
|
||||
// Extend to second wrapped line (5, 1); the focus cell 'P' is included.
|
||||
const changed = view.updateLocalSelection(0, 0, 5, 1, null, null);
|
||||
try std.testing.expect(changed);
|
||||
|
||||
@@ -989,12 +998,12 @@ test "Selection - updateLocalSelection with wrapped lines" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 0), start);
|
||||
try std.testing.expectEqual(@as(u32, 15), end);
|
||||
try std.testing.expectEqual(@as(u32, 16), end);
|
||||
|
||||
var out_buffer: [100]u8 = undefined;
|
||||
const len = view.getSelectedTextIntoBuffer(&out_buffer);
|
||||
const text = out_buffer[0..len];
|
||||
try std.testing.expectEqualStrings("ABCDEFGHIJKLMNO", text);
|
||||
try std.testing.expectEqualStrings("ABCDEFGHIJKLMNOP", text);
|
||||
}
|
||||
|
||||
test "Selection - updateLocalSelection with same focus position maintains selection" {
|
||||
@@ -1020,7 +1029,7 @@ test "Selection - updateLocalSelection with same focus position maintains select
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 0), start);
|
||||
try std.testing.expectEqual(@as(u32, 5), end);
|
||||
try std.testing.expectEqual(@as(u32, 6), end);
|
||||
}
|
||||
|
||||
test "Selection - updateLocalSelection preserves anchor correctly" {
|
||||
@@ -1054,3 +1063,366 @@ test "Selection - updateLocalSelection preserves anchor correctly" {
|
||||
try std.testing.expect(std.mem.find(u8, text, "e 2") != null);
|
||||
try std.testing.expect(std.mem.find(u8, text, "\nLine 3") != null);
|
||||
}
|
||||
|
||||
test "Selection - inclusive forward selection spans wide grapheme at focus" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth);
|
||||
defer tb.deinit();
|
||||
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
// Offsets: a=0 b=1 you=2..3 (width 2) c=4 d=5
|
||||
try tb.setText("ab\u{4F60}cd");
|
||||
|
||||
// Forward drag from cell 0 to cell 2 (start of the wide grapheme): the
|
||||
// whole grapheme is selected, not one of its columns: [0, 4).
|
||||
_ = view.setLocalSelection(0, 0, 2, 0, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 0), start);
|
||||
try std.testing.expectEqual(@as(u32, 4), end);
|
||||
|
||||
var out_buffer: [100]u8 = undefined;
|
||||
const len = view.getSelectedTextIntoBuffer(&out_buffer);
|
||||
try std.testing.expectEqualStrings("ab\u{4F60}", out_buffer[0..len]);
|
||||
}
|
||||
|
||||
test "Selection - inclusive forward selection snaps focus on wide grapheme continuation cell" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth);
|
||||
defer tb.deinit();
|
||||
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
try tb.setText("ab\u{4F60}cd");
|
||||
|
||||
// Focus on the continuation column (cell 3) of the wide grapheme: the
|
||||
// extension is the remaining grapheme width, so the end still lands on
|
||||
// the grapheme boundary: [0, 4).
|
||||
_ = view.setLocalSelection(0, 0, 3, 0, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 4), end);
|
||||
|
||||
var out_buffer: [100]u8 = undefined;
|
||||
const len = view.getSelectedTextIntoBuffer(&out_buffer);
|
||||
try std.testing.expectEqualStrings("ab\u{4F60}", out_buffer[0..len]);
|
||||
}
|
||||
|
||||
test "Selection - inclusive backward selection spans wide grapheme at anchor" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth);
|
||||
defer tb.deinit();
|
||||
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
try tb.setText("ab\u{4F60}cd");
|
||||
|
||||
// Anchor on the wide grapheme (cell 2), drag left to cell 0: the anchor
|
||||
// grapheme stays fully selected: [0, 4), never a half grapheme [0, 3).
|
||||
_ = view.setLocalSelection(2, 0, 2, 0, null, null);
|
||||
const changed = view.updateLocalSelection(2, 0, 0, 0, null, null);
|
||||
try std.testing.expect(changed);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 0), start);
|
||||
try std.testing.expectEqual(@as(u32, 4), end);
|
||||
|
||||
var out_buffer: [100]u8 = undefined;
|
||||
const len = view.getSelectedTextIntoBuffer(&out_buffer);
|
||||
try std.testing.expectEqualStrings("ab\u{4F60}", out_buffer[0..len]);
|
||||
}
|
||||
|
||||
test "Selection - setLocalSelection and updateLocalSelection agree for backward drags" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode);
|
||||
defer tb.deinit();
|
||||
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
try tb.setText("Hello World");
|
||||
|
||||
// Drag path: press at cell 8, extend left to cell 6.
|
||||
_ = view.setLocalSelection(8, 0, 8, 0, null, null);
|
||||
_ = view.updateLocalSelection(8, 0, 6, 0, null, null);
|
||||
const drag_packed = view.packSelectionInfo();
|
||||
|
||||
// Refresh path: renderables replay the same anchor/focus through
|
||||
// setLocalSelection (style change, resize, content update). The result
|
||||
// must be identical or the selection shifts under the user.
|
||||
view.resetLocalSelection();
|
||||
_ = view.setLocalSelection(8, 0, 6, 0, null, null);
|
||||
const replay_packed = view.packSelectionInfo();
|
||||
|
||||
try std.testing.expectEqual(drag_packed, replay_packed);
|
||||
|
||||
const start = @as(u32, @intCast(replay_packed >> 32));
|
||||
const end = @as(u32, @intCast(replay_packed & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 6), start);
|
||||
try std.testing.expectEqual(@as(u32, 9), end);
|
||||
|
||||
var out_buffer: [100]u8 = undefined;
|
||||
const len = view.getSelectedTextIntoBuffer(&out_buffer);
|
||||
try std.testing.expectEqualStrings("Wor", out_buffer[0..len]);
|
||||
}
|
||||
|
||||
test "Selection - wrap padding does not include the next visual line" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode);
|
||||
defer tb.deinit();
|
||||
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
// Word wrap at 18 leaves unused cells on the first visual line:
|
||||
// vline 0 is "hello my good " (14 cols), vline 1 is "friend".
|
||||
try tb.setText("hello my good friend");
|
||||
view.setWrapMode(.word);
|
||||
view.setWrapWidth(18);
|
||||
try std.testing.expectEqual(@as(u32, 2), view.getVirtualLineCount());
|
||||
|
||||
// Drag through the empty padding after col 14. Inclusive selection must
|
||||
// stop at the wrap, not consume the 'f' that starts the next visual line.
|
||||
_ = view.setLocalSelection(0, 0, 17, 0, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 0), start);
|
||||
try std.testing.expectEqual(@as(u32, 14), end);
|
||||
|
||||
var out_buffer: [100]u8 = undefined;
|
||||
const len = view.getSelectedTextIntoBuffer(&out_buffer);
|
||||
try std.testing.expectEqualStrings("hello my good ", out_buffer[0..len]);
|
||||
}
|
||||
|
||||
fn occupancySelected(view: *TextBufferView, out: *[100]u8) []const u8 {
|
||||
const len = view.getSelectedTextIntoBuffer(out);
|
||||
return out[0..len];
|
||||
}
|
||||
|
||||
fn occupancyPacked(view: *const TextBufferView) struct { start: u32, end: u32 } {
|
||||
const packed_info = view.packSelectionInfo();
|
||||
return .{
|
||||
.start = @intCast(packed_info >> 32),
|
||||
.end = @intCast(packed_info & 0xFFFFFFFF),
|
||||
};
|
||||
}
|
||||
|
||||
test "occupancy - cell and boundary derive endpoint ranges" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode);
|
||||
defer tb.deinit();
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
try tb.setText("Hello");
|
||||
_ = view.setLocalSelection(0, 0, 1, 0, null, null);
|
||||
|
||||
var range = occupancyPacked(view);
|
||||
try std.testing.expectEqual(@as(u32, 0), range.start);
|
||||
try std.testing.expectEqual(@as(u32, 2), range.end);
|
||||
|
||||
var out: [100]u8 = undefined;
|
||||
try std.testing.expectEqualStrings("He", occupancySelected(view, &out));
|
||||
|
||||
view.resetLocalSelection();
|
||||
view.setSelectionOccupancy(.boundary);
|
||||
_ = view.setLocalSelection(0, 0, 1, 0, null, null);
|
||||
range = occupancyPacked(view);
|
||||
try std.testing.expectEqual(@as(u32, 0), range.start);
|
||||
try std.testing.expectEqual(@as(u32, 1), range.end);
|
||||
try std.testing.expectEqualStrings("H", occupancySelected(view, &out));
|
||||
|
||||
try tb.setText("abcd");
|
||||
view.resetLocalSelection();
|
||||
view.setSelectionOccupancy(.cell);
|
||||
_ = view.setLocalSelection(2, 0, 1, 0, null, null);
|
||||
try std.testing.expectEqualStrings("bc", occupancySelected(view, &out));
|
||||
|
||||
view.resetLocalSelection();
|
||||
view.setSelectionOccupancy(.boundary);
|
||||
_ = view.setLocalSelection(2, 0, 1, 0, null, null);
|
||||
try std.testing.expectEqualStrings("b", occupancySelected(view, &out));
|
||||
}
|
||||
|
||||
test "occupancy - wide glyph is never half a cell or boundary range" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth);
|
||||
defer tb.deinit();
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
// Offsets: a=0 b=1 you=2..3 (width 2) c=4 d=5
|
||||
try tb.setText("ab\u{4F60}cd");
|
||||
|
||||
_ = view.setLocalSelection(0, 0, 2, 0, null, null);
|
||||
var out: [100]u8 = undefined;
|
||||
try std.testing.expectEqualStrings("ab\u{4F60}", occupancySelected(view, &out));
|
||||
|
||||
view.resetLocalSelection();
|
||||
view.setSelectionOccupancy(.boundary);
|
||||
_ = view.setLocalSelection(0, 0, 3, 0, null, null);
|
||||
var range = occupancyPacked(view);
|
||||
try std.testing.expectEqual(@as(u32, 0), range.start);
|
||||
try std.testing.expectEqual(@as(u32, 4), range.end);
|
||||
try std.testing.expectEqualStrings("ab\u{4F60}", occupancySelected(view, &out));
|
||||
|
||||
view.resetLocalSelection();
|
||||
_ = view.setLocalSelection(3, 0, 5, 0, null, null);
|
||||
range = occupancyPacked(view);
|
||||
try std.testing.expectEqual(@as(u32, 2), range.start);
|
||||
try std.testing.expectEqual(@as(u32, 5), range.end);
|
||||
try std.testing.expectEqualStrings("\u{4F60}c", occupancySelected(view, &out));
|
||||
|
||||
try tb.setText("ab\u{4F60}");
|
||||
view.resetLocalSelection();
|
||||
_ = view.setLocalSelection(0, 0, 3, 0, null, null);
|
||||
range = occupancyPacked(view);
|
||||
try std.testing.expectEqual(@as(u32, 4), range.end);
|
||||
try std.testing.expectEqualStrings("ab\u{4F60}", occupancySelected(view, &out));
|
||||
|
||||
try tb.setText("\u{1F44B}\u{1F3FF}X");
|
||||
view.resetLocalSelection();
|
||||
_ = view.setLocalSelection(0, 0, 2, 0, null, null);
|
||||
try std.testing.expectEqualStrings("\u{1F44B}\u{1F3FF}", occupancySelected(view, &out));
|
||||
}
|
||||
|
||||
test "occupancy - replay clamps tiny truncated views" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode);
|
||||
defer tb.deinit();
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
try tb.setText("abcdefghij");
|
||||
_ = view.setLocalSelection(0, 0, 8, 0, null, null);
|
||||
view.setViewport(.{ .x = 0, .y = 0, .width = 2, .height = 1 });
|
||||
view.setTruncate(true);
|
||||
try tb.setText("abcde");
|
||||
|
||||
view.setSelectionOccupancy(.boundary);
|
||||
try std.testing.expectEqual(@as(u64, 0xFFFFFFFF_FFFFFFFF), view.packSelectionInfo());
|
||||
|
||||
view.setSelection(0, 5, null, null);
|
||||
var out: [100]u8 = undefined;
|
||||
try std.testing.expectEqualStrings("abcde", occupancySelected(view, &out));
|
||||
}
|
||||
|
||||
test "occupancy - EOL focus does not grab a bare newline" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode);
|
||||
defer tb.deinit();
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
try tb.setText("Hello\nWorld");
|
||||
_ = view.setLocalSelection(0, 0, 5, 0, null, null);
|
||||
|
||||
var out: [100]u8 = undefined;
|
||||
try std.testing.expectEqualStrings("Hello", occupancySelected(view, &out));
|
||||
|
||||
view.resetLocalSelection();
|
||||
view.setSelectionOccupancy(.boundary);
|
||||
_ = view.setLocalSelection(0, 0, 5, 0, null, null);
|
||||
try std.testing.expectEqualStrings("Hello", occupancySelected(view, &out));
|
||||
}
|
||||
|
||||
test "occupancy - set and update agree then occupancy replay shrinks the range" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode);
|
||||
defer tb.deinit();
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
try tb.setText("Hello");
|
||||
_ = view.setLocalSelection(0, 0, 0, 0, null, null);
|
||||
_ = view.updateLocalSelection(0, 0, 1, 0, null, null);
|
||||
const drag_packed = view.packSelectionInfo();
|
||||
|
||||
view.resetLocalSelection();
|
||||
_ = view.setLocalSelection(0, 0, 1, 0, null, null);
|
||||
try std.testing.expectEqual(drag_packed, view.packSelectionInfo());
|
||||
|
||||
var out: [100]u8 = undefined;
|
||||
try std.testing.expectEqualStrings("He", occupancySelected(view, &out));
|
||||
|
||||
view.setSelectionOccupancy(.boundary);
|
||||
try std.testing.expectEqualStrings("H", occupancySelected(view, &out));
|
||||
const after = occupancyPacked(view);
|
||||
try std.testing.expectEqual(@as(u32, 0), after.start);
|
||||
try std.testing.expectEqual(@as(u32, 1), after.end);
|
||||
|
||||
view.setSelectionOccupancy(.cell);
|
||||
try std.testing.expectEqualStrings("He", occupancySelected(view, &out));
|
||||
}
|
||||
|
||||
test "occupancy - offset selection clears stored endpoints" {
|
||||
const pool = gp.initGlobalPool(std.testing.allocator);
|
||||
defer gp.deinitGlobalPool();
|
||||
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
|
||||
defer link.deinitGlobalLinkPool();
|
||||
|
||||
var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .unicode);
|
||||
defer tb.deinit();
|
||||
var view = try TextBufferView.init(std.testing.allocator, tb);
|
||||
defer view.deinit();
|
||||
|
||||
try tb.setText("Hello");
|
||||
_ = view.setLocalSelection(0, 0, 2, 0, null, null);
|
||||
view.setSelection(0, 1, null, null);
|
||||
var out: [100]u8 = undefined;
|
||||
try std.testing.expectEqualStrings("H", occupancySelected(view, &out));
|
||||
|
||||
view.setSelectionOccupancy(.boundary);
|
||||
try std.testing.expectEqualStrings("H", occupancySelected(view, &out));
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ test "TextBufferView selection - basic selection without wrap" {
|
||||
|
||||
try tb.setText("Hello World");
|
||||
|
||||
// Inclusive selection: the cell under the focus (7) is selected too.
|
||||
_ = view.setLocalSelection(2, 0, 7, 0, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
@@ -124,7 +125,7 @@ test "TextBufferView selection - basic selection without wrap" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 2), start);
|
||||
try std.testing.expectEqual(@as(u32, 7), end);
|
||||
try std.testing.expectEqual(@as(u32, 8), end);
|
||||
}
|
||||
|
||||
test "TextBufferView selection - with wrapped lines" {
|
||||
@@ -146,6 +147,7 @@ test "TextBufferView selection - with wrapped lines" {
|
||||
|
||||
try std.testing.expectEqual(@as(u32, 2), view.getVirtualLineCount());
|
||||
|
||||
// Inclusive selection: focus cell (5,1) = offset 15 is selected too.
|
||||
_ = view.setLocalSelection(5, 0, 5, 1, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
@@ -154,7 +156,7 @@ test "TextBufferView selection - with wrapped lines" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 5), start);
|
||||
try std.testing.expectEqual(@as(u32, 15), end);
|
||||
try std.testing.expectEqual(@as(u32, 16), end);
|
||||
}
|
||||
|
||||
test "TextBufferView selection - no selection returns all bits set" {
|
||||
@@ -1086,6 +1088,7 @@ test "TextBufferView selection - selection at wrap boundary" {
|
||||
view.setWrapMode(.char);
|
||||
view.setWrapWidth(10);
|
||||
|
||||
// Inclusive selection: focus cell (1,1) = offset 11 is selected too.
|
||||
_ = view.setLocalSelection(9, 0, 1, 1, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
@@ -1094,7 +1097,7 @@ test "TextBufferView selection - selection at wrap boundary" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 9), start);
|
||||
try std.testing.expectEqual(@as(u32, 11), end);
|
||||
try std.testing.expectEqual(@as(u32, 12), end);
|
||||
}
|
||||
|
||||
test "TextBufferView selection - spanning multiple wrapped lines" {
|
||||
@@ -1115,6 +1118,7 @@ test "TextBufferView selection - spanning multiple wrapped lines" {
|
||||
view.setWrapWidth(10);
|
||||
try std.testing.expectEqual(@as(u32, 3), view.getVirtualLineCount());
|
||||
|
||||
// Inclusive selection: focus cell (8,2) = offset 28 is selected too.
|
||||
_ = view.setLocalSelection(2, 0, 8, 2, null, null);
|
||||
|
||||
const packed_info = view.packSelectionInfo();
|
||||
@@ -1123,7 +1127,7 @@ test "TextBufferView selection - spanning multiple wrapped lines" {
|
||||
const start = @as(u32, @intCast(packed_info >> 32));
|
||||
const end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 2), start);
|
||||
try std.testing.expectEqual(@as(u32, 28), end);
|
||||
try std.testing.expectEqual(@as(u32, 29), end);
|
||||
}
|
||||
|
||||
test "TextBufferView selection - changes when wrap width changes" {
|
||||
@@ -1148,7 +1152,7 @@ test "TextBufferView selection - changes when wrap width changes" {
|
||||
var start = @as(u32, @intCast(packed_info >> 32));
|
||||
var end = @as(u32, @intCast(packed_info & 0xFFFFFFFF));
|
||||
try std.testing.expectEqual(@as(u32, 5), start);
|
||||
try std.testing.expectEqual(@as(u32, 15), end);
|
||||
try std.testing.expectEqual(@as(u32, 16), end);
|
||||
|
||||
view.setWrapMode(.char);
|
||||
view.setWrapWidth(5);
|
||||
|
||||
@@ -232,12 +232,17 @@ pub fn lineWidthAt(rope: *UnifiedRope, row: u32) u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes mutable rope for lazy marker cache rebuilding
|
||||
pub fn getGraphemeWidthAt(rope: *UnifiedRope, mem_registry: *const MemRegistry, row: u32, col: u32, tab_width: u8, width_method: utf8.WidthMethod) u32 {
|
||||
const line_width = lineWidthAt(rope, row);
|
||||
if (col >= line_width) return 0;
|
||||
pub const GraphemeBounds = struct {
|
||||
start: u32,
|
||||
end: u32,
|
||||
};
|
||||
|
||||
const linestart = rope.getMarker(.linestart, row) orelse return 0;
|
||||
/// Takes mutable rope for lazy marker cache rebuilding
|
||||
fn getTextUnitBoundsAt(rope: *UnifiedRope, mem_registry: *const MemRegistry, row: u32, col: u32, tab_width: u8, width_method: utf8.WidthMethod, cluster_graphemes: bool) ?GraphemeBounds {
|
||||
const line_width = lineWidthAt(rope, row);
|
||||
if (col >= line_width) return null;
|
||||
|
||||
const linestart = rope.getMarker(.linestart, row) orelse return null;
|
||||
var seg_idx = linestart.leaf_index + 1;
|
||||
var cols_before: u32 = 0;
|
||||
|
||||
@@ -250,21 +255,35 @@ pub fn getGraphemeWidthAt(rope: *UnifiedRope, mem_registry: *const MemRegistry,
|
||||
const local_col: u32 = col - cols_before;
|
||||
const bytes = chunk.getBytes(mem_registry);
|
||||
const is_ascii = (chunk.flags & TextChunk.Flags.ASCII_ONLY) != 0;
|
||||
const pos = utf8.findPosByWidth(bytes, local_col, tab_width, is_ascii, false, width_method);
|
||||
if (pos.byte_offset >= bytes.len) return 0; // at end of chunk
|
||||
const grapheme_start_col = pos.columns_used;
|
||||
const width = utf8.getWidthAt(bytes, pos.byte_offset, tab_width, width_method);
|
||||
|
||||
// Calculate remaining width: if cursor is in the middle of a wide grapheme,
|
||||
// return only the remaining columns to reach the end of the grapheme
|
||||
const grapheme_end_col = grapheme_start_col + width;
|
||||
const remaining_width = grapheme_end_col - local_col;
|
||||
return remaining_width;
|
||||
const pos = if (cluster_graphemes)
|
||||
utf8.findGraphemePosByWidth(bytes, local_col, tab_width, is_ascii, false, width_method)
|
||||
else
|
||||
utf8.findPosByWidth(bytes, local_col, tab_width, is_ascii, false, width_method);
|
||||
if (pos.byte_offset >= bytes.len) return null;
|
||||
const width = if (cluster_graphemes)
|
||||
utf8.getGraphemeWidthAt(bytes, pos.byte_offset, tab_width, width_method)
|
||||
else
|
||||
utf8.getWidthAt(bytes, pos.byte_offset, tab_width, width_method);
|
||||
const start = cols_before + pos.columns_used;
|
||||
return .{ .start = start, .end = start + width };
|
||||
}
|
||||
cols_before = next_cols;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getGraphemeBoundsAt(rope: *UnifiedRope, mem_registry: *const MemRegistry, row: u32, col: u32, tab_width: u8, width_method: utf8.WidthMethod) ?GraphemeBounds {
|
||||
return getTextUnitBoundsAt(rope, mem_registry, row, col, tab_width, width_method, true);
|
||||
}
|
||||
|
||||
pub fn getCursorUnitBoundsAt(rope: *UnifiedRope, mem_registry: *const MemRegistry, row: u32, col: u32, tab_width: u8, width_method: utf8.WidthMethod) ?GraphemeBounds {
|
||||
return getTextUnitBoundsAt(rope, mem_registry, row, col, tab_width, width_method, false);
|
||||
}
|
||||
|
||||
pub fn getGraphemeWidthAt(rope: *UnifiedRope, mem_registry: *const MemRegistry, row: u32, col: u32, tab_width: u8, width_method: utf8.WidthMethod) u32 {
|
||||
const bounds = getCursorUnitBoundsAt(rope, mem_registry, row, col, tab_width, width_method) orelse return 0;
|
||||
return bounds.end -| col;
|
||||
}
|
||||
|
||||
/// Takes mutable rope for lazy marker cache rebuilding
|
||||
@@ -402,8 +421,6 @@ pub fn extractTextBetweenOffsets(
|
||||
var out_index: usize = 0;
|
||||
var col_offset: u32 = 0;
|
||||
|
||||
_ = width_method; // Just ignore for now, will use .unicode as default
|
||||
|
||||
const Context = struct {
|
||||
rope: *const UnifiedRope,
|
||||
mem_registry: *const MemRegistry,
|
||||
@@ -414,6 +431,7 @@ pub fn extractTextBetweenOffsets(
|
||||
start: u32,
|
||||
end: u32,
|
||||
line_count: u32,
|
||||
width_method: utf8.WidthMethod,
|
||||
|
||||
fn segment_callback(ctx_ptr: *anyopaque, line_idx: u32, chunk: *const TextChunk, chunk_idx_in_line: u32) void {
|
||||
_ = line_idx;
|
||||
@@ -439,12 +457,12 @@ pub fn extractTextBetweenOffsets(
|
||||
var byte_end: u32 = @intCast(chunk_bytes.len);
|
||||
|
||||
if (local_start_col > 0) {
|
||||
const start_result = utf8.findPosByWidth(chunk_bytes, local_start_col, ctx.tab_width, is_ascii_only, false, .unicode);
|
||||
const start_result = utf8.findGraphemePosByWidth(chunk_bytes, local_start_col, ctx.tab_width, is_ascii_only, false, ctx.width_method);
|
||||
byte_start = start_result.byte_offset;
|
||||
}
|
||||
|
||||
if (local_end_col < chunk.width) {
|
||||
const end_result = utf8.findPosByWidth(chunk_bytes, local_end_col, ctx.tab_width, is_ascii_only, true, .unicode);
|
||||
const end_result = utf8.findGraphemePosByWidth(chunk_bytes, local_end_col, ctx.tab_width, is_ascii_only, true, ctx.width_method);
|
||||
byte_end = end_result.byte_offset;
|
||||
}
|
||||
|
||||
@@ -488,6 +506,7 @@ pub fn extractTextBetweenOffsets(
|
||||
.start = start_offset,
|
||||
.end = end_offset,
|
||||
.line_count = line_count,
|
||||
.width_method = width_method,
|
||||
};
|
||||
|
||||
walkLinesAndSegments(rope, &ctx, Context.segment_callback, Context.line_end_callback);
|
||||
|
||||
@@ -32,6 +32,20 @@ pub const SelectionStyle = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// How a selection occupies cells between stored anchor and focus offsets.
|
||||
/// `cell` includes the grapheme under the max endpoint (block / Vim-visual).
|
||||
/// `boundary` is the half-open insert range `[min, max)` (thin / GUI carets).
|
||||
/// Occupancy is independent of cursor style; style is paint only.
|
||||
pub const SelectionOccupancy = enum(u8) {
|
||||
cell = 0,
|
||||
boundary = 1,
|
||||
};
|
||||
|
||||
const SelectionEndpoints = struct {
|
||||
anchor: u32,
|
||||
focus: u32,
|
||||
};
|
||||
|
||||
/// Viewport defines a rectangular window into the virtual line space
|
||||
pub const Viewport = struct {
|
||||
x: u32,
|
||||
@@ -129,7 +143,10 @@ pub const UnifiedTextBufferView = struct {
|
||||
original_text_buffer: *UnifiedTextBuffer,
|
||||
view_id: u32,
|
||||
selection: ?TextSelection,
|
||||
selection_anchor_offset: ?u32,
|
||||
/// Local selection endpoints stay separate from the derived range because
|
||||
/// cell occupancy can extend `selection.end` past the focus grapheme.
|
||||
selection_endpoints: ?SelectionEndpoints,
|
||||
selection_occupancy: SelectionOccupancy,
|
||||
viewport: ?Viewport,
|
||||
wrap_width: ?u32,
|
||||
wrap_mode: WrapMode,
|
||||
@@ -187,7 +204,8 @@ pub const UnifiedTextBufferView = struct {
|
||||
.original_text_buffer = text_buffer,
|
||||
.view_id = view_id,
|
||||
.selection = null,
|
||||
.selection_anchor_offset = null,
|
||||
.selection_endpoints = null,
|
||||
.selection_occupancy = .cell,
|
||||
.viewport = null,
|
||||
.wrap_width = null,
|
||||
.wrap_mode = .none,
|
||||
@@ -528,12 +546,40 @@ pub const UnifiedTextBufferView = struct {
|
||||
};
|
||||
}
|
||||
|
||||
fn clearSelectionEndpoints(self: *Self) void {
|
||||
self.selection_endpoints = null;
|
||||
}
|
||||
|
||||
fn offsetSelectionRange(self: *Self, start: u32, end: u32) struct { start: u32, end: u32 } {
|
||||
const text_end = self.text_buffer.textEndOffset();
|
||||
var range_start = @min(@min(start, end), text_end);
|
||||
var range_end = @min(@max(start, end), text_end);
|
||||
if (range_start == range_end) {
|
||||
if (self.text_buffer.graphemeBoundsAtOffset(range_start)) |bounds| range_start = bounds.start;
|
||||
return .{ .start = range_start, .end = range_start };
|
||||
}
|
||||
|
||||
if (self.text_buffer.graphemeBoundsAtOffset(range_start)) |bounds| {
|
||||
range_start = bounds.start;
|
||||
}
|
||||
if (self.text_buffer.graphemeBoundsAtOffset(range_end)) |bounds| {
|
||||
if (bounds.start < range_end) range_end = bounds.end;
|
||||
}
|
||||
|
||||
return .{ .start = range_start, .end = @min(range_end, text_end) };
|
||||
}
|
||||
|
||||
pub fn setSelection(self: *Self, start: u32, end: u32, bgColor: ?RGBA, fgColor: ?RGBA) void {
|
||||
self.setSelectionStyle(start, end, SelectionStyle.rgb(bgColor, fgColor));
|
||||
}
|
||||
|
||||
pub fn setSelectionStyle(self: *Self, start: u32, end: u32, style: SelectionStyle) void {
|
||||
self.selection = selectionFromStyle(start, end, style);
|
||||
// Offset APIs write an already-exclusive [start, end) and do not go
|
||||
// through occupancy. Clear stored cell-pin endpoints so a later
|
||||
// occupancy change or cursor sync cannot replay a stale focus.
|
||||
self.clearSelectionEndpoints();
|
||||
const range = self.offsetSelectionRange(start, end);
|
||||
self.selection = selectionFromStyle(range.start, range.end, style);
|
||||
}
|
||||
|
||||
pub fn updateSelection(self: *Self, end: u32, bgColor: ?RGBA, fgColor: ?RGBA) void {
|
||||
@@ -542,12 +588,51 @@ pub const UnifiedTextBufferView = struct {
|
||||
|
||||
pub fn updateSelectionStyle(self: *Self, end: u32, style: SelectionStyle) void {
|
||||
if (self.selection) |sel| {
|
||||
self.selection = selectionFromStyle(sel.start, end, style);
|
||||
self.clearSelectionEndpoints();
|
||||
const range = self.offsetSelectionRange(sel.start, end);
|
||||
self.selection = selectionFromStyle(range.start, range.end, style);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setSelectionColors(self: *Self, bgColor: ?RGBA, fgColor: ?RGBA) void {
|
||||
if (self.selection) |*selection| {
|
||||
selection.bgColor = bgColor;
|
||||
selection.fgColor = fgColor;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resetSelection(self: *Self) void {
|
||||
self.selection = null;
|
||||
self.clearSelectionEndpoints();
|
||||
}
|
||||
|
||||
pub fn setSelectionOccupancy(self: *Self, occupancy: SelectionOccupancy) void {
|
||||
self.selection_occupancy = occupancy;
|
||||
const endpoints = if (self.selection_endpoints) |*endpoints| endpoints else return;
|
||||
const selection = if (self.selection) |*selection| selection else return;
|
||||
const text_end = self.getTextEndOffset();
|
||||
endpoints.anchor = @min(endpoints.anchor, text_end);
|
||||
endpoints.focus = @min(endpoints.focus, text_end);
|
||||
const range = self.selectionRange(endpoints.anchor, endpoints.focus, text_end);
|
||||
selection.start = range.start;
|
||||
selection.end = range.end;
|
||||
}
|
||||
|
||||
pub fn getSelectionOccupancy(self: *const Self) SelectionOccupancy {
|
||||
return self.selection_occupancy;
|
||||
}
|
||||
|
||||
/// Inclusive-end offset helper: under `cell` occupancy, extend `end` by the
|
||||
/// grapheme at that offset. Under `boundary`, write `[start, end)` as-is.
|
||||
/// Does not store cell-pin endpoints (offset API).
|
||||
pub fn setSelectionInclusiveStyle(self: *Self, start: u32, end: u32, style: SelectionStyle) void {
|
||||
const lo = @min(start, end);
|
||||
const hi = @max(start, end);
|
||||
const exclusive_end = if (self.selection_occupancy == .cell)
|
||||
if (self.text_buffer.graphemeBoundsAtOffset(hi)) |bounds| bounds.end else hi
|
||||
else
|
||||
hi;
|
||||
self.setSelectionStyle(lo, exclusive_end, style);
|
||||
}
|
||||
|
||||
pub fn getSelection(self: *const Self) ?TextSelection {
|
||||
@@ -588,8 +673,7 @@ pub const UnifiedTextBufferView = struct {
|
||||
|
||||
if ((anchor_above and focus_above) or (anchor_below and focus_below)) {
|
||||
const had_selection = self.selection != null;
|
||||
self.selection = null;
|
||||
self.selection_anchor_offset = null;
|
||||
self.resetSelection();
|
||||
return had_selection;
|
||||
}
|
||||
|
||||
@@ -602,8 +686,7 @@ pub const UnifiedTextBufferView = struct {
|
||||
else
|
||||
self.coordsToCharOffset(anchorX, anchorY) orelse {
|
||||
const had_selection = self.selection != null;
|
||||
self.selection = null;
|
||||
self.selection_anchor_offset = null;
|
||||
self.resetSelection();
|
||||
return had_selection;
|
||||
};
|
||||
|
||||
@@ -614,18 +697,16 @@ pub const UnifiedTextBufferView = struct {
|
||||
else
|
||||
self.coordsToCharOffset(focusX, focusY) orelse {
|
||||
const had_selection = self.selection != null;
|
||||
self.selection = null;
|
||||
self.selection_anchor_offset = null;
|
||||
self.resetSelection();
|
||||
return had_selection;
|
||||
};
|
||||
|
||||
self.selection_anchor_offset = anchor_offset;
|
||||
self.selection_endpoints = .{ .anchor = anchor_offset, .focus = focus_offset };
|
||||
|
||||
const new_start = @min(anchor_offset, focus_offset);
|
||||
const new_end = @max(anchor_offset, focus_offset);
|
||||
const range = self.selectionRange(anchor_offset, focus_offset, text_end_offset);
|
||||
|
||||
// Always store selection, even if zero-width, to preserve anchor for updateLocalSelection
|
||||
const new_selection = selectionFromStyle(new_start, new_end, style);
|
||||
const new_selection = selectionFromStyle(range.start, range.end, style);
|
||||
|
||||
const selection_changed = if (self.selection) |old_sel|
|
||||
old_sel.start != new_selection.start or old_sel.end != new_selection.end
|
||||
@@ -641,7 +722,7 @@ pub const UnifiedTextBufferView = struct {
|
||||
}
|
||||
|
||||
pub fn updateLocalSelectionStyle(self: *Self, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, style: SelectionStyle) bool {
|
||||
if (self.selection_anchor_offset) |_| {
|
||||
if (self.selection_endpoints != null) {
|
||||
return self.updateLocalSelectionFocusOnly(focusX, focusY, style);
|
||||
} else {
|
||||
return self.setLocalSelectionStyle(anchorX, anchorY, focusX, focusY, style);
|
||||
@@ -649,7 +730,8 @@ pub const UnifiedTextBufferView = struct {
|
||||
}
|
||||
|
||||
fn updateLocalSelectionFocusOnly(self: *Self, focusX: i32, focusY: i32, style: SelectionStyle) bool {
|
||||
const anchor_offset = self.selection_anchor_offset orelse return false;
|
||||
const endpoints = self.selection_endpoints orelse return false;
|
||||
const anchor_offset = endpoints.anchor;
|
||||
|
||||
self.updateVirtualLines();
|
||||
if (self.truncate and self.viewport != null) {
|
||||
@@ -669,24 +751,47 @@ pub const UnifiedTextBufferView = struct {
|
||||
else
|
||||
self.coordsToCharOffset(focusX, focusY) orelse return false;
|
||||
|
||||
const new_start = @min(anchor_offset, focus_col_offset);
|
||||
var new_end = @max(anchor_offset, focus_col_offset);
|
||||
self.selection_endpoints.?.focus = focus_col_offset;
|
||||
|
||||
if (focus_col_offset < anchor_offset) {
|
||||
new_end = @min(new_end + 1, text_end_offset);
|
||||
}
|
||||
|
||||
self.selection = selectionFromStyle(new_start, new_end, style);
|
||||
const range = self.selectionRange(anchor_offset, focus_col_offset, text_end_offset);
|
||||
self.selection = selectionFromStyle(range.start, range.end, style);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Derive the exclusive highlight/copy/delete range from stored endpoints.
|
||||
/// `cell`: `[min, max + width(max))` so both endpoint graphemes are occupied.
|
||||
/// `boundary`: `[min, max)` — the insert range the caret swept.
|
||||
/// Zero extent (`anchor == focus`) stays empty in both modes: a press is
|
||||
/// not a cell to occupy.
|
||||
fn selectionRange(self: *Self, anchor_offset: u32, focus_offset: u32, text_end_offset: u32) struct { start: u32, end: u32 } {
|
||||
var start = @min(anchor_offset, focus_offset);
|
||||
var end = @max(anchor_offset, focus_offset);
|
||||
if (anchor_offset == focus_offset) {
|
||||
const offset = @min(start, text_end_offset);
|
||||
return .{ .start = offset, .end = offset };
|
||||
}
|
||||
|
||||
if (self.text_buffer.graphemeBoundsAtOffset(start)) |bounds| {
|
||||
start = bounds.start;
|
||||
}
|
||||
if (self.text_buffer.graphemeBoundsAtOffset(end)) |bounds| {
|
||||
if (self.selection_occupancy == .cell or bounds.start < end) {
|
||||
end = bounds.end;
|
||||
}
|
||||
}
|
||||
|
||||
start = @min(start, text_end_offset);
|
||||
end = @min(end, text_end_offset);
|
||||
return .{ .start = start, .end = end };
|
||||
}
|
||||
|
||||
pub fn resetLocalSelection(self: *Self) void {
|
||||
self.selection = null;
|
||||
self.selection_anchor_offset = null;
|
||||
self.resetSelection();
|
||||
}
|
||||
|
||||
fn getTextEndOffset(self: *Self) u32 {
|
||||
self.updateVirtualLines();
|
||||
if (self.truncate and self.viewport != null) {
|
||||
self.ensureTruncation();
|
||||
}
|
||||
@@ -696,12 +801,26 @@ pub const UnifiedTextBufferView = struct {
|
||||
const last_vline = &self.virtual_lines.items[last_line_idx];
|
||||
|
||||
if (last_vline.is_truncated) {
|
||||
return last_vline.col_offset + last_vline.truncation_suffix_start + (last_vline.width_cols - last_vline.ellipsis_pos - 3);
|
||||
return last_vline.col_offset + last_vline.truncation_suffix_start + (last_vline.width_cols -| last_vline.ellipsis_pos -| 3);
|
||||
}
|
||||
|
||||
return last_vline.col_offset + last_vline.width_cols;
|
||||
}
|
||||
|
||||
/// Cell occupancy clamps wrap padding to the last cell. Boundary occupancy
|
||||
/// maps it to the insertion gap after that cell.
|
||||
fn maxLocalXOnVisualLine(self: *const Self, vlines: []const VirtualLine, vline_idx: usize) u32 {
|
||||
const vline = &vlines[vline_idx];
|
||||
if (vline.width_cols == 0) return 0;
|
||||
if (self.selection_occupancy == .cell and vline_idx + 1 < vlines.len) {
|
||||
const next_vline = &vlines[vline_idx + 1];
|
||||
if (next_vline.source_line == vline.source_line) {
|
||||
return vline.width_cols - 1;
|
||||
}
|
||||
}
|
||||
return vline.width_cols;
|
||||
}
|
||||
|
||||
fn coordsToCharOffset(self: *Self, x: i32, y: i32) ?u32 {
|
||||
self.updateVirtualLines();
|
||||
if (self.truncate and self.viewport != null) {
|
||||
@@ -726,9 +845,9 @@ pub const UnifiedTextBufferView = struct {
|
||||
const vline_idx: usize = @intCast(clamped_y);
|
||||
const vline = &self.virtual_lines.items[vline_idx];
|
||||
const lineStart = vline.col_offset;
|
||||
const lineWidth = vline.width_cols;
|
||||
const max_local_x = self.maxLocalXOnVisualLine(self.virtual_lines.items, vline_idx);
|
||||
|
||||
var localX = @max(0, @min(abs_x, @as(i32, @intCast(lineWidth))));
|
||||
var localX = @max(0, @min(abs_x, @as(i32, @intCast(max_local_x))));
|
||||
|
||||
if (vline.is_truncated) {
|
||||
const ellipsis_width: u32 = 3;
|
||||
|
||||
@@ -158,6 +158,40 @@ pub const UnifiedTextBuffer = struct {
|
||||
return iter_mod.getPrevGraphemeWidth(@constCast(&self._rope), &self.mem_registry, row, col, self.tab_width, self.width_method);
|
||||
}
|
||||
|
||||
pub fn textEndOffset(self: *const Self) u32 {
|
||||
return self._rope.totalWeight();
|
||||
}
|
||||
|
||||
pub fn graphemeBoundsAtOffset(self: *const Self, offset: u32) ?struct { start: u32, end: u32 } {
|
||||
const rope_ptr = @constCast(&self._rope);
|
||||
const coords = iter_mod.offsetToCoords(rope_ptr, offset) orelse return null;
|
||||
const bounds = iter_mod.getGraphemeBoundsAt(
|
||||
rope_ptr,
|
||||
&self.mem_registry,
|
||||
coords.row,
|
||||
coords.col,
|
||||
self.tab_width,
|
||||
self.width_method,
|
||||
) orelse return null;
|
||||
const line_start = offset - coords.col;
|
||||
return .{ .start = line_start + bounds.start, .end = line_start + bounds.end };
|
||||
}
|
||||
|
||||
pub fn cursorUnitBoundsAtOffset(self: *const Self, offset: u32) ?struct { start: u32, end: u32 } {
|
||||
const rope_ptr = @constCast(&self._rope);
|
||||
const coords = iter_mod.offsetToCoords(rope_ptr, offset) orelse return null;
|
||||
const bounds = iter_mod.getCursorUnitBoundsAt(
|
||||
rope_ptr,
|
||||
&self.mem_registry,
|
||||
coords.row,
|
||||
coords.col,
|
||||
self.tab_width,
|
||||
self.width_method,
|
||||
) orelse return null;
|
||||
const line_start = offset - coords.col;
|
||||
return .{ .start = line_start + bounds.start, .end = line_start + bounds.end };
|
||||
}
|
||||
|
||||
pub fn getWrapOffsetsFor(self: *const Self, chunk: *const TextChunk) TextBufferError![]const utf8.WrapBreak {
|
||||
return chunk.getWrapOffsets(self.allocator, &self.mem_registry, self.width_method);
|
||||
}
|
||||
|
||||
@@ -1192,6 +1192,19 @@ pub fn findPosByWidth(
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a display position without splitting Unicode grapheme clusters.
|
||||
/// The width method still controls each cluster's display width.
|
||||
pub fn findGraphemePosByWidth(
|
||||
text: []const u8,
|
||||
max_columns: u32,
|
||||
tab_width: u8,
|
||||
isASCIIOnly: bool,
|
||||
include_start_before: bool,
|
||||
width_method: WidthMethod,
|
||||
) PosByWidthResult {
|
||||
return findPosByWidthUnicode(text, max_columns, tab_width, isASCIIOnly, include_start_before, width_method);
|
||||
}
|
||||
|
||||
/// Find position by column width using Unicode grapheme cluster segmentation
|
||||
fn findPosByWidthUnicode(
|
||||
text: []const u8,
|
||||
@@ -1310,6 +1323,9 @@ fn findPosByWidthUnicode(
|
||||
if (state.columns_used >= max_columns) {
|
||||
return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
|
||||
}
|
||||
if (!include_start_before and state.columns_used + state.cluster_width > max_columns) {
|
||||
return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
|
||||
}
|
||||
state.columns_used += state.cluster_width;
|
||||
if (include_start_before) {
|
||||
state.grapheme_count += 1;
|
||||
@@ -1390,6 +1406,10 @@ pub fn getWidthAt(text: []const u8, byte_offset: usize, tab_width: u8, width_met
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getGraphemeWidthAt(text: []const u8, byte_offset: usize, tab_width: u8, width_method: WidthMethod) u32 {
|
||||
return getWidthAtUnicode(text, byte_offset, tab_width, width_method);
|
||||
}
|
||||
|
||||
/// Get width at byte offset using Unicode grapheme cluster segmentation
|
||||
fn getWidthAtUnicode(text: []const u8, byte_offset: usize, tab_width: u8, width_method: WidthMethod) u32 {
|
||||
if (byte_offset >= text.len) return 0;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InputRenderable,
|
||||
InputRenderableEvents,
|
||||
isTextNodeRenderable,
|
||||
isEditBufferRenderable,
|
||||
parseColor,
|
||||
Renderable,
|
||||
RootTextNodeRenderable,
|
||||
@@ -334,6 +335,9 @@ export const {
|
||||
case "style":
|
||||
const nextStyle = value ?? {}
|
||||
const previousStyle = prev ?? {}
|
||||
if (isEditBufferRenderable(node) && previousStyle.selectionOccupancy && !nextStyle.selectionOccupancy) {
|
||||
node.selectionOccupancy = undefined
|
||||
}
|
||||
if (node instanceof ImageRenderable) {
|
||||
for (const prop in previousStyle) {
|
||||
if (Object.prototype.hasOwnProperty.call(nextStyle, prop)) continue
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
|
||||
import { testRender } from "../index.js"
|
||||
import { createSignal } from "solid-js"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { TextAttributes, type TextareaRenderable } from "@opentui/core"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>>
|
||||
|
||||
@@ -19,6 +19,27 @@ describe("Textarea Layout Tests", () => {
|
||||
})
|
||||
|
||||
describe("Basic Textarea Rendering", () => {
|
||||
it("resets selection occupancy removed from style", async () => {
|
||||
const [boundary, setBoundary] = createSignal(true)
|
||||
let textarea!: TextareaRenderable
|
||||
testSetup = await testRender(
|
||||
() => (
|
||||
<textarea
|
||||
ref={(value: TextareaRenderable) => (textarea = value)}
|
||||
style={boundary() ? { selectionOccupancy: "boundary" } : {}}
|
||||
/>
|
||||
),
|
||||
{ width: 20, height: 5 },
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
expect(textarea.selectionOccupancy).toBe("boundary")
|
||||
|
||||
setBoundary(false)
|
||||
await testSetup.renderOnce()
|
||||
expect(textarea.selectionOccupancy).toBe("cell")
|
||||
})
|
||||
|
||||
it("should render simple textarea correctly", async () => {
|
||||
testSetup = await testRender(
|
||||
() => (
|
||||
|
||||
@@ -87,6 +87,7 @@ const textarea = new TextareaRenderable(renderer, {
|
||||
| `selectionFg` | `string` or `RGBA` | - | Selection foreground |
|
||||
| `cursorColor` | `string` or `RGBA` | `#FFFFFF` | Cursor color |
|
||||
| `cursorStyle` | `CursorStyleOptions` | - | Cursor style and blinking |
|
||||
| `selectionOccupancy` | `"cell"` or `"boundary"` | `"cell"` | Which cells a selection occupies |
|
||||
| `keyBindings` | `KeyBinding[]` | - | Custom key bindings |
|
||||
| `keyAliasMap` | `Record<string, string>` | - | Key alias mapping |
|
||||
| `onSubmit` | `(event: SubmitEvent) => void` | - | Submit handler |
|
||||
@@ -139,8 +140,8 @@ textarea.gotoBufferEnd({ select: true })
|
||||
### Selection
|
||||
|
||||
```typescript
|
||||
textarea.setSelection(start, end) // half-open offsets
|
||||
textarea.setSelectionInclusive(start, end) // inclusive end
|
||||
textarea.setSelection(start, end) // half-open [start, end) in both occupancy modes
|
||||
textarea.setSelectionInclusive(start, end) // also selects the grapheme at end in cell mode
|
||||
textarea.selectAll()
|
||||
textarea.clearSelection()
|
||||
textarea.deleteSelection()
|
||||
@@ -166,6 +167,10 @@ textarea.redo()
|
||||
These methods update the editor and request a render as needed. Selection behavior depends on the method. Movement with
|
||||
`{ select: true }` extends the selection. Call `clearSelection()` when a command must clear the global selection.
|
||||
|
||||
The default occupancy is `cell`: the selection covers both endpoint cells, so the first shift+right selects two
|
||||
cells. If you use a bar cursor (`cursorStyle: { style: "line" }`), also set `selectionOccupancy: "boundary"`. The
|
||||
cursor style is visual only and never changes which text you select, copy, or delete.
|
||||
|
||||
## Traits
|
||||
|
||||
The `traits` property tells a host UI which built-in keys the editor wants to capture. It also supplies a visual-suspension hint and an optional status label. Assigning a different `EditorTraits` object emits the `traits-changed` event.
|
||||
|
||||
Reference in New Issue
Block a user