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); + }); +});