fix(runtime): gate per-edge border width on borderStyle (align Ink) (#168)
Per-edge border props (borderTop/Bottom/Left/Right) reserved 1 yoga cell whenever truthy, regardless of borderStyle. Since these props default to `true`, toggling one on an UPDATE while borderStyle stays unset (Vue patches only the changed per-edge prop, not borderStyle) left a spurious 1-cell inset with no border ever drawn — content shifted to "\n HELLO" instead of "HELLO". Mirror Ink's applyBorderStyles: an edge's width is `borderStyle ? 1 : 0`, forced to 0 when that edge is explicitly `false`. A per-edge toggle can only SUBTRACT, never add. The per-edge yoga setters become no-ops; patchProp now recomputes all four edges from el.props on any border-prop change via the new reconcileBorderEdges helper, so borderStyle flipping in EITHER direction (set->unset zeroes, unset->set re-reserves) and per-edge toggles are all handled jointly — the computation a single (n, v) yoga setter cannot do. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,8 @@ import {
|
||||
removeYogaChild,
|
||||
applyYogaProp,
|
||||
isYogaProp,
|
||||
BORDER_PROPS,
|
||||
reconcileBorderEdges,
|
||||
bindTextMeasure,
|
||||
markTextDirty,
|
||||
markTransformDirty,
|
||||
@@ -392,25 +394,18 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
if (STYLE_PROPS.has(key)) {
|
||||
(el as { props: Record<string, unknown> }).props[key] = next;
|
||||
}
|
||||
// Special case: borderStyle resets all four yoga border-edge widths to
|
||||
// 1 (or 0). If per-edge toggles (borderTop/Bottom/Left/Right) were
|
||||
// already applied before this patch, their values were clobbered.
|
||||
// Re-apply any per-edge toggles that are stored in el.props so that
|
||||
// yoga reflects the user's explicit per-edge settings.
|
||||
//
|
||||
// Only re-apply when `next` is truthy (i.e. a border style is actually
|
||||
// being set). When borderStyle is cleared/undefined, applyYogaProp sets
|
||||
// all edges to 0 which is the correct final state — there is nothing to
|
||||
// restore, and re-applying per-edge defaults (e.g. borderTop:true from
|
||||
// Box component defaults) would incorrectly reserve border space even
|
||||
// though no border is drawn.
|
||||
if (key === "borderStyle" && next) {
|
||||
const props = (el as { props: Record<string, unknown> }).props;
|
||||
for (const edge of ["borderTop", "borderBottom", "borderLeft", "borderRight"] as const) {
|
||||
if (props[edge] !== undefined) {
|
||||
applyYogaProp(el, edge, props[edge]);
|
||||
}
|
||||
}
|
||||
// Border edge widths depend jointly on borderStyle AND each per-edge prop
|
||||
// (a yoga setter sees only one value), so any border-prop change triggers a
|
||||
// full recompute from el.props — mirroring Ink's applyBorderStyles, which
|
||||
// rewrites all four edges on any border change. The per-edge yoga setters
|
||||
// are intentional no-ops; this is the single source of truth. Reading
|
||||
// el.props (just updated above for STYLE_PROPS) means borderStyle flipping
|
||||
// in EITHER direction is handled: unset→set re-reserves, set→unset zeroes,
|
||||
// and a per-edge toggle while borderStyle stays unset reserves nothing (so
|
||||
// the borderTop/Left/etc defaults of `true` never inset content with no
|
||||
// border drawn).
|
||||
if (BORDER_PROPS.has(key)) {
|
||||
reconcileBorderEdges(el, (el as { props: Record<string, unknown> }).props);
|
||||
}
|
||||
} else if (STYLE_PROPS.has(key)) {
|
||||
(el as { props: Record<string, unknown> }).props[key] = next;
|
||||
|
||||
@@ -303,18 +303,18 @@ const YOGA_PROP_SETTERS: Record<string, (n: YogaNode, v: unknown) => void> = {
|
||||
paddingLeft: (n, v) => n.setPadding(Yoga.EDGE_LEFT, v == null ? 0 : (v as number)),
|
||||
paddingRight: (n, v) => n.setPadding(Yoga.EDGE_RIGHT, v == null ? 0 : (v as number)),
|
||||
|
||||
borderStyle: (n, v) => {
|
||||
// Border occupies 1 cell on every side when a style is set.
|
||||
const w = v ? 1 : 0;
|
||||
n.setBorder(Yoga.EDGE_TOP, w);
|
||||
n.setBorder(Yoga.EDGE_BOTTOM, w);
|
||||
n.setBorder(Yoga.EDGE_LEFT, w);
|
||||
n.setBorder(Yoga.EDGE_RIGHT, w);
|
||||
},
|
||||
borderTop: (n, v) => n.setBorder(Yoga.EDGE_TOP, v ? 1 : 0),
|
||||
borderBottom: (n, v) => n.setBorder(Yoga.EDGE_BOTTOM, v ? 1 : 0),
|
||||
borderLeft: (n, v) => n.setBorder(Yoga.EDGE_LEFT, v ? 1 : 0),
|
||||
borderRight: (n, v) => n.setBorder(Yoga.EDGE_RIGHT, v ? 1 : 0),
|
||||
// borderStyle and the per-edge toggles do NOT compute their own edge widths
|
||||
// here: an edge's width depends on BOTH borderStyle and that edge's per-edge
|
||||
// prop together, and a yoga setter only sees one value. patchProp owns the
|
||||
// joint reconciliation via reconcileBorderEdges (below), which reads the full
|
||||
// el.props and mirrors Ink's applyBorderStyles. These no-op entries exist only
|
||||
// so isYogaProp still routes border props through the yoga branch (which also
|
||||
// stores them into el.props for the paint pass).
|
||||
borderStyle: () => {},
|
||||
borderTop: () => {},
|
||||
borderBottom: () => {},
|
||||
borderLeft: () => {},
|
||||
borderRight: () => {},
|
||||
|
||||
// Ink styles.ts applyDisplayStyles: `display === 'flex' ? DISPLAY_FLEX : DISPLAY_NONE`,
|
||||
// so ANY present value that isn't 'flex' (incl. off-spec strings reachable via a TS
|
||||
@@ -439,6 +439,36 @@ export function isYogaProp(key: string): boolean {
|
||||
return Object.hasOwn(YOGA_PROP_SETTERS, key);
|
||||
}
|
||||
|
||||
/** Props whose change requires recomputing the yoga border-edge widths. */
|
||||
export const BORDER_PROPS = new Set([
|
||||
"borderStyle",
|
||||
"borderTop",
|
||||
"borderBottom",
|
||||
"borderLeft",
|
||||
"borderRight",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Recompute all four yoga border-edge widths from a box's full prop set, mirroring
|
||||
* Ink's applyBorderStyles (styles.ts:729-763): the per-side width is
|
||||
* `borderStyle ? 1 : 0`, then each edge is forced to 0 when that edge's per-edge
|
||||
* prop is explicitly `false`. So a per-edge toggle can only SUBTRACT an edge — it
|
||||
* can NEVER add width without a borderStyle. This is the joint computation a
|
||||
* single yoga setter cannot do (it sees only one value), and it must run on ANY
|
||||
* border-prop change, including borderStyle flipping in EITHER direction
|
||||
* (set→unset re-zeroes, unset→set re-reserves) — otherwise a per-edge toggle made
|
||||
* while borderStyle stays unset would leave a spurious 1-cell inset with no border
|
||||
* drawn (the per-edge props default to `true`).
|
||||
*/
|
||||
export function reconcileBorderEdges(node: YogaCarrier, props: Record<string, unknown>): void {
|
||||
const y = node.yoga as YogaNode;
|
||||
const borderWidth = props["borderStyle"] ? 1 : 0;
|
||||
y.setBorder(Yoga.EDGE_TOP, props["borderTop"] === false ? 0 : borderWidth);
|
||||
y.setBorder(Yoga.EDGE_BOTTOM, props["borderBottom"] === false ? 0 : borderWidth);
|
||||
y.setBorder(Yoga.EDGE_LEFT, props["borderLeft"] === false ? 0 : borderWidth);
|
||||
y.setBorder(Yoga.EDGE_RIGHT, props["borderRight"] === false ? 0 : borderWidth);
|
||||
}
|
||||
|
||||
const RESETTABLE_PROPS = new Set([
|
||||
// Already handled undefined in their setters (reset to yoga/Ink default on removal):
|
||||
"width",
|
||||
|
||||
Reference in New Issue
Block a user