import { clearScreen, fillRect } from "../io/window.ts"; import { CHAR, font } from "../data/font.ts"; import { drawText, measureText } from "../runtime/builtins.ts"; import { COLOR } from "../data/colors.ts"; import { getCodeSheet, setSheet } from "../io/sheet.ts"; import { K, ctrlKeyDown, getKeyboardString, keyPressed, shiftKeyDown } from "../io/keyboard.ts"; import { clipboard, tokenize } from "../deps.ts"; import { getBuiltins } from "../runtime/runcode.ts"; import { page } from "./viewsheets.ts"; import { mouseDown, mouseHeld, mousePos } from "../io/mouse.ts"; const historyDebounceFrames = 20; const fontHeight = font.height; 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", "NaN", "Infinity", CHAR.PI, ]; 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 = COLOR.PINK; const punctuationColor = COLOR.LIGHTGRAY; const commentColor = COLOR.DARKGREEN; const identifierColor = COLOR.YELLOW; const invalidColor = COLOR.RED; const caretColor = COLOR.WHITE; const selectionColor = COLOR.DARKBLUE; const backgroundColor = COLOR.DARKERBLUE; 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 transformForCopy = (text: string) => { text = text.replaceAll(CHAR.UP, "⬆️"); text = text.replaceAll(CHAR.LEFT, "⬅️"); text = text.replaceAll(CHAR.DOWN, "⬇️"); text = text.replaceAll(CHAR.RIGHT, "➡️"); return text; } const transformForPaste = (text: string) => { let newstr = ""; text = text.replaceAll("⬆️", CHAR.UP); text = text.replaceAll("⬅️", CHAR.LEFT); text = text.replaceAll("⬇️", CHAR.DOWN); text = text.replaceAll("➡️", CHAR.RIGHT); for (const char of text) { if (char in font.chars) { newstr += char; } } return newstr; } const state = { doubleClickTimer: 0, history: [] as Array<{code: string, anchor: number, focus: number}>, historyDebounce: 0, historyIndex: 0, undo() { console.log('undoing'); if (this.historyIndex === this.history.length && this.historyDebounce > 0) { this.snapshot(); } console.log('historyIndex', this.historyIndex); if (this.historyIndex > 0) { this.historyIndex -= 1; const snap = this.history[this.historyIndex]; console.log('historyIndex', this.historyIndex); this.code = snap.code; this.setSelection(snap.anchor, snap.focus); } }, redo() { console.log('redoing'); 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() { const snap = { code: this.code, anchor: this.anchor, focus: this.focus, }; this.history.push(snap); console.log('took snapshot', this.historyIndex, snap); }, startSnapping() { console.log('start snapping', this.historyIndex); if (this.historyDebounce <= 0) { this.historyIndex += 1; } if (this.history.length > this.historyIndex) { this.history.length = this.historyIndex; } this.historyDebounce = historyDebounceFrames; }, wordMode: false, 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;}, get focusPixelX() {return indexToRect(this.code, this.focus).x;}, get focusPixelY() {return indexToRect(this.code, this.focus).y;}, get anchorPixelX() {return indexToRect(this.code, this.anchor).x;}, get anchorPixelY() {return indexToRect(this.code, this.anchor).y;}, isCollapsed() { return this.anchor === this.focus; }, clampInRange(n: number) { return Math.max(0, Math.min(n, this.code.length)) }, findNearestWordBoundaryLeft(index: number) { if (index === this.code.length-1) { return index; } const words1 = this.code.slice(0, index+1).split(/\b/g); if (words1[words1.length-1].length === 1) { return index; } const words = this.code.slice(0, index).split(/\b/g); if (!words.length) { return 0; } return index-words[words.length-1].length; }, findNearestWordBoundaryRight(index: number) { if (index === 0) { return index; } const words1 = this.code.slice(index-1).split(/\b/g); if (words1[0].length === 1) { return index; } const words = this.code.slice(index).split(/\b/g); if (!words.length) { return this.code.length; } return index+words[0].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); if (this.wordMode) { console.log('word mode', this.anchor, this.focus, this.findNearestWordBoundaryLeft(this.anchor), this.findNearestWordBoundaryRight(this.focus)); if (this.anchor <= this.focus) { this.anchor = this.findNearestWordBoundaryLeft(this.anchor); this.focus = this.findNearestWordBoundaryRight(this.focus); } else { this.anchor = this.findNearestWordBoundaryRight(this.anchor); this.focus = this.findNearestWordBoundaryLeft(this.focus); } } this.anchor = this.clampInRange(this.anchor); this.focus = this.clampInRange(this.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); if (this.wordMode) { if (this.anchor <= this.focus) { this.anchor = this.findNearestWordBoundaryLeft(this.anchor); this.focus = this.findNearestWordBoundaryRight(this.focus); } else { this.anchor = this.findNearestWordBoundaryRight(this.anchor); this.focus = this.findNearestWordBoundaryLeft(this.focus); } } this.focus = this.clampInRange(this.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(transformForCopy(selected)); }, async cut() { await this.copy(); this.insertText(""); }, async paste() { this.insertText(transformForPaste(await clipboard.readText())); }, scrollToCursor() { const {focusY, scrollY, scrollX, focus} = this; const fh = fontHeight + 1; const rect = indexToRect(this.code, focus); if (focusY*fh < scrollY) { this.scrollY = focusY*fh; } if (focusY*fh > scrollY+112-fh) { this.scrollY = focusY*fh-112+fh; } if (rect.x < scrollX) { this.scrollX = rect.x; } if (rect.x+rect.w > scrollX+128) { this.scrollX = rect.x-128+rect.w+1; } }, 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 ""; } return match[0]; }, get code() { return getCodeSheet(page.activeSheet); }, set code(val) { setSheet(page.activeSheet, "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)+(y === 0 ? 0 : 1); } const indexToRect = (str: string, index: number) => { const linesUpTo = str.slice(0,index).split("\n"); let extra = 0; if (linesUpTo[linesUpTo.length-1].length > 0) { extra = 1; } return { x: measureText(linesUpTo[linesUpTo.length-1]) + extra, y: (fontHeight + 1)*(linesUpTo.length - 1), w: measureText(str[index] ?? "\n"), h: fontHeight+1, } } const pixelToIndex = (str: string, x: number, y: number) => { const lines = str.split("\n"); if (y < 0) { return 0; } if (y >= (fontHeight+1)*lines.length) { return str.length; } const yy = Math.floor(y/(fontHeight+1)); const prefix = lines.slice(0, yy).join("\n").length+(yy === 0 ? 0 : 1); const line = lines[yy]; let j = 0; while (measureText(line.slice(0, j)) < x && j < line.length) { j+=1; } if (measureText(line) < x) { j+=1; } return prefix + Math.max(0, j-1); } const update = async () => { const { focus } = state; if (state.history.length === 0) { state.snapshot(); } if (state.historyDebounce > 0) { state.historyDebounce -= 1; if (state.historyDebounce <= 0) { state.snapshot(); } } if (state.doubleClickTimer > 0) { state.doubleClickTimer -= 1; } if (mouseDown() && !shiftKeyDown()) { if (state.doubleClickTimer > 0) { state.wordMode = true; } else { state.doubleClickTimer = 10; } const {x, y} = mousePos(); state.setSelection(pixelToIndex(state.code, x+state.scrollX, y+state.scrollY-8)); state.scrollToCursor(); } else if (mouseHeld()) { const {x, y} = mousePos(); state.setFocus(pixelToIndex(state.code, x+state.scrollX, y+state.scrollY-8)); state.scrollToCursor(); } else { state.wordMode = false; } const keyboardString = getKeyboardString(); if (keyboardString) { state.insertText(keyboardString); state.scrollToCursor(); } 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)) { const rect = indexToRect(state.code, focus); const newIndex = pixelToIndex(state.code, rect.x, rect.y+rect.h+1+1); if (shiftKeyDown()) { state.setFocus(newIndex); } else { state.setSelection(newIndex); } state.scrollToCursor(); } if (keyPressed(K.ARROW_UP)) { const rect = indexToRect(state.code, focus); const newIndex = pixelToIndex(state.code, rect.x, rect.y-1-1); if (shiftKeyDown()) { state.setFocus(newIndex); } else { state.setSelection(newIndex); } 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(); const { scrollX, scrollY, anchor, focus, code, } = state; const x = 0; const y = 8; const w = 128; const h = 112; fillRect(x, y, w, h, backgroundColor); if (anchor !== focus) { for (let i = Math.min(anchor, focus); i < Math.max(anchor, focus); i++) { const sel = indexToRect(code, i); fillRect(x+sel.x-scrollX, y+sel.y-scrollY, sel.w+2, sel.h, selectionColor); } } const rect = indexToRect(code, focus); fillRect(x+rect.x-scrollX, y+rect.y-scrollY, 1, rect.h, caretColor); const builtins = Object.keys(getBuiltins()); const tokens = [...tokenize(code)]; let cx = 0; let cy = 0; tokens.forEach((token) => { if (token.type === "LineTerminatorSequence") { cx=0; cy+=fontHeight+1; return; } const lines = token.value.split("\n"); lines.forEach((line, i) => { let color = tokenColors[token.type]; if (builtins.includes(token.value)) { color = builtinColor; } 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(1+x+cx-scrollX, 1+y+cy-scrollY, line, color); if (i === lines.length-1) { cx += measureText(line)+1; } else { cx=0; cy+=fontHeight+1; } }); }) } export const codetab = { update, draw, }