| import type { FocusManager } from './focus.js' |
| import { createLayoutNode } from './layout/engine.js' |
| import type { LayoutNode } from './layout/node.js' |
| import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js' |
| import measureText from './measure-text.js' |
| import { addPendingClear, nodeCache } from './node-cache.js' |
| import squashTextNodes from './squash-text-nodes.js' |
| import type { Styles, TextStyles } from './styles.js' |
| import { expandTabs } from './tabstops.js' |
| import wrapText from './wrap-text.js' |
|
|
| type InkNode = { |
| parentNode: DOMElement | undefined |
| yogaNode?: LayoutNode |
| style: Styles |
| } |
|
|
| export type TextName = '#text' |
| export type ElementNames = |
| | 'ink-root' |
| | 'ink-box' |
| | 'ink-text' |
| | 'ink-virtual-text' |
| | 'ink-link' |
| | 'ink-progress' |
| | 'ink-raw-ansi' |
|
|
| export type NodeNames = ElementNames | TextName |
|
|
| |
| export type DOMElement = { |
| nodeName: ElementNames |
| attributes: Record<string, DOMNodeAttribute> |
| childNodes: DOMNode[] |
| textStyles?: TextStyles |
|
|
| |
| onComputeLayout?: () => void |
| onRender?: () => void |
| onImmediateRender?: () => void |
| |
| hasRenderedContent?: boolean |
|
|
| |
| dirty: boolean |
| |
| isHidden?: boolean |
| |
| |
| |
| _eventHandlers?: Record<string, unknown> |
|
|
| |
| |
| |
| |
| scrollTop?: number |
| |
| |
| |
| |
| pendingScrollDelta?: number |
| |
| |
| |
| |
| |
| |
| |
| scrollClampMin?: number |
| scrollClampMax?: number |
| scrollHeight?: number |
| scrollViewportHeight?: number |
| scrollViewportTop?: number |
| stickyScroll?: boolean |
| |
| |
| |
| |
| |
| |
| scrollAnchor?: { el: DOMElement; offset: number } |
| |
| |
| focusManager?: FocusManager |
| |
| |
| |
| |
| debugOwnerChain?: string[] |
| } & InkNode |
|
|
| export type TextNode = { |
| nodeName: TextName |
| nodeValue: string |
| } & InkNode |
|
|
| |
| export type DOMNode<T = { nodeName: NodeNames }> = T extends { |
| nodeName: infer U |
| } |
| ? U extends '#text' |
| ? TextNode |
| : DOMElement |
| : never |
|
|
| |
| export type DOMNodeAttribute = boolean | string | number |
|
|
| export const createNode = (nodeName: ElementNames): DOMElement => { |
| const needsYogaNode = |
| nodeName !== 'ink-virtual-text' && |
| nodeName !== 'ink-link' && |
| nodeName !== 'ink-progress' |
| const node: DOMElement = { |
| nodeName, |
| style: {}, |
| attributes: {}, |
| childNodes: [], |
| parentNode: undefined, |
| yogaNode: needsYogaNode ? createLayoutNode() : undefined, |
| dirty: false, |
| } |
|
|
| if (nodeName === 'ink-text') { |
| node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node)) |
| } else if (nodeName === 'ink-raw-ansi') { |
| node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node)) |
| } |
|
|
| return node |
| } |
|
|
| export const appendChildNode = ( |
| node: DOMElement, |
| childNode: DOMElement, |
| ): void => { |
| if (childNode.parentNode) { |
| removeChildNode(childNode.parentNode, childNode) |
| } |
|
|
| childNode.parentNode = node |
| node.childNodes.push(childNode) |
|
|
| if (childNode.yogaNode) { |
| node.yogaNode?.insertChild( |
| childNode.yogaNode, |
| node.yogaNode.getChildCount(), |
| ) |
| } |
|
|
| markDirty(node) |
| } |
|
|
| export const insertBeforeNode = ( |
| node: DOMElement, |
| newChildNode: DOMNode, |
| beforeChildNode: DOMNode, |
| ): void => { |
| if (newChildNode.parentNode) { |
| removeChildNode(newChildNode.parentNode, newChildNode) |
| } |
|
|
| newChildNode.parentNode = node |
|
|
| const index = node.childNodes.indexOf(beforeChildNode) |
|
|
| if (index >= 0) { |
| |
| |
| |
| |
| let yogaIndex = 0 |
| if (newChildNode.yogaNode && node.yogaNode) { |
| for (let i = 0; i < index; i++) { |
| if (node.childNodes[i]?.yogaNode) { |
| yogaIndex++ |
| } |
| } |
| } |
|
|
| node.childNodes.splice(index, 0, newChildNode) |
|
|
| if (newChildNode.yogaNode && node.yogaNode) { |
| node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex) |
| } |
|
|
| markDirty(node) |
| return |
| } |
|
|
| node.childNodes.push(newChildNode) |
|
|
| if (newChildNode.yogaNode) { |
| node.yogaNode?.insertChild( |
| newChildNode.yogaNode, |
| node.yogaNode.getChildCount(), |
| ) |
| } |
|
|
| markDirty(node) |
| } |
|
|
| export const removeChildNode = ( |
| node: DOMElement, |
| removeNode: DOMNode, |
| ): void => { |
| if (removeNode.yogaNode) { |
| removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode) |
| } |
|
|
| |
| collectRemovedRects(node, removeNode) |
|
|
| removeNode.parentNode = undefined |
|
|
| const index = node.childNodes.indexOf(removeNode) |
| if (index >= 0) { |
| node.childNodes.splice(index, 1) |
| } |
|
|
| markDirty(node) |
| } |
|
|
| function collectRemovedRects( |
| parent: DOMElement, |
| removed: DOMNode, |
| underAbsolute = false, |
| ): void { |
| if (removed.nodeName === '#text') return |
| const elem = removed as DOMElement |
| |
| |
| |
| |
| const isAbsolute = underAbsolute || elem.style.position === 'absolute' |
| const cached = nodeCache.get(elem) |
| if (cached) { |
| addPendingClear(parent, cached, isAbsolute) |
| nodeCache.delete(elem) |
| } |
| for (const child of elem.childNodes) { |
| collectRemovedRects(parent, child, isAbsolute) |
| } |
| } |
|
|
| export const setAttribute = ( |
| node: DOMElement, |
| key: string, |
| value: DOMNodeAttribute, |
| ): void => { |
| |
| |
| |
| if (key === 'children') { |
| return |
| } |
| |
| if (node.attributes[key] === value) { |
| return |
| } |
| node.attributes[key] = value |
| markDirty(node) |
| } |
|
|
| export const setStyle = (node: DOMNode, style: Styles): void => { |
| |
| |
| if (stylesEqual(node.style, style)) { |
| return |
| } |
| node.style = style |
| markDirty(node) |
| } |
|
|
| export const setTextStyles = ( |
| node: DOMElement, |
| textStyles: TextStyles, |
| ): void => { |
| |
| |
| |
| |
| if (shallowEqual(node.textStyles, textStyles)) { |
| return |
| } |
| node.textStyles = textStyles |
| markDirty(node) |
| } |
|
|
| function stylesEqual(a: Styles, b: Styles): boolean { |
| return shallowEqual(a, b) |
| } |
|
|
| function shallowEqual<T extends object>( |
| a: T | undefined, |
| b: T | undefined, |
| ): boolean { |
| |
| if (a === b) return true |
| if (a === undefined || b === undefined) return false |
|
|
| |
| const aKeys = Object.keys(a) as (keyof T)[] |
| const bKeys = Object.keys(b) as (keyof T)[] |
|
|
| |
| if (aKeys.length !== bKeys.length) return false |
|
|
| |
| for (const key of aKeys) { |
| if (a[key] !== b[key]) return false |
| } |
|
|
| return true |
| } |
|
|
| export const createTextNode = (text: string): TextNode => { |
| const node: TextNode = { |
| nodeName: '#text', |
| nodeValue: text, |
| yogaNode: undefined, |
| parentNode: undefined, |
| style: {}, |
| } |
|
|
| setTextNodeValue(node, text) |
|
|
| return node |
| } |
|
|
| const measureTextNode = function ( |
| node: DOMNode, |
| width: number, |
| widthMode: LayoutMeasureMode, |
| ): { width: number; height: number } { |
| const rawText = |
| node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node) |
|
|
| |
| |
| const text = expandTabs(rawText) |
|
|
| const dimensions = measureText(text, width) |
|
|
| |
| if (dimensions.width <= width) { |
| return dimensions |
| } |
|
|
| |
| |
| if (dimensions.width >= 1 && width > 0 && width < 1) { |
| return dimensions |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) { |
| const effectiveWidth = Math.max(width, dimensions.width) |
| return measureText(text, effectiveWidth) |
| } |
|
|
| const textWrap = node.style?.textWrap ?? 'wrap' |
| const wrappedText = wrapText(text, width, textWrap) |
|
|
| return measureText(wrappedText, width) |
| } |
|
|
| |
| |
| |
| const measureRawAnsiNode = function (node: DOMElement): { |
| width: number |
| height: number |
| } { |
| return { |
| width: node.attributes['rawWidth'] as number, |
| height: node.attributes['rawHeight'] as number, |
| } |
| } |
|
|
| |
| |
| |
| |
| export const markDirty = (node?: DOMNode): void => { |
| let current: DOMNode | undefined = node |
| let markedYoga = false |
|
|
| while (current) { |
| if (current.nodeName !== '#text') { |
| ;(current as DOMElement).dirty = true |
| |
| if ( |
| !markedYoga && |
| (current.nodeName === 'ink-text' || |
| current.nodeName === 'ink-raw-ansi') && |
| current.yogaNode |
| ) { |
| current.yogaNode.markDirty() |
| markedYoga = true |
| } |
| } |
| current = current.parentNode |
| } |
| } |
|
|
| |
| |
| |
| |
| export const scheduleRenderFrom = (node?: DOMNode): void => { |
| let cur: DOMNode | undefined = node |
| while (cur?.parentNode) cur = cur.parentNode |
| if (cur && cur.nodeName !== '#text') (cur as DOMElement).onRender?.() |
| } |
|
|
| export const setTextNodeValue = (node: TextNode, text: string): void => { |
| if (typeof text !== 'string') { |
| text = String(text) |
| } |
|
|
| |
| if (node.nodeValue === text) { |
| return |
| } |
|
|
| node.nodeValue = text |
| markDirty(node) |
| } |
|
|
| function isDOMElement(node: DOMElement | TextNode): node is DOMElement { |
| return node.nodeName !== '#text' |
| } |
|
|
| |
| |
| |
| export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { |
| if ('childNodes' in node) { |
| for (const child of node.childNodes) { |
| clearYogaNodeReferences(child) |
| } |
| } |
| node.yogaNode = undefined |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function findOwnerChainAtRow(root: DOMElement, y: number): string[] { |
| let best: string[] = [] |
| walk(root, 0) |
| return best |
|
|
| function walk(node: DOMElement, offsetY: number): void { |
| const yoga = node.yogaNode |
| if (!yoga || yoga.getDisplay() === LayoutDisplay.None) return |
|
|
| const top = offsetY + yoga.getComputedTop() |
| const height = yoga.getComputedHeight() |
| if (y < top || y >= top + height) return |
|
|
| if (node.debugOwnerChain) best = node.debugOwnerChain |
|
|
| for (const child of node.childNodes) { |
| if (isDOMElement(child)) walk(child, top) |
| } |
| } |
| } |
|
|