Skip to content

Commit 0a70d40

Browse files
committed
✨ parse DSR cursor position and rename row offset to top
Add CursorEvent with 0-based top/left coords by parsing the terminal's DSR response (\x1b[row;colR). Rename createTerm's row option to top so cursor.top feeds directly into createTerm({ top: cursor.top }).
1 parent 4c9cff8 commit 0a70d40

7 files changed

Lines changed: 124 additions & 4 deletions

File tree

input-native.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const EVENT_KEY = 1;
22
export const EVENT_MOUSE = 2;
33
export const EVENT_RESIZE = 3;
4+
export const EVENT_CURSOR = 4;
45

56
export const MOD_ALT = 1;
67
export const MOD_CTRL = 2;

input.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {
1010
createInputNative,
11+
EVENT_CURSOR,
1112
EVENT_KEY,
1213
EVENT_MOUSE,
1314
EVENT_RESIZE,
@@ -350,6 +351,26 @@ export interface ResizeEvent {
350351
height: number;
351352
}
352353

354+
/**
355+
* Cursor position report (DSR response).
356+
*
357+
* Emitted when the terminal responds to a Device Status Report
358+
* query (`\x1b[6n`) with the current cursor position.
359+
*/
360+
export interface CursorEvent {
361+
type: "cursor";
362+
363+
/**
364+
* Cursor row (0-based).
365+
*/
366+
top: number;
367+
368+
/**
369+
* Cursor column (0-based).
370+
*/
371+
left: number;
372+
}
373+
353374
import type { PointerEvent } from "./term.ts";
354375

355376
export type InputEvent =
@@ -359,6 +380,7 @@ export type InputEvent =
359380
| MouseMoveEvent
360381
| WheelEvent
361382
| ResizeEvent
383+
| CursorEvent
362384
| PointerEvent;
363385

364386
/**
@@ -659,6 +681,9 @@ function mapEvent(native: NativeInputEvent): InputEvent {
659681
case EVENT_RESIZE: {
660682
return { type: "resize", width: native.w, height: native.h };
661683
}
684+
case EVENT_CURSOR: {
685+
return { type: "cursor", top: native.y, left: native.x };
686+
}
662687
default: {
663688
return mapKeyEvent(native);
664689
}

src/input.c

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,49 @@ static uint16_t csi_legacy_key(char term, int number) {
573573
}
574574
}
575575

576+
/* Parse DSR cursor position response: CSI row ; col R */
577+
static int parse_cursor(struct InputState *st, struct InputEvent *ev) {
578+
if (st->len < 2)
579+
return PARSE_NEED_MORE;
580+
if (st->buf[0] != '\x1b' || st->buf[1] != '[')
581+
return PARSE_ERR;
582+
if (st->len < 3)
583+
return PARSE_NEED_MORE;
584+
585+
int row = 0;
586+
int col = 0;
587+
int param = 0;
588+
int i = 2;
589+
590+
while (i < st->len) {
591+
char c = st->buf[i];
592+
if (c >= '0' && c <= '9') {
593+
if (param == 0) {
594+
row = row * 10 + (c - '0');
595+
} else {
596+
col = col * 10 + (c - '0');
597+
}
598+
} else if (c == ';') {
599+
if (param > 0)
600+
return PARSE_ERR;
601+
param++;
602+
} else if (c == 'R') {
603+
if (param != 1)
604+
return PARSE_ERR;
605+
i++;
606+
ev->type = EVENT_CURSOR;
607+
ev->y = row - 1;
608+
ev->x = col - 1;
609+
shift(st, i);
610+
return PARSE_OK;
611+
} else {
612+
return PARSE_ERR;
613+
}
614+
i++;
615+
}
616+
return PARSE_NEED_MORE;
617+
}
618+
576619
/* Parse Kitty-enhanced legacy CSI sequences (non-u terminators).
577620
* Format: CSI [number] [; mod[:action]] terminator
578621
* Handles A-D, F, H, P, Q, S, ~ terminators with optional :action */
@@ -986,6 +1029,22 @@ int input_scan(struct InputState *st, const char *buf, int len, double now) {
9861029
}
9871030
}
9881031

1032+
/* try DSR cursor position response */
1033+
{
1034+
struct InputEvent kev;
1035+
memset(&kev, 0, sizeof(kev));
1036+
int rv = parse_cursor(st, &kev);
1037+
if (rv == PARSE_OK) {
1038+
struct InputEvent *ev = emit(st);
1039+
*ev = kev;
1040+
st->esc_time = 0;
1041+
continue;
1042+
}
1043+
if (rv == PARSE_NEED_MORE) {
1044+
return accepted;
1045+
}
1046+
}
1047+
9891048
/* try Kitty-enhanced legacy CSI (arrows, fn keys, etc.) */
9901049
{
9911050
struct InputEvent kev;

src/input.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
#define EVENT_KEY 1
5959
#define EVENT_MOUSE 2
6060
#define EVENT_RESIZE 3
61+
#define EVENT_CURSOR 4
6162

6263
/* ── Modifier flags (bitwise) ─────────────────────────────────────── */
6364

term.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createTermNative } from "./term-native.ts";
44
export interface TermOptions {
55
height: number;
66
width: number;
7-
row?: number;
7+
top?: number;
88
}
99

1010
export interface RenderOptions {
@@ -30,8 +30,8 @@ export interface Term {
3030
}
3131

3232
export async function createTerm(options: TermOptions): Promise<Term> {
33-
let { width, height, row = 0 } = options;
34-
let native = await createTermNative(width, height, row);
33+
let { width, height, top = 0 } = options;
34+
let native = await createTermNative(width, height, top);
3535
let { memory, statePtr, opsBuf } = native;
3636

3737
let prev = new Set<string>();

test/input.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,40 @@ describe("input", () => {
681681
});
682682
});
683683

684+
describe("cursor position (DSR response)", () => {
685+
it("parses cursor position report", () => {
686+
let result = input.scan(str("\x1b[24;80R"));
687+
expect(result.events.length).toBe(1);
688+
expect(result.events[0]).toMatchObject({
689+
type: "cursor",
690+
top: 23,
691+
left: 79,
692+
});
693+
});
694+
695+
it("parses cursor at origin", () => {
696+
let result = input.scan(str("\x1b[1;1R"));
697+
expect(result.events.length).toBe(1);
698+
expect(result.events[0]).toMatchObject({
699+
type: "cursor",
700+
top: 0,
701+
left: 0,
702+
});
703+
});
704+
705+
it("parses cursor interleaved with other input", () => {
706+
let result = input.scan(str("a\x1b[10;5Rb"));
707+
expect(result.events.length).toBe(3);
708+
expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" });
709+
expect(result.events[1]).toMatchObject({
710+
type: "cursor",
711+
top: 9,
712+
left: 4,
713+
});
714+
expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" });
715+
});
716+
});
717+
684718
describe("UTF-8", () => {
685719
it("parses 2-byte UTF-8 (é)", () => {
686720
let result = input.scan(bytes(0xc3, 0xa9));

test/term.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe("term", () => {
9191

9292
describe("row offset", () => {
9393
it("renders two frames at the offset position", async () => {
94-
let term = await createTerm({ width: 20, height: 5, row: 5 });
94+
let term = await createTerm({ width: 20, height: 5, top: 5 });
9595
let box = (msg: string) => [
9696
open("root", {
9797
layout: { width: grow(), height: grow(), direction: "ttb" },

0 commit comments

Comments
 (0)