feat: complete shell revamp

This commit is contained in:
binekrasik
2026-05-19 21:42:14 +02:00
parent 4a484dd546
commit 1d00cf6deb
6 changed files with 1132 additions and 217 deletions

View File

@@ -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' })
})
}
}