Skip to content

Commit dde5727

Browse files
committed
✨ add composable terminal settings with automatic revert
Introduce a Setting type that pairs apply/revert escape sequences, ensuring terminal state is always cleanly restored. Refactor the keyboard demo to use settings() for alternate buffer, cursor, mouse tracking, and progressive input.
1 parent b555a1b commit dde5727

3 files changed

Lines changed: 168 additions & 15 deletions

File tree

demo/keyboard.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ import {
2222
rgba,
2323
text,
2424
} from "../mod.ts";
25+
import {
26+
alternateBuffer,
27+
cursor,
28+
mouseTracking,
29+
progressiveInput,
30+
type Setting,
31+
settings,
32+
} from "../settings.ts";
2533
import { useInput } from "./use-input.ts";
2634
import { useStdin } from "./use-stdin.ts";
2735

@@ -539,20 +547,19 @@ function keyboard(ctx: AppContext): Op[] {
539547
return ops;
540548
}
541549

542-
let encoder = new TextEncoder();
543-
let esc = (s: string) => Deno.stdout.writeSync(encoder.encode(s));
544-
545-
function ttyFlags(ctx: AppContext): Uint8Array {
550+
function ttyFlags(ctx: AppContext): Setting {
551+
let parts: Setting[] = [];
546552
let bits = 0;
547553
if (ctx["Disambiguate escape codes"]) bits |= 1;
548554
if (ctx["Report event types"]) bits |= 2;
549555
if (ctx["Report alternate keys"]) bits |= 4;
550556
if (ctx["Report all keys as escapes"]) bits |= 8;
551557
if (ctx["Report associated text"]) bits |= 16;
552-
let mouse = ctx["Capture mouse events"]
553-
? "\x1b[?1003h\x1b[?1006h"
554-
: "\x1b[?1003l\x1b[?1006l";
555-
return encoder.encode(`\x1b[<u\x1b[>${bits}u${mouse}`);
558+
parts.push(progressiveInput(bits));
559+
if (ctx["Capture mouse events"]) {
560+
parts.push(mouseTracking());
561+
}
562+
return settings(...parts);
556563
}
557564

558565
await main(function* () {
@@ -567,16 +574,19 @@ await main(function* () {
567574

568575
let term = yield* until(createTerm({ width: columns, height: rows }));
569576

570-
esc("\x1b[?1049h\x1b[?25l\x1b[>3u");
571-
yield* ensure(() => {
572-
esc("\x1b[?1003l\x1b[?1006l\x1b[<u\x1b[?25h\x1b[?1049l");
573-
});
577+
let tty = settings(alternateBuffer(), cursor(false));
578+
Deno.stdout.writeSync(tty.apply);
574579

575580
let modality = recognizer();
576-
577581
let context = modality.next().value;
578582

579-
Deno.stdout.writeSync(ttyFlags(context));
583+
let flags = ttyFlags(context);
584+
Deno.stdout.writeSync(flags.apply);
585+
586+
yield* ensure(() => {
587+
Deno.stdout.writeSync(flags.revert);
588+
Deno.stdout.writeSync(tty.revert);
589+
});
580590

581591
let { output } = term.render(keyboard(context));
582592

@@ -606,7 +616,9 @@ await main(function* () {
606616
context = { ...context, logged: prev };
607617
}
608618

609-
Deno.stdout.writeSync(ttyFlags(context));
619+
Deno.stdout.writeSync(flags.revert);
620+
flags = ttyFlags(context);
621+
Deno.stdout.writeSync(flags.apply);
610622

611623
if (context["Capture mouse events"]) {
612624
if ("x" in event) {

settings.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
export interface Setting {
2+
apply: Uint8Array;
3+
revert: Uint8Array;
4+
}
5+
6+
export function settings(...sequence: Setting[]): Setting {
7+
return {
8+
apply: concat(sequence.map((s) => s.apply)),
9+
revert: concat(sequence.map((s) => s.revert).reverse()),
10+
};
11+
}
12+
13+
export function alternateBuffer(): Setting {
14+
return {
15+
apply: csi("?1049h"),
16+
revert: csi("?1049l"),
17+
};
18+
}
19+
20+
export function cursor(visible: boolean): Setting {
21+
if (visible) {
22+
return {
23+
apply: csi("?25h"),
24+
revert: csi("?25l"),
25+
};
26+
} else {
27+
return {
28+
apply: csi("?25l"),
29+
revert: csi("?25h"),
30+
};
31+
}
32+
}
33+
34+
export function progressiveInput(level: number): Setting {
35+
return {
36+
apply: csi(`>${level}u`),
37+
revert: csi("<u"),
38+
};
39+
}
40+
41+
export function mouseTracking(): Setting {
42+
return {
43+
apply: concat([csi("?1003h"), csi("?1006h")]),
44+
revert: concat([csi("?1006l"), csi("?1003l")]),
45+
};
46+
}
47+
48+
let encoder = new TextEncoder();
49+
50+
function encode(str: string): Uint8Array {
51+
return encoder.encode(str);
52+
}
53+
54+
function csi(str: string): Uint8Array {
55+
return encode(`\x1b[${str}`);
56+
}
57+
58+
function concat(arrays: Uint8Array[]): Uint8Array {
59+
let length = arrays.reduce((sum, a) => sum + a.length, 0);
60+
let result = new Uint8Array(length);
61+
let offset = 0;
62+
for (let a of arrays) {
63+
result.set(a, offset);
64+
offset += a.length;
65+
}
66+
return result;
67+
}

test/settings.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from "./suite.ts";
2+
import {
3+
alternateBuffer,
4+
cursor,
5+
mouseTracking,
6+
progressiveInput,
7+
settings,
8+
} from "../settings.ts";
9+
10+
function str(bytes: Uint8Array): string {
11+
return new TextDecoder().decode(bytes);
12+
}
13+
14+
describe("settings", () => {
15+
describe("alternateBuffer", () => {
16+
it("applies with enter and removes with leave", () => {
17+
let s = alternateBuffer();
18+
expect(str(s.apply)).toBe("\x1b[?1049h");
19+
expect(str(s.revert)).toBe("\x1b[?1049l");
20+
});
21+
});
22+
23+
describe("cursor", () => {
24+
it("hides on apply when visible is false", () => {
25+
let s = cursor(false);
26+
expect(str(s.apply)).toBe("\x1b[?25l");
27+
expect(str(s.revert)).toBe("\x1b[?25h");
28+
});
29+
30+
it("shows on apply when visible is true", () => {
31+
let s = cursor(true);
32+
expect(str(s.apply)).toBe("\x1b[?25h");
33+
expect(str(s.revert)).toBe("\x1b[?25l");
34+
});
35+
});
36+
37+
describe("progressiveInput", () => {
38+
it("pushes the given level and pops on remove", () => {
39+
let s = progressiveInput(3);
40+
expect(str(s.apply)).toBe("\x1b[>3u");
41+
expect(str(s.revert)).toBe("\x1b[<u");
42+
});
43+
});
44+
45+
describe("mouseTracking", () => {
46+
it("enables any-event tracking with SGR encoding", () => {
47+
let s = mouseTracking();
48+
expect(str(s.apply)).toBe("\x1b[?1003h\x1b[?1006h");
49+
expect(str(s.revert)).toBe("\x1b[?1006l\x1b[?1003l");
50+
});
51+
});
52+
53+
describe("settings()", () => {
54+
it("concatenates apply in order", () => {
55+
let s = settings(alternateBuffer(), cursor(false));
56+
expect(str(s.apply)).toBe("\x1b[?1049h\x1b[?25l");
57+
});
58+
59+
it("concatenates remove in reverse order", () => {
60+
let s = settings(alternateBuffer(), cursor(false));
61+
expect(str(s.revert)).toBe("\x1b[?25h\x1b[?1049l");
62+
});
63+
64+
it("composes multiple settings", () => {
65+
let s = settings(
66+
alternateBuffer(),
67+
cursor(false),
68+
progressiveInput(3),
69+
);
70+
expect(str(s.apply)).toBe("\x1b[?1049h\x1b[?25l\x1b[>3u");
71+
expect(str(s.revert)).toBe("\x1b[<u\x1b[?25h\x1b[?1049l");
72+
});
73+
});
74+
});

0 commit comments

Comments
 (0)