From 0316c0bb02fca4bb67b47d6c29848e5a041f3a11 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 6 Oct 2026 16:59:33 -0700 Subject: [PATCH] Scene tree: filter, context menus, drag to reparent; inspector fold and pin The Filter objects field takes text (Ctrl+F focuses it) and shows the matching objects with the rows above them. Right-clicking a row opens Rename, Duplicate, Delete, Move to Top Level, Frame and Pin; the panel's ellipsis has Add, Expand All, Collapse All and the filter. Dragging a row drops it before, after or into another object, keeping its world transform, as one undo step. Inspector section headers open and close, the lock pins the inspector to its object, and the viewport camera dropdown switches between perspective and orthographic. Document::moveObject carries the reparent; editor_tests covers it. --- src/editor/document.cpp | 123 ++++++++++ src/editor/document.hh | 14 ++ src/editor/tests/editor_tests.cpp | 85 +++++++ src/mockup/main_screen.cpp | 394 +++++++++++++++++++++++++----- src/mockup/main_screen.h | 14 ++ src/ui/widgets.cpp | 83 ++++++- src/ui/widgets.h | 9 +- 7 files changed, 646 insertions(+), 76 deletions(-) diff --git a/src/editor/document.cpp b/src/editor/document.cpp index 2655ae0..4744127 100644 --- a/src/editor/document.cpp +++ b/src/editor/document.cpp @@ -1,6 +1,7 @@ #include "editor/document.hh" #include +#include #include #include #include @@ -93,6 +94,7 @@ std::optional Document::load(const fs::path& project, const fs::pat clearHistory(); ++m_loadId; m_selection.reset(); + m_pinned.reset(); m_collapsed.clear(); m_meshes.clear(); m_textures.clear(); @@ -126,6 +128,7 @@ void Document::reset(splash::Scene scene, const fs::path& project, const std::st ++m_loadId; ++m_revision; m_selection.reset(); + m_pinned.reset(); m_collapsed.clear(); m_meshes.clear(); m_textures.clear(); @@ -305,11 +308,94 @@ class StructureCommand : public Command { bool m_insert; }; +// Moves one object with its subtree from `from` to `to` (a path in the tree +// as it is once the object has been taken out), swapping its transform. +class MoveCommand : public Command { +public: + MoveCommand(ObjectPath from, ObjectPath to, splash::Transform before, splash::Transform after) + : m_from(std::move(from)), m_to(std::move(to)), m_before(before), m_after(after) {} + void apply(Document& doc) override { move(doc, m_from, m_to, m_after); } + void revert(Document& doc) override { move(doc, m_to, m_from, m_before); } + +private: + static void move(Document& doc, const ObjectPath& a, const ObjectPath& b, const splash::Transform& t) { + std::vector* src = doc.childrenMut(ObjectPath(a.begin(), a.end() - 1)); + if (!src || static_cast(a.back()) >= src->size()) return; + const bool pinned = doc.pinned() && *doc.pinned() == a; + splash::Object obj = std::move((*src)[a.back()]); + src->erase(src->begin() + a.back()); + doc.shiftPaths(a, -1); + std::vector* dst = doc.childrenMut(ObjectPath(b.begin(), b.end() - 1)); + if (!dst) return; + obj.transform = t; + size_t i = std::min(static_cast(b.back()), dst->size()); + dst->insert(dst->begin() + static_cast(i), std::move(obj)); + doc.shiftPaths(b, +1); + doc.select(b); + if (pinned) doc.pin(b); + } + ObjectPath m_from, m_to; + splash::Transform m_before, m_after; +}; + +splash::Quat mulQ(splash::Quat a, splash::Quat b) { + return {a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, a.w * b.y + a.y * b.w + a.z * b.x - a.x * b.z, + a.w * b.z + a.z * b.w + a.x * b.y - a.y * b.x, a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z}; +} + +// World position, rotation and lossy scale composed the way splash::flatten does. +struct World { + splash::Vec3 pos; + splash::Quat rot; + splash::Vec3 scale{1, 1, 1}; + splash::Mat34 m = splash::Mat34::trs({}, {}, {1, 1, 1}); +}; + +World worldOf(const Document& doc, const ObjectPath& path) { + World w; + for (size_t n = 1; n <= path.size(); ++n) { + const splash::Transform& t = doc.object(ObjectPath(path.begin(), path.begin() + n))->transform; + if (n == 1) { + w.pos = t.position; + w.rot = t.rotation; + w.scale = t.scale; + } else { + w.pos = w.m.point(t.position); + w.rot = mulQ(w.rot, t.rotation); + w.scale = splash::scale(w.scale, t.scale); + } + w.m = splash::Mat34::trs(w.pos, w.rot, w.scale); + } + return w; +} + +// The local transform that puts an object at `w` under a parent at `p`. +splash::Transform localUnder(const World& p, const World& w) { + splash::Transform t; + const float(*m)[4] = p.m.m; + splash::Vec3 d = w.pos - p.m.position(); + float a = m[0][0], b = m[0][1], c = m[0][2], e = m[1][0], f = m[1][1], g = m[1][2], h = m[2][0], i = m[2][1], k = m[2][2]; + float det = a * (f * k - g * i) - b * (e * k - g * h) + c * (e * i - f * h); + if (std::fabs(det) < 1e-12f) { + t.position = d; + } else { + float inv = 1.0f / det; + t.position = {((f * k - g * i) * d.x + (c * i - b * k) * d.y + (b * g - c * f) * d.z) * inv, + ((g * h - e * k) * d.x + (a * k - c * h) * d.y + (c * e - a * g) * d.z) * inv, + ((e * i - f * h) * d.x + (b * h - a * i) * d.y + (a * f - b * e) * d.z) * inv}; + } + t.rotation = mulQ({-p.rot.x, -p.rot.y, -p.rot.z, p.rot.w}, w.rot); + auto div = [](float x, float y) { return y != 0 ? x / y : x; }; + t.scale = {div(w.scale.x, p.scale.x), div(w.scale.y, p.scale.y), div(w.scale.z, p.scale.z)}; + return t; +} + } // namespace void Document::shiftPaths(const ObjectPath& at, int delta) { if (at.empty()) return; if (m_selection) m_selection = shifted(*m_selection, at, delta); + if (m_pinned) m_pinned = shifted(*m_pinned, at, delta); std::set collapsed; for (const ObjectPath& p : m_collapsed) if (p.empty()) collapsed.insert(p); @@ -401,6 +487,34 @@ std::optional Document::duplicateObject(const ObjectPath& path) { return at; } +std::optional Document::moveObject(const ObjectPath& from, const ObjectPath& toParent, int index) { + const splash::Object* o = object(from); + if (!o || index < 0) return std::nullopt; + if (!toParent.empty() && !object(toParent)) return std::nullopt; + // Into itself or its own subtree. + if (toParent.size() >= from.size() && std::equal(from.begin(), from.end(), toParent.begin())) return std::nullopt; + const std::vector& kids = toParent.empty() ? m_scene.objects : object(toParent)->children; + index = std::min(index, static_cast(kids.size())); + const ObjectPath fromParent(from.begin(), from.end() - 1); + // Where the object lands once it has been taken out. + ObjectPath to = *shifted(toParent, from, -1); + if (fromParent == toParent && from.back() < index) --index; + to.push_back(index); + if (to == from) return std::nullopt; + const splash::Transform before = o->transform; + splash::Transform after; + const World w = worldOf(*this, from); + if (toParent.empty()) { + after.position = w.pos; + after.rotation = w.rot; + after.scale = w.scale; + } else { + after = localUnder(worldOf(*this, toParent), w); + } + execute(std::make_unique(from, to, before, after)); + return to; +} + bool Document::undo() { if (!canUndo()) return false; m_mergeOpen = false; @@ -453,6 +567,15 @@ void Document::toggleExpanded(const ObjectPath& path) { if (!m_collapsed.erase(path)) m_collapsed.insert(path); } +void Document::setAllExpanded(bool expanded) { + const bool rootCollapsed = m_collapsed.count(ObjectPath{}) != 0; + m_collapsed.clear(); + if (rootCollapsed) m_collapsed.insert(ObjectPath{}); + if (expanded) return; + for (const ObjectPath& p : flatPaths()) + if (!object(p)->children.empty()) m_collapsed.insert(p); +} + const MeshInfo* Document::mesh(const std::string& projectPath) const { auto it = m_meshes.find(projectPath); return it == m_meshes.end() ? nullptr : &it->second; diff --git a/src/editor/document.hh b/src/editor/document.hh index 9634093..79f9f69 100644 --- a/src/editor/document.hh +++ b/src/editor/document.hh @@ -92,6 +92,13 @@ public: // Every node starts expanded; the empty path is the scene root row. bool expanded(const ObjectPath& path) const { return !m_collapsed.count(path); } void toggleExpanded(const ObjectPath& path); + // Expands or collapses every object that has children (the root row stays as it is). + void setAllExpanded(bool expanded); + + // The inspector's pinned object: shown instead of the selection while set. + // Follows the object through inserts and removes; cleared when it goes. + const std::optional& pinned() const { return m_pinned; } + void pin(std::optional path) { m_pinned = std::move(path); } // Editing. Every change goes through the history: it bumps revision() and // marks the document dirty. @@ -112,6 +119,12 @@ public: // Inserts a copy right after the original, named the way Unity names // duplicates ("Crate (2)"), and selects it. Returns the copy's path. std::optional duplicateObject(const ObjectPath& path); + // Moves the object at `from`, with its children, to be child `index` of + // `toParent` (empty = the scene root), keeping where it is in the world. + // `index` counts the new parent's children as they are now, before the + // move. Refused (nullopt) when `toParent` is the object itself or inside + // it. Selects the object and returns its new path. + std::optional moveObject(const ObjectPath& from, const ObjectPath& toParent, int index); // Applies `cmd` and pushes it, dropping anything that could be redone. // With `mergeable`, it may fold into the previous command (see Command::merge). void execute(std::unique_ptr cmd, bool mergeable = false); @@ -176,6 +189,7 @@ private: unsigned m_assetsId = 0; std::optional m_selection; std::set m_collapsed; + std::optional m_pinned; std::map m_meshes; std::map m_textures; std::map m_files; diff --git a/src/editor/tests/editor_tests.cpp b/src/editor/tests/editor_tests.cpp index d9ce839..6782cd6 100644 --- a/src/editor/tests/editor_tests.cpp +++ b/src/editor/tests/editor_tests.cpp @@ -861,6 +861,90 @@ void testAdoptAsset() { } // namespace +// Reparenting keeps the object where it is in the world, follows the paths +// of everything else, and undoes in one step. +void testMove() { + auto worldOf = [](const editor::Document& d, const std::string& name) { + for (const splash::FlatObject& fo : splash::flatten(d.scene())) + if (fo.object->name == name) return fo; + return splash::FlatObject{}; + }; + auto same = [](splash::Vec3 a, splash::Vec3 b) { return near(a.x, b.x) && near(a.y, b.y) && near(a.z, b.z); }; + editor::Document d; + splash::Scene s = sample(); + // B: moved, turned 90 degrees around Y and scaled 2; C sits elsewhere. + s.objects[1].transform.position = {10, 0, 0}; + s.objects[1].transform.rotation = {0, 0.70710678f, 0, 0.70710678f}; + s.objects[1].transform.scale = {2, 2, 2}; + s.objects[1].children[0].transform.position = {1, 2, 3}; + s.objects[2].transform.position = {5, 1, -2}; + d.reset(s); + const splash::FlatObject c0 = worldOf(d, "C"); + const splash::FlatObject b10 = worldOf(d, "B1"); + + // C into B: becomes B's second child, same world position. + d.select(editor::ObjectPath{0}); + d.pin(editor::ObjectPath{0}); + auto to = d.moveObject({2}, {1}, 1); + CHECK(to && *to == editor::ObjectPath({1, 1})); + CHECK(d.scene().objects.size() == 2 && d.object({1, 1}) && d.object({1, 1})->name == "C"); + CHECK(same(worldOf(d, "C").worldPosition, c0.worldPosition)); + CHECK(same(worldOf(d, "C").lossyScale, c0.lossyScale)); + { + // Same rotation (q and -q are the same turn). + splash::Quat a = worldOf(d, "C").worldRotation, b = c0.worldRotation; + CHECK(near(std::fabs(a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w), 1)); + } + CHECK(d.object({1, 1})->transform.scale == splash::Vec3({0.5f, 0.5f, 0.5f})); + CHECK(d.selected() && d.selected()->name == "C"); + CHECK(d.pinned() && d.object(*d.pinned())->name == "A"); // the pin stays on A + CHECK(d.undo()); + CHECK(d.object({2}) && d.object({2})->name == "C" && d.object({1})->children.size() == 1); + CHECK(d.object({2})->transform.position == splash::Vec3({5, 1, -2})); + CHECK(d.redo()); + CHECK(d.object({1, 1})->name == "C"); + CHECK(d.undo()); + + // B1 out to the root, before A: world position kept, B loses its child. + auto out = d.moveObject({1, 0}, {}, 0); + CHECK(out && *out == editor::ObjectPath({0})); + CHECK(d.object({0})->name == "B1" && d.object({2})->name == "B" && d.object({2})->children.empty()); + CHECK(same(worldOf(d, "B1").worldPosition, b10.worldPosition)); + CHECK(same(d.object({0})->transform.position, b10.worldPosition)); + CHECK(d.pinned() && d.object(*d.pinned())->name == "A"); // A moved to index 1, the pin followed + CHECK(d.undo()); + CHECK(d.object({1, 0}) && d.object({1, 0})->name == "B1"); + CHECK(d.object({1, 0})->transform.position == splash::Vec3({1, 2, 3})); + + // Reorder among siblings: A after C (index counts A still in place). + auto re = d.moveObject({0}, {}, 3); + CHECK(re && *re == editor::ObjectPath({2})); + CHECK(d.object({0})->name == "B" && d.object({2})->name == "A"); + CHECK(d.pinned() && *d.pinned() == editor::ObjectPath({2})); // the pinned object itself moved + CHECK(d.undo()); + + // Refused: into itself, into its own child, no-op, bad paths. + size_t steps = d.historySize(); + CHECK(!d.moveObject({1}, {1}, 0)); + CHECK(!d.moveObject({1}, {1, 0}, 0)); + CHECK(!d.moveObject({1}, {}, 1)); + CHECK(!d.moveObject({1}, {}, 2)); // after itself = where it is + CHECK(!d.moveObject({9}, {}, 0)); + CHECK(!d.moveObject({0}, {7}, 0)); + CHECK(d.historySize() == steps); + + // Expand / collapse all. + d.setAllExpanded(false); + CHECK(!d.expanded({1}) && d.expanded({0}) && d.expanded({})); + d.setAllExpanded(true); + CHECK(d.expanded({1})); + + // Removing the pinned object clears the pin. + d.pin(editor::ObjectPath{1}); + CHECK(d.removeObject({1})); + CHECK(!d.pinned()); +} + int main(int argc, char** argv) { std::error_code ec; std::filesystem::path exe = argc > 0 ? std::filesystem::absolute(argv[0], ec) : std::filesystem::path(); @@ -871,6 +955,7 @@ int main(int argc, char** argv) { testRenderPeak(); testDirtyAndSave(); testStructure(); + testMove(); testPick(); testGizmoMath(); testScaleClamp(); diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index 4184c6a..3a979c1 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -411,8 +412,66 @@ struct TreeCtx { ImRect panel; float y; bool renameShown = false; + std::string filter{}; // lower case; empty = every object + int shown = 0; // object rows drawn + // Where a dragged row would land: as child `dropIndex` of `dropParent`, + // shown as a box around a row (into it) or a line between rows. + bool hasDrop = false; + editor::ObjectPath dropParent{}; + int dropIndex = 0; + bool dropInto = false; + ImRect dropMark{}; }; +static std::string lower(std::string s) { + for (char& ch : s) ch = static_cast(std::tolower(static_cast(ch))); + return s; +} + +// The object or something under it has `filter` (lower case) in its name. +static bool treeMatches(const splash::Object& o, const std::string& filter) { + if (lower(o.name).find(filter) != std::string::npos) return true; + for (const splash::Object& c : o.children) + if (treeMatches(c, filter)) return true; + return false; +} + +static bool isUnder(const editor::ObjectPath& p, const editor::ObjectPath& ancestor) { + return p.size() >= ancestor.size() && std::equal(ancestor.begin(), ancestor.end(), p.begin()); +} + +// While a row is dragged: the drop the mouse points at over row `rr`, which +// shows the object at `path` (its top quarter drops before it, the bottom +// quarter after it or, when it is open, as its first child, and the middle into it). +static void treeDropTarget(TreeCtx& c, ImRect rr, const editor::ObjectPath& path, const splash::Object& o, bool open) { + const ImVec2 m = ImGui::GetIO().MousePos; + if (!c.st.treeDragging || m.y < rr.Min.y || m.y >= rr.Max.y || m.x < c.panel.Min.x || m.x >= c.panel.Max.x) return; + if (isUnder(path, c.st.treeDragPath)) return; // onto itself or inside it + const float t = (m.y - rr.Min.y) / rr.GetHeight(); + const float indent = rr.Min.x + space::xs + static_cast(path.size()) * 16.0f + 16; + const editor::ObjectPath parent(path.begin(), path.end() - 1); + c.hasDrop = true; + c.dropInto = false; + if (t < 0.25f) { + c.dropParent = parent; + c.dropIndex = path.back(); + c.dropMark = ImRect(ImVec2(indent, rr.Min.y - 1), ImVec2(rr.Max.x, rr.Min.y + 1)); + } else if (t >= 0.75f && open && !o.children.empty()) { + c.dropParent = path; + c.dropIndex = 0; + c.dropMark = ImRect(ImVec2(indent + 16, rr.Max.y - 1), ImVec2(rr.Max.x, rr.Max.y + 1)); + } else if (t >= 0.75f) { + c.dropParent = parent; + c.dropIndex = path.back() + 1; + c.dropMark = ImRect(ImVec2(indent, rr.Max.y - 1), ImVec2(rr.Max.x, rr.Max.y + 1)); + } else { + c.dropParent = path; + c.dropIndex = static_cast(o.children.size()); + c.dropInto = true; + c.dropMark = rr; + } +} + // Clicks left of the chevron's right edge toggle; anywhere else selects. static bool chevronClicked(ImRect rr, int depth) { return ImGui::GetIO().MousePos.x < rr.Min.x + space::xs + depth * 16.0f + 16; @@ -421,8 +480,10 @@ static bool chevronClicked(ImRect rr, int depth) { static void treeObjects(TreeCtx& c, const std::vector& objs, editor::ObjectPath& path) { for (size_t i = 0; i < objs.size(); ++i) { const splash::Object& o = objs[i]; + if (!c.filter.empty() && !treeMatches(o, c.filter)) continue; path.push_back(static_cast(i)); - bool open = c.doc.expanded(path); + // A filter shows every match with the rows above it, opened. + bool open = !c.filter.empty() || c.doc.expanded(path); ObjectLook look = lookOf(o, open); std::string meta; if (o.mesh) @@ -443,15 +504,35 @@ static void treeObjects(TreeCtx& c, const std::vector& objs, edi ImGui::PushID(static_cast(i)); bool flipActive = false; Hit h = treeRow("row", rr, row, &flipActive); - if (flipActive) c.doc.edit(path, [](splash::Object& ob) { ob.active = !ob.active; }); - else if (h.clicked) { - if (row.hasChildren && chevronClicked(rr, row.depth)) c.doc.toggleExpanded(path); - else c.doc.select(path); + ++c.shown; + if (c.st.treeDragging) { + treeDropTarget(c, rr, path, o, open); + } else if (flipActive) { + c.doc.edit(path, [](splash::Object& ob) { ob.active = !ob.active; }); + } else if (h.clicked) { + if (row.hasChildren && chevronClicked(rr, row.depth)) { + if (c.filter.empty()) c.doc.toggleExpanded(path); + } else { + c.doc.select(path); + } } - if (h.hovered && ImGui::IsMouseDoubleClicked(0) && !(row.hasChildren && chevronClicked(rr, row.depth))) { + if (!c.st.treeDragging && h.hovered && ImGui::IsMouseDoubleClicked(0) && !(row.hasChildren && chevronClicked(rr, row.depth))) { c.doc.select(path); c.st.frameRequest = true; } + // Right-click: select it and open its menu. + if (!c.st.treeDragging && h.hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + c.doc.select(path); + c.st.treeMenu = 1; + c.st.treeMenuPath = path; + c.st.treeMenuPos = ImGui::GetIO().MousePos; + c.st.treeMenuRight = false; + } + // Pressing a row and moving starts dragging it. + if (h.held && !c.st.treeDragging && !c.st.renaming && ImGui::IsMouseDragging(ImGuiMouseButton_Left, 6)) { + c.st.treeDragging = true; + c.st.treeDragPath = path; + } // F2: the label becomes a text field. if (c.st.renaming && c.st.renamePath == path) { c.renameShown = true; @@ -915,29 +996,9 @@ static std::vector menuEntries(int which, State& st, editor::Document } // The open title-bar menu, under its label. -static void menus(State& st, editor::Document& doc, viewport::Ps1View& view) { - static const char* ids[6] = {"##m_file", "##m_edit", "##m_object", "##m_build", "##m_view", "##m_help"}; - static int shown = -1; // the menu whose popup was open last frame - if (shown >= 0 && !ImGui::IsPopupOpen(ids[shown])) { - // Closed by a press elsewhere: that press must not reopen it from its own label. - if (st.menuOpen == shown && ImGui::IsMouseDown(ImGuiMouseButton_Left)) g_menuIgnoreClick = shown; - if (st.menuOpen == shown) st.menuOpen = -1; - shown = -1; - } - // The open popup takes the hover from the title bar, so moving to another - // label is checked against the label rects directly. - if (shown >= 0 && st.menuOpen == shown) - for (int j = 0; j < 6; ++j) - if (j != shown && g_menuRects[j].Contains(ImGui::GetIO().MousePos)) st.menuOpen = j; - if (st.menuOpen != shown) { - // Opening a popup at the same level replaces the one that was open. - if (st.menuOpen >= 0) ImGui::OpenPopup(ids[st.menuOpen]); - shown = st.menuOpen; - } - if (shown < 0) return; - +// Size of a menu popup holding `entries`. +static void menuSize(const std::vector& entries, float* outW, float* outH) { Fonts& f = fonts(); - std::vector entries = menuEntries(shown, st, doc, view); const float pad = space::xs, rowH = size::field + 4, sepH = 9, headH = 24; float w = 220, h = pad * 2; for (const MenuEntry& e : entries) { @@ -949,15 +1010,15 @@ static void menus(State& st, editor::Document& doc, viewport::Ps1View& view) { measure(f.regular, type::label, e.shortcut.c_str()).x + space::md); } } - const ImRect anchor = g_menuRects[shown]; - ImGui::SetNextWindowPos(ImVec2(anchor.Min.x, anchor.Max.y + space::xs)); - ImGui::SetNextWindowSize(ImVec2(w, h)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, 0.0f); - const bool open = ImGui::BeginPopup(ids[shown], ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoSavedSettings); - ImGui::PopStyleVar(2); - if (!open) return; + *outW = w; + *outH = h; +} + +// Draws `entries` in the popup that is open (sized by menuSize) and returns +// the action of the item clicked this frame, if any. +static std::function menuRows(const std::vector& entries, float w, float h) { + Fonts& f = fonts(); + const float pad = space::xs, rowH = size::field + 4, sepH = 9, headH = 24; ImDrawList* dl = ImGui::GetWindowDrawList(); ImRect box(ImGui::GetWindowPos(), ImGui::GetWindowPos() + ImVec2(w, h)); dl->AddRectFilled(box.Min + ImVec2(0, 2), box.Max + ImVec2(0, 6), rgb(0x000000, 80), radius::card); @@ -993,6 +1054,43 @@ static void menus(State& st, editor::Document& doc, viewport::Ps1View& view) { } ImGui::PopID(); } + return run; +} + +static void menus(State& st, editor::Document& doc, viewport::Ps1View& view) { + static const char* ids[6] = {"##m_file", "##m_edit", "##m_object", "##m_build", "##m_view", "##m_help"}; + static int shown = -1; // the menu whose popup was open last frame + if (shown >= 0 && !ImGui::IsPopupOpen(ids[shown])) { + // Closed by a press elsewhere: that press must not reopen it from its own label. + if (st.menuOpen == shown && ImGui::IsMouseDown(ImGuiMouseButton_Left)) g_menuIgnoreClick = shown; + if (st.menuOpen == shown) st.menuOpen = -1; + shown = -1; + } + // The open popup takes the hover from the title bar, so moving to another + // label is checked against the label rects directly. + if (shown >= 0 && st.menuOpen == shown) + for (int j = 0; j < 6; ++j) + if (j != shown && g_menuRects[j].Contains(ImGui::GetIO().MousePos)) st.menuOpen = j; + if (st.menuOpen != shown) { + // Opening a popup at the same level replaces the one that was open. + if (st.menuOpen >= 0) ImGui::OpenPopup(ids[st.menuOpen]); + shown = st.menuOpen; + } + if (shown < 0) return; + + std::vector entries = menuEntries(shown, st, doc, view); + float w, h; + menuSize(entries, &w, &h); + const ImRect anchor = g_menuRects[shown]; + ImGui::SetNextWindowPos(ImVec2(anchor.Min.x, anchor.Max.y + space::xs)); + ImGui::SetNextWindowSize(ImVec2(w, h)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, 0.0f); + const bool open = ImGui::BeginPopup(ids[shown], ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings); + ImGui::PopStyleVar(2); + if (!open) return; + std::function run = menuRows(entries, w, h); if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) st.menuOpen = -1; if (run) st.menuOpen = -1; if (st.menuOpen < 0) { @@ -1071,6 +1169,70 @@ static void importPending(State& st, editor::Document& doc, const viewport::Ps1V } } +// The menu a right-click on a row (kind 1) or the panel's ellipsis (kind 2) opens. +static std::vector treeMenuEntries(int kind, State& st, editor::Document& doc) { + std::vector m; + if (kind == 1) { + const editor::ObjectPath p = st.treeMenuPath; + if (!doc.object(p)) return m; + m.push_back(item("Rename", "F2", true, [&st, p] { + st.renaming = true; + st.renameStart = true; + st.renamePath = p; + })); + m.push_back(item("Duplicate", "Ctrl+D", true, [&doc, p] { doc.duplicateObject(p); })); + m.push_back(item("Delete", "Del", true, [&doc, p] { doc.removeObject(p); })); + m.push_back(separator()); + // Out of its group, to the top level right after the group it was in. + m.push_back(item("Move to Top Level", "", p.size() > 1, [&doc, p] { doc.moveObject(p, {}, p.front() + 1); })); + m.push_back(item("Frame in View", "F", true, [&st] { st.frameRequest = true; })); + const bool pinned = doc.pinned() && *doc.pinned() == p; + m.push_back(item("Pin in Inspector", "", true, [&doc, p, pinned] { doc.pin(pinned ? std::nullopt : std::optional(p)); }, pinned)); + } else { + m.push_back(item("Add Object...", "Ctrl+A", true, [&st] { st.openAddObject = true; })); + m.push_back(separator()); + m.push_back(item("Expand All", "", true, [&doc] { doc.setAllExpanded(true); })); + m.push_back(item("Collapse All", "", true, [&doc] { doc.setAllExpanded(false); })); + m.push_back(separator()); + m.push_back(item("Filter...", "Ctrl+F", true, [&st] { st.focusTreeFilter = true; })); + m.push_back(item("Clear Filter", "", !st.treeFilter.empty(), [&st] { st.treeFilter.clear(); })); + } + return m; +} + +static void treeMenu(State& st, editor::Document& doc) { + static const char* id = "##treemenu"; + if (st.treeMenu) { + st.treeMenuShown = st.treeMenu; + st.treeMenu = 0; + ImGui::OpenPopup(id); + } + if (!ImGui::IsPopupOpen(id)) return; + std::vector entries = treeMenuEntries(st.treeMenuShown, st, doc); + float w, h; + menuSize(entries, &w, &h); + // Kept on screen, flipped up or left when it would run off. + const ImVec2 d = ImGui::GetIO().DisplaySize; + ImVec2 pos = st.treeMenuPos; + if (st.treeMenuRight) pos.x -= w; + if (pos.x + w > d.x - space::xs) pos.x = st.treeMenuPos.x - w; + if (pos.y + h > d.y - space::xs) pos.y -= h; + pos.x = std::max(pos.x, space::xs); + pos.y = std::max(pos.y, space::xs); + ImGui::SetNextWindowPos(pos); + ImGui::SetNextWindowSize(ImVec2(w, h)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, 0.0f); + const bool open = ImGui::BeginPopup(id, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings); + ImGui::PopStyleVar(2); + if (!open) return; + std::function run = menuRows(entries, w, h); + if (run || ImGui::IsKeyPressed(ImGuiKey_Escape, false) || entries.empty()) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (run) run(); +} + static void sceneTree(State& st, ImDrawList* dl, ImRect r, editor::Document& doc, const viewport::Ps1View& view) { panel(dl, r); std::string count = std::to_string(doc.objectCount()) + (doc.objectCount() == 1 ? " object" : " objects"); @@ -1078,28 +1240,98 @@ static void sceneTree(State& st, ImDrawList* dl, ImRect r, editor::Document& doc ImRect addBtn(ImVec2(r.Max.x - 62, r.Min.y + 5), ImVec2(r.Max.x - 36, r.Min.y + 29)); if (iconButton("addobj", addBtn, icon::plus, false, "Add object (Ctrl+A)")) st.openAddObject = true; addObjectPicker(st, doc, view, ImVec2(addBtn.Min.x, addBtn.Max.y + space::xs)); - iconButton("treemenu", ImRect(ImVec2(r.Max.x - 32, r.Min.y + 5), ImVec2(r.Max.x - 6, r.Min.y + 29)), icon::ellipsis); - searchField("treesearch", ImRect(ImVec2(r.Min.x + space::sm, y), ImVec2(r.Max.x - space::sm, y + 28)), "Filter objects", - "Ctrl F"); + ImRect menuBtn(ImVec2(r.Max.x - 32, r.Min.y + 5), ImVec2(r.Max.x - 6, r.Min.y + 29)); + if (iconButton("treemenu", menuBtn, icon::ellipsis, false, "More")) { + st.treeMenu = 2; + st.treeMenuPos = ImVec2(menuBtn.Max.x, menuBtn.Max.y + space::xs); + st.treeMenuRight = true; + } + searchField("treesearch", ImRect(ImVec2(r.Min.x + space::sm, y), ImVec2(r.Max.x - space::sm, y + 28)), "Filter objects", "Ctrl F", + &st.treeFilter, st.focusTreeFilter); + st.focusTreeFilter = false; y += 28 + space::sm; ImRect foot(ImVec2(r.Min.x, r.Max.y - 40), r.Max); ImGui::PushClipRect(ImVec2(r.Min.x, y), ImVec2(r.Max.x, foot.Min.y), true); const editor::ObjectPath root; - bool rootOpen = doc.expanded(root); + TreeCtx ctx{st, doc, r, y + size::row}; + ctx.filter = lower(st.treeFilter); + bool rootOpen = !ctx.filter.empty() || doc.expanded(root); TreeRow top{0, icon::layers, color::accentHover, doc.sceneStem().c_str(), nullptr, !doc.scene().objects.empty(), rootOpen}; ImRect rr(ImVec2(r.Min.x + space::xs + 2, y), ImVec2(r.Max.x - space::xs - 2, y + size::row)); Hit h = treeRow("root", rr, top); - if (h.clicked) { - if (top.hasChildren && chevronClicked(rr, 0)) doc.toggleExpanded(root); - else doc.select(std::nullopt); + if (h.clicked && !st.treeDragging) { + if (top.hasChildren && chevronClicked(rr, 0)) { + if (ctx.filter.empty()) doc.toggleExpanded(root); + } else { + doc.select(std::nullopt); + } + } + if (!st.treeDragging && h.hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + st.treeMenu = 2; + st.treeMenuPos = ImGui::GetIO().MousePos; + st.treeMenuRight = false; } - TreeCtx ctx{st, doc, r, y + size::row}; editor::ObjectPath path; if (rootOpen) treeObjects(ctx, doc.scene().objects, path); if (st.renaming && !ctx.renameShown) st.renaming = false; // the row is collapsed away or gone + if (!ctx.filter.empty() && ctx.shown == 0) { + const char* none = "No objects match"; + text(dl, ImVec2(r.Min.x + space::md + 16, centerY(fonts().regular, type::body, ctx.y, ctx.y + size::row)), fonts().regular, + type::body, color::textFaint, none); + } + + // Dragging a row: the scene row or the empty space under the rows drops it at the top level, last. + if (st.treeDragging) { + const ImVec2 m = ImGui::GetIO().MousePos; + const bool inTree = m.x >= r.Min.x && m.x < r.Max.x && m.y >= y && m.y < foot.Min.y; + if (!ctx.hasDrop && inTree && (m.y < y + size::row || m.y >= ctx.y)) { + ctx.hasDrop = true; + ctx.dropParent.clear(); + ctx.dropIndex = static_cast(doc.scene().objects.size()); + ctx.dropInto = m.y < y + size::row; + ctx.dropMark = ctx.dropInto ? rr : ImRect(ImVec2(rr.Min.x + space::xs + 16, ctx.y - 1), ImVec2(rr.Max.x, ctx.y + 1)); + } + // A drop that leaves the object where it is shows nothing. + if (ctx.hasDrop) { + const editor::ObjectPath& from = st.treeDragPath; + const editor::ObjectPath fromParent(from.begin(), from.end() - 1); + if (ctx.dropParent == fromParent && (ctx.dropIndex == from.back() || ctx.dropIndex == from.back() + 1)) ctx.hasDrop = false; + } + if (ctx.hasDrop) { + if (ctx.dropInto) dl->AddRect(ctx.dropMark.Min, ctx.dropMark.Max, color::accent, radius::field, 0, 1.5f); + else dl->AddRectFilled(ctx.dropMark.Min, ctx.dropMark.Max, color::accent, 1); + } + } ImGui::PopClipRect(); + if (st.treeDragging) { + const splash::Object* dragged = doc.object(st.treeDragPath); + if (dragged) { + // The dragged object's name follows the pointer. + ImDrawList* fg = ImGui::GetForegroundDrawList(); + const ImVec2 m = ImGui::GetIO().MousePos + ImVec2(14, 4); + ImVec2 ts = measure(fonts().regular, type::body, dragged->name.c_str()); + ImRect tag(m, m + ts + ImVec2(space::md * 2, space::xs * 2)); + fg->AddRectFilled(tag.Min, tag.Max, color::raised, radius::field); + fg->AddRect(tag.Min, tag.Max, ctx.hasDrop ? color::accent : color::borderStrong, radius::field); + text(fg, tag.Min + ImVec2(space::md, space::xs), fonts().regular, type::body, color::text, dragged->name.c_str()); + } + if (!dragged || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + st.treeDragging = false; + } else if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + st.treeDragging = false; + if (ctx.hasDrop) + if (std::optional to = doc.moveObject(st.treeDragPath, ctx.dropParent, ctx.dropIndex)) + // Open the rows above it so it stays in sight. + for (size_t n = 0; n < to->size(); ++n) { + const editor::ObjectPath up(to->begin(), to->begin() + static_cast(n)); + if (!doc.expanded(up)) doc.toggleExpanded(up); + } + } + } + treeMenu(st, doc); + // Footer: the project's folder, collapsed. dl->AddLine(ImVec2(foot.Min.x + 1, foot.Min.y), ImVec2(foot.Max.x - 1, foot.Min.y), color::border); std::string files = std::to_string(doc.projectFileCount()) + (doc.projectFileCount() == 1 ? " file" : " files"); @@ -2167,7 +2399,9 @@ static void viewportPanel(State& st, ImDrawList* dl, ImRect r, editor::Document& } float camW = 128; ImVec2 rp(r.Max.x - space::md - segW - space::sm - camW, p.y + 2); - dropdown("camera", ImRect(rp, rp + ImVec2(camW, 28)), icon::camera, view.ortho() ? "Orthographic" : "Perspective"); + int cam = dropdownMenu("camera", ImRect(rp, rp + ImVec2(camW, 28)), icon::camera, view.ortho() ? "Orthographic" : "Perspective", + {"Perspective", "Orthographic"}, view.ortho() ? 1 : 0); + if (cam >= 0) view.setOrtho(cam == 1); st.viewMode = segmented("viewmode", ImVec2(rp.x + camW + space::sm, p.y + 2), {"PS1", "Clean"}, st.viewMode, &w); axisWidget(dl, ImVec2(r.Max.x - 52, r.Min.y + 96), view); @@ -2361,7 +2595,10 @@ static void sceneInspector(State& st, ImDrawList* dl, ImRect r, float y, editor: }; float top = y; - section("s_render", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::cpu, color::textDim, "Render buffers", true, true, false); + // Closed, the body runs under an empty clip rect (see the object inspector's fold). + const bool renderOpen = section("s_render", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::cpu, color::textDim, "Render buffers", + true, true, false); + if (!renderOpen) ImGui::PushClipRect(ImVec2(0, 0), ImVec2(0, 0), false); y += 34 + space::xs; // One buffer: an integer field showing the size the export uses, "auto" @@ -2425,8 +2662,13 @@ static void sceneInspector(State& st, ImDrawList* dl, ImRect r, float y, editor: buUsed && peak->bump > buUsed ? color::warn : color::accentHover); if (stats) caption(kb(stats->rendererBytes()) + " KB of RAM for both, double-buffered", color::textFaint); else caption("Sizes appear once the scene exports.", color::textFaint); - dl->AddRect(ImVec2(x0, top), ImVec2(x1, y + space::sm), rgb(0xffffff, 10), radius::card); - y += space::sm + space::md; + if (!renderOpen) { + ImGui::PopClipRect(); + y = top + 34 + space::md; + } else { + dl->AddRect(ImVec2(x0, top), ImVec2(x1, y + space::sm), rgb(0xffffff, 10), radius::card); + y += space::sm + space::md; + } // An override below the estimate is allowed (the estimate has margin) // but deserves a word. @@ -2458,10 +2700,15 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc Fonts& f = fonts(); panel(dl, r); float y = panelHeader(dl, r, "Inspector", nullptr); - iconButton("lockinsp", ImRect(ImVec2(r.Max.x - 32, r.Min.y + 5), ImVec2(r.Max.x - 6, r.Min.y + 29)), icon::lock, false, - "Keep showing this object"); - - const splash::Object* o = doc.selected(); + // The lock pins what the inspector shows; selecting other objects leaves it there. + const bool pinned = doc.pinned().has_value(); + if (iconButton("lockinsp", ImRect(ImVec2(r.Max.x - 32, r.Min.y + 5), ImVec2(r.Max.x - 6, r.Min.y + 29)), icon::lock, pinned, + pinned ? "Pinned: click to follow the selection again" : "Keep showing this object", color::textDim, + pinned || doc.selection())) + doc.pin(pinned ? std::nullopt : doc.selection()); + + const std::optional target = doc.pinned() ? doc.pinned() : doc.selection(); + const splash::Object* o = target ? doc.object(*target) : nullptr; if (!o) { sceneInspector(st, dl, r, y, doc); return; @@ -2471,12 +2718,12 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc float x0 = r.Min.x + space::md, x1 = r.Max.x - space::md; // Object header. ObjectLook look = lookOf(*o, true); - const splash::Object* parent = doc.parent(*doc.selection()); + const splash::Object* parent = doc.parent(*target); std::string where = "in " + (parent ? parent->name : doc.sceneStem()); ImRect ic(ImVec2(x0, y), ImVec2(x0 + 40, y + 40)); dl->AddRectFilled(ic.Min, ic.Max, (look.color & 0x00ffffffu) | (34u << IM_COL32_A_SHIFT), radius::card); textCentered(dl, ic, f.medium, type::icon + 3, look.color, look.icon); - const editor::ObjectPath path = *doc.selection(); + const editor::ObjectPath path = *target; // Name: double-click to rename. { ImVec2 np(ic.Max.x + space::md, y + 2); @@ -2506,7 +2753,20 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc y += rowH; return property(id, rr, lw, label, tip); }; + // A closed section's body still runs, under an empty clip rect: nothing + // in it draws or takes input, and sectionEnd puts y back under the header. + bool folded = false; + auto fold = [&](bool open) { + folded = !open; + if (folded) ImGui::PushClipRect(ImVec2(0, 0), ImVec2(0, 0), false); + }; auto sectionEnd = [&](float top) { + if (folded) { + ImGui::PopClipRect(); + folded = false; + y = top + 34 + space::md; + return; + } dl->AddRect(ImVec2(x0, top), ImVec2(x1, y + space::sm), rgb(0xffffff, 10), radius::card); y += space::sm + space::md; }; @@ -2539,7 +2799,7 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc const splash::Transform& t = o->transform; splash::Vec3 e = eulerDegrees(t.rotation); float top = y; - section("s_transform", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::move, color::textDim, "Transform", true, true, false); + fold(section("s_transform", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::move, color::textDim, "Transform", true, true, false)); y += 34 + space::xs; // Edits go through the history; a scrub merges into one step per field // until it is released (FieldEdit::done). @@ -2584,13 +2844,13 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc texMeta = std::to_string(ti->width) + " x " + std::to_string(ti->height) + " \xc2\xb7 " + texMeta; top = y; - section("s_mesh", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::box, kind::mesh, "Mesh", true, true, true, - removeFlag(editor::ComponentKind::Mesh)); + fold(section("s_mesh", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::box, kind::mesh, "Mesh", true, true, true, + removeFlag(editor::ComponentKind::Mesh))); y += 34 + space::xs; const ImRect modelRow = row("lmodel", "Model", "The 3D model to draw. Click to pick a .glb or .gltf file, or drop one here."); bool pickModel = assetField("model", modelRow, icon::box, meshSt == editor::AssetStatus::Ok ? kind::mesh : color::warn, modelName.c_str(), tris.empty() ? nullptr : tris.c_str()); - st.modelField = modelRow; + st.modelField = folded ? ImRect() : modelRow; st.modelField.ClipWith(r); st.modelFieldPath = path; if (st.drop.hovering && st.modelField.Contains(st.drop.pos)) @@ -2630,8 +2890,8 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc // Collider. if (o->collider) { top = y; - section("s_col", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::square, kind::collider, "Collider", true, true, true, - removeFlag(editor::ComponentKind::Collider)); + fold(section("s_col", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::square, kind::collider, "Collider", true, true, true, + removeFlag(editor::ComponentKind::Collider))); y += 34 + space::xs; // Menu order follows the tooltip: Static, Dynamic, None. const splash::ColliderKind kinds[3] = {splash::ColliderKind::Static, splash::ColliderKind::Dynamic, splash::ColliderKind::None}; @@ -2647,8 +2907,8 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc const splash::LightComponent& l = *o->light; top = y; bool flipLight = false; - section("s_light", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::lightbulb, kind::light, "Light", true, l.enabled, true, - removeFlag(editor::ComponentKind::Light), &flipLight); + fold(section("s_light", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::lightbulb, kind::light, "Light", true, l.enabled, true, + removeFlag(editor::ComponentKind::Light), &flipLight)); if (flipLight) doc.edit(path, [](splash::Object& ob) { ob.light->enabled = !ob.light->enabled; }); y += 34 + space::xs; // Menu order follows the tooltip: Point, Spot, Directional. @@ -2696,8 +2956,8 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc if (o->script) { const std::string& lua = o->script->lua; top = y; - section("s_script", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::script, kind::script, "Script", true, true, true, - removeFlag(editor::ComponentKind::Script)); + fold(section("s_script", ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), icon::script, kind::script, "Script", true, true, true, + removeFlag(editor::ComponentKind::Script))); y += 34 + space::xs; bool found = doc.fileExists(lua); if (assetField("lua", row("llua", "File", "The Lua file that runs for this object. Click to pick one."), icon::fileCode, @@ -2710,7 +2970,8 @@ static void inspector(State& st, ImDrawList* dl, ImRect r, editor::Document& doc // Components the inspector shows a few fields of. auto compSection = [&](const char* id, editor::ComponentKind k) { ObjectLook lk = lookOf(k); - section(id, ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), lk.icon, lk.color, editor::info(k).label, true, true, true, removeFlag(k)); + fold(section(id, ImRect(ImVec2(x0, y), ImVec2(x1, y + 34)), lk.icon, lk.color, editor::info(k).label, true, true, true, + removeFlag(k))); y += 34 + space::xs; }; auto number = [&](const char* id, const char* label, const char* tip, float v, const char* unit, const char* key, float lo, float hi, @@ -3053,6 +3314,7 @@ static void shortcuts(State& st, editor::Document& doc) { } } if (ctrl && !shift && pressed(ImGuiKey_A)) st.openAddObject = true; + if (ctrl && !shift && pressed(ImGuiKey_F)) st.focusTreeFilter = true; if (!ctrl && pressed(ImGuiKey_F5)) togglePlay(st, doc); if (ctrl && !shift && pressed(ImGuiKey_S)) saveDoc(st, doc); if (ctrl && shift && pressed(ImGuiKey_S) && !doc.projectRoot().empty()) saveDocAs(doc); diff --git a/src/mockup/main_screen.h b/src/mockup/main_screen.h index 62c732d..11aa792 100644 --- a/src/mockup/main_screen.h +++ b/src/mockup/main_screen.h @@ -101,6 +101,20 @@ struct State { // Object > Align with View, done where the viewport has the camera. bool alignRequest = false; + // Scene panel: the filter text and a request to put the caret in it + // (Ctrl+F); a row being dragged onto a new parent; the context menu to + // open next frame (1 = the object at treeMenuPath, 2 = the panel's own + // menu) and where, with treeMenuRight anchoring its right edge there. + std::string treeFilter; + bool focusTreeFilter = false; + bool treeDragging = false; + std::vector treeDragPath; + int treeMenu = 0; + int treeMenuShown = 0; + std::vector treeMenuPath; + ImVec2 treeMenuPos; + bool treeMenuRight = false; + // The title-bar menu that is open (index into File..Help), or -1. int menuOpen = -1; // Recently opened scenes (File menu), and an action held behind the diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index 6be644f..04e3987 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -182,22 +182,82 @@ int segmented(const char* id, ImVec2 pos, std::initializer_list ite return result; } -void searchField(const char* id, ImRect r, const char* placeholder, const char* shortcut) { +static int resizeString(ImGuiInputTextCallbackData* d) { + if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) { + auto* str = static_cast(d->UserData); + str->resize(static_cast(d->BufTextLen)); + d->Buf = str->data(); + } + return 0; +} + +bool searchField(const char* id, ImRect r, const char* placeholder, const char* shortcut, std::string* query, bool focus) { Fonts& f = fonts(); - Hit h = interact(id, r); ImDrawList* dl = ImGui::GetWindowDrawList(); + const float textX = r.Min.x + space::sm + 22; + // The clear button registers first so it wins over the field. + const bool hasText = query && !query->empty(); + ImRect clear(ImVec2(r.Max.x - space::xs - 22, r.Min.y + 3), ImVec2(r.Max.x - space::xs, r.Max.y - 3)); + Hit c{}; + if (hasText) { + c = interact((std::string(id) + "#clear").c_str(), clear); + if (c.clicked) query->clear(); + } + ImGuiID inputId = 0; + bool editing = false; + if (query) { + ImGui::PushID(id); + inputId = ImGui::GetID("##input"); + ImGui::PopID(); + editing = ImGui::GetActiveID() == inputId; + } + Hit h{}; + if (!query) h = interact(id, r); + const bool hovered = query ? ImGui::IsMouseHoveringRect(r.Min, r.Max) : h.hovered; dl->AddRectFilled(r.Min, r.Max, color::field, radius::field + 1); - dl->AddRect(r.Min, r.Max, lerpColor(color::border, color::borderStrong, h.hover), radius::field + 1); - text(dl, ImVec2(r.Min.x + space::sm, centerY(f.medium, type::icon, r.Min.y, r.Max.y)), f.medium, type::icon - 1, color::textFaint, - icon::search); - text(dl, ImVec2(r.Min.x + space::sm + 22, centerY(f.regular, type::body, r.Min.y, r.Max.y)), f.regular, type::body, - color::textFaint, placeholder); - if (shortcut) { + dl->AddRect(r.Min, r.Max, editing ? color::accent : lerpColor(color::border, color::borderStrong, hovered ? 1.0f : h.hover), + radius::field + 1); + text(dl, ImVec2(r.Min.x + space::sm, centerY(f.medium, type::icon, r.Min.y, r.Max.y)), f.medium, type::icon - 1, + hasText || editing ? color::textDim : color::textFaint, icon::search); + if (query) { + const float right = hasText ? clear.Min.x : r.Max.x - space::sm; + float padY = centerY(f.regular, type::body, r.Min.y, r.Max.y) - r.Min.y; + ImGui::SetCursorScreenPos(ImVec2(textX, r.Min.y)); + ImGui::PushFont(f.regular, type::body); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, padY)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_FrameBg, 0u); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, 0u); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, 0u); + ImGui::PushStyleColor(ImGuiCol_Text, color::text); + ImGui::PushStyleColor(ImGuiCol_TextSelectedBg, rgb(0x8b7bff, 110)); + ImGui::PushStyleColor(ImGuiCol_InputTextCursor, color::accentHover); + ImGui::PushStyleColor(ImGuiCol_NavCursor, 0u); + ImGui::SetNextItemWidth(std::max(8.0f, right - textX)); + if (focus) ImGui::SetKeyboardFocusHere(); + ImGui::PushID(id); + // Escape clears the text, then a second Escape leaves the field. + ImGui::InputText("##input", query->data(), query->capacity() + 1, + ImGuiInputTextFlags_CallbackResize | ImGuiInputTextFlags_EscapeClearsAll, resizeString, query); + interactiveRects().push_back(r); + ImGui::PopID(); + ImGui::PopStyleColor(7); + ImGui::PopStyleVar(2); + ImGui::PopFont(); + editing = ImGui::GetActiveID() == inputId; + } + if (!query || (query->empty() && !editing)) + text(dl, ImVec2(textX, centerY(f.regular, type::body, r.Min.y, r.Max.y)), f.regular, type::body, color::textFaint, + placeholder); + if (hasText) { + textCentered(dl, clear, f.medium, type::icon - 2, lerpColor(color::textFaint, color::text, c.hover), icon::x); + } else if (shortcut && !editing) { ImVec2 s = measure(f.medium, type::caption, shortcut); ImRect k(ImVec2(r.Max.x - space::sm - s.x - 10, r.Min.y + 5), ImVec2(r.Max.x - space::sm, r.Max.y - 5)); dl->AddRect(k.Min, k.Max, color::borderStrong, radius::field); textCentered(dl, k, f.medium, type::caption, color::textFaint, shortcut); } + return editing; } Hit treeRow(const char* id, ImRect r, const TreeRow& row, bool* visibilityClicked) { @@ -277,6 +337,13 @@ bool section(const char* id, ImRect r, const char* ic, ImU32 iconColor, const ch if (s.clicked) *toggled = true; } Hit h = interact(id, r); + ImGuiStorage* store = ImGui::GetStateStorage(); + const ImGuiID openKey = ImGui::GetID((std::string(id) + "#open").c_str()); + open = store->GetBool(openKey, open); + if (h.clicked) { + open = !open; + store->SetBool(openKey, open); + } dl->AddRectFilled(r.Min, r.Max, lerpColor(color::raised, color::hover, h.hover * 0.6f), radius::card, open ? ImDrawFlags_RoundCornersTop : ImDrawFlags_RoundCornersAll); float x = r.Min.x + space::sm; diff --git a/src/ui/widgets.h b/src/ui/widgets.h index e2f68f5..75d5869 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -50,7 +50,11 @@ bool iconButton(const char* id, ImRect r, const char* icon, bool toggled = false // Segmented control; returns new selection. int segmented(const char* id, ImVec2 pos, std::initializer_list items, int selected, float* outWidth = nullptr); -void searchField(const char* id, ImRect r, const char* placeholder, const char* shortcut = nullptr); +// A text field with a search icon. With `query` it is editable: click to +// type, Escape clears (a second one leaves), the x clears; `focus` puts the caret in it +// this frame. Returns true while it has the keyboard. +bool searchField(const char* id, ImRect r, const char* placeholder, const char* shortcut = nullptr, std::string* query = nullptr, + bool focus = false); struct TreeRow { int depth = 0; @@ -68,7 +72,8 @@ struct TreeRow { // With `visibilityClicked` the row draws an eye that sets it when clicked; the eye wins over the row. Hit treeRow(const char* id, ImRect r, const TreeRow& row, bool* visibilityClicked = nullptr); -// Collapsible inspector section. Returns open state. +// Collapsible inspector section: clicking the header opens or closes it. The +// state is kept per id; `open` is how a section starts. Returns whether it is open. // A removable section's ellipsis opens a menu; `removed` is set when its // Remove component item is picked. bool section(const char* id, ImRect r, const char* icon, ImU32 iconColor, const char* title, bool open, bool enabled = true,