diff --git a/webfiles/beep.wav b/webfiles/canvas/beep.wav similarity index 100% rename from webfiles/beep.wav rename to webfiles/canvas/beep.wav diff --git a/webfiles/canvas/tcell.html b/webfiles/canvas/tcell.html new file mode 100644 index 00000000..f7867ea8 --- /dev/null +++ b/webfiles/canvas/tcell.html @@ -0,0 +1,12 @@ + + + + + Tcell + + + + + + + \ No newline at end of file diff --git a/webfiles/canvas/tcell.js b/webfiles/canvas/tcell.js new file mode 100644 index 00000000..aa3b33cd --- /dev/null +++ b/webfiles/canvas/tcell.js @@ -0,0 +1,227 @@ +// Copyright 2023 The TCell Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use file except in compliance with the License. +// You may obtain a copy of the license at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const wasmFilePath = "main.wasm" +const fontclass = "16px \"Menlo\", \"Andale Mono\", \"Courier New\", Monospace" +const fontcolor = "#FFFFFF" +const fontback = "#000000" +const dimcolor = "#7F7F7F" +const dimback = "#000000" +const fitwindow = true + +/** @type HTMLCanvasElement */ +const termElement = document.getElementById("terminal") +const term = termElement.getContext("2d") +term.font = fontclass +const fontSize = term.measureText("X") +// Results are less than ideal if these are not integers. +const fontwidth = Math.round(fontSize.width) +const fontheight = Math.round(fontSize.fontBoundingBoxAscent + fontSize.fontBoundingBoxDescent) +const vertoffset = Math.round(fontSize.fontBoundingBoxAscent) + +var width = 80; var height = 24 +if (fitwindow) { + document.documentElement.style.overflow = 'hidden'; + width = Math.floor(document.documentElement.clientWidth / fontwidth) + height = Math.floor(document.documentElement.clientHeight / fontheight) +} + +const beepAudio = new Audio("beep.wav"); + +var cursorClass = "cursor-blinking-block" + +var blinkState = false +var blinkCells = [] + +var blinkId = setInterval(function () { + blinkState = !blinkState + blinkCells.forEach(cell => { + drawCell(cell.x, cell.y, cell.mainc, cell.combc, cell.fg, cell.bg, cell.attrs, true) + }) +}, 500); + +function initialize() { + resize(width, height) // initialize content +} + +function resize(w, h) { + width = w + height = h + termElement.width = fontwidth * width + termElement.height = fontheight * height + clearScreen() +} + +function clearScreen(fg, bg) { + if (fg) { fontcolor = intToHex(fg) } + if (bg) { fontback = intToHex(bg) } + + term.fillStyle = fontback + term.fillRect(0, 0, termElement.width, termElement.height) + blinkCells = [] +} + +function invert(data) { + for (var idx = 0; idx < data.data.byteLength; idx++) { + if (idx % 4 != 3) { // don't invert the alpha channel + data.data[idx] = 255 - data.data[idx] + } + } +} + +function drawCell(x, y, mainc, combc, fg, bg, attrs, blink) { + if (!blink) { + if ((attrs & ((1 << 1) | (1 << 8))) != 0) { + // include x and y so we don't have to deconvert the id + blinkCells[y * width + x] = { x: x, y: y, mainc: mainc, combc: combc, fg: fg, bg: bg, attrs: attrs } + } else { + delete blinkCells[y * width + x] + } + } + + var combString = String.fromCharCode(mainc) + combc.forEach(char => { combString += String.fromCharCode(char) }); + + let fgc = fontcolor + let bgc = fontback + if ((attrs & (1 << 4)) == 0) { + if (fg != -1) { fgc = intToHex(fg) } + if (bg != -1) { bgc = intToHex(bg) } + } else { // dim + fgc = dimcolor + if (fg != -1) { fgc = intToHex((fg & 0xFEFEFE) >> 1) } + bgc = dimback + if (bg != -1) { bgc = intToHex((bg & 0xFEFEFE) >> 1) } + } + + if (((attrs & (1 << 1)) != 0) && !blinkState) { // blink off. Just blank the cell and return. + term.fillStyle = bgc + term.fillRect(x * fontwidth, y * fontheight, fontwidth, fontheight) + return + } + + if ((attrs & (1 << 2)) != 0) { // reverse video + var temp = bgc + bgc = fgc + fgc = temp + } + + term.fillStyle = bgc + term.fillRect(x * fontwidth, y * fontheight, fontwidth, fontheight) + + term.fillStyle = fgc + + let fc = fontclass + + if (attrs != 0) { + if ((attrs & 1) != 0) { fc = "bold " + fc } + if ((attrs & (1 << 3)) != 0) { // underscore + term.fillRect(x * fontwidth, y * fontheight + fontheight - 2, fontwidth, 2) + } + if ((attrs & (1 << 5)) != 0) { fc = "italic " + fc } + if ((attrs & (1 << 6)) != 0) { // strikethrough + term.fillRect(x * fontwidth, y * fontheight + fontheight / 2 - 1, fontwidth, 2) + } + } + + term.font = fc + term.fillText(combString, x * fontwidth, y * fontheight + vertoffset) + + if ((attrs & (1 << 8)) != 0) { // cursor: invert the cursor area + var data + switch (cursorClass) { + case "cursor-blinking-block": + if (!blinkState) break; + case "cursor-steady-block": + data = term.getImageData(x * fontwidth, y * fontheight, fontwidth, fontheight) + invert(data) + term.putImageData(data, x * fontwidth, y * fontheight) + case "cursor-blinking-underline": + if (!blinkState) break; + case "cursor-steady-underline": + data = term.getImageData(x * fontwidth, y * fontheight + fontheight - 2, fontwidth, 2) + invert(data) + term.putImageData(data, x * fontwidth, y * fontheight + fontheight - 2) + break; + case "cursor-blinking-bar": + if (!blinkState) break; + case "cursor-steady-bar": + data = term.getImageData(x * fontwidth, y * fontheight, 2, fontheight) + invert(data) + term.putImageData(data, x * fontwidth, y * fontheight) + break; + } + } +} + +function setCursorStyle(newClass) { + cursorClass = newClass +} + +function beep() { + beepAudio.currentTime = 0; + beepAudio.play(); +} + +function intToHex(n) { + return "#" + n.toString(16).padStart(6, '0') +} + +initialize() + +document.addEventListener("keydown", e => { + onKeyEvent(e.key, e.shiftKey, e.altKey, e.ctrlKey, e.metaKey) +}) + +termElement.addEventListener("click", e => { + onMouseClick(Math.min((e.offsetX / fontwidth) | 0, width - 1), Math.min((e.offsetY / fontheight) | 0, height - 1), e.which, e.shiftKey, e.altKey, e.ctrlKey) +}) + +termElement.addEventListener("mousemove", e => { + onMouseMove(Math.min((e.offsetX / fontwidth) | 0, width - 1), Math.min((e.offsetY / fontheight) | 0, height - 1), e.which, e.shiftKey, e.altKey, e.ctrlKey) +}) + +termElement.addEventListener("focus", e => { + onFocus(true) +}) + +termElement.addEventListener("blur", e => { + onFocus(false) +}) +term.tabIndex = 0 + +document.addEventListener("paste", e => { + e.preventDefault(); + var text = (e.originalEvent || e).clipboardData.getData('text/plain'); + onPaste(true) + for (let i = 0; i < text.length; i++) { + onKeyEvent(text.charAt(i), false, false, false, false) + } + onPaste(false) +}); + +if (fitwindow) { + document.defaultView.addEventListener("resize", e => { + const charWidth = Math.floor(document.documentElement.clientWidth / fontwidth) + const charHeight = Math.floor(document.documentElement.clientHeight / fontheight) + onResizeEvent(charWidth, charHeight) + }) +} + +const go = new Go(); +go.env.LINES = height.toString(); +go.env.COLUMNS = width.toString(); +WebAssembly.instantiateStreaming(fetch(wasmFilePath), go.importObject).then((result) => { + go.run(result.instance); +}); diff --git a/webfiles/dom/beep.wav b/webfiles/dom/beep.wav new file mode 100644 index 00000000..96cefd44 Binary files /dev/null and b/webfiles/dom/beep.wav differ diff --git a/webfiles/tcell.html b/webfiles/dom/tcell.html similarity index 100% rename from webfiles/tcell.html rename to webfiles/dom/tcell.html diff --git a/webfiles/tcell.js b/webfiles/dom/tcell.js similarity index 50% rename from webfiles/tcell.js rename to webfiles/dom/tcell.js index 010ef6b2..8eb111ba 100644 --- a/webfiles/tcell.js +++ b/webfiles/dom/tcell.js @@ -13,6 +13,7 @@ // limitations under the License. const wasmFilePath = "main.wasm" +const fitwindow = true const term = document.getElementById("terminal") var width = 80; var height = 24 const beepAudio = new Audio("beep.wav"); @@ -20,25 +21,13 @@ const beepAudio = new Audio("beep.wav"); var cx = -1; var cy = -1 var cursorClass = "cursor-blinking-block" -var content // {data: row[height], dirty: bool} -// row = {data: element[width], previous: span} -// dirty/[previous being null] indicates if previous (or entire terminal) needs to be recalculated. -// dirty is true/null if terminal/previous need to be re-calculated/shown - function initialize() { resize(width, height) // initialize content - show() // then show the screen } function resize(w, h) { - width = w height = h - content = {data: new Array(height), dirty: true} - for (let i = 0; i < height; i++) { - content.data[i] = {data: new Array(width), previous: null} - } - clearScreen() } @@ -46,23 +35,25 @@ function clearScreen(fg, bg) { if (fg) { term.style.color = intToHex(fg) } if (bg) { term.style.backgroundColor = intToHex(bg) } - content.dirty = true + term.innerHTML = "" for (let i = 0; i < height; i++) { - content.data[i].previous = null // we set the row to be recalculated later + row = document.createElement("span") for (let j = 0; j < width; j++) { - content.data[i].data[j] = document.createTextNode(" ") // set the entire row to spaces. + row.appendChild(document.createTextNode(" ")) } + row.appendChild(document.createTextNode("\n")) + term.appendChild(row) } } function drawCell(x, y, mainc, combc, fg, bg, attrs) { var combString = String.fromCharCode(mainc) - combc.forEach(char => {combString += String.fromCharCode(char)}); + combc.forEach(char => { combString += String.fromCharCode(char) }); var span = document.createElement("span") var use = false - if ((attrs & (1<<2)) != 0) { // reverse video + if ((attrs & (1 << 2)) != 0) { // reverse video var temp = bg bg = fg fg = temp @@ -71,74 +62,26 @@ function drawCell(x, y, mainc, combc, fg, bg, attrs) { if (fg != -1) { span.style.color = intToHex(fg); use = true } if (bg != -1) { span.style.backgroundColor = intToHex(bg); use = true } + if ((x == cx) && (y == cy) && ((attrs & (1 << 8)) == 0)) { + cx = -1 + cy = -1 + } + if (attrs != 0) { use = true if ((attrs & 1) != 0) { span.classList.add("bold") } - if ((attrs & (1<<1)) != 0) { span.classList.add("blink") } - if ((attrs & (1<<3)) != 0) { span.classList.add("underline") } - if ((attrs & (1<<4)) != 0) { span.classList.add("dim") } - if ((attrs & (1<<5)) != 0) { span.classList.add("italic") } - if ((attrs & (1<<6)) != 0) { span.classList.add("strikethrough") } + if ((attrs & (1 << 1)) != 0) { span.classList.add("blink") } + if ((attrs & (1 << 3)) != 0) { span.classList.add("underline") } + if ((attrs & (1 << 4)) != 0) { span.classList.add("dim") } + if ((attrs & (1 << 5)) != 0) { span.classList.add("italic") } + if ((attrs & (1 << 6)) != 0) { span.classList.add("strikethrough") } + if ((attrs & (1 << 8)) != 0) { span.classList.add(cursorClass); cx = x; cy = y } } var textnode = document.createTextNode(combString) span.appendChild(textnode) - content.dirty = true // invalidate terminal- new cell - content.data[y].previous = null // invalidate row- new row - content.data[y].data[x] = use ? span : textnode -} - -function show() { - if (!content.dirty) { - return // no new draws; no need to update - } - - displayCursor() - - term.innerHTML = "" - content.data.forEach(row => { - if (row.previous == null) { - row.previous = document.createElement("span") - row.data.forEach(c => { - row.previous.appendChild(c) - }) - row.previous.appendChild(document.createTextNode("\n")) - } - term.appendChild(row.previous) - }) - - content.dirty = false -} - -function showCursor(x, y) { - content.dirty = true - - if (!(cx < 0 || cy < 0)) { // if original position is a valid cursor position - content.data[cy].previous = null; - if (content.data[cy].data[cx].classList) { - content.data[cy].data[cx].classList.remove(cursorClass) - } - } - - cx = x - cy = y -} - -function displayCursor() { - content.dirty = true - - if (!(cx < 0 || cy < 0)) { // if new position is a valid cursor position - content.data[cy].previous = null; - - if (!content.data[cy].data[cx].classList) { - var span = document.createElement("span") - span.appendChild(content.data[cy].data[cx]) - content.data[cy].data[cx] = span - } - - content.data[cy].data[cx].classList.add(cursorClass) - } + term.childNodes[y].childNodes[x].replaceWith(use ? span : textnode) } function setCursorStyle(newClass) { @@ -147,15 +90,16 @@ function setCursorStyle(newClass) { } if (!(cx < 0 || cy < 0)) { - // mark cursor row as dirty; new class has been applied to (cx, cy) content.dirty = true content.data[cy].previous = null - if (content.data[cy].data[cx].classList) { - content.data[cy].data[cx].classList.remove(cursorClass) + if (!term.childNodes[cy].childNodes[cx].classList) { + var span = document.createElement("span") + span.appendChild(term.childNodes[cy].childNodes[cx]) + term.childNodes[cy].childNodes[cx].replaceWith(span) } - - // adding the new class will be dealt with when displayCursor() is called + term.childNodes[cy].childNodes[cx].classList.remove(cursorClass) + term.childNodes[cy].childNodes[cx].classList.add(newClass) } cursorClass = newClass @@ -175,16 +119,21 @@ initialize() let fontwidth = term.clientWidth / width let fontheight = term.clientHeight / height +if (fitwindow) { + document.documentElement.style.overflow = 'hidden'; + resize(Math.floor(document.documentElement.clientWidth / fontwidth), Math.floor(document.documentElement.clientHeight / fontheight)) +} + document.addEventListener("keydown", e => { onKeyEvent(e.key, e.shiftKey, e.altKey, e.ctrlKey, e.metaKey) }) term.addEventListener("click", e => { - onMouseClick(Math.min((e.offsetX / fontwidth) | 0, width-1), Math.min((e.offsetY / fontheight) | 0, height-1), e.which, e.shiftKey, e.altKey, e.ctrlKey) + onMouseClick(Math.min((e.offsetX / fontwidth) | 0, width - 1), Math.min((e.offsetY / fontheight) | 0, height - 1), e.which, e.shiftKey, e.altKey, e.ctrlKey) }) term.addEventListener("mousemove", e => { - onMouseMove(Math.min((e.offsetX / fontwidth) | 0, width-1), Math.min((e.offsetY / fontheight) | 0, height-1), e.which, e.shiftKey, e.altKey, e.ctrlKey) + onMouseMove(Math.min((e.offsetX / fontwidth) | 0, width - 1), Math.min((e.offsetY / fontheight) | 0, height - 1), e.which, e.shiftKey, e.altKey, e.ctrlKey) }) term.addEventListener("focus", e => { @@ -207,7 +156,17 @@ document.addEventListener("paste", e => { onPaste(false) }); +if (fitwindow) { + document.defaultView.addEventListener("resize", e => { + const charWidth = Math.floor(document.documentElement.clientWidth / fontwidth) + const charHeight = Math.floor(document.documentElement.clientHeight / fontheight) + onResizeEvent(charWidth, charHeight) + }) +} + const go = new Go(); +go.env.LINES = height.toString(); +go.env.COLUMNS = width.toString(); WebAssembly.instantiateStreaming(fetch(wasmFilePath), go.importObject).then((result) => { go.run(result.instance); }); diff --git a/webfiles/termstyle.css b/webfiles/dom/termstyle.css similarity index 100% rename from webfiles/termstyle.css rename to webfiles/dom/termstyle.css diff --git a/wscreen.go b/wscreen.go index bfdce438..44f807eb 100644 --- a/wscreen.go +++ b/wscreen.go @@ -19,11 +19,19 @@ package tcell import ( "errors" - "github.com/gdamore/tcell/v2/terminfo" + "os" + "strconv" "strings" "sync" "syscall/js" "unicode/utf8" + + "github.com/gdamore/tcell/v2/terminfo" +) + +// Extra attributes for web display +const ( + attrCursor AttrMask = AttrInvalid << 1 ) func NewTerminfoScreen() (Screen, error) { @@ -33,6 +41,11 @@ func NewTerminfoScreen() (Screen, error) { return t, nil } +type resized struct { + w int + h int +} + type wScreen struct { w, h int style Style @@ -44,19 +57,31 @@ type wScreen struct { pasteEnabled bool mouseFlags MouseFlags + cx int + cy int cursorStyle CursorStyle quit chan struct{} evch chan Event + rsch chan resized fallback map[rune]string sync.Mutex } func (t *wScreen) Init() error { - t.w, t.h = 80, 24 // default for html as of now + t.w, t.h = 80, 24 + if i, _ := strconv.Atoi(os.Getenv("LINES")); i != 0 { + t.h = i + } + if i, _ := strconv.Atoi(os.Getenv("COLUMNS")); i != 0 { + t.w = i + } + t.cx, t.cy = -1, -1 + t.evch = make(chan Event, 10) t.quit = make(chan struct{}) + t.rsch = make(chan resized, 1) t.Lock() t.running = true @@ -65,6 +90,8 @@ func (t *wScreen) Init() error { t.Unlock() js.Global().Set("onKeyEvent", js.FuncOf(t.onKeyEvent)) + js.Global().Set("onResizeEvent", js.FuncOf(t.onResizeEvent)) + go t.watchResize() return nil } @@ -91,6 +118,9 @@ func (t *wScreen) Fill(r rune, style Style) { func (t *wScreen) SetContent(x, y int, mainc rune, combc []rune, style Style) { t.Lock() + if x == t.cx && y == t.cy { + style.attrs |= attrCursor + } t.cells.SetContent(x, y, mainc, combc, style) t.Unlock() } @@ -98,6 +128,7 @@ func (t *wScreen) SetContent(x, y int, mainc rune, combc []rune, style Style) { func (t *wScreen) GetContent(x, y int) (rune, []rune, Style, int) { t.Lock() mainc, combc, style, width := t.cells.GetContent(x, y) + style.attrs &= ^attrCursor t.Unlock() return mainc, combc, style, width } @@ -175,7 +206,17 @@ func (t *wScreen) drawCell(x, y int) int { func (t *wScreen) ShowCursor(x, y int) { t.Lock() - js.Global().Call("showCursor", x, y) + if t.cx > -1 && t.cy > -1 { + mainc, combc, style, _ := t.cells.GetContent(t.cx, t.cy) + style.attrs &= ^attrCursor + t.cells.SetContent(t.cx, t.cy, mainc, combc, style) + } + t.cx, t.cy = x, y + if t.cx > -1 && t.cy > -1 { + mainc, combc, style, _ := t.cells.GetContent(t.cx, t.cy) + style.attrs |= attrCursor + t.cells.SetContent(t.cx, t.cy, mainc, combc, style) + } t.Unlock() } @@ -212,8 +253,6 @@ func (t *wScreen) draw() { x += width - 1 } } - - js.Global().Call("show") } func (t *wScreen) EnableMouse(flags ...MouseFlags) { @@ -366,6 +405,22 @@ func (t *wScreen) clip(x, y int) (int, int) { return x, y } +func (t *wScreen) watchResize() { + for { + select { + case <-t.quit: + break + case rs := <-t.rsch: + t.SetSize(rs.w, rs.h) + } + } +} + +func (t *wScreen) onResizeEvent(this js.Value, args []js.Value) interface{} { + t.rsch <- resized{w: args[0].Int(), h: args[1].Int()} + return nil +} + func (t *wScreen) onMouseEvent(this js.Value, args []js.Value) interface{} { mod := ModNone button := ButtonNone