diff --git a/src/app.ts b/src/app.ts index 1078eb5..5c1abea 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,7 +4,8 @@ import { Wush } from './shell/wush/Wush' import { Terminal } from './terminal/Terminal' import { EventBroadcaster } from './utils/EventBroadcaster' -export const WEBSHELL_VERSION = "0.1.0" +export const WEBSHELL_VERSION = "0.2.0" +export const VERSION_COMMENT: string | null = "hi" // initialize object store for webfs let WebfsDatabase: IDBDatabase | null = null diff --git a/src/program/Info.ts b/src/program/Info.ts index 7bc6af8..4d05666 100644 --- a/src/program/Info.ts +++ b/src/program/Info.ts @@ -1,4 +1,4 @@ -import { GetCurrentTerminal, WEBSHELL_VERSION } from '../app' +import { GetCurrentTerminal, WEBSHELL_VERSION, VERSION_COMMENT } from '../app' import type { Item } from '../fs/Item' import { Terminal } from '../terminal/Terminal' import type { SimpleStream } from '../utils/SimpleStream' @@ -10,6 +10,10 @@ export class Info extends Program { stdout.emit(`Terminal v${Terminal.Version}\n`) stdout.emit(`Running ${GetCurrentTerminal().GetShell()?.Name} v${GetCurrentTerminal().GetShell()?.Version}\n`) + // print a comment for the current release if any + if (VERSION_COMMENT) + stdout.emit(`-> ${VERSION_COMMENT}\n`) + return 0 } } diff --git a/src/shell/wush/ShellEnvironment.ts b/src/shell/wush/ShellEnvironment.ts index 6f93d23..0b9ec03 100644 --- a/src/shell/wush/ShellEnvironment.ts +++ b/src/shell/wush/ShellEnvironment.ts @@ -4,18 +4,37 @@ */ export class ShellEnvironment { private variables: Record = {} + private aliases: Record = {} SetVariable(name: string, value: string | null): void { - // remove the variable if value is null if (value === null) { + // remove the variable if value is null delete this.variables[name] - return - } - - this.variables[name] = value + } else this.variables[name] = value } - GetVariable(name: string): string | undefined { - return this.variables[name] + GetVariable(name: string): string | null { + return this.variables[name] ?? null + } + + SetAlias(name: string, value: string | null): void { + if (value === null) { + // remove the alias if value is null + delete this.aliases[name] + } else this.aliases[name] = value + } + + GetAlias(name: string): string | null { + return this.aliases[name] ?? null + } + + GetAliasesByValue(value: string): string[] | null { + const keys: string[] = [] + + for (const key in this.aliases) + if (this.aliases[key] === value) + keys.push(key) + + return keys.length === 0 ? null : keys } } diff --git a/src/shell/wush/ShellKeyboardManager.ts b/src/shell/wush/ShellKeyboardManager.ts new file mode 100644 index 0000000..177b937 --- /dev/null +++ b/src/shell/wush/ShellKeyboardManager.ts @@ -0,0 +1,129 @@ +import type { Wush } from "./Wush" + +export type ModifierKey = 'shift' | 'rightShift' | 'ctrl' | 'rightCtrl' | 'alt' | 'rightAlt' + +export class ShellInputManager { + private modifierKeys: ModifierKey[] = [] + private shell: Wush + + constructor(shell: Wush) { + this.shell = shell + } + + /** + * Checks if the modifier includes every key of the combo + * @param combo modifier combo to check for + * @returns boolean according to the check truthfullness + */ + HasModifierCombo(combo: ModifierKey[]) { + let result = true + + // check if the modifier buffer has every key in the combo + combo.forEach(key => { + if (!modifiers.includes(key)) { + result = false + return + } + }) + + return result + } + + /** + * + * @param event keypress event to process + */ + private processKeypressEvent(event: KeyboardEvent) { + + } + + HandleKeyInput(key: string, modifiers: ModifierKey[], isCharacter: boolean) { + // handle special input + switch (key.toUpperCase()) { + case 'R': + if (hasModifierCombo(['ctrl'])) + location.reload() + break + + case 'F5': + location.reload() + break + } + + if (this.execExitCode === -1) { + console.log('redirecting input to a running program') + + this.WriteStdin(key) + return + } + + // handle standard keys + if (!isCharacter) { + switch (key) { + case 'ArrowLeft': + if (this.bufferPos > 0) { + this.bufferPos -= 1 + this.SyncCursorToBuffer() + } + + break + case 'ArrowRight': + if (this.bufferPos < this.buffer.length) { + this.bufferPos += 1 + this.SyncCursorToBuffer() + } + break + case 'ArrowUp': + this.NavigateHistory(-1) + break + case 'ArrowDown': + this.NavigateHistory(1) + break + case 'Backspace': + // don't erase anything if there's nothing left in the buffer + if (this.bufferPos === 0) + break + + this.ExitHistoryNavigation() + this.terminal.RemoveCell() + this.RemoveCharFromBuffer(1, this.bufferPos) + this.SyncCursorToBuffer() + break + case 'Delete': + if (this.bufferPos >= this.buffer.length || this.buffer.length <= 0) + break + + this.ExitHistoryNavigation() + this.bufferPos += 1 + this.SyncCursorToBuffer() + this.terminal.RemoveCell() + this.RemoveCharFromBuffer(1, this.bufferPos) + this.SyncCursorToBuffer() + break + case 'Enter': + // send the buffer to stdin if an exec is running + if (this.execExitCode === -1) { + this.WriteStdin(`${this.buffer.join('')}\n`) + this.FlushBuffer() + } else { + // "execute" the buffer + this.terminal.MoveCursor(0, 1, { x: true, y: false }) + const command = this.buffer.join('') + if (command.length > 0) + this.history.push(command) + + this.historyIndex = null + this.historyDraft = '' + this.ExecuteLineBuffer() + } + + break + } + } else { + this.ExitHistoryNavigation() + // push the character into the buffer + this.InsertText(key) + } + } + +} diff --git a/src/shell/wush/Wush.ts b/src/shell/wush/Wush.ts index f0b3933..99c9585 100644 --- a/src/shell/wush/Wush.ts +++ b/src/shell/wush/Wush.ts @@ -25,11 +25,10 @@ import { Mkdir } from '../../program/Mkdir' import { Cd } from '../../program/Cd' import { Printf } from '../../program/Printf' import { Pwd } from '../../program/Pwd' -// import { Pwd } from '../program/Pwd' -// import { Cd } from '../program/Cd' +import { ShellEnvironment } from './ShellEnvironment' export class Wush extends Shell { - public readonly Version = "0.1.0" + public readonly Version = "0.3.1" public readonly Name = "wush" // buffer @@ -47,8 +46,7 @@ export class Wush extends Shell { */ private execExitCode: number = 0 - // todo: workers - // private workersAllowed: boolean = false + readonly environment: ShellEnvironment = new ShellEnvironment() // streams readonly stdin: SimpleStream @@ -66,6 +64,7 @@ export class Wush extends Shell { } async Init() { + // initialize keyboard listener this.broadcaster.on('keydown', (key: string, isCharacter: boolean) => this.HandleKeyInput(key, isCharacter), ) @@ -73,6 +72,9 @@ export class Wush extends Shell { // load workdir this.workingDirectory = await Item.Root() + // register streams + this.stdout.on(data => this.WriteEscapedString(data)) + // load core programs this.programs['clear'] = new Clear() this.programs['eval'] = new Eval() @@ -92,7 +94,7 @@ export class Wush extends Shell { this.programs['cd'] = new Cd(this) this.programs['printf'] = new Printf() - this.stdout.on(data => this.WriteEscapedString(data)) + // initial prompt this.Prompt() } @@ -158,9 +160,9 @@ export class Wush extends Shell { } WriteEscapedString(data: string) { - let i = 0 let buffer = '' + // write the buffer to screen const flush = () => { if (buffer.length === 0) return @@ -169,11 +171,15 @@ export class Wush extends Shell { buffer = '' } + // process the string + let i = 0 while (i < data.length) { const char = data[i] + // check for escape sequences if (char === '\x1b') { flush() + const nextIndex = this.ProcessEscapeSequence(data, i) if (nextIndex !== null) { i = nextIndex @@ -181,10 +187,13 @@ export class Wush extends Shell { } } + // check for control codes if (char === '\f' || char === '\n') { flush() - this.ProcessSimpleControlCode(char) + + this.ProcessControlCode(char) i++ + continue } @@ -200,15 +209,11 @@ export class Wush extends Shell { this.buffer.splice(this.bufferPos, 0, char) this.bufferPos++ }) - - // console.log(this.buffer) } RemoveCharFromBuffer(amount: number, index: number) { this.buffer.splice(index - amount, amount) this.bufferPos -= amount - - // console.log(this.buffer) } MoveBufferPos(index: number, absolute: boolean = false) { @@ -220,7 +225,8 @@ export class Wush extends Shell { this.bufferPos = 0 } - async ExecuteBuffer() { + // takes the prompt buffer, parses it and executes the contents + async ExecuteLineBuffer(data?: string) { type OperatorToken = '&&' | '||' | '|' | ';' type Token = { type: 'word'; value: string } | { type: 'operator'; value: OperatorToken } type ParsedCommand = { @@ -231,23 +237,32 @@ export class Wush extends Shell { type ParsedPipeline = { commands: ParsedCommand[] } type ParsedListItem = { pipeline: ParsedPipeline; operator: '&&' | '||' | ';' | null } + // splits the buffer into processable pieces (tokens) const tokenize = (text: string): { tokens: Token[]; error: string | null } => { const tokens: Token[] = [] + + // state machine stuff let current = '' let wordStarted = false let inSingleQuote = false let inDoubleQuote = false let escapeNext = false + /** + * adds a new word to the token buffer + */ const pushWord = () => { if (!wordStarted) return tokens.push({ type: 'word', value: current }) + + // reset the state current = '' wordStarted = false } + // iterates over the provided text buffer for (let i = 0; i < text.length; i++) { const char = text[i] @@ -258,6 +273,7 @@ export class Wush extends Shell { continue } + // process strict (single quote) strings if (inSingleQuote) { if (char === "'") { inSingleQuote = false @@ -270,6 +286,7 @@ export class Wush extends Shell { continue } + // process classic (double quote) strings if (inDoubleQuote) { if (char === '"') { inDoubleQuote = false @@ -289,18 +306,23 @@ export class Wush extends Shell { continue } + // state checks + + // escapes if (char === '\\') { escapeNext = true wordStarted = true continue } + // single quote strings if (char === "'") { inSingleQuote = true wordStarted = true continue } + // double quote strings if (char === '"') { inDoubleQuote = true wordStarted = true @@ -365,6 +387,7 @@ export class Wush extends Shell { return { tokens, error: null } } + // parse the tokens into a list of parsed commands and pipelines const parseTokens = (tokens: Token[]): { list: ParsedListItem[]; error: string | null } => { const list: ParsedListItem[] = [] let currentArgs: string[] = [] @@ -426,8 +449,18 @@ export class Wush extends Shell { return { list, error: null } } - const line = this.buffer.join('') - this.FlushBuffer() + // process the buffer + let line: string + if (data) { + // data overrides the buffer + this.buffer = data.split('') + this.bufferPos = 0 + line = data + } else { + // use shell buffer if no arbitrary string was provided + line = this.buffer.join('') + this.FlushBuffer() + } const { tokens, error } = tokenize(line) if (error) { @@ -483,7 +516,7 @@ export class Wush extends Shell { this.Prompt() } - ProcessSimpleControlCode(code: string): boolean { + ProcessControlCode(code: string): boolean { switch (code) { case '\f': this.terminal.NewPage() @@ -542,79 +575,6 @@ export class Wush extends Shell { } } - HandleKeyInput(key: string, isCharacter: boolean) { - if (this.execExitCode === -1) console.log('program running') - if (!isCharacter) { - switch (key) { - case 'ArrowLeft': - if (this.bufferPos > 0) { - this.bufferPos -= 1 - this.SyncCursorToBuffer() - } - - break - case 'ArrowRight': - if (this.bufferPos < this.buffer.length) { - this.bufferPos += 1 - this.SyncCursorToBuffer() - } - break - case 'ArrowUp': - this.NavigateHistory(-1) - break - case 'ArrowDown': - this.NavigateHistory(1) - break - case 'Backspace': - // don't erase anything if there's nothing left in the buffer - if (this.bufferPos === 0) - break - - this.ExitHistoryNavigation() - this.terminal.RemoveCell() - this.RemoveCharFromBuffer(1, this.bufferPos) - this.SyncCursorToBuffer() - break - case 'Delete': - if (this.bufferPos >= this.buffer.length || this.buffer.length <= 0) - break - - this.ExitHistoryNavigation() - this.bufferPos += 1 - this.SyncCursorToBuffer() - this.terminal.RemoveCell() - this.RemoveCharFromBuffer(1, this.bufferPos) - this.SyncCursorToBuffer() - break - case 'Enter': - // send the buffer to stdin if an exec is running - if (this.execExitCode === -1) { - this.WriteStdin(`${this.buffer.join('')}\n`) - this.FlushBuffer() - } else { - // "execute" the buffer - this.terminal.MoveCursor(0, 1, { x: true, y: false }) - const command = this.buffer.join('') - if (command.length > 0) - this.history.push(command) - - this.historyIndex = null - this.historyDraft = '' - this.ExecuteBuffer() - } - - break - case 'F5': - location.reload() - break - } - } else { - this.ExitHistoryNavigation() - // push the character into the buffer - this.InsertText(key) - } - } - private GetWrapWidth(): number { const width = Math.floor(this.terminal.GetWidthCells()) if (!Number.isFinite(width) || width <= 0) diff --git a/src/terminal/Terminal.ts b/src/terminal/Terminal.ts index 465949c..9f23802 100644 --- a/src/terminal/Terminal.ts +++ b/src/terminal/Terminal.ts @@ -5,7 +5,7 @@ import sqs from '../utils/sqs' import type { CursorPosition, CursorStyle } from './CursorProperties' export class Terminal { - public static readonly Version = "0.1.1" + public static readonly Version = "0.2.1" private terminal: HTMLElement private cursor: HTMLElement @@ -15,6 +15,7 @@ export class Terminal { private lines: string[] = [] private lineElements: HTMLElement[] = [] private lineWrapped: boolean[] = [] + private lineStyles: Map> = new Map() private scrollPending = false private cursorRange: Range | null = null @@ -34,7 +35,7 @@ export class Terminal { this.SetCursorStyle('bar') this.NewPage() - this.SetupMetricsRefresh() + this.CreateMetricsRefreshListener() } async LoadShell(shell: Shell) { @@ -53,6 +54,7 @@ export class Terminal { this.lines = [] this.lineElements = [] this.lineWrapped = [] + this.lineStyles.clear() this.SetCursorPosition(0, 0) } @@ -158,6 +160,46 @@ export class Terminal { this.UpdateLine(row) } + SetCellStyle(col: number, row: number, style: string | null) { + if (!Number.isFinite(col) || !Number.isFinite(row)) + return + + const targetRow = Math.max(0, Math.floor(row)) + const targetCol = Math.max(0, Math.floor(col)) + const width = this.GetWrapWidth() + + if (targetCol >= width) + return + + this.EnsureLine(targetRow) + + let line = this.lines[targetRow] ?? '' + if (targetCol >= line.length) { + line += ' '.repeat(targetCol - line.length + 1) + this.lines[targetRow] = line + } + + const cssText = style?.trim() + if (cssText) { + let rowStyles = this.lineStyles.get(targetRow) + if (!rowStyles) { + rowStyles = new Map() + this.lineStyles.set(targetRow, rowStyles) + } + rowStyles.set(targetCol, cssText) + } else { + const rowStyles = this.lineStyles.get(targetRow) + if (rowStyles) { + rowStyles.delete(targetCol) + if (rowStyles.size === 0) + this.lineStyles.delete(targetRow) + } + } + + this.UpdateLine(targetRow) + this.UpdateCursor() + } + InsertCell(char: string) { const width = this.GetWrapWidth() let row = this.cursorPosition.row @@ -208,7 +250,7 @@ export class Terminal { } ResetCellSize() { - // dynamically determine cell size using dom + // dynamically determine cell size with dom const cell = document.createElement('span') cell.textContent = 'A' cell.style.position = 'absolute' @@ -328,8 +370,61 @@ export class Terminal { private UpdateLine(row: number) { const line = this.lineElements[row] - if (line) - line.textContent = this.lines[row] + if (!line) + return + + const text = this.lines[row] ?? '' + const styles = this.lineStyles.get(row) + if (!styles || styles.size === 0) { + line.textContent = text + return + } + + for (const col of Array.from(styles.keys())) { + if (col < 0 || col >= text.length) + styles.delete(col) + } + + if (styles.size === 0) { + this.lineStyles.delete(row) + line.textContent = text + return + } + + const sorted = Array.from(styles.entries()).sort((a, b) => a[0] - b[0]) + const fragment = document.createDocumentFragment() + let cursor = 0 + + let index = 0 + while (index < sorted.length) { + const [col, style] = sorted[index] + if (col > cursor) + fragment.append(document.createTextNode(text.slice(cursor, col))) + + let runStart = col + let runEnd = col + while (index + 1 < sorted.length) { + const [nextCol, nextStyle] = sorted[index + 1] + if (nextCol !== runEnd + 1 || nextStyle !== style) + break + + index += 1 + runEnd = nextCol + } + + const span = document.createElement('span') + span.textContent = text.slice(runStart, runEnd + 1) + span.style.cssText = style + fragment.append(span) + + cursor = runEnd + 1 + index += 1 + } + + if (cursor < text.length) + fragment.append(document.createTextNode(text.slice(cursor))) + + line.replaceChildren(fragment) } private ReflowFromRow(startRow: number) { @@ -350,19 +445,35 @@ export class Terminal { text += this.lines[endRow] ?? '' } + const lineLengths: number[] = [] + for (let row = startRow; row <= endRow; row++) + lineLengths.push((this.lines[row] ?? '').length) + + const styledIndexes = new Map() + let styleOffset = 0 + for (let row = startRow; row <= endRow; row++) { + const rowStyles = this.lineStyles.get(row) + const rowLength = lineLengths[row - startRow] + if (rowStyles && rowLength > 0) { + for (const [col, style] of rowStyles) { + if (col >= 0 && col < rowLength) + styledIndexes.set(styleOffset + col, style) + } + } + styleOffset += rowLength + } + let row = startRow let index = 0 if (text.length === 0) { this.lines[row] = '' - this.UpdateLine(row) this.lineWrapped[row] = false row += 1 } else { while (index < text.length) { const chunk = text.slice(index, index + width) this.lines[row] = chunk - this.UpdateLine(row) this.lineWrapped[row] = index + width < text.length row += 1 @@ -373,13 +484,34 @@ export class Terminal { } } + const newLastRow = text.length > 0 ? startRow + Math.floor((text.length - 1) / width) : startRow + const clearEnd = Math.max(endRow, newLastRow) + for (let clearRow = startRow; clearRow <= clearEnd; clearRow++) + this.lineStyles.delete(clearRow) + + if (styledIndexes.size > 0) { + for (const [styleIndex, style] of styledIndexes) { + const targetRow = startRow + Math.floor(styleIndex / width) + const targetCol = styleIndex % width + let rowStyles = this.lineStyles.get(targetRow) + if (!rowStyles) { + rowStyles = new Map() + this.lineStyles.set(targetRow, rowStyles) + } + rowStyles.set(targetCol, style) + } + } + for (let i = row; i <= endRow; i++) { if (this.lines[i] !== '' || this.lineWrapped[i]) { this.lines[i] = '' - this.UpdateLine(i) this.lineWrapped[i] = false } } + + const renderEnd = Math.max(endRow, newLastRow) + for (let renderRow = startRow; renderRow <= renderEnd; renderRow++) + this.UpdateLine(renderRow) } private GetCursorXOffset(): number { @@ -395,23 +527,55 @@ export class Terminal { const clamped = Math.min(col, textLength) let width = 0 - const textNode = line.firstChild - if (textNode && textNode.nodeType === Node.TEXT_NODE && clamped > 0) { + const hasSingleTextNode = line.childNodes.length === 1 && line.firstChild?.nodeType === Node.TEXT_NODE + if (clamped > 0) { if (clamped === textLength) { width = line.getBoundingClientRect().width } else if (this.cursorRange) { - this.cursorRange.setStart(textNode, 0) - this.cursorRange.setEnd(textNode, clamped) - width = this.cursorRange.getBoundingClientRect().width + if (hasSingleTextNode) { + const textNode = line.firstChild as Text + this.cursorRange.setStart(textNode, 0) + this.cursorRange.setEnd(textNode, clamped) + width = this.cursorRange.getBoundingClientRect().width + } else { + const target = this.FindTextNodeAtOffset(line, clamped) + if (target) { + this.cursorRange.setStart(line, 0) + this.cursorRange.setEnd(target.node, target.offset) + width = this.cursorRange.getBoundingClientRect().width + } + } } } + if (clamped > 0 && width === 0) + width = clamped * this.cellWidth + if (col > textLength) width += (col - textLength) * this.cellWidth return width } + private FindTextNodeAtOffset(root: HTMLElement, offset: number): { node: Text; offset: number } | null { + if (offset <= 0) + return null + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let remaining = offset + let node = walker.nextNode() as Text | null + while (node) { + const length = node.data.length + if (remaining <= length) + return { node, offset: remaining } + + remaining -= length + node = walker.nextNode() as Text | null + } + + return null + } + private RefreshMetrics() { this.ResetCellSize() this.lineElements.forEach(line => { @@ -420,7 +584,7 @@ export class Terminal { this.UpdateCursor() } - private SetupMetricsRefresh() { + private CreateMetricsRefreshListener() { if ('fonts' in document) { document.fonts.ready.then(() => { this.RefreshMetrics()