sync: sync wip changes

This commit is contained in:
binekrasik
2026-05-20 13:10:34 +02:00
parent b5089251ff
commit abe52acaa7
6 changed files with 389 additions and 112 deletions

View File

@@ -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<number, Map<number, string>> = 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<number, string>()
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()