From c95fbc167e26917de6f433a812debd8bd34f5515 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 7 Oct 2026 23:25:05 +0200 Subject: [PATCH] Ping or open an object's asset from its hierarchy row (#353) The asset link in the inspector already pings an asset on a click and opens it on a double-click. The rows of the Scene objects panel only selected and folded. Now they lead to the asset the same way. - A click on the row that is selected already pings the object's asset in the asset panel. The selection stays. The ping comes on release, so picking a row up to drag it pings nothing. - A double-click or Enter on an object's row opens its asset, selected or not. - ListView has a new onReselect for a plain click on the row that is selected alone. onSelect stays quiet then, as nothing changes. Decided without asking: - A double-click on an object's row no longer folds it. The chevron and the arrow keys still do. A group's row has no asset and keeps folding on a double-click. - A click with Ctrl or Shift, and one that narrows a multi-selection to its row, does not ping. Both change the selection. - A row whose asset is missing pings and opens nothing. Findings: - The double-click window goes by time (400 ms), not by the event's detail. Two quick click() calls from the harness are a double-click. The comment in e2e 01 said otherwise and is gone. Tests: - e2e 01 (melektaus-atx): ALL PASSED, 13 checks. The two checks for folding on a double-click became one for the ping and two for opening. Counter-check without the ping: that check red. - Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4142 tests. - Not checked: Enter on a row, a group's row on a double-click. Co-Authored-By: Claude Opus 5.5 --- src/editor/panels/HierarchyPanel.ts | 24 +++++++++++++- src/ui/widgets/ListView.ts | 12 ++++++- tools/e2e/01-hierarchy.mjs | 50 +++++++++++++++++++++++------ 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index f1bdd866..cf39068d 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -14,6 +14,7 @@ import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { groupName, objectName } from '../../core/model/SceneObject'; import { deviationMarks } from '../../core/properties/deviation'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; +import { activateAsset } from '../actions'; import { groupRowItems, selectGroup } from '../objectGroups'; import { openObjectMenu } from '../objectMenu'; import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons'; @@ -59,7 +60,11 @@ export class HierarchyPanel extends Panel { }; this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot); this.list.onToggle = (id, expand) => this.setExpanded(id, expand); - this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id)); + this.list.onActivate = (id) => this.activate(id); + this.list.onReselect = (id) => { + const assetId = this.assetOfRow(id); + if (assetId) state.pingAsset(assetId); + }; this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); @@ -98,6 +103,23 @@ export class HierarchyPanel extends Panel { this.state.execute(oneStep(many ? 'Move objects' : 'Move object', commands)); } + /** + * A double-click or Enter (#353): an object's row opens its asset, as a double-click on the asset + * link in the inspector does. A group's row has no asset and folds instead. + */ + private activate(rowId: string): void { + const assetId = this.assetOfRow(rowId); + if (assetId) activateAsset(this.state, assetId); + else if (groupIdOfRow(rowId)) this.setExpanded(rowId, !this.expanded.has(rowId)); + } + + /** The asset an object's row was placed from – null for a group's row or a missing asset. */ + private assetOfRow(rowId: string): string | null { + const holder = this.holder(); + const object = holder && !groupIdOfRow(rowId) ? findObject(holder, rowId) : undefined; + return object && findAsset(this.state.project, object.assetId) ? object.assetId : null; + } + /** Ctrl-click on a group row: all members join the selection, or leave it when all were in. */ private toggleGroup(holderId: string, groupId: string): void { const holder = this.holder(); diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index ec3cbc96..75ba8652 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -93,6 +93,8 @@ interface DragState { spot: DropSpot | null; /** Pressed inside a multi-selection: a release that never travelled narrows it to this row. */ narrows: boolean; + /** Pressed on the row that was selected alone already: a release that never travelled reselects it. */ + reselects: boolean; } /** @@ -109,6 +111,11 @@ export class ListView extends Widget { private contextId: string | null = null; onSelect?: (id: string | null, modifiers?: PickModifiers) => void; onActivate?: (id: string) => void; + /** + * A plain click on the row that is selected already – `onSelect` stays quiet then, as nothing + * changes. In a list whose rows can be dragged it comes on release, so picking a row up is not it. + */ + onReselect?: (id: string) => void; /** Returning the opened menu keeps its row marked for as long as it is up. */ onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void; /** A right-click on the empty space below the rows or on a heading, which have no menu of their own. */ @@ -349,6 +356,7 @@ export class ListView extends Widget { // A press inside a multi-selection keeps it, so the selection is still there while dragging; // only a plain click (see `onDragEnd`) narrows it down to this row. const narrows = this.highlighted.size > 1 && this.highlighted.has(item.id); + const reselects = !narrows && item.id === this.selectedId; const dragged = Boolean(this.onDrop || this.onDragOut); if (narrows && !dragged) this.pick(item.id); else if (!narrows && !this.onDragOut && (!this.highlighted.has(item.id) || item.id === this.selectedId)) @@ -362,8 +370,9 @@ export class ListView extends Widget { active: false, spot: null, narrows, + reselects, }; - } + } else if (reselects) this.onReselect?.(item.id); } } @@ -411,6 +420,7 @@ export class ListView extends Widget { if (!drag?.active) { // The press never travelled, so it was a click after all – now it may select. if (drag?.narrows) this.pick(drag.id); + else if (drag?.reselects) this.onReselect?.(drag.id); else if (drag && this.onDragOut) this.select(drag.id); return; } diff --git a/tools/e2e/01-hierarchy.mjs b/tools/e2e/01-hierarchy.mjs index 083143d5..dc5414e6 100644 --- a/tools/e2e/01-hierarchy.mjs +++ b/tools/e2e/01-hierarchy.mjs @@ -4,9 +4,10 @@ import { same } from '../lib/harness.mjs'; * Hierarchy folding. A freshly opened scene is fully collapsed, so only its top-level objects * are listed (o4/oc1 hang under o1, oc2 under o2). The chevron folds a row open and shut * without touching the selection, and selecting a nested object unfolds the way down to it. + * A click on the selected row pings the object's asset, a double-click opens it (#353). */ export async function hierarchy(t) { - const { check, evaluate, json, click, clickText, doubleClick, press, select, state, shot } = t; + const { check, evaluate, json, click, clickText, doubleClick, press, select, sleep, state, shot } = t; const rowIds = () => json('__gm.hierarchyOrder()'); const rows = async () => JSON.stringify(await rowIds()); const selectionBefore = (await state()).selection; @@ -31,21 +32,50 @@ export async function hierarchy(t) { same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows(), ); - // The row itself folds on a double-click, the same as its chevron. Pixi leaves `detail` at 0 on - // a press, so this needs a real double-click from the harness, not two `click()` calls. + // 01b (#353). A click on the row that is selected already pings the object's asset in the asset + // panel, as one on the asset link in the inspector does; the first click only selects. + const assetOf = (id) => + json(`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)}).assetId`); + const o3Asset = await assetOf('o3'); + await evaluate('__gm.state.pingedAssetId = null'); + const o3Row = await json(`__gm.hierarchyRow('o3')`); + await click(o3Row.x, o3Row.y); + const afterFirst = await json('__gm.state.pingedAssetId'); + // Past the double-click window, which goes by time: two quick clicks would open the asset. + await sleep(500); + await click(o3Row.x, o3Row.y); + const ping = await json('__gm.assetPing()'); + const pingSelection = (await state()).selection; + check( + 'a click on the selected row pings its asset and keeps the selection, the first click only selects', + afterFirst === null && + ping?.id === o3Asset && + pingSelection?.kind === 'object' && + same(pingSelection.objectIds, ['o3']), + JSON.stringify({ afterFirst, ping, o3Asset, pingSelection }), + ); + // A double-click opens the asset, selected or not – it no longer folds the row. + const o2Asset = await assetOf('o2'); const row = await json(`__gm.hierarchyRow('o2')`); await doubleClick(row.x, row.y); + const o2Selection = (await state()).selection; check( - 'double-clicking the row unfolds it as well', - same(await rowIds(), ['o1', 'o2', 'oc2', 'o3', 'o5']), - await rows(), + 'double-clicking a row selects its asset and leaves the row folded', + o2Selection?.kind === 'asset' && + o2Selection.assetId === o2Asset && + same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), + JSON.stringify({ o2Selection, o2Asset, rows: await rowIds() }), ); - await doubleClick(row.x, row.y); + await select({ kind: 'object', holderId: 'scene1', objectId: 'o5' }); + const o5Row = await json(`__gm.hierarchyRow('o5')`); + await doubleClick(o5Row.x, o5Row.y); + const openedByRow = await json('__gm.documents()'); check( - 'double-clicking it again folds the subtree away', - same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), - await rows(), + 'double-clicking a selected row opens the tab of its asset', + openedByRow.active === 'asset:spawner', + JSON.stringify(openedByRow), ); + await evaluate(`__gm.closeDocument('asset:spawner'); __gm.state.select(null)`); await select({ kind: 'object', holderId: 'scene1', objectId: 'oc1' }); check(