Compare commits

...

3 commits

27 changed files with 195 additions and 254 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
deno.d.ts
.vscode
.local
.output
web/*.js

View file

@ -1,8 +1,9 @@
# TODO
- Add click events
- Add click events for ansi display
- Fix resizing on web_div display
- Prevent ctrl+c on web 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

7
cli.ts Executable file
View file

@ -0,0 +1,7 @@
#!./run
import { runUIDemo } from "./src/demo.ts";
if (import.meta.main) {
await runUIDemo();
}

1
config/fmt.flags Normal file
View file

@ -0,0 +1 @@
--ignore=web-demo/textui.js

29
demo.ts
View file

@ -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 });
}

View file

@ -2,5 +2,5 @@ export {
describe,
expect,
it,
} from "https://js.thunderk.net/devtools@1.3.0/testing.ts";
} from "https://js.thunderk.net/testing@1.0.0/mod.ts";
export { Buffer } from "https://deno.land/std@0.106.0/io/buffer.ts";

43
mod.ts
View file

@ -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";

View file

@ -1,5 +1,5 @@
import { AnsiColorMode, AnsiTerminalDisplay } from "./ansi.ts";
import { Buffer, describe, expect, it } from "./deps.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 () => {

View file

@ -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 {
@ -34,6 +34,8 @@ export class AnsiTerminalDisplay extends Display {
async uninit(): Promise<void> {
await this.writer.write(CLEAR);
await this.setCursorVisibility(true);
await this.writer.write(RESET);
}
async flush(): Promise<void> {
@ -182,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

View file

@ -1,5 +1,5 @@
import { BufferDrawing, BufferLocation, CharBuffer } from "./base.ts";
import { describe, expect, it } from "./deps.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
View 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),
);
}

2
src/controls.ts Normal file
View file

@ -0,0 +1,2 @@
export interface Control {
}

34
src/demo.ts Executable file
View 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 });
}
}

View file

@ -1,5 +1,5 @@
import { Display } from "./display.ts";
import { describe, expect, it } from "./deps.testing.ts";
import { describe, expect, it } from "../deps.testing.ts";
describe(Display, () => {
it("buffers unique events", async () => {

40
src/main.ts Normal file
View 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;
}

View file

@ -1,5 +1,5 @@
import { BufferLocation, Char, Color, SPACE } from "./base.ts";
import { describe, expect, it } from "./deps.testing.ts";
import { describe, expect, it } from "../deps.testing.ts";
import { Display } from "./display.ts";
import { TextUI } from "./ui.ts";

View file

@ -1,12 +1,6 @@
import {
BufferDrawing,
BufferSize,
CharBuffer,
Color,
PaletteMap,
} from "./base.ts";
import { UI_CONFIG_DEFAULTS, UIConfig, UIPalette } from "./config.ts";
import { cmp } from "./deps.ts";
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";
/**
@ -136,76 +130,3 @@ export class TextUI {
}
}
}
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),
);
}

View file

@ -59,10 +59,6 @@ class WebDisplay extends Display {
async setChar(at: BufferLocation, char: Char): Promise<void> {
}
async getKeyStrokes(): Promise<string[]> {
return [];
}
//
// Get the size in pixels of the target area
//
@ -134,43 +130,6 @@ class WebDisplay extends Display {
}
}
//
// 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);
}
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);
}
}
//
// Terminal display using one div per char
//
@ -232,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 } });
});
}
}
@ -262,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 } {

1
web-demo/.gitignore vendored
View file

@ -1 +0,0 @@
textui.js

View file

@ -1,23 +0,0 @@
import { createTextUI } from "./textui.js";
export async function demo(display_type) {
await new Promise((resolve) => setTimeout(resolve, 500));
let x = 0;
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,
onKeyStroke: (key) => {
ui.drawing.color(1, 0).text(key, { x, y: 7 });
x += key.length + 1;
},
}, 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();
}

View file

@ -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>

View file

@ -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>

View file

@ -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>