Commit Graph

35 Commits

Author SHA1 Message Date
Yunfei He bf9d9d4a4a fix(runtime): re-measure text when wrap changes at runtime (#193)
The `wrap` prop changes a <Text> node's MEASURED height (the yoga measure
func reads el.props.wrap to pick wrap/truncate/hard layout) but is NOT a
yoga prop, so a runtime wrap-only change took the generic STYLE_PROPS
branch in patchProp: it stored the new value into el.props and called
onCommit() WITHOUT markTextDirty(el). Yoga kept the OLD wrap mode's cached
height while paint rendered with the NEW wrap, so layout and paint
disagreed -- stale blank rows on wrap->truncate, overflow / overwritten
siblings on truncate->wrap.

Mark the text node dirty when the changed STYLE_PROP is `wrap` on a
tui-text node so yoga re-measures. `wrap` is the only STYLE_PROP that
affects measured dimensions (the rest are paint-only), so it is the sole
case.

Verified Ink v7.0.4 has the identical latent bug -- its applyStyles
ignores textWrap and never markDirty()s, so a wrap-only change goes stale
there too. Recorded as a blessed divergence in ink-divergences.md; the fix
matches the layout Ink produces whenever its measure func is invalidated.

Tests (text-wrap-remeasure.test.tsx) reproduce both directions:
RED produced Ink's stale frame ("aaaa …\n\n\nZZZZ"), GREEN the correct
re-measured layout.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:57:52 +08:00
Yunfei He 4243cff937 fix(runtime): margin/padding edge removal falls back to the surviving shorthand (#184)
* fix(runtime): margin/padding edge removal falls back to the surviving shorthand

Withdrawing a per-edge/axis margin or padding override from a box that still
has a broader shorthand collapsed the edge to 0 instead of falling back. E.g.
`margin={5} marginTop={8}` with marginTop later removed: the setter ran
setMargin(EDGE_TOP, 0), and per yoga edge precedence EDGE_TOP=0 overrides the
surviving EDGE_ALL=5, so the top margin became 0 (the box jumps 5 cells) when
the declarative model (render = f(current props), current = {margin:5}) says 5.
A single per-prop yoga setter can't reconcile an edge that depends on the
specific edge + axis + all-edges shorthand together.

Fix mirrors the existing reconcileBorderEdges pattern: the 14 margin/padding
setters become no-ops, and reconcileMarginEdges/reconcilePaddingEdges recompute
all four physical edges from the box's full el.props with most-specific-wins
precedence (top = marginTop ?? marginY ?? margin ?? 0, ...), zeroing the
composite edges so nothing layers on top. A present-but-non-finite value
(NaN/Infinity) or a withdrawn prop falls THROUGH to the next precedence level,
preserving yoga's prior setMargin(NaN)->fallback behavior; an explicit 0 is
finite and still overrides. margin keeps EDGE_START/END and padding keeps
EDGE_LEFT/RIGHT for left/right, matching the prior setters.

Verified against real yoga-layout@3.2.1 that the SET path produces identical
computed edges as the old per-setter code (no layout regression) across all
combinations and patch orders, and the correct fallback on removal.

This is NOT an Ink-parity item: run against Ink v7.0.4, Ink and pre-fix vue-tui
both collapse to 0 (the identical bug). The fix diverges from Ink by being
declaratively correct under the already-documented G19 reset principle;
recorded in ink-divergences.md alongside the display / flexDirection entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): pin margin/padding spacing to a finite-number contract (Codex review)

The family recompute resolves an edge from a prop only when it is a finite
number (matching the `number` prop type + Ink's number-only spacing); numeric
strings (`margin="5"`) are coerced for Vue static-template ergonomics, but other
non-numeric values (`"50%"`, junk, `""`) are treated as not-set and fall through
to the surviving shorthand instead of being forwarded to yoga.

This makes intentional the behavior change the final review flagged: the OLD
per-setter code incidentally forwarded off-contract strings to yoga (so
`marginTop="50%"` became a percent and `marginTop="foo"` threw). That was
undocumented and non-Ink. Also excludes "" from the present() check so all
non-numeric strings fall through uniformly (Number("")===0 would otherwise
resolve to 0). The numeric/numeric-string SET path is unchanged (re-verified
across all 5040 patch orders).

Tests pin the contract (numeric, numeric-string, "50%"/"foo"/"" fall-through,
NaN/withdrawn fall-through, explicit 0), and ink-divergences.md records it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:18:54 +08:00
Yunfei He 1bd91dbbb9 test(runtime): pin absolute-child position to the padding box; fix wording
Addresses PR review: the containing block for an absolutely-positioned child
is the **padding box** (inside the borders), not the "border-box". Verified by
running yoga (an abs child at top:0/left:0 insets by the border only, never by
padding — confirmed across border/padding combos) and the real Ink/vue-tui
renderers (X lands at the inner-border edge, byte-identical in both).

- Tighten the regression test: exact-frame assertions instead of `toContain`,
  including a border+padding case that distinguishes the padding box from the
  content box — the assertion that would have caught the original wording slip
  (presence-only assertions could not).
- Correct "containing block (border-box)" -> "padding box (inside the borders)"
  in paint.ts, layout-guards.ts, and ink-divergences.md. (The unrelated
  "border-box-like" *sizing* notes are correct and left as-is.)

No runtime behavior change; the code already used yoga's computed position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He 0d50fb7e40 fix(runtime): paint position:absolute children in zero-content boxes
The zero-content-area guard (layout-guards + paint.ts) suppressed ALL
children of a Box whose inner content rect collapsed to zero, including
position:"absolute" children. An absolutely-positioned child is placed
against the containing block (border-box), not the content rect, so Ink
v7.0.4 paints it (verified by running real Ink: a w=2 h=2 single-border
box with an absolute child renders "┌┐#\n└X"); vue-tui dropped it,
rendering "┌┐#\n└┘".

- layout-guards: exempt POSITION_TYPE_ABSOLUTE children from the hide loop
  so they keep their layout.
- paint: move overflow-clip setup above the zero-content early-return and,
  in that branch, paint only absolute children (still clipped by
  overflow:hidden, matching Ink) while keeping in-flow children suppressed
  (the blessed degenerate-box divergence).

Flow-child suppression and overflow:hidden clipping both stay Ink-aligned
(verified byte-identical against real Ink). Known limitation: an absolute
descendant nested under a suppressed in-flow child is still dropped (the
flow ancestor is removed from layout) — scoped to direct absolute children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:02:56 +08:00
Yunfei He 1fd832d297 fix(runtime): align Ink parity behavior
Align several user-observable runtime behaviors with the Ink v7.0.4 parity audit: live input/paste handler refs, duplicate focus id registration, string-only color props, noninteractive empty final newlines, cross-realm error headers, and contained zero-content box layout/paint.

Document Vue-specific KEEP decisions and require Conventional Commits for commit messages and PR titles.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-08 12:51:20 +08:00
Yunfei He c8c2c4a32d test: backfill Ink-parity coverage gaps (suite as a superset of Ink) (#130)
Regression tests for behaviors the audit found correct-but-unpinned, so the
suite is a strict superset of Ink:
- A07: two <Static> regions both render (the additive divergence)
- B04: Static render-prop index = absolute index across appends; container
  vertical padding adds blank rows to the static frame
- B11: lazy raw-mode acquire/release under rawMode:'auto' (the path the
  'always' default masks)
- B19: child useCursor unmount emits the cursor-hide escape (stream-level)
- B20: animation interval 0/negative clamps to 1ms (normalizeInterval unit) and
  advances without busy-hang
- B21/B28: INK_SCREEN_READER env auto-detection + useIsScreenReaderEnabled
  true-path (env tests isolated in a *.sequential file per the global-state rule)
- B29: renderToString serves useCursor/usePaste/useTerminalSize/useAnimation/
  useBoxMetrics as inert no-ops (don't throw)
- B30: dedicated columnGap/rowGap props + their removal-reset

Test-only; no production changes. Codex-reviewed for non-vacuousness, Ink
correctness, and process-global isolation.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:42:33 +08:00
Yunfei He d7c62f9b3a fix(runtime): off-spec display value hides, aligning Ink (drop A21 divergence) (#128)
Ink's applyDisplayStyles hides any present `display` that isn't 'flex'
(DISPLAY_NONE); vue-tui hid only on exact 'none', leaving off-spec values
(reachable via TS-bypass — the prop type is 'flex'|'none') visible. Align: the
yoga display setter now hides any present (non-null) value except 'flex',
matching Ink even for non-string junk (display={5}). The blessed A19 divergence
is preserved — a removed/undefined display (null) still resets to the visible
default (Vue can't distinguish display={undefined} from an omitted prop).

Removes the now-obsolete A21 entry from .agents/docs/ink-divergences.md (the
A19 "removed display resets to visible" entry remains).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:19:48 +08:00
Yunfei He 48558c5af6 test(runtime): lock reconciler, measure, flex, overflow, build-output (Ink parity) (#116)
Final round-2 test-only batch (behaviors already at parity with Ink reconciler.tsx,
measure-text.tsx, flex-*.tsx, overflow.tsx, build-output.ts):
- build-output: every package.json export target resolves on disk (runtime/cli/testing)
  + the .d.mts declaration sibling for the typed libraries (runtime/testing, not cli).
- reconciler: keyed insert-between [a,c]→[a,b,c]; replace a colored <Text> child with a
  plain string; setElementText A→B + the text-context guard; marginLeft removal reset.
- measure: empty <Text> contributes height 0 in a column; non-zero left (marginLeft=5 →
  5,1); measureTextNatural trailing/only-newline heights.
- flex: alignSelf='auto' == default + alignSelf removal resets to AUTO; the two
  space-around known-yoga-bug cases converted from test.skip to test.fails (they assert
  the DESIRED output and flip to a real failure if yoga ever fixes the bug); the documented
  flexDirection/flexWrap removal-reset divergence (was comment-only) now has a visual lock.
- overflow: out-of-bounds writes produce Ink's exact clipped frame (sparse past-width cell,
  filtered hole) — tightened from toBeDefined().
- components: inline + top-level non-empty fragment in <Text>; the previously-skipped
  ST-terminated OSC-8 hyperlink hard-wrap now passes ('abcde\nfghij') — un-skipped as a lock.

Codex-reviewed GENUINE.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 03:28:35 +08:00
Yunfei He 99839397c6 fix(runtime): a write op at the right clip edge still runs its transformers (Ink parity) (#109)
The whole-op horizontal clip skip used `x >= clipH.x2`, dropping a write op that
starts exactly AT the right clip edge. Ink's skip is strict `x > clip.x2`
(output.ts:188): at x === clip.x2 it proceeds to clip each line to empty
(sliceAnsi 0,0) and runs the transformers on the empty slice. A transformer that
produces output from empty input (e.g. `() => '中'`, `s => s + 'X'`) emits at the
clip edge in Ink but was short-circuited away in vue. Changed `>=` to `>`. Normal
and identity ops at x === clip.x2 still emit nothing (empty slice →
characters.length === 0 → skip); the inner per-line clip already used strict `>`.

Tests lock the transform-on-empty + append-on-empty cases (Ink "    中" / "    X")
and the identity/plain controls (""). Also drops a stale comment reference to the
deleted parity-ledger.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:14:28 +08:00
Yunfei He b6dc9a0919 fix(runtime): a 0-width box wraps its text onto its own line, not drops it (Ink parity) (#103)
A 0-width text container (flexBasis=0, width=0, width="0%", a negative parsed
percent) DROPPED its text in vue where Ink wraps it onto its own row. Ink's wrapText
has no width<=0 guard: wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA" (height 2),
so the glyph occupies a second row and a row-sibling renders "B\nA". vue collapsed it
to height 1 (wrapText's `width <= 0 -> [""]` guard), then the paint clamp re-collapsed
the wrap, so the sibling overwrote the text -> "B".

Fixes, all confined to the width<=0 branch:
- text-measure.ts: drop the `width <= 0 -> [""]` guard. A styled string can't go
  through wrapAnsi at width 0 (wrap-ansi@10 byte-splits SGR codes -> garbage like
  "B\n["), so the wrap/hard branch routes through a new wrapZeroWidthAnsi that
  derives its line STRUCTURE from wrapAnsi on the PLAIN (stripped) text — which is
  correct for zero-width graphemes (ZWSP/ZWNJ/ZWJ/combining/VS16/BOM, interior and
  trailing) — then re-applies SGR per grapheme via slice-ansi's slot model, keeping
  wide glyphs whole. Input is NFC-normalized first so combining sequences compose to
  match wrap-ansi (and vue's own normal-width path), not the decomposed source bytes.
- paint.ts: pad the bg to the TRUE wrap width (0), not a >=1-clamped width — a 0-width
  box pads nothing (Ink getMaxWidth=0); clamping bg-padded the empty leading wrap line
  into a stray cell that collided with a row-sibling.

A comparison-battery test locks wrapZeroWidthAnsi's plain output to wrapAnsi's
width-0 layout for ~22 inputs (zero-width, wide, emoji, ZWJ, combining decomposed +
composed, multiline). The full layout suite is byte-unchanged for all width>=1 cases.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:54:12 +08:00
Yunfei He 9ed77fc38c fix(runtime): bare-string dimensions and position offsets are percentages (Ink parity) (#94)
Ink coerces ANY string dimension/position value to a percent before handing it
to yoga (applyDimensionStyles uses parseInt; applyPositionStyles uses parseFloat).
vue forwarded the raw string to native yoga, which only treats %-suffixed strings
as percent — so width="50" rendered as 50 absolute cells instead of 50%, top="2"
as 2 cells instead of 2%, and width="" crashed the render ("Invalid value").

width/height now also fall back to setWidthAuto()/setHeightAuto() on a non-number,
non-string junk value (matching Ink's else branch), so width={false} no longer
throws where Ink renders fine. min/max/position keep forwarding junk to the raw
cell setter, matching Ink (which has no auto fallback there and throws identically).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:50:27 +08:00
Yunfei He 8c3e97ab47 fix(runtime): removing display resets to the default (visible), not persist (Ink divergence) (#89)
vue-tui left `display` out of RESETTABLE_PROPS, so a removed/undefined `display` persisted
its prior value (a removed display="none" stayed hidden). Adds `display` to RESETTABLE_PROPS
-- the setter already maps undefined -> DISPLAY_FLEX -- so a withdrawn `display` returns to
the Box default (visible), per render = f(current props), like flexDirection/flexWrap (G19).

Deliberate, documented divergence from Ink (which hides on a present-undefined `display` via
DISPLAY_NONE, and persists on omitted) -- recorded in ink-divergences.md. The reset is
consistent across the visual and screen-reader paths (both read yoga's display state).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:48:08 +08:00
Yunfei He c0ebf53e63 fix(runtime): string flexBasis is a percent, matching Ink (parity) (#88)
A string flexBasis was forwarded to yoga's setFlexBasis, so a bare numeric string like
"3" became 3 absolute cells; Ink coerces ANY string to a percent via Number.parseInt ->
setFlexBasisPercent (styles.ts:547-555). "3" now means 3% (at width 6 the box collapses
to 0 and the sibling takes the row), matching Ink. The setter branch is now structurally
identical to Ink -- number -> absolute, string -> percent, anything else (incl. a
non-number/non-string value Vue's prop validation only warns about) -> setFlexBasisAuto()
instead of throwing.

A separate, pre-existing downstream divergence (zero/negative flexBasis% wraps the
sibling in Ink) is documented with a skipped test -- not caused or fixed here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:30:17 +08:00
Yunfei He 688da13872 fix(runtime): keep text-measure helpers internal, matching Ink (#68)
Ink keeps its `measure-text` module internal and never re-exports it. vue-tui
exported `measureText` / `measureTextNatural` from the public index under the
mistaken belief — stated verbatim in commit 0e7d775's own message — that doing
so "matched Ink's public API". It does not; Ink keeps that module internal. A
later design doc then rationalized the leak post-hoc as an intentional
divergence. It was neither intentional nor a divergence — it was a mistake.

Align with Ink:
- Drop both from the public index. `measureTextNatural` stays as an internal
  helper (yoga.ts uses it). `measureText` had zero production callers (yoga uses
  `wrapText` + `measureTextNatural`, never `measureText`) and is removed.
- Integration tests that used `measureText(stripAnsi(x), 9999).width` as a
  line-width helper now use `stringWidth(stripAnsi(x))` directly.
- public-api.test.ts gains a regression test asserting neither is exported.
- Remove the now-obsolete entry from .agents/docs/ink-divergences.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:53:34 +08:00
Yunfei He 8cadcca95c fix(runtime): clip horizontally before applying transforms (Ink parity, G63) (#63)
Ink's output.ts maps sliceAnsi(line, from, to) (the horizontal clip) over the
lines BEFORE the lines.entries() loop that runs transformer(line, index), and it
never re-clips the transformer's output. vue-tui's paint write() did the reverse
(commit 2c99431 deliberately moved clip AFTER transform), so a width-sensitive
transform inside an overflowX:"hidden" box received the FULL line and had its
result sliced — corrupting gradients (wrong char count) and OSC-8 hyperlinks
(closing sequence sliced off).

Reorder so the per-line horizontal clip runs first, then the transformers apply
to the already-clipped span. The post-vertical-clip line index passed to each
transformer is unchanged, so transform index/nesting behavior (G21/G32/G52/G58)
is untouched. The pre-existing overflow test that asserted the old re-clip order
(transform-returns-wide-char dropped at the boundary) is rewritten to Ink's
clip-then-transform output (the widened glyph overflows), verified against the
pinned Ink reference (7.0.4 / 40b3a75) via renderToString.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:04:12 +08:00
Yunfei He 2c597e7169 fix(runtime): reset yoga props to default on dynamic removal (Ink parity, G19) (#48)
* fix(runtime): reset yoga props to default on dynamic removal (Ink parity, G19)

Removed style props now reset to the yoga default (margin/padding/min/gap/flexGrow→0, flexShrink→1, flexBasis→auto, flexDirection→ROW, flexWrap→NO_WRAP, alignItems→STRETCH, alignSelf→AUTO, justifyContent→FLEX_START, position→RELATIVE) instead of keeping a stale value — matches Ink's reconciler diff + styles.ts.

The fix threads the previous prop value (prev) from patchProp into applyYogaProp so that resets only fire on genuine removals (prev is a real value, not null/undefined from Vue's initial-mount or never-set patches). RESETTABLE_PROPS is extended with all newly resettable keys.

Follow-up blocker fixes:
- marginX/marginY/paddingX/paddingY now map to Yoga.EDGE_HORIZONTAL/EDGE_VERTICAL (matching Ink styles.ts) instead of concrete EDGE_START/END/TOP/BOTTOM. They compose with the specific edges per yoga precedence, so removing an axis shorthand no longer clobbers a surviving marginLeft/etc.
- applyYogaProp and all setters now treat null the same as undefined (value == null) for the removal/reset path. Vue's host renderer passes next=null (not undefined) when a key disappears from a spread props object (e.g. Static spreads style into host props), which previously bypassed the reset and forwarded raw null into yoga (NaN/0 corruption).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(parity): ledger — G19 pr-open, reconcile G18 merged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 06:31:39 +08:00
Yunfei He 144db33d0b fix(runtime): use terminal-size fallback when stdout reports 0 cols/rows (Ink parity, G12) (#33)
Replace three `stdout.columns ?? 80` / `stdout.rows ?? 24` spots in render.ts with
`resolveSize(stdout).columns/rows`. The `??` guard only falls back on null/undefined,
not on 0 — so non-TTY environments where stdout reports 0 columns would collapse Yoga
layout to width 0. Ink's `getWindowSize` (utils.ts:8-23) uses a truthy guard
(`if (columns && rows)`) and a fallback chain through terminal-size → 80/24 defaults.
`resolveSize()` in useTerminalSize.ts already implements this chain; now exported and
used by the renderer. The non-TTY viewportRows → 24 branch is preserved (Ink-aligned).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 00:24:02 +08:00
Yunfei He 7b8ed05ef9 test: pin narrow-truncate re-measure parity with Ink 2026-05-28 17:57:52 +08:00
Yunfei He 4696b49313 test: pin absolute-non-edge ZWJ parity with Ink (closes #21 final class) 2026-05-28 17:57:52 +08:00
Yunfei He 1550ab9ad1 fix: measure text naturally like Ink, wrap only when constrained (closes #21 height/wrap classes)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:57:52 +08:00
Yunfei He 5c60a0af63 fix: upgrade text stack to grapheme-aware slicing/width (closes #21 grapheme classes)
Bump slice-ansi@9, string-width@8, wrap-ansi@10 and add cli-truncate@6
(both the runtime dep and the pnpm catalog entry for string-width). Rewrite
wrapText truncate variants to delegate to cli-truncate, matching Ink's
wrap-text.ts: grapheme clusters (ZWJ emoji, combining marks) stay whole and
newlines are preserved. Adjust the horizontal-clip left-edge compensation in
paint.ts because slice-ansi@9 drops a straddling wide grapheme whole rather
than splitting it, so lineX must advance by the actually-dropped width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:57:52 +08:00
Yunfei He 1a1d9935d8 fix: skip painting display:none subtrees (closes #21 display-none class)
Adds an early-return guard in paintNode so nodes with DISPLAY_NONE
(already set on their Yoga node) are entirely skipped during paint,
matching Ink's renderNodeToOutput behavior. Without the guard, hidden
text/borders leaked onto visible siblings at x=0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:57:52 +08:00
Yunfei He 2c994314e1 fix: reorder clip/transform pipeline so transforms are clipped correctly
Horizontal clipping now runs per-line AFTER transforms instead of before,
preventing Transform-widened text from escaping clip boundaries. Left-edge
clipping also uses the actual removed width to position subsequent text
correctly when a wide char straddles the boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:06:36 +08:00
Yunfei He 5d532f47d1 fix: clip wide glyphs that overflow grid/clip boundary (closes #10) (#14)
Absolute-positioned wide characters (CJK, emoji) could paint past the
right edge of a clipped box or the terminal grid, producing output wider
than the column limit. Three fixes:

- Safe-slice after sliceAnsi in clip logic to handle wide char overshoot
- Bounds check in grid write loop to skip chars exceeding grid width
- Width-aware border fill to account for measured corner char widths
2026-05-28 00:42:39 +08:00
Yunfei He b8778c1847 fix: Text node defaults to flexDirection='row' matching Ink's ink-text
Set flexDirection=row, flexShrink=1, flexGrow=0 on text yoga nodes at
creation time, matching Ink's <ink-text> style defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:52:37 +08:00
Yunfei He 2e1493b785 test: verify text wraps correctly within border/padding content area
Yoga's measure function already receives the content-area width (minus
parent border/padding), so text wrapping is correct without additional
adjustment in the paint pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:51:27 +08:00
Yunfei He c4161c99a7 fix: Box defaults to flexDirection='row', flexShrink=1, flexWrap='nowrap', flexGrow=0
Match Ink's Box defaults by setting yoga properties at node creation time.
This ensures proper row-based layout regardless of Vue's prop patching order.
Updated affected test snapshots and added explicit flexDirection="column"
where tests relied on the old column default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:49:27 +08:00
Yunfei He bbaa77bce1 feat: support per-axis overflowX/overflowY clipping
Add independent horizontal/vertical clip support in the paint pass,
matching Ink's per-axis overflow behavior. Add overflowX/overflowY to
STYLE_PROPS so they are stored in el.props for the paint pass.
Implement 26 new overflow tests translated from Ink.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:04:57 +08:00
Yunfei He da7c843d44 feat: implement overflow clipping in paint pipeline
Port Ink's clip/unclip approach to vue-tui's paint output. When a box
has overflow="hidden", content painted by its children that falls
outside the box's content area (inside borders) is clipped.

Implementation:
- Add ClipRect, clip(), and unclip() to the Output class
- Maintain a clip stack; active clip is applied to each write op
- Horizontal clipping uses slice-ansi for correct ANSI handling
- Vertical clipping slices the lines array
- Store "overflow" in STYLE_PROPS so it's available in node.props for
  the paint pass

Also adds explicit flexDirection="row" to two overflow tests where
multiple child boxes should lay out side-by-side (Ink defaults to row,
vue-tui defaults to yoga's column).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:47:10 +08:00
Yunfei He 5e59a3a28e fix: add explicit flexDirection="row" to align-self multi-text tests
Ink's Box defaults to flexDirection="row", so multiple <Text> children
lay out side-by-side. Vue-tui uses yoga's default (column). Rather than
changing the global default and breaking 20+ existing tests, add
explicit flexDirection="row" to the inner boxes in these two tests so
they match the intended layout behavior being verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:40:55 +08:00
Yunfei He d990f0f1ee fix: correct space-around expected value to match Ink and yoga output
The space-around alignment distributes space as 1-row gap / AB / 2-row
gap / CD / 1-row gap for a 6-row container with 2 content rows.
Expected value was "\nAB\n\nCD\n\n" but Ink produces "\nAB\n\n\nCD\n".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:37:29 +08:00
Yunfei He d14c11e2d2 fix: correct flex basis column test expectations to match Ink output
height=6 containers produce 6 lines. Ink's renderToString returns
"A\n\n\nB\n\n" (preserving trailing blank lines). The tests incorrectly
expected "A\n\n\nB" with a comment claiming lastFrame({ trimLines: true })
trims trailing newlines — it does not; it only trims trailing spaces per
line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:37:10 +08:00
Yunfei He 5a1f2eba2d fix: trim trailing whitespace per line in paint output to match Ink behavior
Ink's Output.get() calls .trimEnd() on each line of the rendered grid.
Without this, vue-tui's paint grid always fills the full root width with
spaces, causing raw frame output to contain 100-char-wide padding. This
broke tests using { raw: true } and made meaningful trailing-space
assertions impossible with { trimLines: true }.

Also corrects test assertions for 'set width in percent' and 'relative
position' that expected trailing spaces which Ink never produces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:36:48 +08:00
Yunfei He 562b07b60e test: port Ink component tests (38 tests)
Distributes across text, static, transform, newline-spacer,
error-handling, and prop-reset test files.

Tests requiring features not yet in vue-tui are marked test.todo:
- <Transform> inside <Text> (paint pass limitation)
- multi-line transform (transform nodes not yoga carriers)
- text/box nesting validation (no runtime validation yet)
- complex Static rerender patterns (Ink-specific API)

Also fixes wrap="hard" to use wordWrap:false so text breaks at exact
character boundaries rather than word boundaries, matching Ink behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:01:10 +08:00
Yunfei He 22ecbe0f80 test: port Ink layout tests (157 passing, 21 known failures)
Translates flex, direction, wrap, align, justify, gap, padding,
margin, width-height, position, display, and overflow tests from
Ink. Adds trimLines mode and trailing-newline strip to test helper.
Fixes Box type for alignContent (space-between/around/evenly) and
alignSelf (stretch/baseline).

21 tests fail due to behavioral differences (overflow clipping,
percentage dimensions, relative position offsets, align-content
space-* variants). These represent real gaps to investigate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:24:31 +08:00