diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index dffd376f4..e36f6bb41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -159,6 +159,7 @@ mod priority { mod materialize { use super::llm_contract::Node; + use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::node::{ Node as LayoutNode, NodeMetadata, NodeSource, StageStatus, }; @@ -288,6 +289,7 @@ mod materialize { source: NodeSource::Llm, }, components: Vec::new(), + children_display_mode: ChildrenDisplayMode::Exclusive, children: members.into_iter().map(|member| member.node).collect(), }, priority, @@ -385,3 +387,70 @@ pub(crate) async fn merge_ui_impl(state: State) -> Result { })?; Ok(MergeDTO { ui_tree }) } + +#[cfg(test)] +mod tests { + use super::llm_contract::{MergedNode, Node as PlanNode, SimpleNode}; + use super::materialize; + use crate::ui_editor::component::Component; + use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; + use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus}; + use crate::ui_editor::layout::transform::Transform; + use crate::ui_editor::state::{State, UITree}; + use crate::ui_editor::utils::{NodeId, UIDesignImageId}; + use std::collections::HashMap; + + fn node(id: &str, children: Vec) -> Node { + Node { + id: NodeId::new(id).expect("valid node id"), + transform: Transform::stretch(), + metadata: NodeMetadata { + name: id.to_string(), + description: String::new(), + layout_status: StageStatus::Passed, + components_status: StageStatus::Passed, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: NodeSource::Human, + }, + components: Vec::::new(), + children_display_mode: ChildrenDisplayMode::Stack, + children, + } + } + + #[test] + fn merged_materialization_sets_exclusive_children_display_mode() { + let page_id = UIDesignImageId::new("page").expect("valid page id"); + let state = State { + ui_trees: vec![UITree { + src_ui_design: page_id.clone(), + root: node("root", vec![node("a", vec![]), node("b", vec![])]), + }], + ui_design_images: HashMap::new(), + sprite_assets: HashMap::new(), + font_assets: HashMap::new(), + }; + let plan = PlanNode::Merged(MergedNode { + name: "状态容器".to_string(), + description: String::new(), + merged_from: vec![ + PlanNode::Simple(SimpleNode { + id: NodeId::new("a").expect("valid node id"), + children: vec![], + }), + PlanNode::Simple(SimpleNode { + id: NodeId::new("b").expect("valid node id"), + children: vec![], + }), + ], + }); + + let result = materialize::plan(plan, &state, &[1]).expect("materialize plan"); + assert_eq!( + result.root.children_display_mode, + ChildrenDisplayMode::Exclusive + ); + assert_eq!(result.root.children.len(), 2); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index 048b135fd..0485f6013 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -1,5 +1,6 @@ use crate::config::build_game_creator_llm_client_from_config; use crate::ui_editor::commands::utils::strict_json_schema; +use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::dimension::UIRect; use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus}; use crate::ui_editor::layout::transform::Transform; @@ -280,6 +281,7 @@ fn convert_node( }, // TODO: 等识别 DTO 增加 type 字段后,再映射 Image/Text Component。 components: Vec::new(), + children_display_mode: ChildrenDisplayMode::Stack, children, }) } @@ -651,6 +653,7 @@ pub(crate) async fn recognize_ui_impl( }, // TODO: root components can host page-level background content once component preview is supported. components: Vec::new(), + children_display_mode: ChildrenDisplayMode::Stack, children, }; // Tree identity and root identity are assigned by Rust, never chosen by the model. diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/children_display_mode.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/children_display_mode.rs new file mode 100644 index 000000000..d57d6d272 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/children_display_mode.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub enum ChildrenDisplayMode { + Stack, + Exclusive, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs index aa492c350..1a7e4f471 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs @@ -1,3 +1,4 @@ +pub mod children_display_mode; pub mod dimension; pub mod node; pub mod transform; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs index bd0746fd0..3779b49aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -1,4 +1,5 @@ use crate::ui_editor::component::Component; +use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::utils::NodeId; use serde::{Deserialize, Serialize}; @@ -11,6 +12,7 @@ pub struct Node { pub transform: Transform, pub metadata: NodeMetadata, pub components: Vec, + pub children_display_mode: ChildrenDisplayMode, pub children: Vec, } diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts new file mode 100644 index 000000000..7ed4b3362 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts @@ -0,0 +1,2 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +export type ChildrenDisplayMode = 'Stack' | 'Exclusive'; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts index a7d3b1032..6677e4733 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts @@ -1,7 +1,15 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Component } from "./Component"; -import type { NodeId } from "./NodeId"; -import type { NodeMetadata } from "./NodeMetadata"; -import type { Transform } from "./Transform"; +import type { ChildrenDisplayMode } from './ChildrenDisplayMode'; +import type { Component } from './Component'; +import type { NodeId } from './NodeId'; +import type { NodeMetadata } from './NodeMetadata'; +import type { Transform } from './Transform'; -export type Node = { id: NodeId, transform: Transform, metadata: NodeMetadata, components: Array, children: Array, }; +export type Node = { + id: NodeId; + transform: Transform; + metadata: NodeMetadata; + components: Array; + children_display_mode: ChildrenDisplayMode; + children: Array; +}; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index bf81a6fc0..2cb34d10d 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from 'react'; import { validateSpriteBorder } from './spriteBorder'; +import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode'; import type { Component } from './types/Component'; import type { FontAsset } from './types/FontAsset'; import type { FontAssetId } from './types/FontAssetId'; @@ -108,6 +109,7 @@ function createPageRoot(state: State): Node { source: 'System', }, components: [], + children_display_mode: 'Stack', children: [], }; } @@ -131,6 +133,7 @@ function createHumanNode(state: State): Node { source: 'Human', }, components: [], + children_display_mode: 'Stack', children: [], }; } @@ -1151,6 +1154,35 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [commit, guard], ); + const setNodeChildrenDisplayMode = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + childrenDisplayMode: ChildrenDisplayMode, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + if (!findNodeLocation(tree.root, nodeId)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + const node = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + node.children_display_mode = childrenDisplayMode; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + const moveNode = useCallback( ({ sourceTreeId, @@ -1378,6 +1410,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { deleteComponent, moveComponent, setNodeMetadata, + setNodeChildrenDisplayMode, moveNode, removeDesignImage, removeSpriteAsset, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 49d76bfb4..e560882a5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -63,6 +63,7 @@ export function InputSidebar({ source: 'System', }, components: [], + children_display_mode: 'Stack', children: editor.state.ui_trees.map((tree) => tree.root), }; }, [editor.state.ui_trees]); @@ -314,7 +315,7 @@ export function InputSidebar({ selectedNodeId={selectedNodeId} focusRequest={focusRequest} treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null} - hiddenNodeIds={controller.hiddenNodeIds} + isNodePreviewVisible={controller.isNodePreviewVisible} onSelectNode={(treeId, nodeId) => { controller.selectDesignImage(treeId); controller.selectNode(nodeId); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index c7b74eb36..d66efb0d4 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -82,6 +82,7 @@ export function InspectorSidebar({ onSetNodePreviewVisible={(visible) => controller.setNodePreviewVisible(view.node.id, visible) } + onChildrenDisplayModeChange={controller.setNodeChildrenDisplayMode} transformReadOnly={ controller.editor.isLocked || view.node.id === controller.treeForActiveImage?.root.id @@ -228,6 +229,7 @@ function NodeInspector({ readOnly, isNodePreviewVisible, onSetNodePreviewVisible, + onChildrenDisplayModeChange, transformReadOnly, keepChildrenUnchanged, onKeepChildrenUnchangedChange, @@ -247,6 +249,7 @@ function NodeInspector({ readOnly: boolean; isNodePreviewVisible: boolean; onSetNodePreviewVisible: (visible: boolean) => void; + onChildrenDisplayModeChange: UiEditorPageController['setNodeChildrenDisplayMode']; transformReadOnly: boolean; keepChildrenUnchanged: boolean; onKeepChildrenUnchangedChange: (value: boolean) => void; @@ -271,6 +274,24 @@ function NodeInspector({ /> 在预览中显示 + {node.children.length > 0 ? ( + + ) : null}
UIDesignImageId | null; - hiddenNodeIds: ReadonlySet; + isNodePreviewVisible: (nodeId: NodeId) => boolean; onSelectNode: (treeId: UIDesignImageId, id: NodeId) => void; onToggleNodeVisibility: (id: NodeId) => void; onInsertNode: (treeId: UIDesignImageId, parentId: NodeId) => void; @@ -38,18 +38,18 @@ function TreeRow({ style, dragHandle, onSelectNode, - hiddenNodeIds, + isNodeVisible, onToggleNodeVisibility, onOpenContextMenu, }: NodeRendererProps & { onSelectNode: (id: NodeId) => void; - hiddenNodeIds: ReadonlySet; + isNodeVisible: (nodeId: NodeId) => boolean; onToggleNodeVisibility: (id: NodeId) => void; onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void; }) { const data = node.data; const nodeLabel = data.metadata.name || '未命名节点'; - const isVisible = !hiddenNodeIds.has(data.id); + const isVisible = isNodeVisible(data.id); return (
nodeId === root.id diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/ExclusiveChildrenTabs.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/ExclusiveChildrenTabs.tsx new file mode 100644 index 000000000..86819e94a --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/ExclusiveChildrenTabs.tsx @@ -0,0 +1,51 @@ +import type { Node as UiNode } from '../../../../features/ui-editor/types/Node'; +import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; + +type ExclusiveChildrenTabsProps = { + parent: UiNode; + isChildVisible: (nodeId: NodeId) => boolean; + onToggleChild: (nodeId: NodeId) => void; +}; + +export function ExclusiveChildrenTabs({ + parent, + isChildVisible, + onToggleChild, +}: ExclusiveChildrenTabsProps) { + if ( + parent.children_display_mode !== 'Exclusive' || + parent.children.length === 0 + ) { + return null; + } + + return ( +
+ {parent.children.map((child, index) => { + const label = child.metadata.name || `未命名子节点 ${index + 1}`; + const selected = isChildVisible(child.id); + return ( + + ); + })} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index b6fae7ad2..33e636ce2 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -23,6 +23,7 @@ import { } from 'react'; import type { UiEditorPageController } from '../../useUiEditorPage'; +import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs'; import { findNodePageContext } from './nodeTransformGeometry'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; @@ -302,6 +303,17 @@ export function PreviewWorkspace({ logicalSize && (renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? (
+ {renderMode === 'final-preview' && controller.selectedNode ? ( + + controller.isNodePreviewVisible(nodeId) + } + onToggleChild={(nodeId) => + controller.toggleNodePreviewVisibility(nodeId) + } + /> + ) : null} !hiddenNodeIds.has(child.id))?.id ?? + null) + : null; return (
))} - {node.children.map((child) => ( - - ))} + {node.children.map((child) => + exclusiveVisibleChildId && + child.id !== exclusiveVisibleChildId ? null : ( + + ), + )} {isEditorOverlay && !isRoot && selectedNodeId === node.id ? RESIZE_HANDLES.map((handle) => (
, + ancestorsVisible = true, +): boolean | null { + const nodeVisible = ancestorsVisible && !hidden.has(node.id); + if (node.id === targetNodeId) return nodeVisible; + const exclusiveChildId = + node.children_display_mode === 'Exclusive' + ? (node.children.find((child) => !hidden.has(child.id))?.id ?? null) + : null; + for (const child of node.children) { + const visible = isUiNodeEffectivelyVisible( + child, + targetNodeId, + hidden, + nodeVisible && + (node.children_display_mode !== 'Exclusive' || + child.id === exclusiveChildId), + ); + if (visible !== null) return visible; + } + return null; +} + export type PendingWorkflowStepChange = { from: UiEditorStepId; to: UiEditorStepId; @@ -284,31 +324,87 @@ export function useUiEditorPage( }, [editor.state.ui_trees]); const isNodePreviewVisible = useCallback( - (nodeId: NodeId) => !hiddenNodeIds.has(nodeId), - [hiddenNodeIds], + (nodeId: NodeId) => { + for (const tree of editor.state.ui_trees) { + const visible = isUiNodeEffectivelyVisible( + tree.root, + nodeId, + hiddenNodeIds, + ); + if (visible !== null) return visible; + } + return !hiddenNodeIds.has(nodeId); + }, + [editor.state.ui_trees, hiddenNodeIds], ); const setNodePreviewVisible = useCallback( (nodeId: NodeId, visible: boolean) => { setHiddenNodeIds((current) => { - const currentlyVisible = !current.has(nodeId); + let location: { node: UiNode; parent: UiNode | null } | null = null; + for (const tree of editor.state.ui_trees) { + location = findUiNodeLocation(tree.root, nodeId); + if (location) break; + } + const parent = location?.parent; + const isExclusiveChild = + parent?.children_display_mode === 'Exclusive' && + parent.children.some((child) => child.id === nodeId); + const currentlyVisible = isExclusiveChild + ? parent.children.some( + (child) => + child.id === nodeId && + !current.has(child.id) && + !current.has(parent.id), + ) + : !current.has(nodeId); if (currentlyVisible === visible) return current; const next = new Set(current); - if (visible) next.delete(nodeId); - else next.add(nodeId); + if (isExclusiveChild) { + if (visible) { + for (const child of parent.children) { + if (child.id === nodeId) next.delete(child.id); + else next.add(child.id); + } + } else { + for (const child of parent.children) next.add(child.id); + } + } else if (visible) { + next.delete(nodeId); + } else { + next.add(nodeId); + } return next; }); }, - [], + [editor.state.ui_trees], ); const toggleNodePreviewVisibility = useCallback( (nodeId: NodeId) => { - setNodePreviewVisible(nodeId, !isNodePreviewVisible(nodeId)); + let parent: UiNode | null = null; + for (const tree of editor.state.ui_trees) { + const location = findUiNodeLocation(tree.root, nodeId); + if (location) { + parent = location.parent; + break; + } + } + const isExclusiveChild = + parent?.children_display_mode === 'Exclusive' && + parent.children.some((child) => child.id === nodeId); + const visible = isExclusiveChild + ? isNodePreviewVisible(nodeId) + : !hiddenNodeIds.has(nodeId); + setNodePreviewVisible(nodeId, !visible); }, - [isNodePreviewVisible, setNodePreviewVisible], + [ + editor.state.ui_trees, + hiddenNodeIds, + isNodePreviewVisible, + setNodePreviewVisible, + ], ); - const findNodeContext = useCallback(function findNodeContext( node: UiNode, nodeId: NodeId, @@ -592,6 +688,17 @@ export function useUiEditorPage( return result; } + function setNodeChildrenDisplayMode(mode: ChildrenDisplayMode) { + if (!activeImageId || !selectedNodeId) return; + const result = editor.setNodeChildrenDisplayMode( + activeImageId, + selectedNodeId, + mode, + ); + if (!result.ok) setStatus('子节点显示模式更新失败。'); + return result; + } + function setNodeComponents(components: Component[]) { if (!activeImageId || !selectedNodeId) return; const result = editor.setNodeComponents( @@ -893,6 +1000,7 @@ export function useUiEditorPage( setNodeTransform, updateNodeTransform, setNodeMetadata, + setNodeChildrenDisplayMode, setNodeComponents, insertNodeComponent, deleteNodeComponent, diff --git a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts index db6108fa9..59ccdf0ca 100644 --- a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts @@ -30,6 +30,7 @@ function node(id: string, status: StageStatus, children: Node[] = []): Node { source: 'System', }, components: [], + children_display_mode: 'Stack', children, }; } diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 01f717feb..852d75b1b 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -223,4 +223,56 @@ describe('UiEditorPage', () => { act(() => result.current.clearState()); expect(result.current.hiddenNodeIds.size).toBe(0); }); + + it('shares exclusive child visibility between tree actions and final preview state', () => { + const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor')); + act(() => { + result.current.editor.addDesignImages([ + { + id: 'page', + image: { + metadata: { + name: 'Page', + description: '', + role: 'Page', + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [320, 180], + pixels_per_unit: 1, + }, + }, + ]); + result.current.selectDesignImage('page'); + }); + const rootId = result.current.treeForActiveImage!.root.id; + let parentId: string | undefined; + let childAId: string | undefined; + let childBId: string | undefined; + let childBDescendantId: string | undefined; + act(() => { + parentId = result.current.insertNode(rootId)?.value; + childAId = result.current.insertNode(parentId!)?.value; + childBId = result.current.insertNode(parentId!)?.value; + childBDescendantId = result.current.insertNode(childBId!)?.value; + }); + act(() => result.current.selectNode(parentId!)); + act(() => result.current.setNodeChildrenDisplayMode('Exclusive')); + + expect(parentId && childAId && childBId).toBeTruthy(); + expect(result.current.isNodePreviewVisible(childAId!)).toBe(true); + expect(result.current.isNodePreviewVisible(childBId!)).toBe(false); + expect(result.current.isNodePreviewVisible(childBDescendantId!)).toBe( + false, + ); + + act(() => result.current.toggleNodePreviewVisibility(childAId!)); + expect(result.current.isNodePreviewVisible(childAId!)).toBe(false); + expect(result.current.isNodePreviewVisible(childBId!)).toBe(false); + + act(() => result.current.toggleNodePreviewVisibility(childBId!)); + expect(result.current.isNodePreviewVisible(childAId!)).toBe(false); + expect(result.current.isNodePreviewVisible(childBId!)).toBe(true); + expect(result.current.isNodePreviewVisible(childBDescendantId!)).toBe(true); + }); }); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 7b4450777..6fd1bb32e 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom -import { render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { UITree } from '../src/features/ui-editor/types/UITree'; +import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ExclusiveChildrenTabs'; import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer'; function node(id: string, children: UiNode[] = []): UiNode { @@ -26,10 +27,15 @@ function node(id: string, children: UiNode[] = []): UiNode { source: 'System', }, components: [], + children_display_mode: 'Stack', children, }; } +function exclusiveNode(id: string, children: UiNode[]): UiNode { + return { ...node(id, children), children_display_mode: 'Exclusive' }; +} + const tree: UITree = { src_ui_design: 'page', root: node('root', [node('child', [node('grandchild')])]), @@ -81,4 +87,76 @@ describe('UI tree preview visibility', () => { const finalPreview = renderTree('final-preview', new Set(['root'])); expect(finalPreview.container.querySelector('[data-node-id]')).toBeNull(); }); + + it('renders only the first unhidden child for an exclusive parent', () => { + const exclusiveTree: UITree = { + src_ui_design: 'page', + root: exclusiveNode('root', [node('child-a'), node('child-b')]), + }; + const renderExclusive = (hiddenNodeIds: ReadonlySet) => + render( + , + ); + + const first = renderExclusive(new Set()); + expect( + first.container.querySelector('[data-node-id="child-a"]'), + ).toBeTruthy(); + expect( + first.container.querySelector('[data-node-id="child-b"]'), + ).toBeNull(); + first.unmount(); + + const second = renderExclusive(new Set(['child-a'])); + expect( + second.container.querySelector('[data-node-id="child-a"]'), + ).toBeNull(); + expect( + second.container.querySelector('[data-node-id="child-b"]'), + ).toBeTruthy(); + second.unmount(); + + const none = renderExclusive(new Set(['child-a', 'child-b'])); + expect(none.container.querySelector('[data-node-id="child-a"]')).toBeNull(); + expect(none.container.querySelector('[data-node-id="child-b"]')).toBeNull(); + }); + + it('uses a highlighted tab bar for exclusive child switching', () => { + const parent = exclusiveNode('parent', [node('child-a'), node('child-b')]); + const onToggleChild = vi.fn(); + render( + nodeId === 'child-a'} + onToggleChild={onToggleChild} + />, + ); + + expect( + screen + .getByRole('tab', { name: 'child-a' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect( + screen + .getByRole('tab', { name: 'child-b' }) + .getAttribute('aria-selected'), + ).toBe('false'); + fireEvent.click(screen.getByRole('tab', { name: 'child-b' })); + expect(onToggleChild).toHaveBeenCalledWith('child-b'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index b1895c657..6cec46670 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -99,6 +99,7 @@ function pageRoot(id: string, children: Node[] = []): Node { source: 'System', }, components: [], + children_display_mode: 'Stack', children, }; } @@ -345,6 +346,7 @@ describe('useUiEditorState', () => { source: 'System', }, components: [], + children_display_mode: 'Stack', children: [nodeWithSprite('panel')], }, }, diff --git a/docs/technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md b/docs/technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md new file mode 100644 index 000000000..1151c214b --- /dev/null +++ b/docs/technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md @@ -0,0 +1,26 @@ +# UI 编辑器子节点显示规则 + +## 规则 + +`Node.children_display_mode` 控制父节点直接 children 的预览显示方式: + +- `Stack`:所有直接 children 按现有树结构显示。 +- `Exclusive`:直接 children 中允许显示 0 或 1 个。 + +`Merged` 合并容器由 Rust materialize 阶段直接设置为 `Exclusive`,不由 LLM 决定,也不需要历史数据迁移。 + +## 状态与交互 + +编辑器预览可见性仍是按界面图隔离的临时状态。Tree Panel 的节点眼睛操作和最终预览的互斥 tab bar 必须共用同一套可见性状态: + +```text +[子节点 A] [子节点 B] [子节点 C] +``` + +当前可见 child 高亮;点击其它 child 会隐藏兄弟并显示目标;点击当前高亮 child 会将其隐藏,允许进入全部隐藏状态。没有 child 可见时不高亮任何 tab。 + +最终预览只在当前选中的父节点为 `Exclusive` 时显示 tab bar。Inspector 只提供“子节点显示模式:叠加 / 互斥”选择,不增加独立的当前状态或隐藏按钮。 + +## 默认行为 + +`Exclusive` 节点没有显式隐藏状态时,最终预览按 children 顺序显示第一个 child。首次通过 Tree Panel 或 tab bar 修改后,状态由共享可见性状态明确记录。