big commit

This commit is contained in:
りき萌 2024-02-18 23:37:31 +01:00
parent aff885cf17
commit b506f5a219
22 changed files with 692 additions and 556 deletions

View file

@ -12,10 +12,8 @@ function getLiterateProgram(name) {
outputCount: 0,
nextOutputIndex() {
let index = this.outputCount;
++this.outputCount;
return index;
}
return this.outputCount++;
},
});
}
return literatePrograms.get(name);
@ -28,7 +26,7 @@ function getLiterateProgramWorkerCommands(name) {
if (frame.mode == "input") {
commands.push({ kind: "module", source: frame.textContent });
} else if (frame.mode == "output") {
commands.push({ kind: "output", expected: frame.textContent });
commands.push({ kind: "output" });
}
}
return commands;
@ -51,7 +49,7 @@ class InputMode {
{ regex: /"(\\"|[^"])*"/, as: "string" },
{ regex: /`(\\`|[^"])*`/, as: "string" },
// TODO: RegExp literals?
{ regex: /[+=/*^%<>!~|&\.-]+/, as: "operator" },
{ regex: /[+=/*^%<>!~|&\.?:-]+/, as: "operator" },
{ regex: /[,;]/, as: "punct" },
],
keywords: new Map([
@ -132,54 +130,84 @@ class InputMode {
}
}
function messageOutputArrayToString(output) {
return output
.map(x => {
if (typeof x === "object") return JSON.stringify(x);
else return x + "";
})
.join(" ");
}
class OutputMode {
constructor(frame) {
this.clearResultsOnNextOutput = false;
this.frame = frame;
this.frame.program.onChanged.push(_ => this.evaluate());
this.outputIndex = this.frame.program.nextOutputIndex();
this.evaluate();
}
this.console = document.createElement("pre");
this.console.classList.add("console");
this.frame.appendChild(this.console);
this.clearConsoleOnNextOutput = false;
evaluate() {
// This is a small bit of debouncing. If we cleared the output right away, the page would
// jitter around irritatingly.
this.clearResultsOnNextOutput = true;
this.error = document.createElement("pre");
this.error.classList.add("error");
this.frame.appendChild(this.error);
if (this.worker != null) {
this.worker.terminate();
}
this.worker = new Worker(import.meta.resolve("./literate-programming/worker.js"), {
type: "module",
name: `evaluate LiterateOutput ${this.frame.programName}`
});
this.iframe = document.createElement("iframe");
this.iframe.classList.add("hidden");
this.iframe.src = `${TREEHOUSE_SITE}/sandbox`;
this.frame.appendChild(this.iframe);
this.worker.addEventListener("message", event => {
this.iframe.contentWindow.treehouseSandboxInternals = { outputIndex: this.outputIndex };
this.iframe.contentWindow.addEventListener("message", event => {
let message = event.data;
if (message.kind == "evalComplete") {
this.worker.terminate();
if (message.kind == "ready") {
this.evaluate();
} else if (message.kind == "resize" && message.outputIndex == this.outputIndex) {
this.resize();
} else if (message.kind == "output" && message.outputIndex == this.outputIndex) {
this.addOutput(message.output);
if (message.output.kind == "error") {
this.error.textContent = messageOutputArrayToString(message.output.message);
this.iframe.classList.add("hidden");
} else {
this.addOutput(message.output);
}
} else if (message.kind == "evalComplete") {
this.error.textContent = "";
this.flushConsoleClear();
}
});
this.worker.postMessage({
this.frame.program.onChanged.push(_ => this.evaluate());
}
evaluate() {
this.requestConsoleClear();
this.iframe.contentWindow.postMessage({
action: "eval",
input: getLiterateProgramWorkerCommands(this.frame.programName),
});
}
addOutput(output) {
if (this.clearResultsOnNextOutput) {
this.clearResultsOnNextOutput = false;
this.clearResults();
}
clearConsole() {
this.console.replaceChildren();
}
// Don't show anything if the function didn't return a value.
if (output.kind == "result" && output.message[0] === undefined) return;
requestConsoleClear() {
this.clearConsoleOnNextOutput = true;
}
flushConsoleClear() {
if (this.clearConsoleOnNextOutput) {
this.clearConsole();
this.clearConsoleOnNextOutput = false;
}
}
addOutput(output) {
this.flushConsoleClear();
let line = document.createElement("code");
@ -194,65 +222,22 @@ class OutputMode {
})
.join(" ");
this.frame.appendChild(line);
this.console.appendChild(line);
}
clearResults() {
this.frame.replaceChildren();
}
static messageOutputArrayToString(output) {
return output
.map(x => {
if (typeof x === "object") return JSON.stringify(x);
else return x + "";
})
.join(" ");
}
}
class GraphicsMode {
constructor(frame) {
this.frame = frame;
this.error = document.createElement("pre");
this.error.classList.add("error");
this.frame.appendChild(this.error);
this.iframe = document.createElement("iframe");
this.iframe.classList.add("hidden");
this.iframe.src = import.meta.resolve("../../html/sandbox.html");
this.frame.appendChild(this.iframe);
this.iframe.contentWindow.addEventListener("message", event => {
let message = event.data;
if (message.kind == "ready") {
this.evaluate();
}
else if (message.kind == "resize") {
this.resize(message);
} else if (message.kind == "output" && message.output.kind == "error") {
this.error.textContent = OutputMode.messageOutputArrayToString(message.output.message);
this.iframe.classList.add("hidden");
} else if (message.kind == "evalComplete") {
this.error.textContent = "";
}
});
this.frame.program.onChanged.push(_ => this.evaluate());
}
evaluate() {
this.iframe.contentWindow.postMessage({
action: "eval",
input: getLiterateProgramWorkerCommands(this.frame.programName),
});
}
resize(message) {
this.iframe.width = message.width;
this.iframe.height = message.height;
resize() {
// iframe cannot be `display: none` to get its scrollWidth/scrollHeight.
this.iframe.classList.remove("hidden");
let width = this.iframe.contentDocument.body.scrollWidth;
let height = this.iframe.contentDocument.body.scrollHeight;
if (width == 0 || height == 0) {
this.iframe.classList.add("hidden");
} else {
this.iframe.width = width;
this.iframe.height = height;
}
}
}
@ -266,8 +251,6 @@ class LiterateProgram extends HTMLElement {
this.modeImpl = new InputMode(this);
} else if (this.mode == "output") {
this.modeImpl = new OutputMode(this);
} else if (this.mode == "graphics") {
this.modeImpl = new GraphicsMode(this);
}
}

View file

@ -1,8 +1,21 @@
let outputIndex = 0;
export function getOutputIndex() {
return outputIndex;
}
export const jsConsole = console;
// Overwrite globalThis.console with domConsole to redirect output to the DOM console.
// To always output to the JavaScript console regardless, use jsConsole.
export const domConsole = {
log(...message) {
postMessage({
kind: "output",
output: {
kind: "console.log",
message: [...message],
},
outputIndex,
});
}
};
async function withTemporaryGlobalScope(callback) {
let state = {
@ -13,6 +26,7 @@ async function withTemporaryGlobalScope(callback) {
}
};
await callback(state);
jsConsole.trace(state.oldValues, "bringing back old state");
for (let key in state.oldValues) {
globalThis[key] = state.oldValues[key];
}
@ -20,15 +34,11 @@ async function withTemporaryGlobalScope(callback) {
let evaluationComplete = null;
export async function evaluate(commands, { start, success, error }) {
export async function evaluate(commands, { error, newOutput }) {
if (evaluationComplete != null) {
await evaluationComplete;
}
if (start != null) {
start();
}
let signalEvaluationComplete;
evaluationComplete = new Promise((resolve, _reject) => {
signalEvaluationComplete = resolve;
@ -36,21 +46,19 @@ export async function evaluate(commands, { start, success, error }) {
outputIndex = 0;
try {
await withTemporaryGlobalScope(async scope => {
for (let command of commands) {
if (command.kind == "module") {
let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" }));
let module = await import(blobUrl);
for (let exportedKey in module) {
scope.set(exportedKey, module[exportedKey]);
}
} else if (command.kind == "output") {
++outputIndex;
for (let command of commands) {
if (command.kind == "module") {
let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" }));
let module = await import(blobUrl);
for (let exportedKey in module) {
globalThis[exportedKey] = module[exportedKey];
}
} else if (command.kind == "output") {
if (newOutput != null) {
newOutput(outputIndex);
}
++outputIndex;
}
});
if (success != null) {
success();
}
postMessage({
kind: "evalComplete",

View file

@ -1,23 +0,0 @@
import { evaluate, getOutputIndex } from "./eval.js";
let debugLog = console.log;
globalThis.console = {
log(...message) {
postMessage({
kind: "output",
output: {
kind: "log",
message: [...message],
},
outputIndex: getOutputIndex(),
});
}
};
addEventListener("message", async event => {
let message = event.data;
if (message.action == "eval") {
evaluate(message.input, {});
}
});

View file

@ -0,0 +1,134 @@
import { Sketch } from "treehouse/sandbox.js";
export class TileEditor extends Sketch {
constructor({ tilemap, tileSize }) {
super(tilemap.width * tileSize, tilemap.height * tileSize);
this.colorScheme = {
background: "#F7F7F7",
grid: "#00000011",
tileCursor: "#222222",
tiles: [
"transparent", // never actually drawn to the screen with the default renderer!
"#eb134a",
],
};
this.tilemap = tilemap;
this.tileSize = tileSize;
this.hasFocus = false;
this.paintingTile = null;
this.tileCursor = { x: 0, y: 0 };
this.canvas.addEventListener("mousemove", event => this.mouseMoved(event));
this.canvas.addEventListener("mousedown", event => this.mousePressed(event));
this.canvas.addEventListener("mouseup", event => this.mouseReleased(event));
this.canvas.addEventListener("mouseenter", _ => this.mouseEnter());
this.canvas.addEventListener("mouseleave", _ => this.mouseLeave());
this.canvas.addEventListener("contextmenu", event => event.preventDefault());
// Only draw first frame after the constructor already runs.
// That way we can modify the color scheme however much we want without causing additional
// redraws.
requestAnimationFrame(() => this.draw());
}
draw() {
this.drawBackground();
this.drawTilemap();
this.drawGrid();
if (this.hasFocus) {
this.drawTileCursor();
}
}
drawBackground() {
this.ctx.fillStyle = this.colorScheme.background;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
drawTilemap() {
for (let y = 0; y < this.tilemap.height; ++y) {
for (let x = 0; x < this.tilemap.width; ++x) {
let tile = this.tilemap.at(x, y);
if (tile != 0) {
this.ctx.fillStyle = this.colorScheme.tiles[tile];
this.ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize);
}
}
}
}
drawGrid() {
this.ctx.beginPath();
for (let x = 0; x < this.tilemap.width; ++x) {
this.ctx.moveTo(x * this.tileSize, 0);
this.ctx.lineTo(x * this.tileSize, this.canvas.height);
}
for (let y = 0; y < this.tilemap.width; ++y) {
this.ctx.moveTo(0, y * this.tileSize);
this.ctx.lineTo(this.canvas.width, y * this.tileSize);
}
this.ctx.strokeStyle = this.colorScheme.grid;
this.ctx.lineWidth = 1;
this.ctx.stroke();
}
drawTileCursor() {
this.ctx.strokeStyle = this.colorScheme.tileCursor;
this.ctx.lineWidth = 5;
this.ctx.strokeRect(this.tileCursor.x * this.tileSize, this.tileCursor.y * this.tileSize, this.tileSize, this.tileSize);
}
mouseMoved(event) {
this.tileCursor.x = Math.floor(event.offsetX / this.tileSize);
this.tileCursor.y = Math.floor(event.offsetY / this.tileSize);
this.paintTileUnderCursor();
this.draw();
}
mousePressed(event) {
event.preventDefault();
if (event.button == 0) {
this.paintingTile = 1;
} else if (event.button == 2) {
this.paintingTile = 0;
}
this.paintTileUnderCursor();
this.draw();
}
mouseReleased(_event) {
this.stopPainting();
this.draw();
}
mouseEnter() {
this.hasFocus = true;
this.draw();
}
mouseLeave() {
this.hasFocus = false;
this.stopPainting();
this.draw();
}
paintTileUnderCursor() {
if (this.paintingTile != null) {
this.tilemap.setAt(this.tileCursor.x, this.tileCursor.y, this.paintingTile);
}
}
stopPainting() {
this.paintingTile = null;
}
}

View file

@ -1,9 +1,45 @@
import { defineFrame, Frame } from './framework.js';
import { TileEditor, canConnect, shouldConnect } from './tairu.js';
import { TileEditor } from 'tairu/editor.js';
class CardinalDirectionsEditor extends TileEditor {
constructor() {
super();
export function alignTextInRectangle(ctx, text, x, y, width, height, hAlign, vAlign) {
let measurements = ctx.measureText(text);
let leftX;
switch (hAlign) {
case "left":
leftX = x;
break;
case "center":
leftX = x + width / 2 - measurements.width / 2;
break;
case "right":
leftX = x + width - measurements.width;
break;
}
let textHeight = measurements.fontBoundingBoxAscent;
let baselineY;
switch (vAlign) {
case "top":
baselineY = y + textHeight;
break;
case "center":
baselineY = y + height / 2 + textHeight / 2;
break;
case "bottom":
baselineY = y + height;
break;
}
return { leftX, baselineY };
}
export function shouldConnect(a, b) {
return a == b;
}
export class TileEditorWithCardinalDirections extends TileEditor {
constructor(options) {
super(options);
this.colorScheme.tiles[1] = "#f96565";
}
@ -38,4 +74,3 @@ class CardinalDirectionsEditor extends TileEditor {
}
}
}
defineFrame("tairu-editor-cardinal-directions", CardinalDirectionsEditor);

View file

@ -27,4 +27,16 @@ export class Tilemap {
this.tiles[this.tileIndex(x, y)] = tile;
}
}
static parse(alphabet, lineArray) {
let tilemap = new Tilemap(lineArray[0].length, lineArray.length);
for (let y in lineArray) {
let line = lineArray[y];
for (let x = 0; x < line.length; ++x) {
let char = line.charAt(x);
tilemap.setAt(x, y, alphabet.indexOf(char));
}
}
return tilemap;
}
}

View file

@ -1,5 +1,9 @@
export const internals = {
body: document.createElement("body"),
resetBody() {
this.body.replaceChildren();
}
};
export function body() {
@ -19,4 +23,13 @@ export class Sketch {
addElement(this.canvas);
}
animate(draw) {
let animationCallback;
animationCallback = () => {
draw();
requestAnimationFrame(animationCallback);
};
animationCallback();
}
}

View file

@ -1,251 +0,0 @@
import { Frame, defineFrame } from "./framework.js";
import tilemapRegistry from "./tilemap-registry.js";
export function canConnect(tile) {
return tile == 1;
}
export function shouldConnect(a, b) {
return a == b;
}
const dirs47 = {
E: 0b0000_0001,
SE: 0b0000_0010,
S: 0b0000_0100,
SW: 0b0000_1000,
W: 0b0001_0000,
NW: 0b0010_0000,
N: 0b0100_0000,
NE: 0b1000_0000,
};
function isSet(integer, bit) {
return (integer & bit) == bit;
}
function removeRedundancies(t) {
if (isSet(t, dirs47.SE) && (!isSet(t, dirs47.S) || !isSet(t, dirs47.E))) {
t &= ~dirs47.SE;
}
if (isSet(t, dirs47.SW) && (!isSet(t, dirs47.S) || !isSet(t, dirs47.W))) {
t &= ~dirs47.SW;
}
if (isSet(t, dirs47.NW) && (!isSet(t, dirs47.N) || !isSet(t, dirs47.W))) {
t &= ~dirs47.NW;
}
if (isSet(t, dirs47.NE) && (!isSet(t, dirs47.N) || !isSet(t, dirs47.E))) {
t &= ~dirs47.NE;
}
return t;
}
function ordinalDirections() {
let unique = new Set();
for (let i = 0; i <= 0b1111_1111; ++i) {
unique.add(removeRedundancies(i));
}
return Array.from(unique).sort((a, b) => a - b);
}
let xToConnectionBitSet = ordinalDirections();
let connectionBitSetToX = new Uint8Array(256);
for (let i = 0; i < xToConnectionBitSet.length; ++i) {
connectionBitSetToX[xToConnectionBitSet[i]] = i;
}
console.log(connectionBitSetToX);
export class TileEditor extends Frame {
constructor() {
super();
this.tileCursor = { x: 0, y: 0 };
this.colorScheme = {
background: "#F7F7F7",
grid: "#00000011",
tileCursor: "#222222",
tiles: [
"transparent",
"#eb134a",
],
};
this.tileColorPalette = [
"transparent",
"#eb134a",
];
}
connectedCallback() {
super.connectedCallback();
this.tileSize = parseInt(this.getAttribute("data-tile-size"));
let tilemapId = this.getAttribute("data-tilemap-id");
if (tilemapId != null) {
this.tilemap = tilemapRegistry[this.getAttribute("data-tilemap-id")];
} else {
throw new ReferenceError(`tilemap '${tilemapId}' does not exist`);
}
// 0st element is explicitly null because it represents the empty tile.
this.tilesets = [null];
this.tilesets47 = [null];
let attachedImages = this.getElementsByTagName("img");
for (let image of attachedImages) {
if (image.hasAttribute("data-tairu-tileset")) {
let tilesetIndex = parseInt(image.getAttribute("data-tairu-tileset"));
this.tilesets[tilesetIndex] = image;
} else if (image.hasAttribute("data-tairu-tileset-47")) {
let tilesetIndex = parseInt(image.getAttribute("data-tairu-tileset-47"));
this.tilesets47[tilesetIndex] = image;
}
}
this.width = this.tilemap.width * this.tileSize;
this.height = this.tilemap.height * this.tileSize;
this.hasFocus = false;
this.paintingTile = null;
this.addEventListener("mousemove", event => this.mouseMoved(event));
this.addEventListener("mousedown", event => this.mousePressed(event));
this.addEventListener("mouseup", event => this.mouseReleased(event));
this.addEventListener("mouseenter", _ => this.hasFocus = true);
this.addEventListener("mouseleave", _ => this.hasFocus = false);
this.addEventListener("contextmenu", event => event.preventDefault());
// TODO: This should also work on mobile.
}
draw() {
this.ctx.fillStyle = this.colorScheme.background;
this.ctx.fillRect(0, 0, this.width, this.height);
this.drawTiles();
this.drawGrid();
if (this.hasFocus) {
this.drawTileCursor();
}
}
drawGrid() {
this.ctx.beginPath();
for (let x = 0; x < this.tilemap.width; ++x) {
this.ctx.moveTo(x * this.tileSize, 0);
this.ctx.lineTo(x * this.tileSize, this.height);
}
for (let y = 0; y < this.tilemap.width; ++y) {
this.ctx.moveTo(0, y * this.tileSize);
this.ctx.lineTo(this.width, y * this.tileSize);
}
this.ctx.strokeStyle = this.colorScheme.grid;
this.ctx.lineWidth = 1;
this.ctx.stroke();
}
drawTileCursor() {
this.ctx.strokeStyle = this.colorScheme.tileCursor;
this.ctx.lineWidth = 5;
this.ctx.strokeRect(this.tileCursor.x * this.tileSize, this.tileCursor.y * this.tileSize, this.tileSize, this.tileSize);
}
get hasTilesets() {
// Remember that tile 0 represents emptiness.
return this.tilesets.length > 1 || this.tilesets47.length > 1;
}
drawTiles() {
if (this.hasTilesets) {
this.drawTexturedTiles();
} else {
this.drawColoredTiles();
}
}
drawColoredTiles() {
for (let y = 0; y < this.tilemap.height; ++y) {
for (let x = 0; x < this.tilemap.width; ++x) {
let tile = this.tilemap.at(x, y);
if (tile != 0) {
this.ctx.fillStyle = this.colorScheme.tiles[tile];
this.ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize);
}
}
}
}
drawTexturedTiles() {
this.ctx.imageSmoothingEnabled = false;
for (let y = 0; y < this.tilemap.height; ++y) {
for (let x = 0; x < this.tilemap.width; ++x) {
let tile = this.tilemap.at(x, y);
if (tile != 0) {
let tileset16 = this.tilesets[tile];
let tileset47 = this.tilesets47[tile];
let tileset = tileset47 != null ? tileset47 : tileset16;
let tileIndex = 0;
if (tileset47 != null) {
let rawTileIndex = 0;
rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y)) ? dirs47.E : 0;
rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y + 1)) ? dirs47.SE : 0;
rawTileIndex |= shouldConnect(tile, this.tilemap.at(x, y + 1)) ? dirs47.S : 0;
rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y + 1)) ? dirs47.SW : 0;
rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y)) ? dirs47.W : 0;
rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y - 1)) ? dirs47.NW : 0;
rawTileIndex |= shouldConnect(tile, this.tilemap.at(x, y - 1)) ? dirs47.N : 0;
rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y - 1)) ? dirs47.NE : 0;
tileIndex = connectionBitSetToX[removeRedundancies(rawTileIndex)];
} else {
tileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y)) ? 0b0001 : 0;
tileIndex |= shouldConnect(tile, this.tilemap.at(x, y + 1)) ? 0b0010 : 0;
tileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y)) ? 0b0100 : 0;
tileIndex |= shouldConnect(tile, this.tilemap.at(x, y - 1)) ? 0b1000 : 0;
}
let tilesetTileSize = tileset.height;
let tilesetX = tileIndex * tilesetTileSize;
let tilesetY = 0;
this.ctx.drawImage(
tileset,
tilesetX, tilesetY, tilesetTileSize, tilesetTileSize,
x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize,
);
}
}
}
}
mouseMoved(event) {
let mouse = this.getMousePositionFromEvent(event);
this.tileCursor.x = Math.floor(mouse.x / this.tileSize);
this.tileCursor.y = Math.floor(mouse.y / this.tileSize);
this.paintTileUnderCursor();
}
mousePressed(event) {
event.preventDefault();
if (event.button == 0) {
this.paintingTile = 1;
} else if (event.button == 2) {
this.paintingTile = 0;
}
this.paintTileUnderCursor();
}
mouseReleased() {
this.paintingTile = null;
}
paintTileUnderCursor() {
if (this.paintingTile != null) {
this.tilemap.setAt(this.tileCursor.x, this.tileCursor.y, this.paintingTile);
}
}
}
defineFrame("tairu-editor", TileEditor);

View file

@ -23,9 +23,10 @@ export function CodeJar(editor, highlight, opt = {}) {
editor.setAttribute('contenteditable', 'plaintext-only');
editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
editor.style.outline = 'none';
editor.style.overflowWrap = 'break-word';
editor.style.overflowY = 'auto';
editor.style.whiteSpace = 'pre-wrap';
// PATCH(liquidex): I think I know better how I want to handle overflow.
// editor.style.overflowWrap = 'break-word';
// editor.style.overflowY = 'auto';
editor.style.whiteSpace = 'pre';
const doHighlight = (editor, pos) => {
highlight(editor, pos);
};