diff --git a/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt b/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt index e9b8ab1edf..8e889bccf2 100644 --- a/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt +++ b/packages/app/modules/paseo-terminal/android/src/androidTest/java/sh/paseo/terminal/TerminalRowLayoutTest.kt @@ -10,6 +10,7 @@ import androidx.test.platform.app.InstrumentationRegistry import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -55,6 +56,40 @@ class TerminalRowLayoutTest { } } + @Test + fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() { + val red = 0xffff0000.toInt() + val green = 0xff00aa00.toInt() + val overlay = 0x472684ff + val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M") + val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY) + paints.selectionBackground = overlay + for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) { + val actual = raster { canvas -> + canvas.drawColor(terminalBackground) + layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints) + } + val expected = raster { canvas -> + canvas.drawColor(terminalBackground) + canvas.drawColor(green) + canvas.drawColor(overlay) + } + assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0)) + assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y -> + (0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red } + }) + } + val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY) + for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) { + val actual = raster { canvas -> + canvas.drawColor(terminalBackground) + defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints) + } + val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) } + assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0)) + } + } + private fun starts(row: JSONObject, key: String): List = row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } } diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt index 3fcf012e03..03f76cf91b 100644 --- a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/PaseoTerminalView.kt @@ -63,7 +63,6 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con backgroundColor = argb(value.background) cursorColor = argb(value.cursor) paints.selectionBackground = argb(value.selectionBackground) - paints.selectionForeground = argb(value.selectionForeground) invalidate() } diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt index c27be7cc7a..a33eca76e6 100644 --- a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalFrame.kt @@ -27,7 +27,6 @@ class TerminalThemeRecord : Record { @Field val background: Double = 0.0 @Field val cursor: Double = 0.0 @Field val selectionBackground: Double = 0.0 - @Field val selectionForeground: Double = 0.0 } class TerminalCustomGlyphRecord : Record { diff --git a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt index d2ee2355bc..6680f15ed9 100644 --- a/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt +++ b/packages/app/modules/paseo-terminal/android/src/main/java/sh/paseo/terminal/TerminalRowLayout.kt @@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f * either half alone would show an explicit virama. Each resulting group is drawn from the shaped * run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when * its glyphs are wider than those columns. Colors never split groups or reshape the row: a group - * whose cells differ in color or selection is drawn once per color, clipped to those cells' columns. + * whose cells differ in color is drawn once per color, clipped to those cells' columns. */ internal class TerminalRowLayout( val source: TerminalRowSource, @@ -195,7 +195,7 @@ internal class TerminalRowLayout( canvas.drawRenderNode(recorded) } - /** Draws the row with selected cells in selection colors. Selection never reshapes the row. */ + /** Draws backgrounds, the selection overlay, then text with its original colors. */ fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) { drawContent(canvas, row, selection, paints) } @@ -213,17 +213,17 @@ internal class TerminalRowLayout( val cellHeight = font.cellHeightPx.toFloat() val fill = paints.fill for (cell in 0 until cellCount) { - val background = - if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell] - if (background ushr 24 == 0) continue - fill.color = background - canvas.drawRect( - cellCol[cell] * cellWidth, - 0f, - (cellCol[cell] + cellSpan[cell]) * cellWidth, - cellHeight, - fill, - ) + val left = cellCol[cell] * cellWidth + val right = (cellCol[cell] + cellSpan[cell]) * cellWidth + val background = cellBackground[cell] + if (background ushr 24 != 0) { + fill.color = background + canvas.drawRect(left, 0f, right, cellHeight, fill) + } + if (selected(row, selection, cell)) { + fill.color = paints.selectionBackground + canvas.drawRect(left, 0f, right, cellHeight, fill) + } } val baseline = font.baselinePx.toFloat() @@ -234,9 +234,9 @@ internal class TerminalRowLayout( val paint = font.paint(cellFlags[group.firstCell]) var first = group.firstCell while (first <= group.lastCell) { - val color = foreground(row, selection, first, paints) + val color = cellForeground[first] var last = first - while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1 + while (last < group.lastCell && cellForeground[last + 1] == color) last += 1 // Only edges between differently colored cells clip; the group's outer edges may overhang. val clipped = first > group.firstCell || last < group.lastCell canvas.save() @@ -257,7 +257,7 @@ internal class TerminalRowLayout( for (cell in 0 until cellCount) { val glyph = cellGlyph[cell] val flags = cellFlags[cell] - val foreground = foreground(row, selection, cell, paints) + val foreground = cellForeground[cell] if (glyph != null) { fill.color = foreground glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill) @@ -291,9 +291,6 @@ internal class TerminalRowLayout( canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint) } - private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int = - if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell] - /** The row's text as accessibility reads it. */ fun describe(): String = source.text.trimEnd() } @@ -301,6 +298,5 @@ internal class TerminalRowLayout( /** Paint state shared by every row of one view. */ internal class TerminalPaints { val fill = Paint(Paint.ANTI_ALIAS_FLAG) - var selectionBackground: Int = 0xFFFFFF00.toInt() - var selectionForeground: Int = 0xFF000000.toInt() + var selectionBackground: Int = 0x472684FF } diff --git a/packages/app/modules/paseo-terminal/contract.ts b/packages/app/modules/paseo-terminal/contract.ts index ee4fe45ab0..6fd6c0906b 100644 --- a/packages/app/modules/paseo-terminal/contract.ts +++ b/packages/app/modules/paseo-terminal/contract.ts @@ -43,7 +43,6 @@ export interface PaseoTerminalTheme { background: number; cursor: number; selectionBackground: number; - selectionForeground: number; } export interface PaseoTerminalCellMetrics { diff --git a/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift b/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift index 240cddddab..9739202cad 100644 --- a/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift +++ b/packages/app/modules/paseo-terminal/ios/PaseoTerminalView.swift @@ -24,8 +24,7 @@ final class PaseoTerminalView: ExpoView { private var background = TerminalColor(Double(0xff00_0000)) private var cursorColor = TerminalColor(Double(0xffff_ffff)) private var rowPaint = TerminalRowPaint( - selectionBackground: TerminalColor(Double(0xffff_ff00)), - selectionForeground: TerminalColor(Double(0xff00_0000))) + selectionBackground: TerminalColor(Double(0x4726_84ff))) private var rowCache: [String: TerminalRowLayout] = [:] required init(appContext: AppContext? = nil) { @@ -44,8 +43,7 @@ final class PaseoTerminalView: ExpoView { background = TerminalColor(record.background) cursorColor = TerminalColor(record.cursor) rowPaint = TerminalRowPaint( - selectionBackground: TerminalColor(record.selectionBackground), - selectionForeground: TerminalColor(record.selectionForeground)) + selectionBackground: TerminalColor(record.selectionBackground)) backgroundColor = UIColor(cgColor: background.cgColor()) setNeedsDisplay() } diff --git a/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift b/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift index 84ba6e8b0c..2cba97fd61 100644 --- a/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift +++ b/packages/app/modules/paseo-terminal/ios/TerminalFrame.swift @@ -25,7 +25,6 @@ struct TerminalThemeRecord: Record { @Field var background: Double = 0 @Field var cursor: Double = 0 @Field var selectionBackground: Double = 0 - @Field var selectionForeground: Double = 0 } struct TerminalCustomGlyphRecord: Record { diff --git a/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift b/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift index 80050c9040..83d731f1e3 100644 --- a/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift +++ b/packages/app/modules/paseo-terminal/ios/TerminalRowLayout.swift @@ -27,7 +27,6 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = { /// Colors a row is painted with besides its own styles. struct TerminalRowPaint { let selectionBackground: TerminalColor - let selectionForeground: TerminalColor } /// One row shaped once and placed on the terminal grid. @@ -267,31 +266,33 @@ final class TerminalRowLayout { selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false } - private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor { - isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell] - } - - /// Draws the row with its top edge at `top`, selected cells in selection colors. + /// Draws cell backgrounds, the selection overlay, then text with its original colors. func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) { let cellWidth = font.cellWidth let cellHeight = font.cellHeight for cell in 0.. Math.abs(gesture.dx) ? eventDx : gesture.dx; - const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy; - return { - x: press.startX + dx, - y: press.startY + dy, - }; -} - function initialNativeTerminalSize(): TerminalMeasuredSize { return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS }; } @@ -163,19 +127,14 @@ function NativeTerminalEmulator({ const outputDrainRef = useRef(null); const screenModelRef = useRef(null); const selectionModelRef = useRef(createTerminalSelectionModel()); - const selectionLongPressTimeoutRef = useRef | null>(null); - const selectionPressRef = useRef(null); + const selectionControllerRef = useRef | null>(null); + const gestureStartRef = useRef({ x: 0, y: 0 }); + const [cellMetrics, setCellMetrics] = useState(null); const latestResolvedScreenRef = useRef(null); const paintFrameRef = useRef(null); const outputDecoderRef = useRef(new TextDecoder()); - const panStateRef = useRef({ - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }); const inputModeTrackerRef = useRef(new TerminalInputModeTracker()); const lastInputModeStateRef = useRef({ ...inputModeTrackerRef.current.getState(), @@ -245,6 +204,7 @@ function NativeTerminalEmulator({ }, []); const clearSelection = useCallback(() => { + selectionControllerRef.current?.cancel(); const snapshot = selectionModelRef.current.clear(); commitSelectionRange(snapshot.range); }, [commitSelectionRange]); @@ -255,20 +215,14 @@ function NativeTerminalEmulator({ const syncSelectionWithTerminal = useCallback( (terminal: NativeHeadlessTerminal) => { + const previous = selectionModelRef.current.getSnapshot().range; const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() }); + if (previous && !snapshot.range) selectionControllerRef.current?.cancel(); commitSelectionRange(snapshot.range); }, [commitSelectionRange], ); - const clearSelectionLongPressTimeout = useCallback(() => { - if (selectionLongPressTimeoutRef.current === null) { - return; - } - clearTimeout(selectionLongPressTimeoutRef.current); - selectionLongPressTimeoutRef.current = null; - }, []); - const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => { return ( resolveMeasuredNativeTerminalSize({ @@ -523,8 +477,7 @@ function NativeTerminalEmulator({ emitMeasuredSize({ source: "measure" }); return () => { callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); - clearSelectionLongPressTimeout(); - selectionPressRef.current = null; + selectionControllerRef.current?.cancel(); if (paintFrameRef.current !== null) { cancelAnimationFrame(paintFrameRef.current); paintFrameRef.current = null; @@ -534,7 +487,7 @@ function NativeTerminalEmulator({ terminalRef.current?.dispose(); terminalRef.current = null; }; - }, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]); + }, [emitMeasuredSize, resetTerminal, streamKey]); useEffect(() => { if (focusRequestToken <= 0) return; @@ -591,6 +544,7 @@ function NativeTerminalEmulator({ const handleCellMetricsChange = useCallback( (metrics: TerminalGridCellMetrics) => { metricsRef.current = metrics; + setCellMetrics(metrics); emitMeasuredSize({ source: "measure" }); }, [emitMeasuredSize], @@ -663,14 +617,6 @@ function NativeTerminalEmulator({ if (!coordinate) { return; } - const existingPress = selectionPressRef.current; - selectionPressRef.current = { - startX: point.x, - startY: point.y, - startedAt: existingPress?.startedAt ?? Date.now(), - startedWithSelection: existingPress?.startedWithSelection ?? false, - status: "selecting", - }; const bounds = terminal.getBufferBounds(); const wordSelection = resolveTerminalWordSelection({ terminal, coordinate }); let snapshot = selectionModelRef.current.begin({ @@ -685,222 +631,61 @@ function NativeTerminalEmulator({ [commitSelectionRange, resolveSelectionCoordinate], ); - const updateSelectionAtPoint = useCallback( - (point: { x: number; y: number }) => { - const terminal = terminalRef.current; - if (!terminal || selectionPressRef.current?.status !== "selecting") { - return; - } - const coordinate = resolveSelectionCoordinate(point); - if (!coordinate) { - return; - } - const snapshot = selectionModelRef.current.update({ - coordinate, - bounds: terminal.getBufferBounds(), - }); - commitSelectionRange(snapshot.range); - }, - [commitSelectionRange, resolveSelectionCoordinate], - ); - - const handlePanResponderScroll = useCallback( - (currentDy: number) => { - const cellHeight = metricsRef.current?.cellHeight; - if (!cellHeight || cellHeight <= 0) { - return; - } - const panState = panStateRef.current; - const update = advanceTerminalScrollGesture({ - state: panState, - currentDy, - cellHeight, - }); - panState.lastDy = update.state.lastDy; - panState.rowRemainder = update.state.rowRemainder; - panState.didScroll = update.state.didScroll; - const { rowDelta } = update; - if (rowDelta === 0) { - return; - } - if (rowDelta > 0) { - applyScroll("up", rowDelta); - return; - } - applyScroll("down", Math.abs(rowDelta)); - }, - [applyScroll], + const selectionController = useMemo( + () => + createTerminalSelectionController({ + getGeometry: () => { + const viewport = resolveSelectionViewport(); + const metrics = metricsRef.current; + return viewport && metrics + ? { range: selectionRangeRef.current, viewport, metrics } + : null; + }, + onBeginHandle: (endpoint, cols) => + selectionModelRef.current.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + const terminal = terminalRef.current; + if (!terminal) return; + commitSelectionRange( + selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() }) + .range, + ); + }, + onLongPress: beginSelectionAtPoint, + onTap: focusTerminalFromTap, + onScroll: applyScroll, + schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number, + cancel: (timer) => clearTimeout(timer), + }), + [ + applyScroll, + beginSelectionAtPoint, + commitSelectionRange, + focusTerminalFromTap, + resolveSelectionViewport, + ], ); + selectionControllerRef.current = selectionController; const panResponder = useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: (_event, gesture) => { - const intent = classifyTerminalGestureIntent({ - status: selectionRangeRef.current ? "selecting" : "pressing", - dx: gesture.dx, - dy: gesture.dy, - vx: gesture.vx, - vy: gesture.vy, - }); - return intent === "scroll" || intent === "select"; - }, - onMoveShouldSetPanResponderCapture: (_event, gesture) => { - const intent = classifyTerminalGestureIntent({ - status: selectionRangeRef.current ? "selecting" : "pressing", - dx: gesture.dx, - dy: gesture.dy, - vx: gesture.vx, - vy: gesture.vy, - }); - return intent === "scroll" || intent === "select"; - }, onPanResponderGrant: (event) => { - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; - const point = { - x: event.nativeEvent.locationX, - y: event.nativeEvent.locationY, - }; - const pressStatus = selectionRangeRef.current ? "selecting" : "pressing"; - selectionPressRef.current = { - startX: point.x, - startY: point.y, - startedAt: Date.now(), - startedWithSelection: pressStatus === "selecting", - status: pressStatus, - }; - clearSelectionLongPressTimeout(); - if (pressStatus === "selecting") { - return; - } - selectionLongPressTimeoutRef.current = setTimeout(() => { - selectionLongPressTimeoutRef.current = null; - const press = selectionPressRef.current; - if (press?.status !== "pressing") { - return; - } - beginSelectionAtPoint({ x: press.startX, y: press.startY }); - }, TERMINAL_GESTURE_LONG_PRESS_MS); - }, - onPanResponderMove: (event, gesture) => { - const selectionPress = selectionPressRef.current; - const panState = panStateRef.current; - if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { - panState.movedBeyondTapTolerance = true; - } - if (selectionPress?.status === "selecting") { - if (panState.movedBeyondTapTolerance) { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); - } - return; - } - - if (panState.intent === "scroll") { - handlePanResponderScroll(gesture.dy); - return; - } - if (panState.didNavigate) { - return; - } - - if (selectionPress?.status === "pressing") { - const point = resolvePanResponderPoint(selectionPress, event, gesture); - const dx = point.x - selectionPress.startX; - const dy = point.y - selectionPress.startY; - const intent = classifyTerminalGestureIntent({ - status: "pressing", - dx, - dy, - vx: gesture.vx, - vy: gesture.vy, - elapsedMs: Date.now() - selectionPress.startedAt, - }); - if (intent === "tap" || intent === "pending") { - return; - } - - clearSelectionLongPressTimeout(); - panState.intent = intent; - - if (intent === "scroll") { - selectionPressRef.current = null; - handlePanResponderScroll(gesture.dy); - return; - } - - if (intent === "select") { - beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); - updateSelectionAtPoint(point); - return; - } - } - }, - onPanResponderRelease: (event, gesture) => { - clearSelectionLongPressTimeout(); - const selectionPress = selectionPressRef.current; - if ( - selectionPress?.status === "selecting" && - panStateRef.current.movedBeyondTapTolerance - ) { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); - } - selectionPressRef.current = null; - const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current; - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; - const releaseAction = resolveTerminalGestureReleaseAction({ - status: selectionPress?.status ?? "idle", - startedWithSelection: selectionPress?.startedWithSelection, - didScroll, - didNavigate, - movedBeyondTapTolerance, - pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: terminalScreenRef.current?.scroll.mode ?? "following", - }); - if (releaseAction === "select" && selectionPress) { - beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); - return; - } - if (releaseAction === "focus" || releaseAction === "clear") { - focusTerminalFromTap(); - } - }, - onPanResponderTerminate: () => { - clearSelectionLongPressTimeout(); - selectionPressRef.current = null; - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; + const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY }; + gestureStartRef.current = point; + selectionController.press(point); }, + onPanResponderMove: (_event, gesture) => + selectionController.move({ + x: gestureStartRef.current.x + gesture.dx, + y: gestureStartRef.current.y + gesture.dy, + }), + onPanResponderRelease: () => selectionController.release(), + onPanResponderTerminate: () => selectionController.cancel(), }), - [ - beginSelectionAtPoint, - clearSelectionLongPressTimeout, - focusTerminalFromTap, - handlePanResponderScroll, - updateSelectionAtPoint, - ], + [selectionController], ); const backgroundColor = xtermTheme.background ?? "#0b0b0b"; @@ -911,6 +696,11 @@ function NativeTerminalEmulator({ const terminalState = terminalScreen?.viewport ?? null; const isScrolled = terminalScreen?.scroll.mode === "scrolled"; + const viewport = resolveSelectionViewport(); + const handles = + viewport && cellMetrics + ? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics }) + : []; const terminalGrid = terminalState ? ( {terminalGrid} + + {handles.map((handle) => ( + + + + + + + ))} + { + it("preserves the grab offset while moving a handle", () => { + const range = { start: { row: 1, col: 2 }, end: { row: 1, col: 5 }, coordinateEpoch: 0 }; + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 23, y: 15 }); + controller.move({ x: 63, y: 15 }); + controller.release(); + expect(moves).toEqual([{ row: 1, col: 5 }]); + }); +}); + +it("selects on hold, preserves a handle tap, and cancels an active drag", () => { + const actions: string[] = []; + let timer: (() => void) | null = null; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: (endpoint) => actions.push(endpoint), + onMoveHandle: () => actions.push("move"), + onLongPress: () => actions.push("long"), + onTap: () => actions.push("tap"), + onScroll: () => actions.push("scroll"), + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 90, y: 80 }); + (timer as (() => void) | null)?.(); + controller.release(); + controller.press({ x: 20, y: 25 }); + controller.release(); + controller.press({ x: 20, y: 25 }); + controller.cancel(); + controller.move({ x: 50, y: 25 }); + expect(actions).toEqual(["long", "start", "start"]); +}); + +it("scrolls selection into history while holding a handle at the edge", () => { + let firstRow = 10; + let timer: (() => void) | null = null; + const coordinates: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 12, col: 2 }, end: { row: 13, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (point) => coordinates.push(point), + onLongPress: () => {}, + onTap: () => {}, + onScroll: (direction) => { + if (direction === "up") firstRow--; + }, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 20, y: 25 }); + controller.move({ x: 20, y: 1 }); + (timer as (() => void) | null)?.(); + (timer as (() => void) | null)?.(); + controller.release(); + expect(coordinates).toEqual([ + { row: 10, col: 2 }, + { row: 9, col: 2 }, + { row: 8, col: 2 }, + ]); + expect(timer).toBeNull(); +}); + +it("scrolls outside handles without clearing selection and clears on an outside tap", () => { + const actions: string[] = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => actions.push("handle"), + onMoveHandle: () => actions.push("move"), + onLongPress: () => actions.push("long"), + onTap: () => actions.push("tap"), + onScroll: (direction, rows) => actions.push(`${direction}:${rows}`), + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 95, y: 70 }); + controller.move({ x: 95, y: 90 }); + controller.release(); + controller.press({ x: 95, y: 70 }); + controller.release(); + expect(actions).toEqual(["up:2", "tap"]); +}); + +it("extends the selection after a long press and drag", () => { + let timer: (() => void) | null = null; + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: null, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 25, y: 25 }); + (timer as (() => void) | null)?.(); + controller.move({ x: 65, y: 35 }); + controller.release(); + expect(moves).toEqual([{ row: 3, col: 6 }]); +}); + +it.each([ + { + endpoint: "start" as const, + startX: 20, + crossedX: 80, + crossedText: "gh", + crossedStart: 6, + crossedEnd: 7, + backX: 10, + backText: "bcdef", + backStart: 1, + backEnd: 5, + }, + { + endpoint: "end" as const, + startX: 60, + crossedX: 10, + crossedText: "b", + crossedStart: 1, + crossedEnd: 1, + backX: 80, + backText: "cdefgh", + backStart: 2, + backEnd: 7, + }, +])("tracks the $endpoint handle through crossing and back", async (scenario) => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 }); + try { + await terminal.write("abcdefghij"); + const bounds = terminal.getBufferBounds(); + const model = createTerminalSelectionModel(); + model.begin({ coordinate: { row: 0, col: 2 }, bounds }); + model.update({ coordinate: { row: 0, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 4, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics }); + const controller = createTerminalSelectionController({ + getGeometry: geometry, + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + const check = (start: number, end: number, text: string) => { + const range = model.getSnapshot().range; + expect(range).toEqual({ + start: { row: 0, col: start }, + end: { row: 0, col: end }, + coordinateEpoch: bounds.coordinateEpoch, + }); + expect(terminalSelectionHandles(geometry())).toMatchObject([ + { endpoint: "start", x: start * 10, y: 5 }, + { endpoint: "end", x: (end + 1) * 10, y: 5 }, + ]); + expect(resolveTerminalSelectionRects({ selection: range, viewport, metrics })).toEqual([ + { + key: `0:${start}:${end}`, + x: start * 10, + y: 0, + width: (end - start + 1) * 10, + height: 10, + }, + ]); + expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(text); + }; + controller.press({ x: scenario.startX, y: 5 }); + controller.move({ x: scenario.crossedX, y: 5 }); + check(scenario.crossedStart, scenario.crossedEnd, scenario.crossedText); + controller.move({ x: scenario.backX, y: 5 }); + check(scenario.backStart, scenario.backEnd, scenario.backText); + controller.release(); + } finally { + terminal.dispose(); + } +}); + +it.each([ + { + endpoint: "end" as const, + initialRow: 0, + targetRow: 1, + targetX: 0, + expectedRow: 0, + expectedCol: 9, + text: "cdefghij", + }, + { + endpoint: "start" as const, + initialRow: 1, + targetRow: 0, + targetX: 100, + expectedRow: 1, + expectedCol: 0, + text: "KLMNOP", + }, +])("preserves a $endpoint boundary at a row edge", async (scenario) => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 }); + try { + await terminal.write("abcdefghij\r\nKLMNOPQRST"); + const bounds = terminal.getBufferBounds(); + const model = createTerminalSelectionModel(); + model.begin({ coordinate: { row: scenario.initialRow, col: 2 }, bounds }); + model.update({ coordinate: { row: scenario.initialRow, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 4, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics }); + const controller = createTerminalSelectionController({ + getGeometry: geometry, + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: scenario.endpoint === "end" ? 60 : 20, y: scenario.initialRow * 10 + 5 }); + controller.move({ x: scenario.targetX, y: scenario.targetRow * 10 + 5 }); + const range = model.getSnapshot().range; + expect(range?.[scenario.endpoint]).toEqual({ + row: scenario.expectedRow, + col: scenario.expectedCol, + }); + expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(scenario.text); + expect( + terminalSelectionHandles(geometry()).find((handle) => handle.endpoint === scenario.endpoint), + ).toMatchObject({ + x: scenario.endpoint === "end" ? 100 : 0, + y: scenario.expectedRow * 10 + 5, + }); + controller.release(); + } finally { + terminal.dispose(); + } +}); + +it("cancels a drag when buffer coordinates are invalidated", () => { + const model = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 }; + model.begin({ coordinate: { row: 2, col: 2 }, bounds }); + model.update({ coordinate: { row: 2, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 10, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ range: model.getSnapshot().range, viewport, metrics }), + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 20, y: 25 }); + const previous = model.getSnapshot().range; + const snapshot = model.sync({ bounds: { ...bounds, coordinateEpoch: 1 } }); + if (previous && !snapshot.range) controller.cancel(); + controller.move({ x: 80, y: 25 }); + controller.release(); + expect(model.getSnapshot().range).toBeNull(); +}); + +it("preserves a newly selected word through small movement at the viewport edge", () => { + const model = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 }; + let timer: (() => void) | null = null; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: model.getSnapshot().range, + viewport: { firstRow: 0, rows: 10, cols: 20 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => { + model.begin({ coordinate: { row: 0, col: 2 }, bounds }); + model.update({ coordinate: { row: 0, col: 6 }, bounds }); + }, + onTap: () => {}, + onScroll: () => {}, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 45, y: 5 }); + const fire = timer as (() => void) | null; + timer = null; + fire?.(); + controller.move({ x: 44, y: 4 }); + controller.release(); + expect(model.getSnapshot().range).toEqual({ + start: { row: 0, col: 2 }, + end: { row: 0, col: 6 }, + coordinateEpoch: 0, + }); + expect(timer).toBeNull(); +}); + +it("places mirrored teardrop grips below the selected row", () => { + const handles = terminalSelectionHandles({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }); + expect(handles).toMatchObject([ + { endpoint: "start", x: 20, y: 25, gripX: 11, gripY: 39, gripDirection: "left" }, + { endpoint: "end", x: 60, y: 25, gripX: 69, gripY: 39, gripDirection: "right" }, + ]); +}); + +it("keeps edge grips reachable and preserves the offset from the cell when dragging a grip", () => { + const range = { start: { row: 0, col: 0 }, end: { row: 9, col: 9 }, coordinateEpoch: 0 }; + const viewport = { firstRow: 0, rows: 10, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const handles = terminalSelectionHandles({ range, viewport, metrics }); + expect(handles.map(({ gripX, gripY }) => ({ gripX, gripY }))).toEqual([ + { gripX: 9, gripY: 19 }, + { gripX: 91, gripY: 81 }, + ]); + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ range, viewport, metrics }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 9, y: 19 }); + controller.move({ x: 19, y: 29 }); + controller.release(); + expect(moves).toEqual([{ row: 1, col: 1 }]); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts b/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts new file mode 100644 index 0000000000..721fd501d7 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts @@ -0,0 +1,208 @@ +import type { TerminalGridCellMetrics } from "./terminal-grid-metrics"; +import type { + TerminalBufferCoordinate, + TerminalSelectionPoint, + TerminalSelectionRange, + TerminalSelectionViewport, +} from "./terminal-selection"; +export const TERMINAL_SELECTION_GRIP_SIZE = 18; +const TERMINAL_GESTURE_LONG_PRESS_MS = 450; +const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8; + +type Endpoint = "start" | "end"; +interface Geometry { + range: TerminalSelectionRange | null; + viewport: TerminalSelectionViewport; + metrics: TerminalGridCellMetrics; +} +interface Ports { + getGeometry(): Geometry | null; + onBeginHandle(endpoint: Endpoint, cols: number): void; + onMoveHandle(coordinate: TerminalBufferCoordinate): void; + onLongPress(point: TerminalSelectionPoint): void; + onTap(): void; + onScroll(direction: "up" | "down", rows: number): void; + schedule(callback: () => void, delay: number): number; + cancel(timer: number): void; +} + +export function terminalSelectionHandles(geometry: Geometry): Array<{ + endpoint: Endpoint; + x: number; + y: number; + gripX: number; + gripY: number; + gripDirection: "left" | "right"; + gripAbove: boolean; + stemTop: number; + stemHeight: number; +}> { + const { range, viewport, metrics } = geometry; + if (!range) return []; + return (["start", "end"] as const).flatMap((endpoint) => { + const cell = range[endpoint]; + if (cell.row < viewport.firstRow || cell.row >= viewport.firstRow + viewport.rows) return []; + const x = (cell.col + (endpoint === "end" ? 1 : 0)) * metrics.cellWidth; + const stemTop = (cell.row - viewport.firstRow) * metrics.cellHeight; + const size = TERMINAL_SELECTION_GRIP_SIZE; + const width = viewport.cols * metrics.cellWidth; + const height = viewport.rows * metrics.cellHeight; + const wantsLeft = endpoint === "start"; + const gripDirection = + (wantsLeft && x >= size) || (!wantsLeft && x > width - size) ? "left" : "right"; + const gripAbove = stemTop + metrics.cellHeight + size > height; + const gripTop = gripAbove ? Math.max(0, stemTop - size) : stemTop + metrics.cellHeight; + return [ + { + endpoint, + x, + y: stemTop + metrics.cellHeight / 2, + gripX: x + (gripDirection === "left" ? -size / 2 : size / 2), + gripY: gripTop + size / 2, + gripDirection, + gripAbove, + stemTop, + stemHeight: metrics.cellHeight, + }, + ]; + }); +} + +export function createTerminalSelectionController(ports: Ports) { + let press: { + start: TerminalSelectionPoint; + point: TerminalSelectionPoint; + endpoint: Endpoint | null; + offset: TerminalSelectionPoint; + fixed: TerminalBufferCoordinate | null; + moved: boolean; + long: boolean; + scrolling: boolean; + remainder: number; + } | null = null; + let timer: number | null = null; + function stopTimer() { + if (timer !== null) ports.cancel(timer); + timer = null; + } + function updateHandle() { + const geometry = ports.getGeometry(); + if (!press || (!press.endpoint && !press.long) || !geometry) return; + const { viewport, metrics } = geometry; + const x = press.point.x - press.offset.x; + const y = press.point.y - press.offset.y; + let row = + viewport.firstRow + + Math.max(0, Math.min(viewport.rows - 1, Math.floor(y / metrics.cellHeight))); + let col = Math.floor(x / metrics.cellWidth); + if (press.endpoint && press.fixed) { + const boundary = Math.round(x / metrics.cellWidth); + const isEnd = + row > press.fixed.row || (row === press.fixed.row && boundary > press.fixed.col); + col = boundary - (isEnd ? 1 : 0); + if (col < 0 && isEnd) { + row -= 1; + col = viewport.cols - 1; + } else if (col >= viewport.cols && !isEnd) { + row += 1; + col = 0; + } + } + col = Math.max(0, Math.min(viewport.cols - 1, col)); + ports.onMoveHandle({ row, col }); + } + function edgeTick() { + timer = null; + const geometry = ports.getGeometry(); + if (!press || (!press.endpoint && !press.long) || !geometry) return; + const y = press.point.y - press.offset.y; + const height = geometry.viewport.rows * geometry.metrics.cellHeight; + let direction: "up" | "down" | null = null; + if (y < geometry.metrics.cellHeight) direction = "up"; + else if (y > height - geometry.metrics.cellHeight) direction = "down"; + if (!direction) return; + ports.onScroll(direction, 1); + if (!press) return; + updateHandle(); + if (press) timer = ports.schedule(edgeTick, 100); + } + return { + press(point: TerminalSelectionPoint) { + stopTimer(); + const geometry = ports.getGeometry(); + const handles = geometry ? terminalSelectionHandles(geometry) : []; + const handle = handles + .map((item) => ({ + endpoint: item.endpoint, + x: item.x, + y: item.y, + distance: Math.hypot(point.x - item.gripX, point.y - item.gripY), + })) + .filter((item) => item.distance <= 28) + .sort((a, b) => a.distance - b.distance)[0]; + press = { + start: point, + point, + endpoint: handle?.endpoint ?? null, + offset: handle ? { x: point.x - handle.x, y: point.y - handle.y } : { x: 0, y: 0 }, + fixed: + handle && geometry?.range + ? geometry.range[handle.endpoint === "start" ? "end" : "start"] + : null, + moved: false, + long: false, + scrolling: false, + remainder: 0, + }; + if (handle && geometry) { + ports.onBeginHandle(handle.endpoint, geometry.viewport.cols); + return; + } + timer = ports.schedule(() => { + timer = null; + if (press && !press.moved) { + press.long = true; + ports.onLongPress(press.start); + } + }, TERMINAL_GESTURE_LONG_PRESS_MS); + }, + move(point: TerminalSelectionPoint) { + if (!press) return; + const previous = press.point; + press.point = point; + const dx = point.x - press.start.x, + dy = point.y - press.start.y; + if (Math.hypot(dx, dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { + press.moved = true; + if (!press.endpoint) stopTimer(); + } + if (press.endpoint || press.long) { + if (press.endpoint || press.moved) { + updateHandle(); + stopTimer(); + if (press) timer = ports.schedule(edgeTick, 100); + } + } else if (!press.long) { + if (!press.scrolling && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) { + press.scrolling = true; + press.remainder = dy; + } else if (press.scrolling) press.remainder += point.y - previous.y; + const height = ports.getGeometry()?.metrics.cellHeight ?? 0; + if (press.scrolling && height > 0) { + const rows = Math.trunc(press.remainder / height); + press.remainder -= rows * height; + if (rows) ports.onScroll(rows > 0 ? "up" : "down", Math.abs(rows)); + } + } + }, + release() { + stopTimer(); + if (press && !press.endpoint && !press.long && !press.moved) ports.onTap(); + press = null; + }, + cancel() { + stopTimer(); + press = null; + }, + }; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts deleted file mode 100644 index aed6857d5c..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - TERMINAL_GESTURE_LONG_PRESS_MS, - TERMINAL_GESTURE_TAP_TOLERANCE_PX, - TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX, - advanceTerminalScrollGesture, - classifyTerminalGestureIntent, - resolveTerminalGestureReleaseAction, -} from "./terminal-selection-gesture"; - -describe("native terminal gesture policy", () => { - describe("advanceTerminalScrollGesture", () => { - it("keeps consuming a slow drag after scroll intent arms below one row", () => { - let state = { lastDy: 0, rowRemainder: 0, didScroll: false }; - const rowDeltas: number[] = []; - - for (const currentDy of [13, 15, 18, 22, 28]) { - const update = advanceTerminalScrollGesture({ - state, - currentDy, - cellHeight: 14, - }); - state = update.state; - rowDeltas.push(update.rowDelta); - } - - expect({ rowDeltas, state }).toEqual({ - rowDeltas: [0, 1, 0, 0, 1], - state: { lastDy: 28, rowRemainder: 0, didScroll: true }, - }); - }); - }); - - describe("classifyTerminalGestureIntent", () => { - it("treats a motionless press as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: 0, - dy: 0, - vx: 0, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("treats a tiny jitter within tap tolerance as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4, - dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4, - vx: 0, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("leaves horizontal movement available to terminal selection", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20, - dy: 4, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not infer selection from slow horizontal drag dwell", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 3, - vx: 0, - vy: 0, - elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - }), - ).toEqual("pending"); - }); - - it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1, - dy: 1, - vx: 2, - vy: 0, - elapsedMs: 20, - }), - ).toEqual("tap"); - }); - - it("keeps horizontal movement below navigation distance pending", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10, - dy: 2, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("treats a clearly vertical drag as scroll", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: 4, - dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5, - vx: 0, - vy: 0.2, - }), - ).toEqual("scroll"); - }); - - it("does not navigate on a fast rightward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 4, - vx: 2, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate on a slow rightward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 4, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate on a fast leftward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40), - dy: -4, - vx: -2, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate while movement stays inside tap tolerance", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1, - dy: 2, - vx: 2, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("keeps selection intent once selection is active", () => { - expect( - classifyTerminalGestureIntent({ - status: "selecting", - dx: 100, - dy: 50, - vx: 2, - vy: 1, - }), - ).toEqual("select"); - }); - }); - - describe("resolveTerminalGestureReleaseAction", () => { - it("focuses the terminal after a normal tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("focus"); - }); - - it("focuses the terminal after a normal tap while scrolled", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "scrolled", - }), - ).toEqual("focus"); - }); - - it("starts selection after a long press", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("select"); - }); - - it("starts selection after a 500ms hold", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 500, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("select"); - }); - - it("keeps a 400ms press as a tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 400, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("focus"); - }); - - it("does nothing after a scroll gesture", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: true, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "scrolled", - }), - ).toEqual("none"); - }); - - it("does nothing after a navigation swipe", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: true, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does nothing while actively selecting", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("clears an existing selection after a short tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - startedWithSelection: true, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("clear"); - }); - - it("preserves a word selection created by the current long press", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - startedWithSelection: false, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does not focus after an undecided fast drag", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does nothing from idle", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "idle", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - }); -}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts deleted file mode 100644 index 07b5267e87..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts +++ /dev/null @@ -1,108 +0,0 @@ -export type TerminalGestureStatus = "idle" | "pressing" | "selecting"; -export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll"; -export type TerminalGestureScrollMode = "following" | "scrolled"; - -export interface TerminalGestureMoveInput { - status: TerminalGestureStatus; - dx: number; - dy: number; - vx: number; - vy: number; - elapsedMs?: number; -} - -export interface TerminalGestureReleaseInput { - status: TerminalGestureStatus; - startedWithSelection?: boolean; - didScroll: boolean; - didNavigate: boolean; - movedBeyondTapTolerance: boolean; - pressDurationMs: number; - longPressMs: number; - scrollMode: TerminalGestureScrollMode; -} - -export interface TerminalScrollGestureState { - lastDy: number; - rowRemainder: number; - didScroll: boolean; -} - -export interface TerminalScrollGestureUpdate { - state: TerminalScrollGestureState; - rowDelta: number; -} - -export type TerminalGestureAction = "none" | "focus" | "select" | "clear"; - -export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8; -export const TERMINAL_GESTURE_LONG_PRESS_MS = 450; -export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12; - -export function classifyTerminalGestureIntent( - input: TerminalGestureMoveInput, -): TerminalGestureIntent { - if (input.status === "selecting") { - return "select"; - } - - const absDx = Math.abs(input.dx); - const absDy = Math.abs(input.dy); - - if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) { - return "scroll"; - } - - if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { - return "pending"; - } - - return "tap"; -} - -export function advanceTerminalScrollGesture(input: { - state: TerminalScrollGestureState; - currentDy: number; - cellHeight: number; -}): TerminalScrollGestureUpdate { - const deltaY = input.currentDy - input.state.lastDy; - const accumulatedRows = input.state.rowRemainder + deltaY; - const rowDelta = Math.trunc(accumulatedRows / input.cellHeight); - return { - state: { - lastDy: input.currentDy, - rowRemainder: accumulatedRows - rowDelta * input.cellHeight, - didScroll: input.state.didScroll || rowDelta !== 0, - }, - rowDelta, - }; -} - -export function resolveTerminalGestureReleaseAction( - input: TerminalGestureReleaseInput, -): TerminalGestureAction { - if (input.didScroll || input.didNavigate) { - return "none"; - } - - if (input.status === "selecting") { - if (!input.startedWithSelection) { - return "none"; - } - return input.movedBeyondTapTolerance ? "none" : "clear"; - } - - if (input.status !== "pressing") { - return "none"; - } - - if (input.pressDurationMs >= input.longPressMs) { - return "select"; - } - - if (input.movedBeyondTapTolerance) { - return "none"; - } - - return "focus"; -} diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts index c188671ffe..6141bf1efd 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -483,3 +483,51 @@ describe("native terminal selection", () => { expect(copied).toEqual([]); }); }); + +describe("endpoint dragging", () => { + it.each([ + { + endpoint: "start" as const, + fixed: { row: 2, col: 9 }, + target: { row: 3, col: 2 }, + expected: { row: 3, col: 0 }, + }, + { + endpoint: "end" as const, + fixed: { row: 2, col: 0 }, + target: { row: 1, col: 7 }, + expected: { row: 1, col: 9 }, + }, + ])( + "preserves the fixed boundary across a row edge for $endpoint", + ({ endpoint, fixed, target, expected }) => { + const selection = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 }; + selection.begin({ coordinate: fixed, bounds }); + selection.update({ coordinate: fixed, bounds }); + selection.beginEndpointDrag(endpoint, 10); + selection.update({ coordinate: target, bounds }); + expect(selection.getSnapshot().range?.[endpoint === "start" ? "start" : "end"]).toEqual( + expected, + ); + }, + ); + + it.each(["start", "end"] as const)( + "keeps the opposite endpoint fixed as %s crosses it", + (endpoint) => { + const selection = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 }; + selection.begin({ coordinate: { row: 2, col: 2 }, bounds }); + selection.update({ coordinate: { row: 3, col: 4 }, bounds }); + selection.beginEndpointDrag(endpoint, 10); + const coordinate = endpoint === "start" ? { row: 4, col: 5 } : { row: 1, col: 1 }; + selection.update({ coordinate, bounds }); + expect(selection.getSnapshot().range).toEqual( + endpoint === "start" + ? { start: { row: 3, col: 5 }, end: coordinate, coordinateEpoch: 1 } + : { start: coordinate, end: { row: 2, col: 1 }, coordinateEpoch: 1 }, + ); + }, + ); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.ts b/packages/app/src/terminal/native-renderer/terminal-selection.ts index 450a3813ac..0f7391e669 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.ts @@ -25,6 +25,7 @@ export interface TerminalSelectionBounds { export interface TerminalSelectionModel { begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; + beginEndpointDrag(endpoint: "start" | "end", cols: number): void; clear(): TerminalSelectionSnapshot; sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot; getSnapshot(): TerminalSelectionSnapshot; @@ -252,6 +253,7 @@ export function resolveTerminalSelectionRects( export function createTerminalSelectionModel(): TerminalSelectionModel { let state: TerminalSelectionState = { anchor: null, range: null }; + let drag: { endpoint: "start" | "end"; cols: number } | null = null; function snapshot(): TerminalSelectionSnapshot { return { range: state.range }; @@ -259,18 +261,31 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { const coordinate = clampCoordinate(input.coordinate, input.bounds); - const anchor = state.anchor ?? coordinate; + const fixed = state.anchor ?? coordinate; + let anchor = fixed; + if (drag?.endpoint === "start" && compareCoordinates(coordinate, fixed) > 0) { + anchor = + fixed.col + 1 < drag.cols + ? { row: fixed.row, col: fixed.col + 1 } + : { row: fixed.row + 1, col: 0 }; + } else if (drag?.endpoint === "end" && compareCoordinates(coordinate, fixed) < 0) { + anchor = + fixed.col > 0 + ? { row: fixed.row, col: fixed.col - 1 } + : { row: fixed.row - 1, col: drag.cols - 1 }; + } const range = normalizeTerminalSelection({ anchor, focus: coordinate, coordinateEpoch: input.bounds.coordinateEpoch, }); - state = { anchor, range }; + state = { anchor: fixed, range }; return snapshot(); } return { begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { + drag = null; const coordinate = clampCoordinate(input.coordinate, input.bounds); state = { anchor: coordinate, @@ -285,7 +300,14 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { return setSelection(input); }, + beginEndpointDrag(endpoint, cols) { + if (state.range) { + drag = { endpoint, cols }; + state = { ...state, anchor: state.range[endpoint === "start" ? "end" : "start"] }; + } + }, clear(): TerminalSelectionSnapshot { + drag = null; state = { anchor: null, range: null }; return snapshot(); }, @@ -293,6 +315,7 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { if (!state.range || rangeIsSafe(state.range, input.bounds)) { return snapshot(); } + drag = null; state = { anchor: null, range: null }; return snapshot(); },