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,