Context 메뉴와 지연 처리로 트리 안전하게 수정하기
UI에서는 Hierarchy를 그리는 동안 컨테이너를 바꾸면 이터레이터가 무효화됩니다. 이 글에서는 우클릭 컨텍스트 메뉴에서 삭제·복제·재부모화를 명령으로 큐에 넣고, 트리 순회가 끝난 뒤 적용하는 패턴을 정리합니다.
완성할 예제
예제는 다음을 포함합니다.
노드 우클릭 → BeginPopupContextItem
PendingOp에 상태 저장 (Delete / Duplicate / Reparent)
프레임 종료 flushPendingOps()
앞서 선택된 트리노드의 ID가 지워진 노드를 가리키지 않도록 정리
읽고 나면 순회 중 크래시 없이 Hierarchy 편집 UX를 붙일 수 있습니다.
왜 즉시 erase가 위험한가
전형적인 실패 코드입니다.
Cpp
for (auto& child : node.children) { DrawNode(*child); if (should_delete) { // 순회 중 erase → 미정의 동작 / 크래시 } }
ImGui는 매 프레임 트리를 다시 그립니다. 그 프레임의 그리기 스택·열림 상태와 데이터 구조가 어긋나면 디버깅이 어렵습니다. UI 출력 과정 과 데이터 변경 작업 을 시간적으로 분리하는 것이 안전합니다.
명령 큐 모델
Cpp
enum class OpType { Delete, Duplicate, Reparent }; struct PendingOp { OpType type; std::string target_id; std::string parent_id; // Reparent용 }; struct SceneDocument { std::unique_ptr<SceneNode> root; std::string selected_id; std::vector<PendingOp> pending; };
UI는 큐에만 push합니다.
Cpp
void RequestDelete(SceneDocument& doc, const std::string& id) { doc.pending.push_back({OpType::Delete, id, {}}); }
컨텍스트 메뉴와 트리
Cpp
void DrawSceneNode(SceneNode& node, SceneDocument& doc) { ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth; if (node.children.empty()) { flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; } if (node.id == doc.selected_id) { flags |= ImGuiTreeNodeFlags_Selected; } const bool open = ImGui::TreeNodeEx( node.id.c_str(), flags, "%s", node.name.c_str()); if (ImGui::IsItemClicked()) { doc.selected_id = node.id; } // 아이템 기준 컨텍스트 메뉴 if (ImGui::BeginPopupContextItem()) { if (ImGui::MenuItem("Duplicate")) { doc.pending.push_back( {OpType::Duplicate, node.id, {}}); } if (ImGui::MenuItem("Delete")) { RequestDelete(doc, node.id); } ImGui::EndPopup(); } if (open && !node.children.empty()) { for (auto& child : node.children) { DrawSceneNode(*child, doc); } ImGui::TreePop(); } }
BeginPopupContextItem은 직전 아이템에 묶입니다. TreeNodeEx 직후에 호출하는 것이 중요합니다.
프레임 끝에서 flush
Hierarchy / Properties등 UI를 모두 그린 뒤, 예정된 상태에 대한 업데이트 단계에서 작업 큐(Queue)를 비웁니다.
Cpp
SceneNode* FindMutable(SceneNode& node, const std::string& id); void EraseChildById(SceneNode& parent, const std::string& id); void DuplicateNode(SceneDocument& doc, const std::string& id); void FlushPendingOps(SceneDocument& doc) { std::vector<PendingOp> ops; ops.swap(doc.pending); for (const auto& op : ops) { switch (op.type) { case OpType::Delete: if (doc.root && doc.root->id == op.target_id) { break; // 루트 삭제 정책은 별도 } if (doc.root) { // 부모를 찾아 erase — 재귀 헬퍼로 구현 // EraseById(*doc.root, op.target_id); } if (doc.selected_id == op.target_id) { doc.selected_id.clear(); } break; case OpType::Duplicate: DuplicateNode(doc, op.target_id); break; case OpType::Reparent: // parent_id 아래로 이동 break; } } }
메인 루프 스케치:
Cpp
DrawHierarchy(doc); DrawProperties(doc); FlushPendingOps(doc);
Delete 확인 모달
실수로 지우는 것을 막으려면 큐에 넣기 전 확인 창을 둡니다.
Cpp
static std::string s_delete_candidate; // 메뉴에서: // s_delete_candidate = node.id; ImGui::OpenPopup("ConfirmDelete"); if (ImGui::BeginPopupModal("ConfirmDelete", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::Text("Delete selected node?"); if (ImGui::Button("OK")) { RequestDelete(doc, s_delete_candidate); s_delete_candidate.clear(); ImGui::CloseCurrentPopup(); } ImGui::SameLine(); if (ImGui::Button("Cancel")) { ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); }
모달에서도 즉시 erase하지 않고 큐에만 넣습니다.
설계 체크리스트
트리 순회와 컨테이너 수정을 같은 루프에 두지 않습니다.
큐에는 포인터 대신 안정 ID를 저장합니다.
BeginPopupContextItem은 대상 아이템 직후에 호출합니다.
flush는 모든 트리 UI가 끝난 뒤에 수행합니다.
삭제 후 selected_id / 클립보드 참조를 정리합니다.
Undo를 넣을 계획이면 PendingOp를 커맨드 패턴으로 확장합니다.
자주 발생하는 문제
메뉴가 안 열림
TreeNode의 클릭 영역이 좁거나, OpenOnArrow만 있어 아이템 클릭이 먹지 않는 경우에는 SpanAvailWidth와 클릭 정책을 조정합니다.
한 번 삭제에 두 번 적용됨
flush를 두 번 호출하거나, 메뉴가 두 프레임에 걸쳐 push하는 경우입니다. swap으로 스냅샷을 분리했는지 확인합니다.
팝업 ID 충돌
같은 라벨의 팝업이 여러 노드에서 열립니다. OpenPopupOnItemClick과 노드 ID를 조합하거나 BeginPopupContextItem("##ctx")처럼 ID를 명시합니다.