502 lines
11 KiB
TypeScript
502 lines
11 KiB
TypeScript
import { clearScreen, fillRect } from "./window.ts";
|
|
import { fontWidth, fontHeight } from "./font.ts";
|
|
import { drawText } from "./builtins.ts";
|
|
import { COLOR } from "./colors.ts";
|
|
import {getCodeSheet, setSheet} from "./sheet.ts";
|
|
import { K, ctrlKeyDown, getKeyboardString, keyPressed, shiftKeyDown } from "./keyboard.ts";
|
|
import { clipboard, tokenize } from "./deps.ts";
|
|
|
|
const historyDebounceFrames = 20;
|
|
|
|
const state = {
|
|
history: [{code: getCodeSheet(0), anchor: 0, focus: 0}],
|
|
historyDebounce: 0,
|
|
historyIndex: 0,
|
|
undo() {
|
|
if (this.historyIndex === this.history.length && this.historyDebounce > 0) {
|
|
this.snapshot();
|
|
}
|
|
if (this.historyIndex > 0) {
|
|
this.historyIndex -= 1;
|
|
const snap = this.history[this.historyIndex];
|
|
this.code = snap.code;
|
|
this.setSelection(snap.anchor, snap.focus);
|
|
}
|
|
},
|
|
redo() {
|
|
if (this.historyIndex < this.history.length-1) {
|
|
this.historyIndex += 1;
|
|
const snap = this.history[this.historyIndex];
|
|
this.code = snap.code;
|
|
this.setSelection(snap.anchor, snap.focus);
|
|
}
|
|
},
|
|
snapshot() {
|
|
this.history.push({
|
|
code: this.code,
|
|
anchor: this.anchor,
|
|
focus: this.focus,
|
|
});
|
|
},
|
|
startSnapping() {
|
|
this.historyIndex += 1;
|
|
if (this.history.length > this.historyIndex) {
|
|
this.history.length = this.historyIndex;
|
|
}
|
|
this.historyDebounce = historyDebounceFrames;
|
|
},
|
|
scrollX: 0,
|
|
scrollY: 0,
|
|
anchor: 0,
|
|
focus: 0,
|
|
get focusX() {return indexToGrid(this.code, this.focus).x;},
|
|
get focusY() {return indexToGrid(this.code, this.focus).y;},
|
|
get anchorX() {return indexToGrid(this.code, this.anchor).x;},
|
|
get anchorY() {return indexToGrid(this.code, this.anchor).y;},
|
|
isCollapsed() {
|
|
return this.anchor === this.focus;
|
|
},
|
|
clampInRange(n: number) {
|
|
return Math.max(0, Math.min(n, this.code.length))
|
|
},
|
|
setSelection(anchor: number | {x: number, y: number}, focus?: number | {x: number, y: number}) {
|
|
if (typeof anchor !== "number") {
|
|
anchor = gridToIndex(this.code, anchor.x, anchor.y);
|
|
}
|
|
focus = focus ?? anchor;
|
|
if (typeof focus !== "number") {
|
|
focus = gridToIndex(this.code, focus.x, focus.y);
|
|
}
|
|
this.anchor = this.clampInRange(anchor),
|
|
this.focus = this.clampInRange(focus);
|
|
},
|
|
setFocus(focus: number | {x: number, y: number}) {
|
|
if (typeof focus !== "number") {
|
|
focus = gridToIndex(this.code, focus.x, focus.y);
|
|
}
|
|
this.focus = this.clampInRange(focus);
|
|
},
|
|
insertText(text: string) {
|
|
const {code, anchor, focus} = this;
|
|
this.code = code.slice(0, Math.min(anchor, focus)) + text + code.slice(Math.max(anchor, focus));
|
|
this.setSelection(Math.min(anchor, focus) + text.length);
|
|
this.startSnapping();
|
|
},
|
|
indent(indentString: string) {
|
|
const lines = this.code.split("\n");
|
|
const {focusX, focusY, anchorX, anchorY} = this;
|
|
const newLines = lines.map((line, i) => {
|
|
if (i >= Math.min(focusY, anchorY) && i <= Math.max(focusY, anchorY)) {
|
|
return indentString+line;
|
|
} else {
|
|
return line;
|
|
}
|
|
});
|
|
this.code = newLines.join("\n");
|
|
this.setSelection({x: anchorX+1, y: anchorY}, {x: focusX+1, y: focusY});
|
|
this.startSnapping();
|
|
},
|
|
outdent(outdentRegex: RegExp) {
|
|
const lines = this.code.split("\n");
|
|
const {focusX, focusY, anchorX, anchorY} = this;
|
|
const newLines = lines.map((line, i) => {
|
|
const match = line.match(outdentRegex);
|
|
if (i >= Math.min(focusY, anchorY) && i <= Math.max(focusY, anchorY) && match) {
|
|
return line.slice(match[0].length);
|
|
} else {
|
|
return line;
|
|
}
|
|
});
|
|
this.code = newLines.join("\n");
|
|
this.setSelection({x: Math.max(0,anchorX-1), y: anchorY}, {x: Math.max(0,focusX-1), y: focusY});
|
|
this.startSnapping();
|
|
},
|
|
backspace() {
|
|
const {code, focus} = this;
|
|
if (this.isCollapsed()) {
|
|
if (focus > 0) {
|
|
this.code = code.slice(0, focus-1) + code.slice(focus);
|
|
this.setSelection(focus-1);
|
|
this.startSnapping();
|
|
}
|
|
} else {
|
|
this.insertText("");
|
|
}
|
|
},
|
|
delete() {
|
|
const {code, focus} = this;
|
|
if (this.isCollapsed()) {
|
|
if (focus < code.length) {
|
|
this.code = code.slice(0, focus) + code.slice(1+focus);
|
|
this.startSnapping();
|
|
}
|
|
} else {
|
|
this.insertText("");
|
|
}
|
|
},
|
|
async copy() {
|
|
const {code, anchor, focus} = this;
|
|
const selected = code.slice(Math.min(anchor,focus), Math.max(anchor,focus));
|
|
await clipboard.writeText(selected);
|
|
},
|
|
async cut() {
|
|
await this.copy();
|
|
this.insertText("");
|
|
},
|
|
async paste() {
|
|
this.insertText(await clipboard.readText());
|
|
},
|
|
scrollToCursor() {
|
|
const {focusY, focusX, scrollY, scrollX} = this;
|
|
const fh = fontHeight + 1;
|
|
const fw = fontWidth;
|
|
if (focusY*fh < scrollY) {
|
|
this.scrollY = focusY*fh;
|
|
}
|
|
if (focusY*fh > scrollY+112-fh) {
|
|
this.scrollY = focusY*fh-112+fh;
|
|
}
|
|
if (focusX*fw < scrollX) {
|
|
this.scrollX = focusX*fw;
|
|
}
|
|
if (focusX*fw > scrollX+128-fw) {
|
|
this.scrollX = focusX*fw-128+fw;
|
|
}
|
|
},
|
|
currentIndentation() {
|
|
const lines = this.code.slice(0, this.focus).split("\n");
|
|
const line = lines[lines.length-1];
|
|
const match = line.match(/^\s*/);
|
|
if (!match) {
|
|
return "";
|
|
}
|
|
console.log(lines);
|
|
console.log(line);
|
|
console.log(match);
|
|
return match[0];
|
|
},
|
|
get code() {
|
|
return getCodeSheet(0);
|
|
},
|
|
set code(val) {
|
|
setSheet(0, "code", val);
|
|
}
|
|
}
|
|
|
|
const indexToGrid = (str: string, index: number) => {
|
|
const linesUpTo = str.slice(0,index).split("\n");
|
|
return {
|
|
x: linesUpTo[linesUpTo.length-1].length,
|
|
y: linesUpTo.length - 1,
|
|
}
|
|
}
|
|
|
|
const gridToIndex = (str: string, x: number, y: number) => {
|
|
const lines = str.split("\n");
|
|
if (y < 0) {
|
|
return 0;
|
|
}
|
|
if (y >= lines.length) {
|
|
return str.length;
|
|
}
|
|
return lines.slice(0, y).join("\n").length+Math.min(x, lines[y].length)+1;
|
|
}
|
|
|
|
const keywords = [
|
|
"break",
|
|
"case",
|
|
"catch",
|
|
"class",
|
|
"const",
|
|
"continue",
|
|
"debugger",
|
|
"default",
|
|
"delete",
|
|
"do",
|
|
"else",
|
|
"export",
|
|
"extends",
|
|
"finally",
|
|
"for",
|
|
"function",
|
|
"if",
|
|
"import",
|
|
"in",
|
|
"instanceof",
|
|
"new",
|
|
"return",
|
|
"super",
|
|
"switch",
|
|
"this",
|
|
"throw",
|
|
"try",
|
|
"typeof",
|
|
"var",
|
|
"void",
|
|
"while",
|
|
"with",
|
|
"let",
|
|
"static",
|
|
"yield",
|
|
"await",
|
|
"enum",
|
|
"implements",
|
|
"interface",
|
|
"package",
|
|
"private",
|
|
"protected",
|
|
"public",
|
|
"=>",
|
|
];
|
|
const values = [
|
|
"false",
|
|
"null",
|
|
"true",
|
|
"undefined",
|
|
];
|
|
const operator = [
|
|
"&&",
|
|
"||",
|
|
"??",
|
|
"--",
|
|
"++",
|
|
".",
|
|
"?.",
|
|
"<",
|
|
"<=",
|
|
">",
|
|
">=",
|
|
"!=",
|
|
"!==",
|
|
"==",
|
|
"===",
|
|
"+",
|
|
"-",
|
|
"%",
|
|
"&",
|
|
"|",
|
|
"^",
|
|
"/",
|
|
"*",
|
|
"**",
|
|
"<<",
|
|
">>",
|
|
">>>",
|
|
"=",
|
|
"+=",
|
|
"-=",
|
|
"%=",
|
|
"&=",
|
|
"|=",
|
|
"^=",
|
|
"/=",
|
|
"*=",
|
|
"**=",
|
|
"<<=",
|
|
">>=",
|
|
">>>=",
|
|
"!",
|
|
"?",
|
|
"~",
|
|
"...",
|
|
];
|
|
const punctuation = [
|
|
"(",
|
|
")",
|
|
"[",
|
|
"]",
|
|
"{",
|
|
"}",
|
|
".",
|
|
":",
|
|
";",
|
|
",",
|
|
];
|
|
|
|
const keywordColor = COLOR.PURPLE;
|
|
const operatorColor = COLOR.CYAN;
|
|
const valueColor = COLOR.ORANGE;
|
|
const stringColor = COLOR.GREEN;
|
|
const regexColor = stringColor;
|
|
const punctuationColor = COLOR.WHITE;
|
|
const commentColor = COLOR.GRAY;
|
|
const identifierColor = COLOR.LIGHTGRAY;
|
|
const invalidColor = COLOR.RED;
|
|
|
|
const tokenColors = {
|
|
"StringLiteral": stringColor,
|
|
"NoSubstitutionTemplate": stringColor,
|
|
"TemplateHead": stringColor,
|
|
"TemplateMiddle": stringColor,
|
|
"TemplateTail": stringColor,
|
|
"RegularExpressionLiteral": regexColor,
|
|
"MultiLineComment": commentColor,
|
|
"SingleLineComment": commentColor,
|
|
"IdentifierName": identifierColor,
|
|
"PrivateIdentifier": identifierColor,
|
|
"NumericLiteral": valueColor,
|
|
"Punctuator": punctuationColor,
|
|
"WhiteSpace": punctuationColor,
|
|
"LineTerminatorSequence": punctuationColor,
|
|
"Invalid": invalidColor,
|
|
}
|
|
|
|
const drawCodeField = (code: string, x: number, y: number, w: number, h: number) => {
|
|
const {
|
|
scrollX,
|
|
scrollY,
|
|
anchor,
|
|
focus,
|
|
} = state;
|
|
const {
|
|
x: focusX,
|
|
y: focusY,
|
|
} = indexToGrid(code, focus);
|
|
const {
|
|
x: anchorX,
|
|
y: anchorY,
|
|
} = indexToGrid(code, anchor);
|
|
fillRect(x, y, w, h, COLOR.DARKBLUE);
|
|
if (anchor === focus) {
|
|
fillRect(x+focusX*fontWidth-scrollX, y+focusY*(fontHeight+1)-scrollY, fontWidth+1, fontHeight+1, COLOR.RED);
|
|
} else {
|
|
// TODO: Draw this selection better
|
|
fillRect(x+anchorX*fontWidth-scrollX, y+anchorY*(fontHeight+1)-scrollY, fontWidth+1, fontHeight+1, COLOR.GREEN);
|
|
fillRect(x+focusX*fontWidth-scrollX, y+focusY*(fontHeight+1)-scrollY, fontWidth+1, fontHeight+1, COLOR.YELLOW);
|
|
}
|
|
// TODO: syntax highlight built-in functions
|
|
const tokens = [...tokenize(code)];
|
|
let cx = 0;
|
|
let cy = 0;
|
|
tokens.forEach((token) => {
|
|
const lines = token.value.split("\n");
|
|
lines.forEach((line, i) => {
|
|
let color = tokenColors[token.type];
|
|
if (keywords.includes(token.value)) {
|
|
color = keywordColor;
|
|
}
|
|
if (values.includes(token.value)) {
|
|
color = valueColor;
|
|
}
|
|
if (operator.includes(token.value)) {
|
|
color = operatorColor;
|
|
}
|
|
if (punctuation.includes(token.value)) {
|
|
color = punctuationColor;
|
|
}
|
|
drawText(x+cx-scrollX, 1+y+cy-scrollY, line, color);
|
|
if (i === lines.length-1) {
|
|
cx += fontWidth*line.length;
|
|
} else {
|
|
cx=0;
|
|
cy+=fontHeight+1;
|
|
}
|
|
});
|
|
})
|
|
}
|
|
|
|
const update = async () => {
|
|
const { focus, focusX, focusY} = state;
|
|
if (state.historyDebounce > 0) {
|
|
state.historyDebounce -= 1;
|
|
if (state.historyDebounce <= 0) {
|
|
state.snapshot();
|
|
}
|
|
}
|
|
|
|
const keyboardString = getKeyboardString();
|
|
if (keyboardString) {
|
|
state.insertText(keyboardString);
|
|
state.scrollToCursor();
|
|
}
|
|
// TODO: Make ctrl-/ do commenting out (take inspiration from tab)
|
|
|
|
if (keyPressed(K.ENTER)) {
|
|
state.insertText("\n"+state.currentIndentation());
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed(K.TAB)) {
|
|
if (!shiftKeyDown()) {
|
|
if (state.isCollapsed()) {
|
|
state.insertText("\t");
|
|
} else {
|
|
state.indent("\t");
|
|
}
|
|
} else {
|
|
state.outdent(/^(\t| )/);
|
|
}
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed(K.BACKSPACE)) {
|
|
state.backspace();
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed(K.DELETE)) {
|
|
state.delete();
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed(K.ARROW_RIGHT)) {
|
|
if (shiftKeyDown()) {
|
|
state.setFocus(focus+1);
|
|
} else {
|
|
state.setSelection(focus+1);
|
|
}
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed(K.ARROW_LEFT)) {
|
|
if (shiftKeyDown()) {
|
|
state.setFocus(focus-1);
|
|
} else {
|
|
state.setSelection(focus-1);
|
|
}
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed(K.ARROW_DOWN)) {
|
|
if (shiftKeyDown()) {
|
|
state.setFocus({x: focusX, y: focusY+1});
|
|
} else {
|
|
state.setSelection({x: focusX, y: focusY+1});
|
|
}
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed(K.ARROW_UP)) {
|
|
if (shiftKeyDown()) {
|
|
state.setFocus({x: focusX, y: focusY-1});
|
|
} else {
|
|
state.setSelection({x: focusX, y: focusY-1});
|
|
}
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed("C") && ctrlKeyDown()) {
|
|
await state.copy();
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed("X") && ctrlKeyDown()) {
|
|
await state.cut();
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed("V") && ctrlKeyDown()) {
|
|
await state.paste();
|
|
state.scrollToCursor();
|
|
}
|
|
if (keyPressed("Z") && ctrlKeyDown()) {
|
|
if (shiftKeyDown()) {
|
|
state.redo();
|
|
} else {
|
|
state.undo();
|
|
}
|
|
}
|
|
if (keyPressed("Y") && ctrlKeyDown()) {
|
|
state.redo();
|
|
}
|
|
}
|
|
|
|
const draw = () => {
|
|
clearScreen();
|
|
drawCodeField(state.code, 0, 8, 128, 112);
|
|
}
|
|
|
|
export const codetab = {
|
|
update,
|
|
draw,
|
|
} |