Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ CI runs typecheck + lint + build + test on every push and PR to `main` (`.github
- **`virtualAnchorRow` / `virtualFocusRow`** track pre-clamp positions during selection drag-to-scroll. Both are required for the drag→follow transition to be correct. If you touch selection code, read `selection.ts` end-to-end first.
- **`onMouseDown` + gesture capture for drag interactions.** Box exposes `onMouseDown(e)` alongside `onClick`. Inside the handler, calling `e.captureGesture({ onMove, onUp })` claims all subsequent mouse-motion events and the eventual release for that one drag — selection extension is suppressed for the duration, and the captured handlers fire even when the cursor leaves the originally-pressed element's bounds. The active gesture lives on `App.activeGesture`; it's drained on FOCUS_OUT and on lost-release recovery so a drag aborted by leaving the terminal window can't leave a dangling handler. `onClick` and selection still work normally when no gesture is captured. Read `events/mouse-event.test.ts` for the dispatch and capture semantics, and the comments around `App.handleMouseEvent` for how the routing decisions interact with selection state.
- **Press intent classifies every left-press up front.** `dispatchMouseDown` reports `{ gesture, clickable }`; `computePressIntent` reduces that plus the modifier state to one of `gesture-confirmed | gesture-tentative | click | select`. The `click` intent (no capture, hit chain has `onClick`, no force-select modifier) skips `startSelection` AND skips multi-click escalation, then fires `onClickAt` on release via `App.pressIntentClick`. Without this, accidental motion between press and release on a clickable Box would escalate the press into a selection drag and eat the click (B8 — issue #61). Force-select modifier (`Shift` or `Alt` — SGR bits 0x04 / 0x08) demotes `click` → `select` so consumers can still highlight text inside a clickable region (Button label, link anchor text). Confirmed gestures and tentative gestures take precedence over both — `captureGesture` always wins the press regardless of `clickable`. The flag is drained on FOCUS_OUT, on no-button-motion lost-release, and at the next fresh press, mirroring how `activeGesture` is drained. Read `press-intent.ts` (truth table) and `App.press-intent.test.ts` (end-to-end behaviors) before changing the routing.
- **`measureTextNode` returns NATURAL dimensions in `Undefined` mode** — never wraps, never truncates, never appends an ellipsis. Yoga calls measureFunc with `widthMode=Undefined` (and `width=NaN`) during the basis pass when no width constraint is known yet (e.g. column parents auto-sizing to content). Pre-fix, the function ran `wrapText(text, NaN, …)`, which for `truncate` modes appended an ellipsis to natural text (`width=natural+1`) and for `truncate-start` collapsed the whole string to a single `"…"` cell. That poisoned the flex basis with a width derived from imaginary fitting, cascading into silent truncation downstream (shakedown A1 / A5 — issues #51 / #58). Wrap and truncate only make sense when there's an actual constraint — `AtMost` or `Exactly` — and that's when measureFunc applies them. Read `dom.test.ts > measureTextNode` for the full invariant matrix before touching the early-return logic.
- **Percent dimensions on in-flow children resolve against the parent's CONTENT box** (outer − border − padding) in the yoga port — `width="100%"` on a child of a `border={1}` parent gives the child a width of `parentWidth - 2`, not `parentWidth`. Matches CSS containing-block rules. Pre-fix the port resolved against parent outer, which made `<Box width="100%">` overpaint the right border of any bordered parent (shakedown B7 — issue #60). Deviation from Yoga upstream: child percent `margin`/`padding`/`border` also resolve against parent content box here (upstream resolves against `ownerWidth` = parent outer). Intentional — the "% on child = fraction of available space" model is consistent and matches the TUI mental model. Absolute children are unaffected; they resolve against the parent's padding-box per CSS §10.1 (`layoutAbsoluteChild`, untouched). Per-edge position values (`top` / `right` / `bottom` / `left`) all resolve against `ownerWidth` per the existing Yoga upstream convention — not against `ownerHeight` for the vertical edges. Read `yoga-layout/index.test.ts` for the full coverage matrix before touching `ownerW`/`ownerH` in `layoutNode`.
- **`zIndex` only applies to `position: 'absolute'`.** The `Styles.zIndex` property is silently ignored on in-flow / relative nodes (they don't overlap, so paint order has no meaning). A dev-mode warning fires from `setStyle` when set on a non-absolute node. Stacking is **flat per parent's render group**, not CSS-stacking-context-global: a nested z-indexed absolute sorts among its siblings inside its parent, not against arbitrarily distant cousins. This emerges naturally from `renderChildren` recursing per parent — each call sorts only that parent's direct children. Equal effective-z preserves DOM order via stable sort, so the no-zIndex case stays bit-for-bit identical to pre-feature behavior. Negative zIndex paints under in-flow content (the backdrop pattern). Read `render-node-to-output.test.ts` for the exact paint-order semantics across overlap, nesting, and equal-z cases.
- **Dirty-absolute rects are collected tree-wide once per frame.** The "force re-render clean siblings overlapping a moving absolute" guard in `renderChildren` reads from a module-scope list (`globalDirtyAbsoluteRects`) populated by a single walk at the `ink-root` entry of each render. Tree-wide because `absoluteClears` (output.ts pass 1) is global — a moving absolute's clear can suppress blits at any level, including non-sibling subtrees. The earlier per-renderChildren pre-scan only saw direct dirty-absolute children and missed cross-subtree contamination (the constrained-drag notch bug). Don't revert this without re-introducing the regression test in `render-node-to-output.test.ts > clean cousin of a moving absolute is repainted`.
Expand Down
119 changes: 119 additions & 0 deletions packages/renderer/src/dom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Unit tests for the text-node measure helper bound onto each
* `ink-text` yoga node via `setMeasureFunc`. The function decides
* what (width, height) yoga sees during intrinsic measurement
* (basis) and during the perform-layout (Exactly) pass — and that
* decision is load-bearing because mis-reporting dimensions during
* the basis pass cascades into a wrong flex distribution and
* downstream truncation, even when the parent has abundant space
* (shakedown A1 / A5 — issues #51 and #58).
*
* Invariant: in `Undefined` mode (yoga is asking "what's your
* intrinsic size? no constraint") the function MUST return the
* natural dimensions. Wrapping or truncating here would poison the
* basis with a width derived from an imaginary constraint — most
* visibly for `truncate` mode, where `wrapText(…, NaN, 'truncate')`
* appends an ellipsis to the natural string and inflates the basis
* by one cell. In `AtMost` mode the function may wrap to fit; in
* `Exactly` mode wrapping is required.
*/

import { describe, expect, it } from 'vitest'
import {
type DOMElement,
appendChildNode,
createNode,
createTextNode,
measureTextNode,
setStyle,
} from './dom.js'
import { LayoutMeasureMode } from './layout/node.js'
import type { Styles } from './styles.js'

/** Build an ink-text node with a single text child. */
function inkText(text: string, style: Styles = {}): DOMElement {
const node = createNode('ink-text')
setStyle(node, style)
appendChildNode(node, createTextNode(text) as unknown as DOMElement)
return node
}

describe('measureTextNode — Undefined mode returns natural dimensions (A1/A5, #51/#58)', () => {
it('truncate-mode text in Undefined mode returns natural width (no ellipsis poisoning)', () => {
// The A1 root cause. Yoga's column-direction basis pass calls
// measureFunc with widthMode=Undefined (NaN width). Pre-fix, the
// truncate branch fired and `wrapText(text, NaN, 'truncate')`
// appended an ellipsis — measureText then reported width = 17
// (natural 16 + ellipsis) instead of natural 16. The inflated
// basis poisons flex distribution downstream.
const node = inkText('13:47:04 · May 3', { textWrap: 'truncate' })
const result = measureTextNode(node, Number.NaN, LayoutMeasureMode.Undefined)
expect(result).toEqual({ width: 16, height: 1 })
})

it('wrap-mode text in Undefined mode returns natural width (no premature wrapping)', () => {
// Same invariant on wrap mode. The natural width must come back
// unaltered so the basis pass sees the true intrinsic size.
const node = inkText('A longer line that could wrap', { textWrap: 'wrap' })
const result = measureTextNode(node, Number.NaN, LayoutMeasureMode.Undefined)
expect(result).toEqual({ width: 29, height: 1 })
})

it('truncate-middle in Undefined mode returns natural width', () => {
const node = inkText('Lorem ipsum dolor', { textWrap: 'truncate-middle' })
const result = measureTextNode(node, Number.NaN, LayoutMeasureMode.Undefined)
expect(result).toEqual({ width: 17, height: 1 })
})

it('truncate-start in Undefined mode returns natural width', () => {
const node = inkText('foo bar baz', { textWrap: 'truncate-start' })
const result = measureTextNode(node, Number.NaN, LayoutMeasureMode.Undefined)
expect(result).toEqual({ width: 11, height: 1 })
})

it('multi-line text in Undefined mode returns natural (widest line, line count)', () => {
// Pre-existing handling for embedded newlines already returns
// natural in Undefined mode. Pin it so a future refactor doesn't
// regress this path.
const node = inkText('line one\nline two longer\nthird', { textWrap: 'wrap' })
const result = measureTextNode(node, Number.NaN, LayoutMeasureMode.Undefined)
expect(result.height).toBe(3)
expect(result.width).toBe('line two longer'.length)
})
})

describe('measureTextNode — AtMost mode wraps/truncates to fit', () => {
it('wraps wrap-mode text that exceeds the available width', () => {
const node = inkText('one two three four', { textWrap: 'wrap' })
const result = measureTextNode(node, 10, LayoutMeasureMode.AtMost)
expect(result.width).toBeLessThanOrEqual(10)
expect(result.height).toBeGreaterThan(1)
})

it('returns natural when natural width fits within AtMost', () => {
const node = inkText('short', { textWrap: 'wrap' })
const result = measureTextNode(node, 80, LayoutMeasureMode.AtMost)
expect(result).toEqual({ width: 5, height: 1 })
})

it('truncates truncate-mode text that exceeds the available width', () => {
const node = inkText('thirteen chars', { textWrap: 'truncate' })
const result = measureTextNode(node, 8, LayoutMeasureMode.AtMost)
// Width reported by measureText on the truncated output.
expect(result.width).toBeLessThanOrEqual(8)
expect(result.height).toBe(1)
})
})

describe('measureTextNode — Exactly mode behaves like AtMost for current callers', () => {
// The mode is provided by yoga for spec correctness; in practice
// the path inside measureTextNode treats Exactly and AtMost
// identically (both wrap when natural exceeds width). Pin the
// observable behavior so a future refactor that splits these
// modes makes a deliberate, reviewed decision.
it('Exactly mode with natural width fitting returns natural', () => {
const node = inkText('short', { textWrap: 'wrap' })
const result = measureTextNode(node, 10, LayoutMeasureMode.Exactly)
expect(result).toEqual({ width: 5, height: 1 })
})
})
40 changes: 26 additions & 14 deletions packages/renderer/src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,12 @@ export const createTextNode = (text: string): TextNode => {
return node
}

const measureTextNode = (
// Exported for unit tests. The function is bound to a node and registered as
// the yoga measureFunc — see `setMeasureFunc` above — but exposing it lets
// tests pin the measure-layer behavior (e.g. the Undefined-mode contract
// that protects nested-text basis from wrap-pass poisoning) without spinning
// up a yoga calculateLayout pass.
export const measureTextNode = (
node: DOMNode,
width: number,
widthMode: LayoutMeasureMode,
Expand All @@ -320,6 +325,26 @@ const measureTextNode = (
// Actual tab expansion happens in output.ts based on screen position.
const text = expandTabs(rawText)

// Undefined mode = "what's your intrinsic size? no constraint." Return
// the natural dimensions. Calling wrapText with NaN (or any sentinel
// width) would corrupt the basis: e.g. `wrapText(text, NaN, 'truncate')`
// happily appends an ellipsis and reports natural+1, so the parent's
// flex distribution gets a width derived from imaginary fitting. The
// worst case is `truncate-start`, where `wrapText(text, NaN, …)` can
// collapse the entire string to a single "…" cell (shakedown A1 / A5
// — issues #51 / #58). Wrap and truncate are about fitting within an
// ACTUAL constraint; without one, the natural size is the only honest
// answer.
//
// Embedded newlines: measureText already accounts for line breaks in
// its single-pass walk (each \n bumps height and resets the width
// accumulator). The natural dimensions reflect the longest line and
// the total line count, which is exactly what an unconstrained basis
// call wants.
if (widthMode === LayoutMeasureMode.Undefined) {
return measureText(text, Number.POSITIVE_INFINITY)
}

const dimensions = measureText(text, width)

// Text fits into container, no need to wrap
Expand All @@ -333,19 +358,6 @@ const measureTextNode = (
return dimensions
}

// For text with embedded newlines (pre-wrapped content), avoid re-wrapping
// at measurement width when layout is asking for intrinsic size (Undefined mode).
// This prevents height inflation during min/max size checks.
//
// However, when layout provides an actual constraint (Exactly or AtMost mode),
// we must respect it and measure at that width. Otherwise, if the actual
// rendering width is smaller than the natural width, the text will wrap to
// more lines than layout expects, causing content to be truncated.
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)

Expand Down
Loading
Loading