Skip to content

Commit 5372af8

Browse files
authored
Merge pull request #4 from thefrontside/line-mode
Add line rendering mode, termcodes, and inline region demo
2 parents 4971170 + e413755 commit 5372af8

16 files changed

Lines changed: 662 additions & 81 deletions

demo/inline-region.ts

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
/**
2+
* Inline region demo — renders animated regions into normal scrollback.
3+
*
4+
* Shows the region lifecycle:
5+
* 1. Allocate space with raw newlines
6+
* 2. DSR — queries cursor position to compute `top`
7+
* 3. CUP mode (all frames) — renders at `top`
8+
* 4. Commit — restore cursor past region, advance with \n
9+
*/
10+
11+
import { main, type Operation, sleep, until } from "effection";
12+
import {
13+
close,
14+
createInput,
15+
createTerm,
16+
CSI,
17+
type CursorEvent,
18+
DSR,
19+
ESC,
20+
fixed,
21+
grow,
22+
type Op,
23+
open,
24+
rgba,
25+
SHOWCURSOR,
26+
text,
27+
} from "../mod.ts";
28+
import { cursor, settings } from "../settings.ts";
29+
import { validated } from "../validate.ts";
30+
31+
const encode = (s: string) => new TextEncoder().encode(s);
32+
const write = (b: Uint8Array) => Deno.stdout.writeSync(b);
33+
34+
const GREEN = rgba(80, 250, 123);
35+
const GRAY = rgba(100, 100, 100);
36+
const CYAN = rgba(139, 233, 253);
37+
38+
const RED = rgba(255, 0, 0);
39+
const ORANGE = rgba(255, 153, 0);
40+
const YELLOW = rgba(255, 255, 0);
41+
const NGREEN = rgba(51, 255, 0);
42+
const BLUE = rgba(0, 153, 255);
43+
const VIOLET = rgba(102, 0, 255);
44+
const RAINBOW = [RED, ORANGE, YELLOW, NGREEN, BLUE, VIOLET];
45+
46+
const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
47+
48+
function* queryCursor(): Operation<CursorEvent> {
49+
let parser = yield* until(createInput({ escLatency: 100 }));
50+
write(DSR());
51+
52+
let buf = new Uint8Array(32);
53+
while (true) {
54+
let n = Deno.stdin.readSync(buf);
55+
if (n === null) continue;
56+
let result = parser.scan(buf.subarray(0, n));
57+
for (let ev of result.events) {
58+
if (ev.type === "cursor") {
59+
return ev;
60+
}
61+
}
62+
}
63+
}
64+
65+
function waitKey() {
66+
let buf = new Uint8Array(32);
67+
while (true) {
68+
let n = Deno.stdin.readSync(buf);
69+
if (n === null) continue;
70+
for (let i = 0; i < n; i++) {
71+
if (buf[i] === 0x03) {
72+
Deno.stdin.setRaw(false);
73+
write(SHOWCURSOR());
74+
Deno.exit(0);
75+
}
76+
}
77+
return;
78+
}
79+
}
80+
81+
function box(msg: string, fg: number, border: number): Op[] {
82+
return [
83+
open("root", {
84+
layout: { width: grow(), height: grow(), direction: "ttb" },
85+
}),
86+
open("box", {
87+
layout: {
88+
width: grow(),
89+
height: grow(),
90+
direction: "ttb",
91+
padding: { left: 1 },
92+
alignY: 2,
93+
},
94+
border: {
95+
color: border,
96+
left: 1,
97+
right: 1,
98+
top: 1,
99+
bottom: 1,
100+
},
101+
cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 },
102+
}),
103+
text(msg, { color: fg }),
104+
close(),
105+
close(),
106+
];
107+
}
108+
109+
function* transaction(
110+
height: number,
111+
renderFrame: (frame: number) => Op[],
112+
frames: number,
113+
interval: number,
114+
): Operation<void> {
115+
let { columns } = Deno.consoleSize();
116+
117+
write(encode("\n".repeat(height)));
118+
119+
let pos = yield* queryCursor();
120+
/** 1-based terminal row where the region starts */
121+
let row = pos.row - height + 1;
122+
123+
write(ESC("7"));
124+
let tty = settings(cursor(false));
125+
write(tty.apply);
126+
127+
let term = validated(
128+
yield* until(createTerm({ width: columns, height })),
129+
);
130+
for (let i = 0; i < frames; i++) {
131+
let result = term.render(renderFrame(i), { row });
132+
write(new Uint8Array(result.output));
133+
yield* sleep(interval);
134+
}
135+
136+
write(tty.revert);
137+
write(ESC("8"));
138+
write(encode("\n"));
139+
}
140+
141+
function say(msg: string) {
142+
write(encode(msg + "\n"));
143+
}
144+
145+
function pause() {
146+
waitKey();
147+
write(encode("\n"));
148+
}
149+
150+
await main(function* () {
151+
let { columns } = Deno.consoleSize();
152+
Deno.stdin.setRaw(true);
153+
let tty = settings(cursor(false));
154+
write(tty.apply);
155+
156+
// Introduction
157+
say("Clayterm can render entire scenes, but it can also render");
158+
say('"inline" for a streaming UI. This is useful for semi-interactive');
159+
say("CLI commands that write output to the normal console screen.");
160+
say("");
161+
162+
// Demo 1: Spinner box
163+
write(encode("\n\n\n"));
164+
165+
let pos = yield* queryCursor();
166+
/** 1-based terminal row where the region starts */
167+
let row = pos.row - 2;
168+
169+
write(ESC("7"));
170+
171+
let frames = 30;
172+
let term = validated(
173+
yield* until(createTerm({ width: columns, height: 3 })),
174+
);
175+
176+
let first = term.render(
177+
box("Press any key to compile modules.", CYAN, GRAY),
178+
{ row },
179+
);
180+
write(new Uint8Array(first.output));
181+
182+
waitKey();
183+
184+
for (let i = 0; i < frames; i++) {
185+
let done = i === frames - 1;
186+
let icon = done ? "✓" : BRAILLE[i % BRAILLE.length];
187+
let time = `${((i + 1) * 0.08).toFixed(1)}s`;
188+
let label = done ? "Compiled modules" : "Compiling modules...";
189+
let result = term.render(
190+
box(
191+
`${icon} ${label} ${time}`,
192+
done ? GREEN : CYAN,
193+
done ? GREEN : GRAY,
194+
),
195+
{ row },
196+
);
197+
write(new Uint8Array(result.output));
198+
yield* sleep(80);
199+
}
200+
201+
write(ESC("8"));
202+
write(CSI("0m"));
203+
write(encode("\n"));
204+
205+
yield* sleep(500);
206+
207+
write(
208+
encode(
209+
"\nRegions can be multi-line, but they can be a single line too. (continue...)",
210+
),
211+
);
212+
pause();
213+
214+
// Demo 2: Progress bar
215+
let barWidth = Math.min(columns, 50);
216+
let barFrames = 40;
217+
yield* transaction(
218+
1,
219+
(i) => {
220+
let done = i === barFrames - 1;
221+
if (done) {
222+
return [
223+
open("root", {
224+
layout: {
225+
width: fixed(barWidth),
226+
height: fixed(1),
227+
direction: "ltr",
228+
},
229+
}),
230+
text("✓ Frobnicated", { color: GREEN }),
231+
close(),
232+
];
233+
}
234+
let progress = i / (barFrames - 1);
235+
let label = "Frobnicating.. ";
236+
let remaining = barWidth - label.length - 5;
237+
let filled = Math.round(remaining * Math.min(progress, 1));
238+
let empty = remaining - filled;
239+
let pct = `${Math.round(progress * 100)}%`;
240+
let bar = "█".repeat(filled) + "░".repeat(empty);
241+
return [
242+
open("root", {
243+
layout: {
244+
width: fixed(barWidth),
245+
height: fixed(1),
246+
direction: "ltr",
247+
},
248+
}),
249+
text(label, { color: CYAN }),
250+
text(bar, { color: CYAN }),
251+
text(` ${pct.padStart(4)}`, { color: GRAY }),
252+
close(),
253+
];
254+
},
255+
barFrames,
256+
50,
257+
);
258+
259+
write(CSI("0m"));
260+
yield* sleep(500);
261+
write(encode("\nGoodbye sadness with limitless sky. (continue...)"));
262+
pause();
263+
264+
// Demo 3: Nyan cat
265+
let nyanWidth = Math.min(columns, 120);
266+
let nyanFrames = 50;
267+
let cat = [
268+
"╭─────╮",
269+
"│ ^.^ │",
270+
"╰─────╯",
271+
];
272+
let catWidth = cat[0].length;
273+
274+
yield* transaction(
275+
3,
276+
(i) => {
277+
let done = i === nyanFrames - 1;
278+
let progress = i / (nyanFrames - 1);
279+
let trail = Math.round((nyanWidth - catWidth) * Math.min(progress, 1));
280+
281+
if (done) {
282+
// "IMAGINATION IS BEAUTIFUL WORLD!" in 3-row block font
283+
let font: string[] = [
284+
"█ █▄█▄█ █▀█ █▀▀ █ █▀█ █▀█ ▀█▀ █ █▀█ █▀█ █ █▀▀ ██▄ █▀▀ █▀█ █ █ ▀█▀ █ █▀▀ █ █ █ █ █ █ █▀█ █▀█ █ █▀▄ █",
285+
"█ █ ▀ █ █▀█ █ █ █ █ █ █▀█ █ █ █ █ █ █ █ ▀▀█ █▀█ █▀▀ █▀█ █ █ █ █ █▀ █ █ █ █▄█▄█ █ █ █▀▄ █ █ █ ▀",
286+
"▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀ ▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀ █",
287+
];
288+
let ops: Op[] = [
289+
open("root", {
290+
layout: {
291+
width: fixed(nyanWidth),
292+
height: fixed(3),
293+
direction: "ttb",
294+
},
295+
}),
296+
];
297+
for (let row = 0; row < 3; row++) {
298+
let color = RAINBOW[(row * 2) % RAINBOW.length];
299+
ops.push(text(font[row], { color }));
300+
}
301+
ops.push(close());
302+
return ops;
303+
}
304+
305+
let ops: Op[] = [
306+
open("root", {
307+
layout: {
308+
width: fixed(nyanWidth),
309+
height: fixed(3),
310+
direction: "ttb",
311+
},
312+
}),
313+
];
314+
315+
for (let row = 0; row < 3; row++) {
316+
ops.push(
317+
open(`row${row}`, {
318+
layout: { width: grow(), height: fixed(1), direction: "ltr" },
319+
}),
320+
);
321+
322+
if (trail > 0) {
323+
let color = RAINBOW[(row * 2 + i) % RAINBOW.length];
324+
ops.push(text("█".repeat(trail), { color }));
325+
}
326+
327+
ops.push(text(cat[row], { color: CYAN }));
328+
329+
ops.push(close());
330+
}
331+
332+
ops.push(close());
333+
return ops;
334+
},
335+
nyanFrames,
336+
60,
337+
);
338+
339+
write(CSI("0m"));
340+
write(encode("\n"));
341+
write(tty.revert);
342+
Deno.stdin.setRaw(false);
343+
});

demo/keyboard.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,15 @@ import {
3333
import { useInput } from "./use-input.ts";
3434
import { useStdin } from "./use-stdin.ts";
3535

36-
let active = rgba(60, 120, 220);
37-
let inactive = rgba(50, 50, 60);
38-
let on = rgba(40, 180, 80);
39-
let label = rgba(220, 220, 220);
40-
let dim = rgba(100, 100, 120);
41-
let highlight = rgba(255, 220, 80);
36+
const active = rgba(60, 120, 220);
37+
const inactive = rgba(50, 50, 60);
38+
const on = rgba(40, 180, 80);
39+
const label = rgba(220, 220, 220);
40+
const dim = rgba(100, 100, 120);
41+
const highlight = rgba(255, 220, 80);
4242

43-
let KEY_W = 5;
44-
let GAP = 1;
43+
const KEY_W = 5;
44+
const GAP = 1;
4545

4646
interface KeyDef {
4747
label: string;
@@ -58,7 +58,7 @@ function matches(k: KeyDef, event: InputEvent | PointerEvent): boolean {
5858
event.code.toUpperCase() === k.code.toUpperCase();
5959
}
6060

61-
let hovered = rgba(80, 80, 100);
61+
const hovered = rgba(80, 80, 100);
6262

6363
function key(ops: Op[], k: KeyDef, ctx: AppContext): void {
6464
let pressed = ctx.event && matches(k, ctx.event);
@@ -328,7 +328,7 @@ function toggle(ops: Op[], enabled: boolean, name: string): void {
328328
);
329329
}
330330

331-
let flagNames:
331+
const flagNames:
332332
(keyof Omit<AppContext, "mode" | "event" | "logged" | "log" | "entered">)[] =
333333
[
334334
"Disambiguate escape codes",
@@ -338,7 +338,7 @@ let flagNames:
338338
"Report associated text",
339339
];
340340

341-
let logEntries: { key: string; name: keyof EventFilter }[] = [
341+
const logEntries: { key: string; name: keyof EventFilter }[] = [
342342
{ key: "a", name: "keydown" },
343343
{ key: "b", name: "keyup" },
344344
{ key: "c", name: "keyrepeat" },

0 commit comments

Comments
 (0)