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 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fde3e975e
commit
32051b5097
11 files changed
+133
-22
No files matched your search
@@ -177,12 +177,12 @@ export abstract class RowList<Item extends PickerItem, Row extends PickerRow> 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 {
|
||||
|
||||
@@ -93,7 +93,7 @@ class FontPicker extends FilePicker<FontItem> {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -122,7 +122,7 @@ class SoundPicker extends FilePicker<SoundItem> {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -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;
|
||||
|
||||
+37
-5
@@ -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<LabelOptions['align']>;
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user