Lay out a scroll container's content when the content changes

A short list opened after a long one drew all its rows stacked on one
point. The fault was not in the list tab: rebuild() handed the row stack
to setContent() while it was still empty, so the content was sized to
max(rows, viewport) = the viewport. The rows arrived after that, and for
a short list the same formula yields the same height, so resize() saw no
change and skipped layout(). A long list changes the height and never
showed it.

relayoutContent() now runs whenever the content changed, rather than
leaning on resize()'s change detection - that detection is the wrong
instrument for "the children moved".

The list tab was the only affected caller: the panels use ListView,
which positions its rows itself, and the inspector fills its body
before setContent.

Also drops the comma line under the list. Every section that can carry a
list supplies openList, so the comma fallback in fieldFactory was
unreachable; listToText/listFromText go with it. The list-value test now
covers coerceListItem through coerceValue instead, so the boolean
spelling stays guarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 10:58:40 +02:00
1 parent e52e95c0e9
commit 4d07946c74
9 files changed
+102 -87

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ export const ListLiteral: NodeDef = {
type: 'List',
label: 'List',
category: 'data',
description: 'A constant list, typed as one comma-separated line.',
description: 'A constant list, edited item by item in the List tab.',
inputs: [],
outputs: [list('value')],
params: [{ key: 'value', label: 'Value', kind: 'list', default: [], items: 'number' }],
-13
View File
@@ -225,19 +225,6 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): FieldValue
}
}
/** Colors read back as `#rrggbb` so what the inspector shows can be typed in again unchanged. */
export function listToText(list: ListValue, items: ListItemKind): string {
return list
.map((v) => (items === 'color' ? `#${Number(v).toString(16).padStart(6, '0')}` : String(v)))
.join(', ');
}
export function listFromText(text: string, items: ListItemKind): ListValue {
const trimmed = text.trim();
if (trimmed === '') return [];
return trimmed.split(',').map((part) => coerceListItem(part.trim(), items));
}
export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean {
if (items === 'text') return isList(value) ? value.join(', ') : textOf(value);
if (items === 'boolean') {
+7 -19
View File
@@ -6,7 +6,7 @@ import {
assetRefLabel,
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import { listFromText, listToText, type PropertyDef } from '../../core/properties/PropertySchema';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
@@ -39,10 +39,7 @@ export interface FieldHandlers {
onInput: (value: PropertyValue) => void;
/** Final value – commit as a command. */
onChange: (value: PropertyValue) => void;
/**
* Sends a list to the List tab. Only the caller knows where the value it edits lives, so a
* list row falls back to the comma-separated text field wherever this is not given.
*/
/** Sends a list to the List tab; only the caller knows where the value it edits lives. */
openList?: () => void;
}
@@ -200,20 +197,11 @@ export function createField(
return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) };
}
case 'list': {
const asList = (v: PropertyValue) => (isList(v) ? v : []);
const { openList } = handlers;
if (openList) {
// A board is hundreds of entries: the row says how many and hands the editing to the
// List tab, where a row per item has the space for it.
const count = (v: PropertyValue) => `${asList(v).length} items`;
const button = new Button(count(value), { variant: 'ghost', onClick: openList });
return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) };
}
const asText = (v: PropertyValue) => listToText(asList(v), def.items);
const field = new TextField(asText(value), {
onChange: (text) => handlers.onChange(listFromText(String(text), def.items)),
});
return { widget: field, grow: true, setValue: (v) => field.setValue(asText(v)) };
// A board is hundreds of entries: the row says how many and hands the editing to the
// List tab, where a row per item has the space for it.
const count = (v: PropertyValue) => `${(isList(v) ? v : []).length} items`;
const button = new Button(count(value), { variant: 'ghost', onClick: handlers.openList });
return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) };
}
case 'boolean': {
const field = new Checkbox(Boolean(value));
+6 -36
View File
@@ -1,25 +1,10 @@
/**
* Edits one list, a row per item, with the same field widgets the inspector builds. A list row in
* the inspector has one line for a value that can be hundreds long; the inspector stays visible
* beside this tab, so what the list belongs to is still on screen while it is edited.
* Edits one list, a row per item, with the same field widgets the inspector builds -- a value that
* can be hundreds long has no room in an inspector row. The inspector stays visible beside this
* tab, so what the list belongs to is still on screen while it is edited.
*/
import {
listFromText,
type ListItemKind,
listToText,
type PropertyDef,
} from '../../core/properties/PropertySchema';
import {
Button,
HStack,
Label,
ScrollContainer,
Spacer,
TextField,
Theme,
VStack,
type Widget,
} from '../../ui';
import type { ListItemKind, PropertyDef } from '../../core/properties/PropertySchema';
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
import { createField, type FieldContext } from '../inspector/fieldFactory';
import type { EditorState } from '../state';
import { type ListAccess, resolveList } from './slot';
@@ -34,7 +19,6 @@ export class ListEditor extends VStack {
private readonly count = new Label('', { style: 'small', align: 'right' });
private readonly scroll = new ScrollContainer();
private readonly addButton: Button;
private readonly bulk: TextField;
private rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
/** The structure the rows were built for; only a change here may destroy a row being typed in. */
private signature = '';
@@ -54,16 +38,10 @@ export class ListEditor extends VStack {
this.scroll.setContent(this.rows);
this.add(this.scroll, 1);
// The comma text is what the inspector used to show. It stays, as the way to paste a whole
// list at once – per-item rows do not replace that.
const footer = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
this.addButton = new Button('+ Item', { variant: 'ghost', onClick: () => this.addItem() });
footer.add(this.addButton);
this.bulk = new TextField('', {
placeholder: 'comma-separated',
onChange: (text) => this.setAll(text),
});
footer.add(this.bulk, 1);
footer.add(new Spacer(), 1);
this.add(footer);
state.on('list', () => this.rebuild());
@@ -109,7 +87,6 @@ export class ListEditor extends VStack {
}
if (!access) return;
this.setters.forEach((set, i) => set(access.value[i] ?? ''));
this.bulk.setValue(listToText(access.value, access.def.items));
}
private rebuild(): void {
@@ -124,12 +101,10 @@ export class ListEditor extends VStack {
this.title.setText(access ? access.title : 'No list open');
this.count.setText(access ? `${access.value.length} items` : '');
this.addButton.visible = access !== null;
this.bulk.visible = access !== null;
if (!access) {
this.rows.add(new Label('Open a list from its row in the inspector.', { style: 'muted' }));
} else {
this.bulk.setValue(listToText(access.value, access.def.items));
access.value.forEach((item, index) => this.rows.add(this.itemRow(access, item, index)));
if (access.value.length === 0) this.rows.add(new Label('The list is empty.', { style: 'muted' }));
}
@@ -162,11 +137,6 @@ export class ListEditor extends VStack {
const access = this.access();
if (access) access.replace([...access.value, itemDef(access.def.items, 0).default]);
}
private setAll(text: string): void {
const access = this.access();
if (access) access.replace(listFromText(text, access.def.items));
}
}
/** One item as a property definition, so the inspector's own widget renders it. */
+6 -1
View File
@@ -76,7 +76,12 @@ export class ScrollContainer extends Widget {
relayoutContent(): void {
if (!this.content) return;
const size = this.content.measure();
this.content.resize(this.w, Math.max(size.h, this.h));
const height = Math.max(size.h, 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 === this.w && this.content.h === height;
this.content.resize(this.w, height);
if (unchanged) this.content.layout();
this.scrollY = clamp(this.scrollY, 0, this.maxScroll);
this.applyScroll();
}