From 32051b5097234b99e611ea0fc35899e1960ad0b9 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 6 Oct 2026 19:54:43 +0200 Subject: [PATCH] Wrap node descriptions and notes in the inspector A node's description and every note in the inspector were one line cut with an ellipsis. Stop pattern says what it does with no pattern picked, but only past the cut, so nobody could read it. - Label takes a wrap option. It breaks its text at the width it gets and keeps the margin a single line has, above and below all lines. - Widget.heightFor(width) gives the height at a width. It is measure().h everywhere except where text wraps. - VStack and SectionStack lay a column out with it, at the width each row will get. - ScrollContainer measures again at the narrower width when its scrollbar shows, because the text then takes more lines. - addNote and the empty inspector's hint wrap. - The file list's own heightFor(count) is now heightOfRows, so the layout does not hand it a width as a row count. Tests: - Unit scroll-container: 7 green, 2 new (height for width, again beside the scrollbar) - Unit ui + inspector: 17 files, 336 green (before the 2 new ones) - e2e 02 (stein2, atx busy with suite 545): ALL PASSED, 29 checks - Screenshot: Stop pattern shows its description in six lines Co-Authored-By: Claude Opus 5.5 --- src/editor/FilePicker.ts | 4 +-- src/editor/font/FontPicker.ts | 2 +- src/editor/inspector/Inspector.ts | 5 +++- src/editor/inspector/host.ts | 2 +- src/editor/sound/SoundPicker.ts | 2 +- src/ui/Widget.ts | 5 ++++ src/ui/layout/SectionStack.ts | 23 ++++++++++++++--- src/ui/layout/Stack.ts | 23 ++++++++++++++--- src/ui/widgets/Label.ts | 42 +++++++++++++++++++++++++++---- src/ui/widgets/ScrollContainer.ts | 8 +++--- tests/ui/scroll-container.test.ts | 39 +++++++++++++++++++++++++++- 11 files changed, 133 insertions(+), 22 deletions(-) diff --git a/src/editor/FilePicker.ts b/src/editor/FilePicker.ts index 938b0ed8..a4be137b 100644 --- a/src/editor/FilePicker.ts +++ b/src/editor/FilePicker.ts @@ -177,12 +177,12 @@ export abstract class RowList ex return { x: p.x + fromX + (this.w - fromX) / 2, y: p.y + this.rowHeight / 2 }; } - heightFor(count: number): number { + heightOfRows(count: number): number { return count === 0 ? 0 : count * this.rowHeight + (count - 1) * this.rowGap; } override measure(): { w: number; h: number } { - return { w: this.minWidth, h: Math.max(this.minHeight, this.heightFor(this.items.length)) }; + return { w: this.minWidth, h: Math.max(this.minHeight, this.heightOfRows(this.items.length)) }; } override layout(): void { diff --git a/src/editor/font/FontPicker.ts b/src/editor/font/FontPicker.ts index a68f08d3..18c77653 100644 --- a/src/editor/font/FontPicker.ts +++ b/src/editor/font/FontPicker.ts @@ -93,7 +93,7 @@ class FontPicker extends FilePicker { } protected contentHeight(count: number): { unit: number; total: number } { - return { unit: ROW_HEIGHT, total: this.list.heightFor(count) }; + return { unit: ROW_HEIGHT, total: this.list.heightOfRows(count) }; } protected adopt(item: FontItem): void { diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index cfeb6baf..3b41b1bd 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -124,7 +124,10 @@ export class Inspector extends Panel { this.signature = this.currentSignature(); const s = this.state.selection; - if (!s) this.host.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' })); + if (!s) + this.host.body.add( + new Label('Select an asset, a scene object or a node.', { style: 'muted', wrap: true }), + ); else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId); else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId); else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId); diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts index e6e4f4e8..70e46d21 100644 --- a/src/editor/inspector/host.ts +++ b/src/editor/inspector/host.ts @@ -68,7 +68,7 @@ export function addNote( text: string, style: 'muted' | 'small' | 'warning' = 'muted', ): void { - host.body.add(new Label(text, { style })); + host.body.add(new Label(text, { style, wrap: true })); } /** The rows `build` adds to the body – what one block of a sortable group covers (#310). */ diff --git a/src/editor/sound/SoundPicker.ts b/src/editor/sound/SoundPicker.ts index 833ad6a7..e1cd7bf4 100644 --- a/src/editor/sound/SoundPicker.ts +++ b/src/editor/sound/SoundPicker.ts @@ -122,7 +122,7 @@ class SoundPicker extends FilePicker { } protected contentHeight(count: number): { unit: number; total: number } { - return { unit: ROW_HEIGHT, total: this.list.heightFor(count) }; + return { unit: ROW_HEIGHT, total: this.list.heightOfRows(count) }; } protected adopt(item: SoundItem): void { diff --git a/src/ui/Widget.ts b/src/ui/Widget.ts index 59e41da4..3a3337ea 100644 --- a/src/ui/Widget.ts +++ b/src/ui/Widget.ts @@ -41,6 +41,11 @@ export abstract class Widget extends Container { return { w: this.minWidth, h: this.minHeight }; } + /** Height needed at `width`. Differs from `measure().h` only where content wraps. */ + heightFor(_width: number): number { + return this.measure().h; + } + /** Re-runs `layout()` only when the size changed, and always repaints. */ resize(w: number, h: number): void { const changed = w !== this.w || h !== this.h; diff --git a/src/ui/layout/SectionStack.ts b/src/ui/layout/SectionStack.ts index 2fedaaf4..73707be0 100644 --- a/src/ui/layout/SectionStack.ts +++ b/src/ui/layout/SectionStack.ts @@ -52,19 +52,34 @@ export class SectionStack extends VStack { this.applyFolds(); const sizes = this.items.map((item) => item.measure()); const w = Math.max(0, ...sizes.map((size) => size.w)) + this.padding * 2; - const { height } = sectionOffsets(this.rows(sizes), this.gap, this.padding, hairline()); - return { w: Math.max(w, this.minWidth), h: Math.max(height, this.minHeight) }; + return { w: Math.max(w, this.minWidth), h: this.heightFor(this.w) }; + } + + override heightFor(width: number): number { + this.applyFolds(); + const { height } = sectionOffsets(this.rows(this.heights(width)), this.gap, this.padding, hairline()); + return Math.max(height, this.minHeight); + } + + /** A band runs across the padding, a row stands inside it. */ + private widthOf(item: Widget, width: number): number { + const band = sectionStartOf(item)?.sectionStart === 'band'; + return Math.max(0, band ? width : width - this.padding * 2); + } + + private heights(width: number): Size[] { + return this.items.map((item) => ({ w: 0, h: item.heightFor(this.widthOf(item, width)) })); } override layout(): void { this.applyFolds(); const items = this.items; - const sizes = items.map((item) => item.measure()); + const sizes = this.heights(this.w); const { ys } = sectionOffsets(this.rows(sizes), this.gap, this.padding, hairline()); items.forEach((item, i) => { const band = sectionStartOf(item)?.sectionStart === 'band'; item.position.set(band ? 0 : this.padding, ys[i] ?? 0); - item.resize(Math.max(0, band ? this.w : this.w - this.padding * 2), Math.round(sizes[i]!.h)); + item.resize(this.widthOf(item, this.w), Math.round(sizes[i]!.h)); }); } } diff --git a/src/ui/layout/Stack.ts b/src/ui/layout/Stack.ts index 4aeafd01..fe2c7c1b 100644 --- a/src/ui/layout/Stack.ts +++ b/src/ui/layout/Stack.ts @@ -55,11 +55,29 @@ export abstract class Stack extends Widget { return { w: Math.max(w, this.minWidth), h: Math.max(h, this.minHeight) }; } + /** In a column, each child's height at the width the layout will give it. */ + override heightFor(width: number): number { + if (this.axis === 'x') return this.measure().h; + const items = this.items; + const crossTotal = width - this.padding * 2; + let h = Math.max(0, items.length - 1) * this.gap + this.padding * 2; + for (const item of items) h += item.heightFor(this.crossSize(item.measure().w, crossTotal)); + return Math.max(h, this.minHeight); + } + + private crossSize(preferred: number, crossTotal: number): number { + return this.align === 'stretch' ? crossTotal : Math.min(preferred, crossTotal); + } + override layout(): void { const items = this.items; const mainTotal = (this.axis === 'x' ? this.w : this.h) - this.padding * 2; const crossTotal = (this.axis === 'x' ? this.h : this.w) - this.padding * 2; - const sizes = items.map((item) => item.measure()); + const sizes = items.map((item) => { + const size = item.measure(); + if (this.axis === 'x') return size; + return { w: size.w, h: item.heightFor(this.crossSize(size.w, crossTotal)) }; + }); let fixed = Math.max(0, items.length - 1) * this.gap; let flexSum = 0; items.forEach((item, i) => { @@ -72,8 +90,7 @@ export abstract class Stack extends Widget { items.forEach((item, i) => { const size = sizes[i]!; const mainSize = item.flex > 0 ? (free * item.flex) / flexSum : this.axis === 'x' ? size.w : size.h; - const preferredCross = this.axis === 'x' ? size.h : size.w; - const crossSize = this.align === 'stretch' ? crossTotal : Math.min(preferredCross, crossTotal); + const crossSize = this.crossSize(this.axis === 'x' ? size.h : size.w, crossTotal); let crossPos = this.padding; if (this.align === 'center') crossPos += (crossTotal - crossSize) / 2; else if (this.align === 'end') crossPos += crossTotal - crossSize; diff --git a/src/ui/widgets/Label.ts b/src/ui/widgets/Label.ts index 49f6b3d4..fabbe4b5 100644 --- a/src/ui/widgets/Label.ts +++ b/src/ui/widgets/Label.ts @@ -1,4 +1,4 @@ -import { CanvasTextMetrics, Graphics, type Text } from 'pixi.js'; +import { CanvasTextMetrics, Graphics, type Text, type TextStyle } from 'pixi.js'; import { centreY } from '../math'; import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { type Size, Widget } from '../Widget'; @@ -10,15 +10,21 @@ export interface LabelOptions { padding?: number; /** Fixed width – a column that must not grow with a long text; the text is truncated instead. */ width?: number; + /** + * Breaks the text into lines at the width it is given instead of truncating it. The label then + * asks for no width of its own, and its height follows `heightFor`. + */ + wrap?: boolean; } -/** Single-line text that truncates with an ellipsis when narrower than its content. */ +/** Text that truncates with an ellipsis when narrower than its content, or wraps with `wrap`. */ export class Label extends Widget { private readonly textNode: Text; private fullText: string; private readonly align: NonNullable; private readonly padding: number; private readonly fixedWidth: number; + private readonly wrap: boolean; private underline: Graphics | null = null; constructor(text = '', options: LabelOptions = {}) { @@ -28,12 +34,22 @@ export class Label extends Widget { this.align = options.align ?? 'left'; this.padding = options.padding ?? 0; this.fixedWidth = options.width ?? 0; + this.wrap = options.wrap ?? false; this.minWidth = this.fixedWidth; - this.textNode = crispText({ text, style: textStyle(options.style ?? 'body') }); + this.textNode = crispText({ text, style: this.styleFor(options.style ?? 'body') }); this.addChild(this.textNode); this.minHeight = Theme.rowHeight; } + /** The shared style, or an own copy for a wrapping label: it carries its wrap width. */ + private styleFor(token: TextStyleToken): TextStyle { + if (!this.wrap) return textStyle(token); + const style = textStyle(token).clone(); + style.wordWrap = true; + style.breakWords = true; + return style; + } + /** The full text, not the truncated version on screen. */ get text(): string { return this.fullText; @@ -47,7 +63,7 @@ export class Label extends Widget { /** Repaints only; the measured size may change, so the parent must relayout when that matters. */ setStyle(token: TextStyleToken): void { - this.textNode.style = textStyle(token); + this.textNode.style = this.styleFor(token); this.invalidate(); } @@ -69,6 +85,7 @@ export class Label extends Widget { override measure(): Size { if (this.fixedWidth) return { w: this.fixedWidth, h: this.minHeight }; + if (this.wrap) return { w: Math.max(this.minWidth, this.padding * 2), h: this.heightFor(this.w) }; const metrics = CanvasTextMetrics.measureText(this.fullText, this.textNode.style); return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), @@ -76,9 +93,24 @@ export class Label extends Widget { }; } + /** A wrapping label keeps the margin a single line has in its row, above and below all lines. */ + override heightFor(width: number): number { + if (!this.wrap) return this.measure().h; + const available = width - this.padding * 2; + if (available <= 0) return this.minHeight; + const style = this.textNode.style; + style.wordWrapWidth = available; + const metrics = CanvasTextMetrics.measureText(this.fullText, style); + const margin = Math.max(0, this.minHeight - metrics.lineHeight); + return Math.max(this.minHeight, Math.ceil(metrics.height + margin)); + } + override redraw(): void { const available = this.w - this.padding * 2; - this.textNode.text = truncate(this.fullText, this.textNode.style, available); + if (this.wrap) { + this.textNode.style.wordWrapWidth = Math.max(1, available); + this.textNode.text = available > 0 ? this.fullText : ''; + } else this.textNode.text = truncate(this.fullText, this.textNode.style, available); const tw = this.textNode.width; let x = this.padding; if (this.align === 'center') x = (this.w - tw) / 2; diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index ac0d5e30..27f1528f 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -85,9 +85,11 @@ export class ScrollContainer extends Widget { /** Re-measure the content after it changed (rows added/removed). */ relayoutContent(): void { if (!this.content) return; - const size = this.content.measure(); - const height = Math.max(size.h, this.h); - const width = Math.max(0, this.w - (size.h > this.h ? Theme.scrollbarWidth : 0)); + // Wrapped text grows taller when the scrollbar takes its width, so measure again at that width. + let needed = this.content.heightFor(this.w); + const width = Math.max(0, this.w - (needed > this.h ? Theme.scrollbarWidth : 0)); + if (width !== this.w) needed = this.content.heightFor(width); + const height = Math.max(needed, this.h); // Content shorter than the viewport is pinned to the viewport height either way, so `resize` // would see no change and skip the layout -- leaving rows added since stacked at the origin. const unchanged = this.content.w === width && this.content.h === height; diff --git a/tests/ui/scroll-container.test.ts b/tests/ui/scroll-container.test.ts index 6879e991..6411be95 100644 --- a/tests/ui/scroll-container.test.ts +++ b/tests/ui/scroll-container.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ScrollContainer, Spacer, Theme, VStack } from '../../src/ui'; +import { ScrollContainer, Spacer, Theme, VStack, Widget } from '../../src/ui'; /** Row tops after a layout; an unlaid-out stack leaves every child at 0. */ const tops = (stack: VStack) => stack.children.map((c) => c.y); @@ -61,3 +61,40 @@ describe('a scroll container and its scrollbar', () => { expect(rows.w).toBe(200); }); }); + +/** Stands in for wrapped text: 20 px per line, a line per 100 px of text. */ +class Wrapping extends Widget { + constructor(private readonly textWidth: number) { + super(); + } + + override heightFor(width: number): number { + return Math.ceil(this.textWidth / width) * 20; + } +} + +describe('a scroll container with content that wraps', () => { + it('gives a row the height it needs at the width it gets', () => { + const scroll = new ScrollContainer(); + scroll.resize(200, 300); + const rows = new VStack(); + const text = rows.add(new Wrapping(500)); + rows.add(new Spacer(20)); + scroll.setContent(rows); + expect(text.h).toBe(60); + expect(tops(rows)).toEqual([0, 60]); + }); + + it('measures again once the scrollbar narrows the content', () => { + const scroll = new ScrollContainer(); + scroll.resize(200, 100); + const rows = new VStack(); + // 400 px of text is two lines at 200 px, but three beside the bar. + const text = rows.add(new Wrapping(400)); + rows.add(new Spacer(70)); + scroll.setContent(rows); + expect(rows.w).toBe(200 - Theme.scrollbarWidth); + expect(text.h).toBe(60); + expect(scroll.maxScroll).toBe(30); + }); +});