diff --git a/.changeset/tidy-rows-stack.md b/.changeset/tidy-rows-stack.md new file mode 100644 index 00000000..ede08cbf --- /dev/null +++ b/.changeset/tidy-rows-stack.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep overcommitted STML rows within their requested width without dropping inline content. diff --git a/src/ui/lib/stml/layout.test.ts b/src/ui/lib/stml/layout.test.ts index f632f2df..66d38969 100644 --- a/src/ui/lib/stml/layout.test.ts +++ b/src/ui/lib/stml/layout.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { measureTextWidth } from "../text"; import { layoutStml, layoutStmlCached, type StmlLine } from "./layout"; function lineText(line: StmlLine): string { @@ -102,10 +103,24 @@ describe("layoutStml", () => { expect(top.length).toBe(20); }); - test("degrades a too-narrow row to stacked blocks with a note", () => { - const { lines, errors } = layoutStml("" + "x".repeat(6) + "", 9); + test("stacks overcommitted fixed-width columns within the row width", () => { + const width = 20; + const { lines, errors } = layoutStml( + 'ab', + width, + ); + + expect(errors.some((error) => error.includes("too narrow"))).toBe(true); + expect(lines.every((line) => measureTextWidth(lineText(line)) <= width)).toBe(true); + }); + + test("preserves loose text when a too-narrow row stacks its blocks", () => { + const markup = "prefix" + "x".repeat(6) + ""; + const { lines, errors } = layoutStml(markup, 9); + expect(errors.some((error) => error.includes("too narrow"))).toBe(true); - expect(lines.length).toBeGreaterThan(6); + expect(frameText(lines)[0]).toBe("prefix"); + expect(lines.length).toBeGreaterThan(7); }); test("renders ordered and unordered lists with hanging indents", () => { diff --git a/src/ui/lib/stml/layout.ts b/src/ui/lib/stml/layout.ts index b9bfc248..a627e973 100644 --- a/src/ui/lib/stml/layout.ts +++ b/src/ui/lib/stml/layout.ts @@ -552,6 +552,7 @@ function mergeColumns(columns: StmlLine[][], widths: number[], gap: number): Stm return merged; } +/** Lay out one row horizontally when its columns fit, otherwise stack every child. */ function layoutRow( el: StmlElement, width: number, @@ -571,25 +572,28 @@ function layoutRow( if (looseInline.some((node) => node.type !== "text" || node.value.trim() !== "")) { errors.add(" mixes bare text with block children; text laid out above the row"); } + const inlinePrefix = + looseInline.length > 0 ? layoutBlockNodes(looseInline, width, style, errors) : []; const gap = Math.max(0, numAttr(el.attrs.gap) ?? 1); const totalGap = gap * (children.length - 1); const available = width - totalGap; - // Fixed-width columns claim their space first; the rest share what remains. + // Fixed-width columns claim their space first; flex columns need at least one cell each. const fixed = children.map((child) => widthAttr(child.attrs.width, available)); const fixedTotal = fixed.reduce((total, w) => total + (w ?? 0), 0); const flexCount = fixed.filter((w) => w === undefined).length; - const flexSpace = Math.max(flexCount, available - fixedTotal); - const flexWidth = flexCount > 0 ? Math.floor(flexSpace / flexCount) : 0; - let flexRemainder = flexCount > 0 ? flexSpace - flexWidth * flexCount : 0; - - if (available < children.length) { - // Too narrow to sit side by side — degrade to stacked blocks. + if (fixedTotal + flexCount > available) { errors.add(" too narrow for its columns; stacking vertically"); - return children.flatMap((child) => layoutBlock(child, width, style, errors)); + return [ + ...inlinePrefix, + ...children.flatMap((child) => layoutBlock(child, width, style, errors)), + ]; } + const flexSpace = available - fixedTotal; + const flexWidth = flexCount > 0 ? Math.floor(flexSpace / flexCount) : 0; + let flexRemainder = flexCount > 0 ? flexSpace - flexWidth * flexCount : 0; const widths = fixed.map((w) => { if (w !== undefined) { return Math.max(1, Math.min(w, available)); @@ -598,9 +602,6 @@ function layoutRow( flexRemainder -= extra; return Math.max(1, flexWidth + extra); }); - - const inlinePrefix = - looseInline.length > 0 ? layoutBlockNodes(looseInline, width, style, errors) : []; const columns = children.map((child, index) => layoutBlock(child, widths[index]!, style, errors)); return [...inlinePrefix, ...mergeColumns(columns, widths, gap)]; }