chore: uptick

This commit is contained in:
Brooklyn Nicholson 2026-04-14 19:38:04 -05:00
parent 77cd5bf565
commit 4cbf54fb33
8 changed files with 282 additions and 239 deletions

View file

@ -11,6 +11,8 @@ export type Frame = {
readonly scrollHint?: ScrollHint | null
/** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */
readonly scrollDrainPending?: boolean
/** Absolute overlay moved/resized — schedule corrective frame without prevScreen. */
readonly absoluteOverlayMoved?: boolean
}
export function emptyFrame(

View file

@ -903,21 +903,12 @@ export default class Ink {
// becomes frontFrame (= next frame's prevScreen). If we applied the
// selection overlay, that buffer has inverted cells. selActive/hlActive
// are only ever true in alt-screen; in main-screen this is false→false.
this.prevFrameContaminated = selActive || hlActive
this.prevFrameContaminated = selActive || hlActive || !!frame.absoluteOverlayMoved
// A ScrollBox has pendingScrollDelta left to drain — schedule the next
// frame. MUST NOT call this.scheduleRender() here: we're inside a
// trailing-edge throttle invocation, timerId is undefined, and lodash's
// debounce sees timeSinceLastCall >= wait (last call was at the start
// of this window) → leadingEdge fires IMMEDIATELY → double render ~0.1ms
// apart → jank. Use a plain timeout. If a wheel event arrives first,
// its scheduleRender path fires a render which clears this timer at
// the top of onRender — no double.
//
// Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) so run at
// quarter interval (~250fps, setTimeout practical floor) for max scroll
// speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle.
if (frame.scrollDrainPending) {
// Schedule corrective frame for scroll drain or absolute overlay resize.
// Plain timeout instead of scheduleRender to avoid double-render from
// lodash throttle's leadingEdge firing inside a trailing invocation.
if (frame.scrollDrainPending || frame.absoluteOverlayMoved) {
this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2)
}

View file

@ -371,10 +371,10 @@ export default class Output {
continue
}
// Skip rows covered by an absolute-positioned node's clear.
// Exclude cells covered by an absolute-positioned node's clear.
// Absolute nodes overlay normal-flow siblings, so prevScreen in
// that region holds the absolute node's stale paint — blitting
// it back would ghost. See absoluteClears collection above.
// that region holds stale overlay paint. If we blit those cells
// back, removed/moved overlays ghost as a duplicate.
if (absoluteClears.length === 0) {
blitRegion(screen, src, startX, startY, maxX, maxY)
blitCells += (maxY - startY) * (maxX - startX)
@ -382,20 +382,45 @@ export default class Output {
continue
}
let rowStart = startY
for (let row = startY; row < maxY; row++) {
let spans: [number, number][] = [[startX, maxX]]
for (let row = startY; row <= maxY; row++) {
const excluded =
row < maxY &&
absoluteClears.some(r => row >= r.y && row < r.y + r.height && startX >= r.x && maxX <= r.x + r.width)
if (excluded || row === maxY) {
if (row > rowStart) {
blitRegion(screen, src, startX, rowStart, maxX, row)
blitCells += (row - rowStart) * (maxX - startX)
for (const r of absoluteClears) {
if (row < r.y || row >= r.y + r.height || !spans.length) {
break
}
rowStart = row + 1
const cs = Math.max(startX, r.x)
const ce = Math.min(maxX, r.x + r.width)
if (cs >= ce) {
continue
}
const next: [number, number][] = []
for (const [sx, ex] of spans) {
if (ce <= sx || cs >= ex) {
next.push([sx, ex])
continue
}
if (sx < cs) {
next.push([sx, cs])
}
if (ce < ex) {
next.push([ce, ex])
}
}
spans = next
}
for (const [sx, ex] of spans) {
blitRegion(screen, src, sx, row, ex, row + 1)
blitCells += ex - sx
}
}

View file

@ -727,10 +727,7 @@ function parseKeypress(s: string = ''): ParsedKey {
return createNavKey(s, 'mouse', false)
}
if (s === '\r') {
key.raw = undefined
key.name = 'return'
} else if (s === '\n') {
if (s === '\r' || s === '\n') {
key.raw = undefined
key.name = 'return'
} else if (s === '\t') {

View file

@ -30,15 +30,21 @@ function isXtermJsHost(): boolean {
// shift layout → narrow damage bounds → O(changed cells) diff instead of
// O(rows×cols).
let layoutShifted = false
let absoluteOverlayMoved = false
export function resetLayoutShifted(): void {
layoutShifted = false
absoluteOverlayMoved = false
}
export function didLayoutShift(): boolean {
return layoutShifted
}
export function didAbsoluteOverlayMove(): boolean {
return absoluteOverlayMoved
}
// DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes
// between frames (and nothing else moved), log-update.ts can emit a
// hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole
@ -496,6 +502,7 @@ function renderNodeToOutput(
if (positionChanged) {
layoutShifted = true
absoluteOverlayMoved ||= node.style.position === 'absolute'
}
if (cached && (node.dirty || positionChanged)) {

View file

@ -5,6 +5,7 @@ import type { Frame } from './frame.js'
import { consumeAbsoluteRemovedFlag } from './node-cache.js'
import Output from './output.js'
import renderNodeToOutput, {
didAbsoluteOverlayMove,
getScrollDrainNode,
getScrollHint,
resetLayoutShifted,
@ -135,6 +136,7 @@ export default function createRenderer(node: DOMElement, stylePool: StylePool):
}
return {
absoluteOverlayMoved: didAbsoluteOverlayMove(),
scrollHint: options.altScreen ? getScrollHint() : null,
scrollDrainPending: drainNode !== null,
screen: renderedScreen,