Compare commits
4 commits
Author | SHA1 | Date | |
---|---|---|---|
Michaël Lemaire | 3e0e4fb8aa | ||
Michaël Lemaire | bb898ba0a8 | ||
Michaël Lemaire | c0bde58ac4 | ||
Michaël Lemaire | 204d29a69e |
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
|||
deno.d.ts
|
||||
.vscode
|
||||
.local
|
||||
.output
|
||||
web/*.js
|
||||
|
|
7
TODO.md
7
TODO.md
|
@ -1,7 +1,10 @@
|
|||
# TODO
|
||||
|
||||
- Add click events
|
||||
- Add keystrokes event for web displays
|
||||
- Add click events for ansi display
|
||||
- Fix resizing on web_div display
|
||||
- Ignore ctrl+c on web display (generally speaking, on displays that does not
|
||||
support proper 'quit')
|
||||
- Optimize drawing to display, by allowing sequences of characters with the same
|
||||
colors (if supported by the display)
|
||||
- Restore ansi terminal properly after exit (ctrl+c does not work anymore, for
|
||||
example)
|
||||
|
|
7
cli.ts
Executable file
7
cli.ts
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!./run
|
||||
|
||||
import { runUIDemo } from "./src/demo.ts";
|
||||
|
||||
if (import.meta.main) {
|
||||
await runUIDemo();
|
||||
}
|
1
config/fmt.flags
Normal file
1
config/fmt.flags
Normal file
|
@ -0,0 +1 @@
|
|||
--ignore=web-demo/textui.js
|
29
demo.ts
29
demo.ts
|
@ -1,29 +0,0 @@
|
|||
#!./run
|
||||
|
||||
import { AnsiTerminalDisplay } from "./ansi.ts";
|
||||
import { UIConfig } from "./config.ts";
|
||||
import { TextUI } from "./ui.ts";
|
||||
|
||||
const display = new AnsiTerminalDisplay();
|
||||
let x = 0;
|
||||
const config: Partial<UIConfig> = {
|
||||
palette: [
|
||||
{ r: 0, g: 0, b: 0 },
|
||||
{ r: 1, g: 1, b: 1 },
|
||||
{ r: 0, g: 1, b: 1 },
|
||||
],
|
||||
onResize: draw,
|
||||
onKeyStroke: (key) => {
|
||||
ui.drawing.color(1, 0).text(key, { x, y: 7 });
|
||||
x += key.length + 1;
|
||||
},
|
||||
};
|
||||
const ui = new TextUI(display, config);
|
||||
await ui.init();
|
||||
draw();
|
||||
await ui.loop();
|
||||
|
||||
function draw() {
|
||||
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
|
||||
ui.drawing.color(0, 1).text("world", { x: 10, y: 5 });
|
||||
}
|
6
deps.testing.ts
Normal file
6
deps.testing.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "https://js.thunderk.net/testing@1.0.0/mod.ts";
|
||||
export { Buffer } from "https://deno.land/std@0.106.0/io/buffer.ts";
|
2
deps.ts
2
deps.ts
|
@ -1,2 +1,2 @@
|
|||
export * from "https://code.thunderk.net/typescript/functional/raw/1.0.0/all.ts";
|
||||
export { cmp } from "https://js.thunderk.net/functional@1.0.0/all.ts";
|
||||
export { readKeypress } from "https://deno.land/x/keypress@0.0.7/mod.ts";
|
||||
|
|
62
display.ts
62
display.ts
|
@ -1,62 +0,0 @@
|
|||
import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
|
||||
|
||||
/**
|
||||
* Display protocol, to allow the UI to draw things on "screen"
|
||||
*/
|
||||
export class Display {
|
||||
/**
|
||||
* Init the display (will be the first method called)
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the display as before *init*
|
||||
*/
|
||||
async uninit(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current grid size
|
||||
*/
|
||||
async getSize(): Promise<BufferSize> {
|
||||
return { w: 0, h: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the palette for color display
|
||||
*
|
||||
* If the display supports the whole RGB range, it may return the array as-is.
|
||||
* If the display only supports a limited palette, it may return only supported colors.
|
||||
*
|
||||
* From this call forward, colors will be received by numbered index in the returned array.
|
||||
*/
|
||||
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the cursor visibility
|
||||
*/
|
||||
async setCursorVisibility(visible: boolean): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the display
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a single character on screen
|
||||
*/
|
||||
async setChar(at: BufferLocation, char: Char): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the keys pressed since last call
|
||||
*/
|
||||
async getKeyStrokes(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
43
mod.ts
43
mod.ts
|
@ -1,40 +1,3 @@
|
|||
import { AnsiTerminalDisplay } from "./ansi.ts";
|
||||
import { UIConfig } from "./config.ts";
|
||||
import { Display } from "./display.ts";
|
||||
import { TextUI } from "./ui.ts";
|
||||
import {
|
||||
CanvasTerminalDisplay,
|
||||
DivTerminalDisplay,
|
||||
PreTerminalDisplay,
|
||||
} from "./web.ts";
|
||||
|
||||
export { TextUI };
|
||||
|
||||
export const UI_DISPLAY_TYPES = {
|
||||
autodetect: undefined,
|
||||
ansi: AnsiTerminalDisplay,
|
||||
web_pre: PreTerminalDisplay,
|
||||
web_div: DivTerminalDisplay,
|
||||
web_canvas: CanvasTerminalDisplay,
|
||||
dummy: Display,
|
||||
} as const;
|
||||
|
||||
export async function createTextUI(
|
||||
config: Partial<UIConfig>,
|
||||
display_type: keyof typeof UI_DISPLAY_TYPES = "autodetect",
|
||||
): Promise<TextUI> {
|
||||
if (display_type == "autodetect") {
|
||||
if (typeof (window as any).document != "undefined") {
|
||||
display_type = "web_canvas";
|
||||
} else {
|
||||
display_type = "ansi";
|
||||
}
|
||||
}
|
||||
|
||||
var display = new UI_DISPLAY_TYPES[display_type]();
|
||||
|
||||
var ui = new TextUI(display, config);
|
||||
await ui.init();
|
||||
|
||||
return ui;
|
||||
}
|
||||
export { TextUI } from "./src/ui.ts";
|
||||
export { createTextUI, UI_DISPLAY_TYPES } from "./src/main.ts";
|
||||
export { runUIDemo } from "./src/demo.ts";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { AnsiColorMode, AnsiTerminalDisplay } from "./ansi.ts";
|
||||
import { Buffer, describe, expect, it } from "./testing.ts";
|
||||
import { Buffer, describe, expect, it } from "../deps.testing.ts";
|
||||
|
||||
function createTestDisplay(): {
|
||||
stdout: Buffer;
|
||||
|
@ -18,7 +18,7 @@ describe(AnsiTerminalDisplay, () => {
|
|||
await display.init();
|
||||
checkSequence(stdout, "![2J");
|
||||
await display.uninit();
|
||||
checkSequence(stdout, "![2J![2J");
|
||||
checkSequence(stdout, "![2J![2J![?25h!c");
|
||||
});
|
||||
|
||||
it("writes truecolor characters", async () => {
|
|
@ -1,5 +1,5 @@
|
|||
import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
|
||||
import { readKeypress } from "./deps.ts";
|
||||
import { readKeypress } from "../deps.ts";
|
||||
import { Display } from "./display.ts";
|
||||
|
||||
export enum AnsiColorMode {
|
||||
|
@ -11,17 +11,18 @@ export enum AnsiColorMode {
|
|||
/**
|
||||
* ANSI terminal display
|
||||
*/
|
||||
export class AnsiTerminalDisplay implements Display {
|
||||
export class AnsiTerminalDisplay extends Display {
|
||||
private palette_bg: readonly Uint8Array[] = [];
|
||||
private palette_fg: readonly Uint8Array[] = [];
|
||||
private width = 1;
|
||||
private state = { x: -1, y: -1, f: -1, b: -1 }; // current location and color
|
||||
private keys: string[] = [];
|
||||
|
||||
constructor(
|
||||
private writer: Deno.Writer = Deno.stdout,
|
||||
reader: Deno.Reader = Deno.stdin,
|
||||
) {
|
||||
super();
|
||||
|
||||
if (hasRawMode(reader)) {
|
||||
this.readKeyPresses(reader); // purposefully not awaited
|
||||
}
|
||||
|
@ -33,6 +34,8 @@ export class AnsiTerminalDisplay implements Display {
|
|||
|
||||
async uninit(): Promise<void> {
|
||||
await this.writer.write(CLEAR);
|
||||
await this.setCursorVisibility(true);
|
||||
await this.writer.write(RESET);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
|
@ -118,12 +121,6 @@ export class AnsiTerminalDisplay implements Display {
|
|||
this.state = { x, y, f, b };
|
||||
}
|
||||
|
||||
async getKeyStrokes(): Promise<string[]> {
|
||||
const result = this.keys;
|
||||
this.keys = [];
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the display size for subsequent prints
|
||||
*/
|
||||
|
@ -144,7 +141,7 @@ export class AnsiTerminalDisplay implements Display {
|
|||
if (keypress.ctrlKey) {
|
||||
key = "ctrl+" + key;
|
||||
}
|
||||
this.keys.push(key);
|
||||
await this.pushEvent({ key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -187,6 +184,7 @@ function get256Colors(): readonly Color[] {
|
|||
}
|
||||
|
||||
const CLEAR = escape("[2J");
|
||||
const RESET = escape("c");
|
||||
|
||||
/**
|
||||
* Check if a reader will be compatible with raw mode
|
|
@ -1,5 +1,5 @@
|
|||
import { BufferDrawing, BufferLocation, CharBuffer } from "./base.ts";
|
||||
import { describe, expect, it } from "./testing.ts";
|
||||
import { describe, expect, it } from "../deps.testing.ts";
|
||||
|
||||
describe(CharBuffer, () => {
|
||||
it("initializes empty, sets and gets characters", () => {
|
77
src/colors.ts
Normal file
77
src/colors.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { Color, PaletteMap } from "./base.ts";
|
||||
import { UIPalette } from "./config.ts";
|
||||
import { cmp } from "../deps.ts";
|
||||
import { Display } from "./display.ts";
|
||||
|
||||
export async function getPaletteMapping(
|
||||
palette: UIPalette,
|
||||
display: Display,
|
||||
): Promise<PaletteMap> {
|
||||
// get the colors supported by display
|
||||
const app_colors = palette.map((c): Color[] => Array.isArray(c) ? c : [c]);
|
||||
const all_colors = app_colors.reduce((acc, val) => acc.concat(val), []);
|
||||
const display_colors = await display.setupPalette(all_colors);
|
||||
|
||||
// rank all supported colors by proximity to each app color
|
||||
let ranked: {
|
||||
color: Color;
|
||||
idx: number;
|
||||
penalty: number;
|
||||
matches: { color: Color; idx: number; distance: number }[];
|
||||
}[] = [];
|
||||
app_colors.forEach((colors, idx) => {
|
||||
colors.forEach((color, alt) => {
|
||||
ranked.push({
|
||||
color,
|
||||
idx,
|
||||
penalty: alt + 1,
|
||||
matches: display_colors.map((display_color, didx) => {
|
||||
return {
|
||||
color: display_color,
|
||||
idx: didx,
|
||||
distance: colorDistance(color, display_color),
|
||||
};
|
||||
}).sort(cmp({ key: (info) => info.distance })),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// TODO negatively score colors too much near previously chosen ones
|
||||
|
||||
// find the best color mapping for each source color
|
||||
const result = palette.map(() => -1);
|
||||
while (ranked.length > 0) {
|
||||
ranked.sort(
|
||||
cmp({ key: (info) => info.matches[0].distance * info.penalty }),
|
||||
);
|
||||
|
||||
const best = ranked[0];
|
||||
const app_idx = best.idx;
|
||||
const display_idx = best.matches[0].idx;
|
||||
result[app_idx] = display_idx;
|
||||
|
||||
for (const color of ranked) {
|
||||
color.matches = color.matches.filter((match) =>
|
||||
match.idx !== display_idx
|
||||
);
|
||||
}
|
||||
ranked = ranked.filter((color) =>
|
||||
color.idx !== app_idx && color.matches.length > 0
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function colorDistance(e1: Color, e2: Color): number {
|
||||
/*return (e2.r - e1.r) * (e2.r - e1.r) +
|
||||
(e2.g - e1.g) * (e2.g - e1.g) +
|
||||
(e2.b - e1.b) * (e2.b - e1.b);*/
|
||||
const c = (x: number) => Math.round(x * 255);
|
||||
const rmean = (c(e1.r) + c(e2.r)) / 2;
|
||||
const r = c(e1.r) - c(e2.r);
|
||||
const g = c(e1.g) - c(e2.g);
|
||||
const b = c(e1.b) - c(e2.b);
|
||||
return Math.sqrt(
|
||||
(((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8),
|
||||
);
|
||||
}
|
|
@ -32,12 +32,15 @@ export type UIConfig = Readonly<{
|
|||
onResize: (size: { w: number; h: number }) => void;
|
||||
// Callback to receive key strokes
|
||||
onKeyStroke: (key: string) => void;
|
||||
// Callback to receive clicks
|
||||
onMouseClick: (loc: { x: number; y: number }) => void;
|
||||
}>;
|
||||
|
||||
export const UI_CONFIG_DEFAULTS: UIConfig = {
|
||||
ignoreCtrlC: false,
|
||||
hideCursor: true,
|
||||
palette: [],
|
||||
onResize: (size) => {},
|
||||
onResize: () => {},
|
||||
onKeyStroke: () => {},
|
||||
onMouseClick: () => {},
|
||||
};
|
2
src/controls.ts
Normal file
2
src/controls.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export interface Control {
|
||||
}
|
34
src/demo.ts
Executable file
34
src/demo.ts
Executable file
|
@ -0,0 +1,34 @@
|
|||
import { UIConfig } from "./config.ts";
|
||||
import { createTextUI, UI_DISPLAY_TYPES } from "./main.ts";
|
||||
|
||||
export async function runUIDemo(
|
||||
display_type: keyof typeof UI_DISPLAY_TYPES = "autodetect",
|
||||
): Promise<void> {
|
||||
let x = 0;
|
||||
const config: Partial<UIConfig> = {
|
||||
palette: [
|
||||
{ r: 0, g: 0, b: 0 },
|
||||
{ r: 1, g: 1, b: 1 },
|
||||
{ r: 0, g: 1, b: 1 },
|
||||
],
|
||||
onResize: draw,
|
||||
onKeyStroke: (key) => {
|
||||
ui.drawing.color(1, 0).text(key, { x, y: 7 });
|
||||
x += key.length + 1;
|
||||
},
|
||||
onMouseClick: (loc) => {
|
||||
const text = `${loc.x}:${loc.y}`;
|
||||
ui.drawing.color(1, 0).text(text, { x, y: 7 });
|
||||
x += text.length + 1;
|
||||
},
|
||||
};
|
||||
const ui = await createTextUI(config, display_type);
|
||||
await ui.init();
|
||||
draw();
|
||||
await ui.loop();
|
||||
|
||||
function draw() {
|
||||
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
|
||||
ui.drawing.color(0, 1).text("world", { x: 10, y: 5 });
|
||||
}
|
||||
}
|
28
src/display.test.ts
Normal file
28
src/display.test.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Display } from "./display.ts";
|
||||
import { describe, expect, it } from "../deps.testing.ts";
|
||||
|
||||
describe(Display, () => {
|
||||
it("buffers unique events", async () => {
|
||||
const display = new Display();
|
||||
await display.pushEvent({ key: "a" });
|
||||
await display.pushEvent({ key: "b" });
|
||||
await display.pushEvent({ key: "a" });
|
||||
await display.pushEvent({ click: { x: 0, y: 0 } });
|
||||
await display.pushEvent({ click: { x: 1, y: 0 } });
|
||||
await display.pushEvent({ click: { x: 0, y: 0 } });
|
||||
await display.pushEvent({ size: { w: 1, h: 1 } });
|
||||
await display.pushEvent({ size: { w: 1, h: 2 } });
|
||||
await display.pushEvent({ size: { w: 1, h: 1 } });
|
||||
await display.pushEvent({ key: "b" });
|
||||
await display.pushEvent({ click: { x: 1, y: 0 } });
|
||||
await display.pushEvent({ size: { w: 1, h: 2 } });
|
||||
expect(await display.getEvents()).toEqual([
|
||||
{ key: "a" },
|
||||
{ key: "b" },
|
||||
{ click: { x: 0, y: 0 } },
|
||||
{ click: { x: 1, y: 0 } },
|
||||
{ size: { w: 1, h: 1 } },
|
||||
{ size: { w: 1, h: 2 } },
|
||||
]);
|
||||
});
|
||||
});
|
106
src/display.ts
Normal file
106
src/display.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { BufferLocation, BufferSize, Char, Color } from "./base.ts";
|
||||
|
||||
type DisplayKeyEvent = { key: string };
|
||||
type DisplayClickEvent = { click: BufferLocation };
|
||||
type DisplaySizeEvent = { size: BufferSize };
|
||||
type DisplayEvent = Readonly<
|
||||
| DisplayKeyEvent
|
||||
| DisplayClickEvent
|
||||
| DisplaySizeEvent
|
||||
>;
|
||||
type DisplayEventCombined = Partial<
|
||||
& DisplayKeyEvent
|
||||
& DisplayClickEvent
|
||||
& DisplaySizeEvent
|
||||
>;
|
||||
|
||||
/**
|
||||
* Display protocol, to allow the UI to draw things on "screen"
|
||||
*/
|
||||
export class Display {
|
||||
private events: DisplayEvent[] = [];
|
||||
private known_size = { w: 0, h: 0 };
|
||||
|
||||
/**
|
||||
* Init the display (will be the first method called)
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
this.known_size = await this.getSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the display as before *init*
|
||||
*/
|
||||
async uninit(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current grid size
|
||||
*/
|
||||
async getSize(): Promise<BufferSize> {
|
||||
return { w: 0, h: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the palette for color display
|
||||
*
|
||||
* If the display supports the whole RGB range, it may return the array as-is.
|
||||
* If the display only supports a limited palette, it may return only supported colors.
|
||||
*
|
||||
* From this call forward, colors will be received by numbered index in the returned array.
|
||||
*/
|
||||
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the cursor visibility
|
||||
*/
|
||||
async setCursorVisibility(visible: boolean): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the display
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a single character on screen
|
||||
*/
|
||||
async setChar(at: BufferLocation, char: Char): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a new event
|
||||
*/
|
||||
async pushEvent(event: DisplayEvent): Promise<void> {
|
||||
if (!this.events.some((ev) => sameEvent(ev, event))) {
|
||||
this.events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the queued events
|
||||
*/
|
||||
async getEvents(auto_resize = true): Promise<DisplayEventCombined[]> {
|
||||
// TODO check only a few cycles?
|
||||
if (auto_resize) {
|
||||
const size = await this.getSize();
|
||||
if (size.w != this.known_size.w || size.h != this.known_size.h) {
|
||||
this.known_size = size;
|
||||
await this.pushEvent({ size });
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.events;
|
||||
this.events = [];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function sameEvent(ev1: DisplayEventCombined, ev2: DisplayEventCombined) {
|
||||
return ev1.key == ev2.key && ev1.click?.x == ev2.click?.x &&
|
||||
ev1.click?.y == ev2.click?.y && ev1.size?.w == ev2.size?.w &&
|
||||
ev1.size?.h == ev2.size?.h;
|
||||
}
|
40
src/main.ts
Normal file
40
src/main.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { AnsiTerminalDisplay } from "./ansi.ts";
|
||||
import { UIConfig } from "./config.ts";
|
||||
import { Display } from "./display.ts";
|
||||
import { TextUI } from "./ui.ts";
|
||||
import { CanvasTerminalDisplay, DivTerminalDisplay } from "./web.ts";
|
||||
|
||||
export const UI_DISPLAY_TYPES = {
|
||||
autodetect: undefined,
|
||||
ansi: AnsiTerminalDisplay,
|
||||
web_div: DivTerminalDisplay,
|
||||
web_canvas: CanvasTerminalDisplay,
|
||||
dummy: Display,
|
||||
} as const;
|
||||
|
||||
export async function createTextUI(
|
||||
config: Partial<UIConfig>,
|
||||
display_type: keyof typeof UI_DISPLAY_TYPES = "autodetect",
|
||||
): Promise<TextUI> {
|
||||
if (display_type == "autodetect") {
|
||||
if (typeof (window as any).document != "undefined") {
|
||||
display_type = "web_canvas";
|
||||
// TODO if canvas is not available, fall back to div
|
||||
} else if (typeof (Deno as any) != "undefined") {
|
||||
display_type = "ansi";
|
||||
} else {
|
||||
const message = "Cannot initialize display";
|
||||
if (typeof alert == "function") {
|
||||
alert(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
var display = new UI_DISPLAY_TYPES[display_type]();
|
||||
|
||||
var ui = new TextUI(display, config);
|
||||
await ui.init();
|
||||
|
||||
return ui;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { BufferLocation, Char, Color, SPACE } from "./base.ts";
|
||||
import { describe, expect, it } from "./testing.ts";
|
||||
import { describe, expect, it } from "../deps.testing.ts";
|
||||
import { Display } from "./display.ts";
|
||||
import { TextUI } from "./ui.ts";
|
||||
|
132
src/ui.ts
Normal file
132
src/ui.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import { BufferDrawing, BufferSize, CharBuffer, PaletteMap } from "./base.ts";
|
||||
import { getPaletteMapping } from "./colors.ts";
|
||||
import { UI_CONFIG_DEFAULTS, UIConfig } from "./config.ts";
|
||||
import { Display } from "./display.ts";
|
||||
|
||||
/**
|
||||
* Common abstraction for a textual UI
|
||||
*/
|
||||
export class TextUI {
|
||||
private config: UIConfig;
|
||||
private buffer = new CharBuffer({ w: 1, h: 1 });
|
||||
private palettemap: PaletteMap = [];
|
||||
private quitting = false;
|
||||
|
||||
constructor(private display: Display, config: Partial<UIConfig>) {
|
||||
this.config = { ...UI_CONFIG_DEFAULTS, ...config };
|
||||
}
|
||||
|
||||
get drawing(): BufferDrawing {
|
||||
return new BufferDrawing(this.buffer, this.palettemap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the UI and display
|
||||
*
|
||||
* If config.loopInterval is defined, the UI loop is
|
||||
* started but not awaited.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
var size = await this.display.getSize();
|
||||
this.buffer = new CharBuffer(size);
|
||||
|
||||
await this.display.init();
|
||||
this.palettemap = await getPaletteMapping(
|
||||
this.config.palette,
|
||||
this.display,
|
||||
);
|
||||
if (this.config.hideCursor) {
|
||||
await this.display.setCursorVisibility(false);
|
||||
}
|
||||
await this.clear();
|
||||
|
||||
if (this.config.loopInterval) {
|
||||
this.loop(this.config.loopInterval); // purposefully not awaited
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit the UI (this will exit the executable)
|
||||
*/
|
||||
async quit(): Promise<void> {
|
||||
this.quitting = true;
|
||||
await this.clear();
|
||||
await this.display.setCursorVisibility(true);
|
||||
await this.display.uninit();
|
||||
if (typeof Deno != "undefined") {
|
||||
Deno.exit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current display size
|
||||
*/
|
||||
getSize(): BufferSize {
|
||||
return this.buffer.getSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the internal buffer to the display
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
await this.buffer.forEachDirty((at, char) =>
|
||||
this.display.setChar(at, char)
|
||||
);
|
||||
await this.display.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the whole screen
|
||||
*/
|
||||
async clear(bg = 0): Promise<void> {
|
||||
const { w, h } = this.getSize();
|
||||
const drawing = this.drawing.color(bg, bg);
|
||||
for (let y = 0; y < h; y++) {
|
||||
drawing.text(Array(w).fill(" ").join(""), { x: 0, y });
|
||||
}
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the buffer to match the display
|
||||
*/
|
||||
async resize(size: BufferSize): Promise<void> {
|
||||
this.buffer = new CharBuffer(size);
|
||||
await this.clear();
|
||||
this.config.onResize(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the event loop, waiting for input
|
||||
*/
|
||||
async loop(refresh = 100): Promise<void> {
|
||||
while (!this.quitting) {
|
||||
// handle events
|
||||
for (const event of await this.display.getEvents()) {
|
||||
const { key, click, size } = event;
|
||||
|
||||
if (key) {
|
||||
if (!this.config.ignoreCtrlC && key == "ctrl+c") {
|
||||
await this.quit();
|
||||
} else {
|
||||
this.config.onKeyStroke(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (size) {
|
||||
await this.resize(size);
|
||||
}
|
||||
|
||||
if (click) {
|
||||
this.config.onMouseClick(click);
|
||||
}
|
||||
}
|
||||
|
||||
// flush
|
||||
await this.flush();
|
||||
|
||||
// wait
|
||||
await new Promise((resolve) => setTimeout(resolve, refresh));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,9 +4,10 @@ import { Display } from "./display.ts";
|
|||
//
|
||||
// Base for all web-based terminal displays
|
||||
//
|
||||
class WebDisplay implements Display {
|
||||
class WebDisplay extends Display {
|
||||
readonly document = (window as any).document;
|
||||
readonly parent = this.document.body;
|
||||
readonly parent = this.document.getElementById("-textui-container-") ||
|
||||
this.document.body;
|
||||
spacing = 2;
|
||||
font_height = 24;
|
||||
char_size = this.estimateCharSize(this.font_height);
|
||||
|
@ -21,7 +22,16 @@ class WebDisplay implements Display {
|
|||
this.updateGrid();
|
||||
this.resizing = false;
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
const key = convertKeyEvent(event);
|
||||
if (key !== null) {
|
||||
this.pushEvent({ key });
|
||||
}
|
||||
});
|
||||
}
|
||||
this.parent.style.overflow = "hidden";
|
||||
this.parent.style.padding = "0";
|
||||
this.parent.style.margin = "0";
|
||||
this.updateGrid();
|
||||
}
|
||||
|
||||
|
@ -37,6 +47,9 @@ class WebDisplay implements Display {
|
|||
|
||||
async setupPalette(colors: readonly Color[]): Promise<readonly Color[]> {
|
||||
this.palette = colors;
|
||||
if (colors.length > 0) {
|
||||
this.parent.style.background = color2RGB(colors[0]);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
|
@ -46,10 +59,6 @@ class WebDisplay implements Display {
|
|||
async setChar(at: BufferLocation, char: Char): Promise<void> {
|
||||
}
|
||||
|
||||
async getKeyStrokes(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
//
|
||||
// Get the size in pixels of the target area
|
||||
//
|
||||
|
@ -73,7 +82,9 @@ class WebDisplay implements Display {
|
|||
//
|
||||
// Update the grid to match the display size
|
||||
//
|
||||
updateGrid(): void {
|
||||
// Returns true if the size changed
|
||||
//
|
||||
updateGrid(): boolean {
|
||||
const target_size = this.getTargetSize();
|
||||
let char_size = { x: 0, y: 0 };
|
||||
let font_height = 24;
|
||||
|
@ -96,47 +107,26 @@ class WebDisplay implements Display {
|
|||
recomputeSize();
|
||||
}
|
||||
|
||||
console.debug("Resizing", { font_height, char_size, width, height });
|
||||
this.font_height = font_height;
|
||||
this.char_size = char_size;
|
||||
this.size = { w: width, h: height };
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Basic terminal display using a single "pre" tag
|
||||
//
|
||||
export class PreTerminalDisplay extends WebDisplay {
|
||||
element: any;
|
||||
|
||||
override async init(): Promise<void> {
|
||||
await super.init();
|
||||
|
||||
if (!this.element) {
|
||||
this.element = this.document.createElement("pre");
|
||||
this.parent.appendChild(this.element);
|
||||
if (
|
||||
width != this.size.w || height != this.size.h ||
|
||||
font_height != this.font_height || char_size.x != this.char_size.x ||
|
||||
char_size.y != this.char_size.y
|
||||
) {
|
||||
console.debug("Resizing", {
|
||||
target_size,
|
||||
font_height,
|
||||
char_size,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
this.font_height = font_height;
|
||||
this.char_size = char_size;
|
||||
this.size = { w: width, h: height };
|
||||
this.pushEvent({ size: this.size });
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { w, h } = this.size;
|
||||
const line = Array(w).fill(" ").join("");
|
||||
this.element.textContent = Array(h).fill(line).join("\n");
|
||||
}
|
||||
|
||||
override async uninit(): Promise<void> {
|
||||
if (this.element) {
|
||||
this.parent.removeChild(this.element);
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
await super.uninit();
|
||||
}
|
||||
|
||||
override async setChar(at: BufferLocation, char: Char): Promise<void> {
|
||||
const { w, h } = this.size;
|
||||
const offset = at.y * (w + 1) + at.x;
|
||||
const text = this.element.textContent;
|
||||
this.element.textContent = text.slice(0, offset) + char.ch +
|
||||
text.slice(offset + 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -201,11 +191,9 @@ export class DivTerminalDisplay extends WebDisplay {
|
|||
div.style.overflow = "hidden";
|
||||
this.parent.appendChild(div);
|
||||
divs.push(div);
|
||||
/*div.addEventListener("click", () => {
|
||||
if (this.onclick) {
|
||||
this.onclick({ x, y });
|
||||
}
|
||||
});*/
|
||||
div.addEventListener("click", () => {
|
||||
this.pushEvent({ click: { x, y } });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -231,14 +219,14 @@ export class CanvasTerminalDisplay extends WebDisplay {
|
|||
this.compose = new Canvas(this.document, undefined);
|
||||
this.present = new Canvas(this.document, this.parent);
|
||||
|
||||
/*this.present.element.addEventListener("click", (ev) => {
|
||||
if (this.onclick) {
|
||||
this.onclick({
|
||||
this.present.element.addEventListener("click", (ev: any) => {
|
||||
this.pushEvent({
|
||||
click: {
|
||||
x: Math.round((ev.offsetX * this.ratio) / this.char_size.x),
|
||||
y: Math.round((ev.offsetY * this.ratio) / this.char_size.y),
|
||||
});
|
||||
}
|
||||
});*/
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override getTargetSize(): { x: number; y: number } {
|
||||
|
@ -249,26 +237,30 @@ export class CanvasTerminalDisplay extends WebDisplay {
|
|||
};
|
||||
}
|
||||
|
||||
override updateGrid(): void {
|
||||
super.updateGrid();
|
||||
override updateGrid(): boolean {
|
||||
if (super.updateGrid()) {
|
||||
const swidth = this.char_size.x * this.size.w;
|
||||
const sheight = this.char_size.y * this.size.h;
|
||||
|
||||
const swidth = this.char_size.x * this.size.w;
|
||||
const sheight = this.char_size.y * this.size.h;
|
||||
this.draw.resize(this.char_size.x, this.char_size.y, this.font_height);
|
||||
this.compose.resize(
|
||||
swidth * this.ratio,
|
||||
sheight * this.ratio,
|
||||
this.font_height,
|
||||
);
|
||||
this.present.resize(
|
||||
swidth * this.ratio,
|
||||
sheight * this.ratio,
|
||||
this.font_height,
|
||||
);
|
||||
|
||||
this.draw.resize(this.char_size.x, this.char_size.y, this.font_height);
|
||||
this.compose.resize(
|
||||
swidth * this.ratio,
|
||||
sheight * this.ratio,
|
||||
this.font_height,
|
||||
);
|
||||
this.present.resize(
|
||||
swidth * this.ratio,
|
||||
sheight * this.ratio,
|
||||
this.font_height,
|
||||
);
|
||||
this.present.element.style.width = `${Math.floor(swidth)}px`;
|
||||
this.present.element.style.height = `${Math.floor(sheight)}px`;
|
||||
|
||||
this.present.element.style.width = `${Math.floor(swidth)}px`;
|
||||
this.present.element.style.height = `${Math.floor(sheight)}px`;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
override async init(): Promise<void> {
|
||||
|
@ -368,7 +360,7 @@ class Canvas {
|
|||
this.element.width = Math.floor(width);
|
||||
this.element.height = Math.floor(height);
|
||||
|
||||
this.ctx.font = `${font_height}px intrusion`;
|
||||
this.ctx.font = `${font_height}px textui, monospace`;
|
||||
this.ctx.textAlign = "center";
|
||||
this.ctx.textBaseline = "middle";
|
||||
}
|
||||
|
@ -419,3 +411,33 @@ function color2RGB({ r, g, b }: Color): string {
|
|||
Math.round(b * 255)
|
||||
})`;
|
||||
}
|
||||
|
||||
function convertKeyEvent(event: any): string | null {
|
||||
if (!event.key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keycode = event.key.toLocaleLowerCase();
|
||||
let key = MAPPING_KEYS[keycode] ?? keycode;
|
||||
|
||||
if (event.shiftKey) {
|
||||
key = "shift+" + key.toLocaleLowerCase();
|
||||
}
|
||||
if (event.altKey) {
|
||||
key = "alt+" + key;
|
||||
}
|
||||
if (event.ctrlKey) {
|
||||
key = "ctrl+" + key;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
const MAPPING_KEYS: { [key: string]: string } = {
|
||||
"arrowdown": "down",
|
||||
"arrowup": "up",
|
||||
"arrowleft": "left",
|
||||
"arrowright": "right",
|
||||
"enter": "return",
|
||||
" ": "space",
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
export * from "https://code.thunderk.net/typescript/devtools/raw/1.2.2/testing.ts";
|
||||
export { Buffer } from "https://deno.land/std@0.96.0/io/buffer.ts";
|
206
ui.ts
206
ui.ts
|
@ -1,206 +0,0 @@
|
|||
import {
|
||||
BufferDrawing,
|
||||
BufferSize,
|
||||
CharBuffer,
|
||||
Color,
|
||||
PaletteMap,
|
||||
} from "./base.ts";
|
||||
import { UI_CONFIG_DEFAULTS, UIConfig, UIPalette } from "./config.ts";
|
||||
import { cmp } from "./deps.ts";
|
||||
import { Display } from "./display.ts";
|
||||
|
||||
/**
|
||||
* Common abstraction for a textual UI
|
||||
*/
|
||||
export class TextUI {
|
||||
private config: UIConfig;
|
||||
private buffer = new CharBuffer({ w: 1, h: 1 });
|
||||
private palettemap: PaletteMap = [];
|
||||
private quitting = false;
|
||||
|
||||
constructor(private display: Display, config: Partial<UIConfig>) {
|
||||
this.config = { ...UI_CONFIG_DEFAULTS, ...config };
|
||||
}
|
||||
|
||||
get drawing(): BufferDrawing {
|
||||
return new BufferDrawing(this.buffer, this.palettemap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the UI and display
|
||||
*
|
||||
* If config.loopInterval is defined, the UI loop is
|
||||
* started but not awaited.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
var size = await this.display.getSize();
|
||||
this.buffer = new CharBuffer(size);
|
||||
|
||||
await this.display.init();
|
||||
this.palettemap = await getPaletteMapping(
|
||||
this.config.palette,
|
||||
this.display,
|
||||
);
|
||||
if (this.config.hideCursor) {
|
||||
await this.display.setCursorVisibility(false);
|
||||
}
|
||||
await this.clear();
|
||||
|
||||
if (this.config.loopInterval) {
|
||||
this.loop(this.config.loopInterval); // purposefully not awaited
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit the UI (this will exit the executable)
|
||||
*/
|
||||
async quit(): Promise<void> {
|
||||
this.quitting = true;
|
||||
await this.clear();
|
||||
await this.display.setCursorVisibility(true);
|
||||
await this.display.uninit();
|
||||
if (typeof Deno != "undefined") {
|
||||
Deno.exit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current display size
|
||||
*/
|
||||
getSize(): BufferSize {
|
||||
return this.buffer.getSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the internal buffer to the display
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
await this.buffer.forEachDirty((at, char) =>
|
||||
this.display.setChar(at, char)
|
||||
);
|
||||
await this.display.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the whole screen
|
||||
*/
|
||||
async clear(bg = 0): Promise<void> {
|
||||
const { w, h } = this.getSize();
|
||||
const drawing = this.drawing.color(bg, bg);
|
||||
for (let y = 0; y < h; y++) {
|
||||
drawing.text(Array(w).fill(" ").join(""), { x: 0, y });
|
||||
}
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the buffer to match the display
|
||||
*/
|
||||
async resize(size: BufferSize): Promise<void> {
|
||||
this.buffer = new CharBuffer(size);
|
||||
await this.clear();
|
||||
this.config.onResize(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the event loop, waiting for input
|
||||
*/
|
||||
async loop(refresh = 100): Promise<void> {
|
||||
while (!this.quitting) {
|
||||
// handle resize
|
||||
const dsize = await this.display.getSize();
|
||||
const bsize = this.buffer.getSize();
|
||||
if (dsize.w != bsize.w || dsize.h != bsize.h) {
|
||||
await this.resize(dsize);
|
||||
}
|
||||
|
||||
// handle keystrokes
|
||||
for (const key of await this.display.getKeyStrokes()) {
|
||||
if (!this.config.ignoreCtrlC && key == "ctrl+c") {
|
||||
await this.quit();
|
||||
} else {
|
||||
this.config.onKeyStroke(key);
|
||||
}
|
||||
}
|
||||
|
||||
// flush
|
||||
await this.flush();
|
||||
|
||||
// wait
|
||||
await new Promise((resolve) => setTimeout(resolve, refresh));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getPaletteMapping(
|
||||
palette: UIPalette,
|
||||
display: Display,
|
||||
): Promise<PaletteMap> {
|
||||
// get the colors supported by display
|
||||
const app_colors = palette.map((c): Color[] => Array.isArray(c) ? c : [c]);
|
||||
const all_colors = app_colors.reduce((acc, val) => acc.concat(val), []);
|
||||
const display_colors = await display.setupPalette(all_colors);
|
||||
|
||||
// rank all supported colors by proximity to each app color
|
||||
let ranked: {
|
||||
color: Color;
|
||||
idx: number;
|
||||
penalty: number;
|
||||
matches: { color: Color; idx: number; distance: number }[];
|
||||
}[] = [];
|
||||
app_colors.forEach((colors, idx) => {
|
||||
colors.forEach((color, alt) => {
|
||||
ranked.push({
|
||||
color,
|
||||
idx,
|
||||
penalty: alt + 1,
|
||||
matches: display_colors.map((display_color, didx) => {
|
||||
return {
|
||||
color: display_color,
|
||||
idx: didx,
|
||||
distance: colorDistance(color, display_color),
|
||||
};
|
||||
}).sort(cmp({ key: (info) => info.distance })),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// TODO negatively score colors too much near previously chosen ones
|
||||
|
||||
// find the best color mapping for each source color
|
||||
const result = palette.map(() => -1);
|
||||
while (ranked.length > 0) {
|
||||
ranked.sort(
|
||||
cmp({ key: (info) => info.matches[0].distance * info.penalty }),
|
||||
);
|
||||
|
||||
const best = ranked[0];
|
||||
const app_idx = best.idx;
|
||||
const display_idx = best.matches[0].idx;
|
||||
result[app_idx] = display_idx;
|
||||
|
||||
for (const color of ranked) {
|
||||
color.matches = color.matches.filter((match) =>
|
||||
match.idx !== display_idx
|
||||
);
|
||||
}
|
||||
ranked = ranked.filter((color) =>
|
||||
color.idx !== app_idx && color.matches.length > 0
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function colorDistance(e1: Color, e2: Color): number {
|
||||
/*return (e2.r - e1.r) * (e2.r - e1.r) +
|
||||
(e2.g - e1.g) * (e2.g - e1.g) +
|
||||
(e2.b - e1.b) * (e2.b - e1.b);*/
|
||||
const c = (x: number) => Math.round(x * 255);
|
||||
const rmean = (c(e1.r) + c(e2.r)) / 2;
|
||||
const r = c(e1.r) - c(e2.r);
|
||||
const g = c(e1.g) - c(e2.g);
|
||||
const b = c(e1.b) - c(e2.b);
|
||||
return Math.sqrt(
|
||||
(((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8),
|
||||
);
|
||||
}
|
1
web-demo/.gitignore
vendored
1
web-demo/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
textui.js
|
|
@ -1,18 +0,0 @@
|
|||
import { createTextUI } from "./textui.js";
|
||||
|
||||
export async function demo(display_type) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const ui = await createTextUI({
|
||||
palette: [
|
||||
{ r: 0, g: 0, b: 0 },
|
||||
{ r: 1, g: 1, b: 1 },
|
||||
{ r: 0, g: 1, b: 1 },
|
||||
],
|
||||
onResize: draw,
|
||||
}, display_type);
|
||||
function draw() {
|
||||
ui.drawing.color(2, 0).text("hello", { x: 10, y: 3 });
|
||||
ui.drawing.color(0, 1).text("world", { x: 10, y: 5 });
|
||||
}
|
||||
await ui.loop();
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="demo.css">
|
||||
<script type="module">
|
||||
import { demo } from "./demo.js";
|
||||
demo("web_pre");
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
|
||||
</html>
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="demo.css">
|
||||
<script type="module">
|
||||
import { demo } from "./demo.js";
|
||||
demo("web_canvas");
|
||||
import { runUIDemo } from "./mod.js";
|
||||
runUIDemo("web_canvas");
|
||||
</script>
|
||||
</head>
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="demo.css">
|
||||
<script type="module">
|
||||
import { demo } from "./demo.js";
|
||||
demo("web_div");
|
||||
import { runUIDemo } from "./mod.js";
|
||||
runUIDemo("web_div");
|
||||
</script>
|
||||
</head>
|
||||
|
Loading…
Reference in a new issue