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"; import { getContext } from "./runcode.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(); }, toggleComment() { const lines = this.code.split("\n"); const {focusX, focusY, anchorX, anchorY} = this; const lineInSelection = (i: number) => i >= Math.min(focusY, anchorY) && i <= Math.max(focusY, anchorY); const allLinesAreCommented = lines.every((line, i) => { if (lineInSelection(i) && !line.trim().startsWith("// ")) { return false; } else { return true; } }); const newLines = lines.map((line, i) => { if (lineInSelection(i)) { if (allLinesAreCommented) { return line.slice(3); } else { return "// "+line; } } else { return line; } }); this.code = newLines.join("\n"); const shiftBy = allLinesAreCommented ? -3 : 3; this.setSelection({x: anchorX+shiftBy, y: anchorY}, {x: focusX+shiftBy, y: focusY}); 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 builtinColor = COLOR.BLUE; const keywordColor = COLOR.PURPLE; const operatorColor = COLOR.CYAN; const valueColor = COLOR.ORANGE; const stringColor = COLOR.GREEN; const regexColor = stringColor; const punctuationColor = COLOR.LIGHTGRAY; const commentColor = COLOR.GRAY; const identifierColor = COLOR.REDDISH; 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.YELLOW); } else { // TODO: Draw this selection better fillRect(x+anchorX*fontWidth-scrollX, y+anchorY*(fontHeight+1)-scrollY, fontWidth+1, fontHeight+1, COLOR.WHITE); fillRect(x+focusX*fontWidth-scrollX, y+focusY*(fontHeight+1)-scrollY, fontWidth+1, fontHeight+1, COLOR.YELLOW); } const builtins = Object.keys(getContext()); 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; } if (builtins.includes(token.value)) { color = builtinColor; } 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(); } if (keyPressed("/") && ctrlKeyDown()) { state.toggleComment(); } } const draw = () => { clearScreen(); drawCodeField(state.code, 0, 8, 128, 112); } export const codetab = { update, draw, }