/** * Groups of rows with a band as their head (#290): which rows a folded group hides, where the bands * and rows of a column go, what a click on a band does, how a fold is remembered, and which groups * the inspector builds. */ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { CanvasTextMetrics } from 'pixi.js'; import { findAsset } from '../../src/core/model/Project'; import { buildAssetSection } from '../../src/editor/inspector/assetSection'; import { addTitle, type InspectorHost, sectionHeader } from '../../src/editor/inspector/host'; import { buildObjectSection } from '../../src/editor/inspector/objectSection'; import { foldKey, isFolded, listFold, resetFolds, setFolded } from '../../src/editor/sectionFolds'; import { EditorState } from '../../src/editor/state'; import { Box, hiddenBySections, Label, SECTION_BAND_HEIGHT, SectionHeader, sectionOffsets, SectionStack, SORTABLE_ROW_HEIGHT, SortableList, type Widget, } from '../../src/ui'; import { makeBehavior, makeSpriteScene, place } from '../helpers'; beforeAll(() => { CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; }); const GAP = 4; const PADDING = 8; function row(h = 24): Box { const box = new Box(); box.minHeight = h; return box; } function column(...children: Widget[]): SectionStack { const stack = new SectionStack({ gap: GAP, padding: PADDING }); for (const child of children) stack.add(child); const size = stack.measure(); stack.resize(200, size.h); return stack; } function memoryStore(initial: Record = {}) { const items = new Map(Object.entries(initial)); return { items, getItem: (key: string) => items.get(key) ?? null, setItem: (key: string, value: string) => void items.set(key, value), }; } describe('the rows a folded group hides', () => { it('are the ones down to the next head, never a head', () => { expect(hiddenBySections([false, null, null, true, null, null, false, null])).toEqual([ false, false, false, false, true, true, false, false, ]); }); it('are none in front of the first head', () => { expect(hiddenBySections([null, true, null])).toEqual([false, false, true]); }); }); describe('where bands and rows go', () => { const band = { h: SECTION_BAND_HEIGHT, start: true, band: true }; const plain = { h: 24, start: false, band: false }; it('puts the first band on the top edge and keeps the padding under the last row', () => { const { ys, height } = sectionOffsets([band, plain, plain], GAP, PADDING); expect(ys).toEqual([0, SECTION_BAND_HEIGHT + GAP + 2, SECTION_BAND_HEIGHT + GAP + 2 + 24 + GAP]); expect(height).toBe(ys[2]! + 24 + PADDING); }); it('keeps the padding between a group and the next band', () => { const { ys } = sectionOffsets([band, plain, band], GAP, PADDING); expect(ys[2]! - (ys[1]! + 24)).toBe(PADDING); }); it('stands folded bands on each other with one line between them', () => { const { ys, height } = sectionOffsets([band, band, band], GAP, PADDING); expect(ys).toEqual([0, SECTION_BAND_HEIGHT - 1, 2 * (SECTION_BAND_HEIGHT - 1)]); expect(height).toBe(ys[2]! + SECTION_BAND_HEIGHT); }); it('shares exactly the line the bands draw, and still ends on a whole pixel', () => { const line = 2 / 3; const { ys, height } = sectionOffsets([band, band], GAP, PADDING, line); expect(ys[1]).toBeCloseTo(SECTION_BAND_HEIGHT - line, 10); expect(height).toBe(Math.ceil(2 * SECTION_BAND_HEIGHT - line)); }); it('starts inside the padding without a band on top', () => { expect(sectionOffsets([plain, plain], GAP, PADDING).ys).toEqual([PADDING, PADDING + 24 + GAP]); }); }); describe('a column of groups', () => { it('runs a band across the padding and keeps the rows inside it', () => { const head = new SectionHeader('POSITION'); const first = row(); const stack = column(head, first); expect([head.x, head.w, head.h]).toEqual([0, 200, SECTION_BAND_HEIGHT]); expect([first.x, first.w]).toEqual([PADDING, 200 - 2 * PADDING]); expect(first.y).toBe(SECTION_BAND_HEIGHT + GAP + 2); expect(stack.measure().h).toBe(first.y + 24 + PADDING); }); it('hides the rows of a folded group, takes their room and gives both back', () => { const a = new SectionHeader('A'); const b = new SectionHeader('B'); const rows = [row(), row(), row()]; const stack = column(a, rows[0]!, rows[1]!, b, rows[2]!); const open = stack.measure().h; let told = 0; stack.onFold = () => (told += 1); a.toggle(); expect(rows.map((r) => r.visible)).toEqual([false, false, true]); expect(stack.measure().h).toBe(open - 2 * 24 - 2 * GAP - 2 - PADDING - 1); expect(told).toBe(1); stack.layout(); expect(b.y).toBe(SECTION_BAND_HEIGHT - 1); a.toggle(); expect(rows.map((r) => r.visible)).toEqual([true, true, true]); expect(stack.measure().h).toBe(open); }); it('leaves a row hidden that was hidden for its own reason', () => { const head = new SectionHeader('A'); const own = row(); own.visible = false; const other = row(); column(head, own, other); head.toggle(); head.toggle(); expect([own.visible, other.visible]).toEqual([false, true]); }); it('starts with a group folded that is built folded', () => { const rows = [row()]; column(new SectionHeader('A', { collapsed: true }), rows[0]!); expect(rows[0]!.visible).toBe(false); }); }); describe('a band', () => { it('holds its title and puts a control at its right end, inside it', () => { const head = new SectionHeader('VARIABLES'); const control = head.add(row()); control.minWidth = 40; column(head); const texts = head.children.flatMap((c) => c.children).filter((c): c is Label => c instanceof Label); expect(texts.map((label) => label.text)).toEqual(['VARIABLES']); const right = control.parent!.x + control.x + control.w; expect(right).toBe(200 - PADDING); expect(control.w).toBe(40); }); it('knows where its controls stand, so a press a disabled one lets through folds nothing', () => { const head = new SectionHeader('PROPERTIES'); const control = head.add(row()); control.minWidth = 40; control.setEnabled(false); column(head); const y = SECTION_BAND_HEIGHT / 2; expect(head.controlAt({ x: 200 - PADDING - 20, y })).toBe(true); expect(head.controlAt({ x: 200 - PADDING - 41, y })).toBe(false); expect(head.controlAt({ x: 60, y })).toBe(false); }); it('tells its owner each time it is folded or unfolded', () => { const told: boolean[] = []; const head = new SectionHeader('A', { onToggle: (on) => told.push(on) }); head.toggle(); head.toggle(); expect(told).toEqual([true, false]); expect(head.sectionCollapsed).toBe(false); }); }); describe('a list with a title', () => { const options = { onPick: () => {}, onMove: () => {} }; it('folds down to its band and opens a group in its column', () => { const told: boolean[] = []; const list = new SortableList({ ...options, title: 'Layers', collapsed: true, onFold: (on) => told.push(on), }); list.setItems( [ { id: 'a', label: 'A' }, { id: 'b', label: 'B' }, ], [], ); const note = row(); column(list, note); expect(list.measure().h).toBe(SECTION_BAND_HEIGHT); expect(list.sectionStart).toBe('inset'); expect(note.visible).toBe(false); expect(list.x).toBe(PADDING); }); it('shows its rows while it is open', () => { const list = new SortableList({ ...options, title: 'Layers' }); list.setItems([{ id: 'a', label: 'A' }], []); expect(list.measure().h).toBe(SECTION_BAND_HEIGHT + SORTABLE_ROW_HEIGHT); }); it('is no group without a title, and cannot start folded', () => { const list = new SortableList({ ...options, collapsed: true }); expect(list.sectionStart).toBeNull(); expect(list.sectionCollapsed).toBe(false); }); }); describe('the memory of folded groups', () => { beforeEach(() => resetFolds()); afterEach(() => { vi.unstubAllGlobals(); resetFolds(); }); it('names a group by panel and title, whatever the title counts', () => { expect(foldKey('inspector', 'KEY AT FRAME 12')).toBe(foldKey('inspector', 'KEY AT FRAME 3')); expect(foldKey('inspector', '2 OBJECTS SELECTED')).toBe(foldKey('inspector', '14 OBJECTS SELECTED')); expect(foldKey('inspector', 'LAYERS')).not.toBe(foldKey('tilemap', 'LAYERS')); expect(foldKey('inspector', 'POSITION')).not.toBe(foldKey('inspector', 'TRANSFORM')); }); it('writes a fold to the browser and reads it back on a new page', () => { const store = memoryStore(); vi.stubGlobal('window', { localStorage: store }); const key = foldKey('inspector', 'TRANSFORM'); expect(isFolded(key)).toBe(false); setFolded(key, true); expect(isFolded(key)).toBe(true); resetFolds(); expect(isFolded(key)).toBe(true); setFolded(key, false); resetFolds(); expect(isFolded(key)).toBe(false); }); it('remembers for the page where the browser gives no storage, and reads rubbish as nothing', () => { const key = foldKey('inspector', 'TRANSFORM'); setFolded(key, true); expect(isFolded(key)).toBe(true); resetFolds(); vi.stubGlobal('window', { localStorage: memoryStore({ 'game-maker.folded-sections': '{broken' }) }); expect(isFolded(key)).toBe(false); }); it('builds a band folded that was folded before, and remembers a click on it', () => { const store = memoryStore(); vi.stubGlobal('window', { localStorage: store }); const host = makeHost(); setFolded(foldKey('inspector', 'TRANSFORM'), true); expect(sectionHeader(host, 'TRANSFORM').sectionCollapsed).toBe(true); const position = sectionHeader(host, 'POSITION'); expect(position.sectionCollapsed).toBe(false); position.toggle(); expect(isFolded(foldKey('inspector', 'POSITION'))).toBe(true); // Another panel's group of the same name is its own. expect(sectionHeader({ ...host, foldScope: 'tilemap' }, 'TRANSFORM').sectionCollapsed).toBe(false); }); it('gives a list its fold under the title in capitals', () => { vi.stubGlobal('window', { localStorage: memoryStore() }); listFold('pixelart', 'Layers').onFold(true); expect(isFolded(foldKey('pixelart', 'LAYERS'))).toBe(true); expect(listFold('pixelart', 'Layers').collapsed).toBe(true); expect(listFold('pixelart', 'Frames').collapsed).toBe(false); }); }); function makeHost(state = new EditorState()): InspectorHost { return { state, body: new SectionStack({ gap: GAP, padding: PADDING }), fields: new Map(), nameField: null, project: () => state.project, liveVariables: () => null, }; } /** What the body shows, top to bottom: a band by its title in brackets, a row by the label it starts with. */ function outline(host: InspectorHost): string[] { const firstLabel = (node: Widget): string => { if (node instanceof Label) return node.text; for (const child of node.children) { const found = firstLabel(child as Widget); if (found) return found; } return ''; }; return (host.body.children as Widget[]).map((child) => child instanceof SectionHeader ? `[${child.title}]` : firstLabel(child), ); } describe('the groups of the inspector', () => { beforeEach(() => resetFolds()); function fixture() { const f = makeSpriteScene(); const state = new EditorState(); state.loadProject(f.project, 1); return { f, state }; } it('gives the switches of a placed object a group of their own, under its head', () => { const { f, state } = fixture(); const objectId = place(f, f.spriteId, { x: 0, y: 0 }); const host = makeHost(state); buildObjectSection(host, f.sceneId, objectId); const shown = outline(host); expect(shown.slice(0, 8)).toEqual([ '[SPRITE OBJECT]', 'Name', 'Asset', 'Parent', '[SWITCHES]', 'Enabled', 'Keep on scene load', 'Render layer', ]); expect(shown[8]).toBe('[POSITION]'); expect(shown.filter((line) => line.startsWith('['))).toEqual([ '[SPRITE OBJECT]', '[SWITCHES]', '[POSITION]', '[TRANSFORM]', '[EDITOR OFFSET (SCENE VIEW ONLY)]', '[PROPERTIES]', '[PREVIEW]', ]); }); it('puts the switches of a behavior there too, and its Open button into the band of its variables', () => { const { f, state } = fixture(); const behavior = makeBehavior(f, 'Mover', ['speed']); const objectId = place(f, behavior.id, { x: 0, y: 0 }); const host = makeHost(state); buildObjectSection(host, f.sceneId, objectId); const shown = outline(host); const from = shown.indexOf('[SWITCHES]'); const to = shown.indexOf('[POSITION]'); expect(shown.slice(from + 1, to)).toEqual([ 'Enabled', 'Keep on scene load', 'Run in editor', 'Run while paused', ]); const variables = host.body.children.find( (child): child is SectionHeader => child instanceof SectionHeader && child.title === 'VARIABLES', )!; const texts: string[] = []; const walk = (node: Widget): void => { if ('text' in node && typeof node.text === 'string') texts.push(node.text); for (const child of node.children) walk(child as Widget); }; walk(variables); expect(texts).toContain('Open'); }); it('splits the rows of an asset into switches and properties', () => { const { f, state } = fixture(); const host = makeHost(state); buildAssetSection(host, f.spriteId); const shown = outline(host); expect(shown.slice(0, 6)).toEqual([ '[SPRITE ASSET]', 'Name', '[SWITCHES]', 'Enabled', 'Keep on scene load', 'Render layer', ]); expect(shown[6]).toBe('[PROPERTIES]'); expect(shown.length).toBeGreaterThan(7); }); it('heads the rows of a behavior asset as switches, with no empty group after them', () => { const { f, state } = fixture(); const behavior = makeBehavior(f, 'Mover', ['speed']); const host = makeHost(state); buildAssetSection(host, behavior.id); expect(findAsset(f.project, behavior.id)?.type).toBe('behavior'); expect(outline(host).filter((line) => line.startsWith('['))).toEqual([ '[BEHAVIOR ASSET]', '[SWITCHES]', '[VARIABLES]', ]); }); it('builds a title as a band, in any body', () => { const host = makeHost(); addTitle(host, 'CONTENT'); expect(host.body.children[0]).toBeInstanceOf(SectionHeader); }); });