From a907ec018213734229081036c2c9a27e84800c63 Mon Sep 17 00:00:00 2001 From: indigo Date: Mon, 3 Aug 2026 10:03:05 +0800 Subject: [PATCH] Add collapsible Sublayers section to Scene Hierarchy panel Puts the sublayer stack directly under the edit-target combo in the Scene Hierarchy, so muting and reordering no longer require switching to the separate Stage Editor window. Each row mirrors StageEditorPanel: drag handle with drag-drop reorder, dirty indicator, name (dimmed when muted, [RL] badge for render-layer sublayers), edit-target checkbox and mute checkbox. Adds dedicated up/down chevron icon buttons for reordering, which the Stage Editor only exposed as context-menu items. Reorder goes through the existing LayerReorderCommand so it is undoable; mute calls LayerManager directly, matching current app-wide behaviour. New Icon::ChevronUp / ChevronDown with matching SVGs. Co-Authored-By: Claude Opus 5 --- resources/icons/chevron-down.svg | 1 + resources/icons/chevron-up.svg | 1 + src/ui/IconManager.cpp | 3 + src/ui/IconManager.h | 3 + src/ui/SceneHierarchyPanel.cpp | 276 +++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 resources/icons/chevron-down.svg create mode 100644 resources/icons/chevron-up.svg diff --git a/resources/icons/chevron-down.svg b/resources/icons/chevron-down.svg new file mode 100644 index 0000000..e6e3bed --- /dev/null +++ b/resources/icons/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/chevron-up.svg b/resources/icons/chevron-up.svg new file mode 100644 index 0000000..f105c34 --- /dev/null +++ b/resources/icons/chevron-up.svg @@ -0,0 +1 @@ + diff --git a/src/ui/IconManager.cpp b/src/ui/IconManager.cpp index 7a8b899..0738faa 100644 --- a/src/ui/IconManager.cpp +++ b/src/ui/IconManager.cpp @@ -61,6 +61,8 @@ static const char* IconFilename(Icon icon) { case Icon::FilePlus: return "file-plus.svg"; case Icon::Pen: return "pen.svg"; case Icon::Settings: return "gear.svg"; + case Icon::ChevronUp: return "chevron-up.svg"; + case Icon::ChevronDown: return "chevron-down.svg"; default: return nullptr; } } @@ -77,6 +79,7 @@ static constexpr Icon kAllIcons[] = { Icon::SkipBack, Icon::StepBack, Icon::PlayBack, Icon::Play, Icon::Pause, Icon::StepForward, Icon::SkipEnd, Icon::Loop, Icon::Bounce, Icon::Refresh, Icon::FilePlus, Icon::Pen, Icon::Settings, + Icon::ChevronUp, Icon::ChevronDown, }; // --------------------------------------------------------------------------- diff --git a/src/ui/IconManager.h b/src/ui/IconManager.h index 47ed9e4..7fb9bbf 100644 --- a/src/ui/IconManager.h +++ b/src/ui/IconManager.h @@ -55,6 +55,9 @@ enum class Icon { FilePlus, // create new file Pen, // edit / edit target Settings, // gear / viewport options + // List reordering + ChevronUp, // move item up + ChevronDown, // move item down }; /// Loads SVG files from disk, rasterizes them with NanoSVG, uploads them as diff --git a/src/ui/SceneHierarchyPanel.cpp b/src/ui/SceneHierarchyPanel.cpp index afa76ce..2e63a82 100644 --- a/src/ui/SceneHierarchyPanel.cpp +++ b/src/ui/SceneHierarchyPanel.cpp @@ -8,6 +8,7 @@ #include "../core/commands/RenamePrimCommand.h" #include "../core/commands/ReparentPrimCommand.h" #include "../core/commands/GroupPrimsCommand.h" +#include "../core/commands/LayerCommands.h" #include #include #include @@ -309,6 +310,8 @@ void SceneHierarchyPanel::Render() { } } + bool renderLayerActive = m_renderLayerManager && !m_renderLayerManager->IsDefaultActive(); + ImGui::BeginDisabled(renderLayerActive); ImGui::SetNextItemWidth(-1.0f); if (ImGui::BeginCombo("##layerswitch", currentLabel.c_str(), ImGuiComboFlags_HeightRegular)) { @@ -326,9 +329,16 @@ void SceneHierarchyPanel::Render() { } ImGui::EndCombo(); } + bool comboHovered = ImGui::IsItemHovered(); + ImGui::EndDisabled(); + if (renderLayerActive && comboHovered) + ImGui::SetTooltip("Edit target is locked to the active render layer"); ImGui::Spacing(); } + RenderSublayerSection(); + ImGui::Spacing(); + auto paths = m_propertyManager->GetPrimPaths(); if (paths.empty()) { ImGui::TextDisabled("No prims in stage"); @@ -582,6 +592,272 @@ void SceneHierarchyPanel::Render() { ProcessPendingReplaceRef(); } +// --------------------------------------------------------------------------- +void SceneHierarchyPanel::RenderSublayerSection() { + if (!ImGui::CollapsingHeader("Sublayers", ImGuiTreeNodeFlags_DefaultOpen)) + return; + + if (!m_layerManager) { + ImGui::TextDisabled(" No layer manager"); + return; + } + + auto sublayers = m_layerManager->GetSublayers(); + if (sublayers.empty()) { + ImGui::TextDisabled(" No sublayers"); + return; + } + + const float rowHeight = ImGui::GetTextLineHeightWithSpacing(); + const int visibleRows = std::min(static_cast(sublayers.size()), 6); + float tableHeight = rowHeight * (visibleRows + 1); // +1 for header row + + if (!ImGui::BeginTable("HierarchySublayerTable", 6, + ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_ScrollY, + ImVec2(0, tableHeight))) + return; + + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("##drag", ImGuiTableColumnFlags_WidthFixed, 14.0f); + ImGui::TableSetupColumn("##dirty", ImGuiTableColumnFlags_WidthFixed, 14.0f); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##et", ImGuiTableColumnFlags_WidthFixed, 28.0f); + ImGui::TableSetupColumn("Muted", ImGuiTableColumnFlags_WidthFixed, 55.0f); + ImGui::TableSetupColumn("##order", ImGuiTableColumnFlags_WidthFixed, 44.0f); + + // Custom header row: pen icon for the ET column, text for the rest. + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + ImGui::TableSetColumnIndex(2); ImGui::TableHeader("Name"); + ImGui::TableSetColumnIndex(3); + { + float cellW = ImGui::GetContentRegionAvail().x; + float iconW = 14.0f; + float off = (cellW - iconW) * 0.5f; + if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off); + if (m_iconManager) + ImGui::Image(ImTextureRef(m_iconManager->Get(Icon::Pen)), ImVec2(iconW, iconW)); + else + ImGui::TextUnformatted("ET"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Edit Target"); + } + ImGui::TableSetColumnIndex(4); ImGui::TableHeader("Muted"); + ImGui::TableSetColumnIndex(5); ImGui::TableHeader("Order"); + + for (int i = 0; i < static_cast(sublayers.size()); i++) { + const auto& li = sublayers[i]; + ImGui::TableNextRow(); + ImGui::PushID(i); + + // Col 0: invisible span-all Selectable for row selection / drag / context menu. + ImGui::TableSetColumnIndex(0); + ImVec2 rowStart = ImGui::GetCursorScreenPos(); // save before Selectable moves cursor + + bool isSelected = (m_sublayerSelectedIdx == i); + if (ImGui::Selectable("##row", isSelected, + ImGuiSelectableFlags_SpanAllColumns | + ImGuiSelectableFlags_AllowOverlap, + ImVec2(0, 0))) + m_sublayerSelectedIdx = i; + + // Context menu: must come immediately after the span-all Selectable so + // BeginPopupContextItem sees it as the "last item" and responds to + // right-click anywhere in the row. + RenderSublayerContextMenu(i, sublayers); + + // Drag source: also attached to the Selectable (left-button drag). + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + ImGui::SetDragDropPayload("HIER_SUBLAYER_IDX", &i, sizeof(int)); + ImGui::Text("%s", li.displayName.c_str()); + ImGui::EndDragDropSource(); + } + + // Drop target on the Selectable. + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* payload = + ImGui::AcceptDragDropPayload("HIER_SUBLAYER_IDX")) { + int src = *static_cast(payload->Data); + int dst = i; + if (src != dst) { + std::vector before, after; + for (const auto& sl : sublayers) before.push_back(sl.identifier); + after = before; + std::string moved = after[src]; + after.erase(after.begin() + src); + after.insert(after.begin() + dst, moved); + if (m_commandHistory) { + m_commandHistory->Push(std::make_unique( + m_layerManager, before, after)); + } else { + LayerReorderCommand cmd(m_layerManager, before, after); + static_cast(cmd).Execute(); + } + } + } + ImGui::EndDragDropTarget(); + } + + // "=" drag handle: overlay at col 0 using the saved screen position so it + // sits inside the row, not below it (TableSetColumnIndex only resets X). + ImGui::SetCursorScreenPos(rowStart); + ImGui::TextDisabled("="); + + // Col 1: dirty indicator + ImGui::TableSetColumnIndex(1); + if (li.isDirty) + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.2f, 1.0f), "*"); + else + ImGui::TextDisabled(" "); + + bool isRenderLayer = RenderLayerManager::IsRenderLayerSublayer(li.layer); + bool renderLayerActive = m_renderLayerManager && !m_renderLayerManager->IsDefaultActive(); + + // Col 2: layer name + ImGui::TableSetColumnIndex(2); + ImVec4 nameColor = li.isMuted + ? ImVec4(0.5f, 0.5f, 0.5f, 1.0f) + : ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + ImGui::TextColored(nameColor, "%s", li.displayName.c_str()); + if (ImGui::IsItemHovered() && !li.realPath.empty()) + ImGui::SetTooltip("%s\n%s", li.identifier.c_str(), li.realPath.c_str()); + if (isRenderLayer) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.2f, 1.0f), "[RL]"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Managed from the Render Layer panel"); + } + + // Col 3: edit target checkbox — checking sets this layer as edit target; + // unchecking does nothing (there is always an active edit target). + ImGui::TableSetColumnIndex(3); + { + bool isET = li.isEditTarget; + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2.0f, 1.0f)); + ImGui::BeginDisabled(renderLayerActive); + if (ImGui::Checkbox("##et", &isET) && isET) + m_layerManager->SetEditTarget(li.identifier); + ImGui::EndDisabled(); + ImGui::PopStyleVar(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(renderLayerActive ? "Edit target is locked to the active render layer" + : li.isEditTarget ? "Edit target (active)" + : "Set as edit target"); + } + + // Col 4: mute checkbox — disabled for render-layer sublayers, which + // RenderLayerManager mutes/unmutes exclusively as layers are switched. + ImGui::TableSetColumnIndex(4); + { + bool muted = li.isMuted; + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2.0f, 1.0f)); + ImGui::BeginDisabled(isRenderLayer); + if (ImGui::Checkbox("##muted", &muted)) { + if (muted) m_layerManager->MuteLayer(li.identifier); + else m_layerManager->UnmuteLayer(li.identifier); + } + ImGui::EndDisabled(); + ImGui::PopStyleVar(); + if (isRenderLayer && ImGui::IsItemHovered()) + ImGui::SetTooltip("Managed from the Render Layer panel"); + } + + // Col 5: up/down reorder icon buttons. + ImGui::TableSetColumnIndex(5); + { + bool canUp = (i > 0); + bool canDown = (i < static_cast(sublayers.size()) - 1); + + auto buildBeforeAfter = [&](int a, int b, + std::vector& before, + std::vector& after) { + for (const auto& sl : sublayers) before.push_back(sl.identifier); + after = before; + std::swap(after[a], after[b]); + }; + + ImVec2 iconSize(14.0f, 14.0f); + + ImGui::BeginDisabled(!canUp); + ImTextureID upTex = m_iconManager ? m_iconManager->Get(Icon::ChevronUp) : ImTextureID_Invalid; + if (m_iconManager ? ImGui::ImageButton("##up", ImTextureRef(upTex), iconSize) + : ImGui::ArrowButton("##up", ImGuiDir_Up)) { + std::vector before, after; + buildBeforeAfter(i, i - 1, before, after); + if (m_commandHistory) + m_commandHistory->Push( + std::make_unique(m_layerManager, before, after)); + else { + LayerReorderCommand cmd(m_layerManager, before, after); + static_cast(cmd).Execute(); + } + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Move Up"); + + ImGui::SameLine(0.0f, 2.0f); + + ImGui::BeginDisabled(!canDown); + ImTextureID downTex = m_iconManager ? m_iconManager->Get(Icon::ChevronDown) : ImTextureID_Invalid; + if (m_iconManager ? ImGui::ImageButton("##down", ImTextureRef(downTex), iconSize) + : ImGui::ArrowButton("##down", ImGuiDir_Down)) { + std::vector before, after; + buildBeforeAfter(i, i + 1, before, after); + if (m_commandHistory) + m_commandHistory->Push( + std::make_unique(m_layerManager, before, after)); + else { + LayerReorderCommand cmd(m_layerManager, before, after); + static_cast(cmd).Execute(); + } + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Move Down"); + } + + ImGui::PopID(); + } + + ImGui::EndTable(); +} + +// --------------------------------------------------------------------------- +void SceneHierarchyPanel::RenderSublayerContextMenu(int i, + const std::vector& sublayers) { + if (!ImGui::BeginPopupContextItem(("ctx_sublayer_" + std::to_string(i)).c_str())) + return; + + const auto& li = sublayers[i]; + bool isRenderLayer = RenderLayerManager::IsRenderLayerSublayer(li.layer); + bool renderLayerActive = m_renderLayerManager && !m_renderLayerManager->IsDefaultActive(); + + if (ImGui::MenuItem(li.isMuted ? "Unmute" : "Mute", nullptr, false, !isRenderLayer)) { + if (li.isMuted) m_layerManager->UnmuteLayer(li.identifier); + else m_layerManager->MuteLayer(li.identifier); + } + if (isRenderLayer && ImGui::IsItemHovered()) + ImGui::SetTooltip("Managed from the Render Layer panel"); + + if (!li.isEditTarget && + ImGui::MenuItem("Set as Edit Target", nullptr, false, !renderLayerActive)) + m_layerManager->SetEditTarget(li.identifier); + if (renderLayerActive && !li.isEditTarget && ImGui::IsItemHovered()) + ImGui::SetTooltip("Edit target is locked to the active render layer"); + + ImGui::Separator(); + + if (ImGui::MenuItem("Remove", nullptr, false, !isRenderLayer)) { + if (m_commandHistory) + m_commandHistory->Push( + std::make_unique(m_layerManager, i)); + else + m_layerManager->RemoveSublayer(i); + } + if (isRenderLayer && ImGui::IsItemHovered()) + ImGui::SetTooltip("Managed from the Render Layer panel"); + + ImGui::EndPopup(); +} + void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { if (!prim.IsValid()) return;