| ... | @@ -418,6 +418,31 @@ export function shouldReportTerminalMouse( | ... | @@ -418,6 +418,31 @@ export function shouldReportTerminalMouse( |
| 418 | return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; | 418 | return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; |
| 419 | } | 419 | } |
| 420 | | 420 | |
| | 421 | type TerminalMouseAction = "press" | "release" | "motion"; |
| | 422 | |
| | 423 | export function resolveTerminalMouseData( |
| | 424 | action: TerminalMouseAction, |
| | 425 | data: string, |
| | 426 | previousMotionData: string, |
| | 427 | ): { readonly send: boolean; readonly nextMotionData: string } { |
| | 428 | const nextMotionData = action === "motion" ? data : ""; |
| | 429 | return { |
| | 430 | send: data.length > 0 && (action !== "motion" || data !== previousMotionData), |
| | 431 | nextMotionData, |
| | 432 | }; |
| | 433 | } |
| | 434 | |
| | 435 | export function resolveTerminalMouseTrackingState( |
| | 436 | previousTracking: boolean, |
| | 437 | tracking: boolean, |
| | 438 | motionData: string, |
| | 439 | ): { readonly tracking: boolean; readonly motionData: string } { |
| | 440 | return { |
| | 441 | tracking, |
| | 442 | motionData: previousTracking === tracking ? motionData : "", |
| | 443 | }; |
| | 444 | } |
| | 445 | |
| 421 | export function terminalWheelDeltaRows( | 446 | export function terminalWheelDeltaRows( |
| 422 | event: Pick<WheelEvent, "deltaY" | "deltaMode">, | 447 | event: Pick<WheelEvent, "deltaY" | "deltaMode">, |
| 423 | cellHeight: number, | 448 | cellHeight: number, |
| ... | @@ -593,6 +618,8 @@ export class GhosttyTerminalSurface { | ... | @@ -593,6 +618,8 @@ export class GhosttyTerminalSurface { |
| 593 | private clearSelectionAfterCopy = false; | 618 | private clearSelectionAfterCopy = false; |
| 594 | private primedCopySelection = ""; | 619 | private primedCopySelection = ""; |
| 595 | private wheelRemainder = 0; | 620 | private wheelRemainder = 0; |
| | 621 | private lastMouseMotionData = ""; |
| | 622 | private mouseAnyEventTracking = false; |
| 596 | private dprMedia: MediaQueryList | null = null; | 623 | private dprMedia: MediaQueryList | null = null; |
| 597 | // Read live on every blink decision, and watched so that dropping the | 624 | // Read live on every blink decision, and watched so that dropping the |
| 598 | // preference restarts a blink cycle that has no timer left to notice it. | 625 | // preference restarts a blink cycle that has no timer left to notice it. |
| ... | @@ -619,6 +646,7 @@ export class GhosttyTerminalSurface { | ... | @@ -619,6 +646,7 @@ export class GhosttyTerminalSurface { |
| 619 | this.scrollbarThumb = scrollbarThumb; | 646 | this.scrollbarThumb = scrollbarThumb; |
| 620 | this.context = context; | 647 | this.context = context; |
| 621 | this.core = core; | 648 | this.core = core; |
| | 649 | this.mouseAnyEventTracking = core.isMouseAnyEventTracking(); |
| 622 | this.metrics = metrics; | 650 | this.metrics = metrics; |
| 623 | this.options = options; | 651 | this.options = options; |
| 624 | this.theme = options.theme; | 652 | this.theme = options.theme; |
| ... | @@ -710,6 +738,7 @@ export class GhosttyTerminalSurface { | ... | @@ -710,6 +738,7 @@ export class GhosttyTerminalSurface { |
| 710 | write(data: string): void { | 738 | write(data: string): void { |
| 711 | if (this.disposed) return; | 739 | if (this.disposed) return; |
| 712 | this.core.write(data); | 740 | this.core.write(data); |
| | 741 | this.synchronizeMouseTrackingState(); |
| 713 | // Restart the blink cycle from the visible phase so the cursor never sits | 742 | // Restart the blink cycle from the visible phase so the cursor never sits |
| 714 | // invisible through a stream of output or a burst of typing echo. | 743 | // invisible through a stream of output or a burst of typing echo. |
| 715 | this.cursorOn = true; | 744 | this.cursorOn = true; |
| ... | @@ -719,7 +748,9 @@ export class GhosttyTerminalSurface { | ... | @@ -719,7 +748,9 @@ export class GhosttyTerminalSurface { |
| 719 | | 748 | |
| 720 | resetAndWrite(data: string): void { | 749 | resetAndWrite(data: string): void { |
| 721 | if (this.disposed) return; | 750 | if (this.disposed) return; |
| | 751 | this.lastMouseMotionData = ""; |
| 722 | this.core.resetAndWrite(data); | 752 | this.core.resetAndWrite(data); |
| | 753 | this.synchronizeMouseTrackingState(); |
| 723 | // A replayed session starts from the visible phase like any other write: | 754 | // A replayed session starts from the visible phase like any other write: |
| 724 | // reattaching mid-blink must not open on an invisible cursor. | 755 | // reattaching mid-blink must not open on an invisible cursor. |
| 725 | this.cursorOn = true; | 756 | this.cursorOn = true; |
| ... | @@ -1272,9 +1303,10 @@ export class GhosttyTerminalSurface { | ... | @@ -1272,9 +1303,10 @@ export class GhosttyTerminalSurface { |
| 1272 | if (this.linkActivationPointerId === event.pointerId) return; | 1303 | if (this.linkActivationPointerId === event.pointerId) return; |
| 1273 | // Hover motion is only reportable in any-event tracking (DEC 1003); normal and | 1304 | // Hover motion is only reportable in any-event tracking (DEC 1003); normal and |
| 1274 | // button-event tracking never report motion without a captured pressed button. | 1305 | // button-event tracking never report motion without a captured pressed button. |
| | 1306 | const anyEventTracking = this.synchronizeMouseTrackingState(); |
| 1275 | if ( | 1307 | if ( |
| 1276 | this.mouseReportingPointerId === event.pointerId || | 1308 | this.mouseReportingPointerId === event.pointerId || |
| 1277 | shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) | 1309 | shouldReportTerminalMouse(anyEventTracking, event) |
| 1278 | ) { | 1310 | ) { |
| 1279 | event.preventDefault(); | 1311 | event.preventDefault(); |
| 1280 | this.hoverPointer = { x: event.clientX, y: event.clientY }; | 1312 | this.hoverPointer = { x: event.clientX, y: event.clientY }; |
| ... | @@ -1286,6 +1318,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1286,6 +1318,7 @@ export class GhosttyTerminalSurface { |
| 1286 | this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); | 1318 | this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); |
| 1287 | return; | 1319 | return; |
| 1288 | } | 1320 | } |
| | 1321 | this.lastMouseMotionData = ""; |
| 1289 | if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { | 1322 | if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { |
| 1290 | this.updateHoverCursor(event); | 1323 | this.updateHoverCursor(event); |
| 1291 | return; | 1324 | return; |
| ... | @@ -1364,6 +1397,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1364,6 +1397,7 @@ export class GhosttyTerminalSurface { |
| 1364 | } | 1397 | } |
| 1365 | | 1398 | |
| 1366 | private readonly onPointerLeave = () => { | 1399 | private readonly onPointerLeave = () => { |
| | 1400 | this.lastMouseMotionData = ""; |
| 1367 | this.clearHoveredLink(); | 1401 | this.clearHoveredLink(); |
| 1368 | }; | 1402 | }; |
| 1369 | | 1403 | |
| ... | @@ -1822,11 +1856,7 @@ export class GhosttyTerminalSurface { | ... | @@ -1822,11 +1856,7 @@ export class GhosttyTerminalSurface { |
| 1822 | return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x); | 1856 | return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x); |
| 1823 | } | 1857 | } |
| 1824 | | 1858 | |
| 1825 | private sendMouse( | 1859 | private sendMouse(action: TerminalMouseAction, button: number | null, event: MouseEvent): void { |
| 1826 | action: "press" | "release" | "motion", | | |
| 1827 | button: number | null, | | |
| 1828 | event: MouseEvent, | | |
| 1829 | ): void { | | |
| 1830 | const bounds = this.canvas.getBoundingClientRect(); | 1860 | const bounds = this.canvas.getBoundingClientRect(); |
| 1831 | const data = this.core.encodeMouse({ | 1861 | const data = this.core.encodeMouse({ |
| 1832 | action, | 1862 | action, |
| ... | @@ -1848,7 +1878,23 @@ export class GhosttyTerminalSurface { | ... | @@ -1848,7 +1878,23 @@ export class GhosttyTerminalSurface { |
| 1848 | paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height), | 1878 | paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height), |
| 1849 | anyButtonPressed: event.buttons !== 0, | 1879 | anyButtonPressed: event.buttons !== 0, |
| 1850 | }); | 1880 | }); |
| 1851 | if (data.length > 0) this.options.onData(data); | 1881 | const resolution = resolveTerminalMouseData(action, data, this.lastMouseMotionData); |
| | 1882 | this.lastMouseMotionData = resolution.nextMotionData; |
| | 1883 | if (resolution.send) this.options.onData(data); |
| | 1884 | } |
| | 1885 | |
| | 1886 | private synchronizeMouseTrackingState(): boolean { |
| | 1887 | // Output writes can toggle DEC 1003 without moving the pointer. Keep the |
| | 1888 | // previous mode so the next same-cell motion starts a fresh tracking session. |
| | 1889 | const tracking = this.core.isMouseAnyEventTracking(); |
| | 1890 | const state = resolveTerminalMouseTrackingState( |
| | 1891 | this.mouseAnyEventTracking, |
| | 1892 | tracking, |
| | 1893 | this.lastMouseMotionData, |
| | 1894 | ); |
| | 1895 | this.mouseAnyEventTracking = state.tracking; |
| | 1896 | this.lastMouseMotionData = state.motionData; |
| | 1897 | return tracking; |
| 1852 | } | 1898 | } |
| 1853 | | 1899 | |
| 1854 | private buttonFromButtons(buttons: number): number | null { | 1900 | private buttonFromButtons(buttons: number): number | null { |