feat: complete shell revamp
This commit is contained in:
@@ -12,6 +12,11 @@ export class Terminal {
|
||||
private cursorStyle: CursorStyle = 'bar'
|
||||
private cellHeight = 0
|
||||
private cellWidth = 0
|
||||
private lines: string[] = []
|
||||
private lineElements: HTMLElement[] = []
|
||||
private lineWrapped: boolean[] = []
|
||||
private scrollPending = false
|
||||
private cursorRange: Range | null = null
|
||||
|
||||
private cursorPosition: CursorPosition = {
|
||||
col: 0,
|
||||
@@ -25,9 +30,11 @@ export class Terminal {
|
||||
|
||||
this.terminal = sqs('#terminal')
|
||||
this.cursor = sqs('#cursor')
|
||||
this.cursorRange = document.createRange()
|
||||
|
||||
this.SetCursorStyle('bar')
|
||||
this.NewPage()
|
||||
this.SetupMetricsRefresh()
|
||||
}
|
||||
|
||||
async LoadShell(shell: Shell) {
|
||||
@@ -43,98 +50,170 @@ export class Terminal {
|
||||
this.ResetCellSize()
|
||||
|
||||
this.terminal.innerHTML = ''
|
||||
this.lines = []
|
||||
this.lineElements = []
|
||||
this.lineWrapped = []
|
||||
this.SetCursorPosition(0, 0)
|
||||
}
|
||||
|
||||
AppendLine() {
|
||||
const paragraph = document.createElement('p')
|
||||
paragraph.style.height = `${this.cellHeight}px`
|
||||
paragraph.id = `line-${this.cursorPosition.row}`
|
||||
paragraph.className = 'line'
|
||||
|
||||
this.terminal.appendChild(paragraph)
|
||||
this.UpdateLines()
|
||||
paragraph.scrollIntoView({behavior: 'smooth'})
|
||||
}
|
||||
|
||||
UpdateLines() {
|
||||
const lines = new Array(...this.terminal.children)
|
||||
lines.forEach((line, i) => line.id = `line-${i}`)
|
||||
this.EnsureLine(this.lineElements.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns index of the last line on the page. -1 if there are no lines
|
||||
*/
|
||||
GetLastLineIndex(): number {
|
||||
return this.terminal.children.length - 1
|
||||
return this.lineElements.length - 1
|
||||
}
|
||||
|
||||
Write(text: string) {
|
||||
if (text.length === 0)
|
||||
return
|
||||
|
||||
text.split('').forEach((char) => {
|
||||
this.SetCell(char)
|
||||
this.MoveCursor(1, 0)
|
||||
})
|
||||
const width = this.GetWrapWidth()
|
||||
let row = this.cursorPosition.row
|
||||
let col = this.cursorPosition.col
|
||||
|
||||
while (col >= width) {
|
||||
this.EnsureLine(row)
|
||||
this.lineWrapped[row] = true
|
||||
col -= width
|
||||
row += 1
|
||||
}
|
||||
|
||||
this.EnsureLine(row)
|
||||
|
||||
let remaining = text
|
||||
while (remaining.length > 0) {
|
||||
let line = this.lines[row]
|
||||
if (col > line.length)
|
||||
line += ' '.repeat(col - line.length)
|
||||
|
||||
const available = width - col
|
||||
if (available <= 0) {
|
||||
this.lineWrapped[row] = true
|
||||
row += 1
|
||||
col = 0
|
||||
this.EnsureLine(row)
|
||||
continue
|
||||
}
|
||||
|
||||
const chunk = remaining.slice(0, available)
|
||||
if (col === line.length) {
|
||||
line += chunk
|
||||
} else {
|
||||
const prefix = line.slice(0, col)
|
||||
const suffixStart = Math.min(line.length, col + chunk.length)
|
||||
const suffix = line.slice(suffixStart)
|
||||
line = prefix + chunk + suffix
|
||||
}
|
||||
|
||||
this.lines[row] = line
|
||||
this.UpdateLine(row)
|
||||
|
||||
remaining = remaining.slice(chunk.length)
|
||||
col += chunk.length
|
||||
|
||||
if (remaining.length > 0) {
|
||||
this.lineWrapped[row] = true
|
||||
row += 1
|
||||
col = 0
|
||||
this.EnsureLine(row)
|
||||
} else {
|
||||
this.lineWrapped[row] = false
|
||||
}
|
||||
}
|
||||
|
||||
this.cursorPosition.col = Math.max(col, 0)
|
||||
this.cursorPosition.row = Math.max(row, 0)
|
||||
this.UpdateCursor()
|
||||
}
|
||||
|
||||
SetCell(char: string) {
|
||||
const selector = `#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`
|
||||
const width = this.GetWrapWidth()
|
||||
let row = this.cursorPosition.row
|
||||
let col = this.cursorPosition.col
|
||||
|
||||
// adjust for the cursor
|
||||
if (!document.querySelector(`#line-${this.cursorPosition.row}`)) {
|
||||
for (let i = this.terminal.children.length - 1; i < this.cursorPosition.row; i++) {
|
||||
this.AppendLine()
|
||||
}
|
||||
while (col >= width) {
|
||||
this.EnsureLine(row)
|
||||
this.lineWrapped[row] = true
|
||||
col -= width
|
||||
row += 1
|
||||
}
|
||||
|
||||
if (!document.querySelector(selector)) {
|
||||
const line = sqs(`#line-${this.cursorPosition.row}`)
|
||||
this.EnsureLine(row)
|
||||
|
||||
for (let i = line.children.length; i < this.cursorPosition.col + 1; i++) {
|
||||
const cell = document.createElement('span')
|
||||
cell.className = `cell-${i}`
|
||||
cell.style.width = `${this.cellWidth}px`
|
||||
cell.style.height = `${this.cellHeight}px`
|
||||
let line = this.lines[row]
|
||||
if (col > line.length)
|
||||
line += ' '.repeat(col - line.length)
|
||||
|
||||
line.appendChild(cell)
|
||||
}
|
||||
if (col === line.length) {
|
||||
line += char[0]
|
||||
} else {
|
||||
line = line.slice(0, col) + char[0] + line.slice(col + 1)
|
||||
}
|
||||
|
||||
sqs(selector).innerText = char[0]
|
||||
}
|
||||
|
||||
UpdateCells() {
|
||||
const cells = new Array(...sqs(`#line-${this.cursorPosition.row}`).children)
|
||||
cells.forEach((cell, i) => cell.className = `cell-${i}`)
|
||||
this.lines[row] = line
|
||||
this.UpdateLine(row)
|
||||
}
|
||||
|
||||
InsertCell(char: string) {
|
||||
const cell = document.createElement('span')
|
||||
cell.className = `cell-${this.cursorPosition.col}`
|
||||
cell.style.width = `${this.cellWidth}px`
|
||||
cell.style.height = `${this.cellHeight}px`
|
||||
const width = this.GetWrapWidth()
|
||||
let row = this.cursorPosition.row
|
||||
let col = this.cursorPosition.col
|
||||
|
||||
cell.innerText = char[0]
|
||||
while (col >= width) {
|
||||
this.EnsureLine(row)
|
||||
this.lineWrapped[row] = true
|
||||
col -= width
|
||||
row += 1
|
||||
}
|
||||
|
||||
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`).insertAdjacentElement('beforebegin', cell)
|
||||
this.EnsureLine(row)
|
||||
|
||||
this.UpdateCells()
|
||||
let line = this.lines[row]
|
||||
if (col > line.length)
|
||||
line += ' '.repeat(col - line.length)
|
||||
|
||||
line = line.slice(0, col) + char[0] + line.slice(col)
|
||||
this.lines[row] = line
|
||||
this.UpdateLine(row)
|
||||
this.ReflowFromRow(row)
|
||||
}
|
||||
|
||||
RemoveCell() {
|
||||
try {
|
||||
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col - 1}`).remove()
|
||||
} catch (_) {
|
||||
let row = this.cursorPosition.row
|
||||
let removeIndex = this.cursorPosition.col - 1
|
||||
|
||||
} finally {
|
||||
this.UpdateCells()
|
||||
if (removeIndex < 0) {
|
||||
if (row <= 0 || !this.lineWrapped[row - 1])
|
||||
return
|
||||
|
||||
row -= 1
|
||||
removeIndex = (this.lines[row] ?? '').length - 1
|
||||
if (removeIndex < 0)
|
||||
return
|
||||
}
|
||||
|
||||
this.EnsureLine(row)
|
||||
let line = this.lines[row]
|
||||
if (removeIndex >= line.length)
|
||||
return
|
||||
|
||||
line = line.slice(0, removeIndex) + line.slice(removeIndex + 1)
|
||||
this.lines[row] = line
|
||||
this.UpdateLine(row)
|
||||
this.ReflowFromRow(row)
|
||||
}
|
||||
|
||||
ResetCellSize() {
|
||||
// dynamically determine cell size using dom
|
||||
const cell = document.createElement('span')
|
||||
cell.textContent = 'A'
|
||||
cell.style.position = 'absolute'
|
||||
cell.style.visibility = 'hidden'
|
||||
cell.style.whiteSpace = 'pre'
|
||||
|
||||
this.terminal.appendChild(cell)
|
||||
|
||||
@@ -145,42 +224,78 @@ export class Terminal {
|
||||
}
|
||||
|
||||
GetHeightCells(): number {
|
||||
return this.terminal.clientHeight / this.cellHeight
|
||||
const rect = this.terminal.getBoundingClientRect()
|
||||
const height = Math.max(0, document.documentElement.clientHeight - rect.top)
|
||||
return height / this.cellHeight
|
||||
}
|
||||
|
||||
GetWidthCells(): number {
|
||||
return this.terminal.clientWidth / this.cellWidth
|
||||
const rect = this.terminal.getBoundingClientRect()
|
||||
const width = Math.max(0, document.documentElement.clientWidth - rect.left)
|
||||
return width / this.cellWidth
|
||||
}
|
||||
|
||||
private GetWrapWidth(): number {
|
||||
const width = this.GetWidthCells()
|
||||
if (!Number.isFinite(width) || width <= 0)
|
||||
return 1
|
||||
|
||||
return Math.max(1, Math.floor(width))
|
||||
}
|
||||
|
||||
UpdateCursor() {
|
||||
this.SetCursorStyle(this.cursorStyle)
|
||||
|
||||
this.cursor.style.left = `${this.cursorPosition.col * this.cellWidth + this.terminal.offsetLeft}px `
|
||||
this.cursor.style.top = `${this.cursorPosition.row * this.cellHeight + this.terminal.offsetTop}px`
|
||||
const rect = this.terminal.getBoundingClientRect()
|
||||
const left = this.GetCursorXOffset() + rect.left + window.scrollX
|
||||
const top = this.cursorPosition.row * this.cellHeight + rect.top + window.scrollY
|
||||
|
||||
this.cursor.style.left = `${left}px`
|
||||
this.cursor.style.top = `${top}px`
|
||||
}
|
||||
|
||||
GetCursorPosition(): CursorPosition {
|
||||
return this.cursorPosition
|
||||
return { col: this.cursorPosition.col, row: this.cursorPosition.row }
|
||||
}
|
||||
|
||||
SetCursorPosition(col: number, row: number) {
|
||||
this.cursorPosition.col = Math.max(col, 0)
|
||||
const width = this.GetWrapWidth()
|
||||
const clampedCol = Number.isFinite(width) ? Math.min(col, width - 1) : col
|
||||
|
||||
this.cursorPosition.col = Math.max(clampedCol, 0)
|
||||
this.cursorPosition.row = Math.max(row, 0)
|
||||
|
||||
try {
|
||||
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`)
|
||||
} catch (_) {
|
||||
this.SetCell(' ')
|
||||
} finally {
|
||||
this.UpdateCursor()
|
||||
}
|
||||
this.EnsureLine(this.cursorPosition.row)
|
||||
this.UpdateCursor()
|
||||
}
|
||||
|
||||
MoveCursor(col: number, row: number, absolute: { x: boolean; y: boolean } = { x: false, y: false }) {
|
||||
this.SetCursorPosition(
|
||||
!absolute.x ? this.cursorPosition.col + col : col,
|
||||
!absolute.y ? this.cursorPosition.row + row : row,
|
||||
)
|
||||
const width = this.GetWrapWidth()
|
||||
let targetCol = !absolute.x ? this.cursorPosition.col + col : col
|
||||
let targetRow = !absolute.y ? this.cursorPosition.row + row : row
|
||||
|
||||
if (!absolute.x && col !== 0) {
|
||||
if (targetCol >= width) {
|
||||
const shift = Math.floor(targetCol / width)
|
||||
targetRow += shift
|
||||
targetCol = targetCol % width
|
||||
} else if (targetCol < 0) {
|
||||
while (targetCol < 0 && targetRow > 0) {
|
||||
if (!this.lineWrapped[targetRow - 1]) {
|
||||
targetCol = 0
|
||||
break
|
||||
}
|
||||
|
||||
targetRow -= 1
|
||||
targetCol += width
|
||||
}
|
||||
|
||||
if (targetCol < 0)
|
||||
targetCol = 0
|
||||
}
|
||||
}
|
||||
|
||||
this.SetCursorPosition(targetCol, targetRow)
|
||||
}
|
||||
|
||||
SetCursorStyle(style: CursorStyle) {
|
||||
@@ -190,4 +305,142 @@ export class Terminal {
|
||||
this.cursor.style.height = `${this.cellHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
private EnsureLine(row: number) {
|
||||
let added = false
|
||||
|
||||
while (this.lineElements.length <= row) {
|
||||
const paragraph = document.createElement('p')
|
||||
paragraph.style.height = `${this.cellHeight}px`
|
||||
paragraph.id = `line-${this.lineElements.length}`
|
||||
paragraph.className = 'line'
|
||||
|
||||
this.terminal.appendChild(paragraph)
|
||||
this.lineElements.push(paragraph)
|
||||
this.lines.push('')
|
||||
this.lineWrapped.push(false)
|
||||
added = true
|
||||
}
|
||||
|
||||
if (added)
|
||||
this.ScheduleScroll()
|
||||
}
|
||||
|
||||
private UpdateLine(row: number) {
|
||||
const line = this.lineElements[row]
|
||||
if (line)
|
||||
line.textContent = this.lines[row]
|
||||
}
|
||||
|
||||
private ReflowFromRow(startRow: number) {
|
||||
const width = this.GetWrapWidth()
|
||||
if (width <= 0)
|
||||
return
|
||||
|
||||
this.EnsureLine(startRow)
|
||||
|
||||
let text = this.lines[startRow] ?? ''
|
||||
let endRow = startRow
|
||||
|
||||
while (this.lineWrapped[endRow]) {
|
||||
endRow += 1
|
||||
if (endRow >= this.lines.length)
|
||||
break
|
||||
|
||||
text += this.lines[endRow] ?? ''
|
||||
}
|
||||
|
||||
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
|
||||
index += width
|
||||
|
||||
if (index < text.length)
|
||||
this.EnsureLine(row)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = row; i <= endRow; i++) {
|
||||
if (this.lines[i] !== '' || this.lineWrapped[i]) {
|
||||
this.lines[i] = ''
|
||||
this.UpdateLine(i)
|
||||
this.lineWrapped[i] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GetCursorXOffset(): number {
|
||||
const row = this.cursorPosition.row
|
||||
const col = this.cursorPosition.col
|
||||
const line = this.lineElements[row]
|
||||
const text = this.lines[row] ?? ''
|
||||
|
||||
if (!line)
|
||||
return col * this.cellWidth
|
||||
|
||||
const textLength = text.length
|
||||
const clamped = Math.min(col, textLength)
|
||||
let width = 0
|
||||
|
||||
const textNode = line.firstChild
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE && 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 (col > textLength)
|
||||
width += (col - textLength) * this.cellWidth
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
private RefreshMetrics() {
|
||||
this.ResetCellSize()
|
||||
this.lineElements.forEach(line => {
|
||||
line.style.height = `${this.cellHeight}px`
|
||||
})
|
||||
this.UpdateCursor()
|
||||
}
|
||||
|
||||
private SetupMetricsRefresh() {
|
||||
if ('fonts' in document) {
|
||||
document.fonts.ready.then(() => {
|
||||
this.RefreshMetrics()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => this.RefreshMetrics())
|
||||
}
|
||||
|
||||
private ScheduleScroll() {
|
||||
if (this.scrollPending)
|
||||
return
|
||||
|
||||
this.scrollPending = true
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollPending = false
|
||||
|
||||
const lastLine = this.lineElements[this.lineElements.length - 1]
|
||||
if (lastLine)
|
||||
lastLine.scrollIntoView({ block: 'end' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user