From 31425e5bc8ca543350f09ceec1912710bb545103 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 16:27:18 -0700 Subject: [PATCH 1/4] Scripted runs: print the selection, name the Scene filter and rows A scripted editor run now ends with "selected: " (or "(none)") next to its other state lines, so a GUI check can tell what a click picked without reading the inspector. The Scene panel's filter field is the named control treesearch, and the part of the panel the rows are drawn in is treelist.panel, which --scroll-to also takes as a panel. Nothing is drawn differently. --- src/main.cpp | 4 ++++ src/mockup/main_screen.cpp | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 8e6364e..b14ab96 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1033,6 +1033,10 @@ int main(int argc, char** argv) { std::printf("game attached: %s\n", state.play.cons[0].game.attached() ? "yes" : "no"); std::printf("status: %s\n", state.notice.c_str()); std::printf("unsaved changes: %s\n", doc.dirty() ? "yes" : "no"); + { + const splash::Object* sel = doc.selection() ? doc.object(*doc.selection()) : nullptr; + std::printf("selected: %s\n", sel ? sel->name.c_str() : "(none)"); + } std::printf("inspector import warnings: %zu\n", state.importPanel.shown.size()); for (const std::string& w : state.importPanel.shown) std::printf(" import warning: %s\n", w.c_str()); std::printf("project panel open: %s\n", state.assets.open ? "yes" : "no"); diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index f7f5b28..29714e6 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -3358,8 +3358,9 @@ static void sceneTree(State& st, ImDrawList* dl, ImRect r, editor::Document& doc 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); + const ImRect searchRect(ImVec2(r.Min.x + space::sm, y), ImVec2(r.Max.x - space::sm, y + 28)); + nameRect("treesearch", searchRect); + searchField("treesearch", searchRect, "Filter objects", "Ctrl F", &st.treeFilter, st.focusTreeFilter); st.focusTreeFilter = false; y += 28 + space::sm; @@ -3386,6 +3387,7 @@ static void sceneTree(State& st, ImDrawList* dl, ImRect r, editor::Document& doc } ImRect foot(ImVec2(r.Min.x, r.Max.y - 40), r.Max); + nameRect("treelist.panel", ImRect(ImVec2(r.Min.x, y), ImVec2(r.Max.x, foot.Min.y))); // the rows' visible part ImGui::PushClipRect(ImVec2(r.Min.x, y), ImVec2(r.Max.x, foot.Min.y), true); const editor::ObjectPath root; TreeCtx ctx{st, doc, r, y + size::row}; From 4d6527031e75aa19321832a216fb33961e281d10 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 16:27:24 -0700 Subject: [PATCH 2/4] Scene panel: scroll the rows (#227) A scene with more objects than the panel fits could not reach its last rows. The rows now scroll with the wheel, clamped to them, with a thin thumb at the right edge as in the inspector that can also be dragged. A row dragged near the top or bottom edge scrolls the list. A selection made anywhere (viewport, a new object, --select) or a new filter brings the selected row into view once; the wheel is not fought otherwise. Rows scrolled out of the list take no drop or dropped file. The scroll arithmetic is in ui/list_scroll.hh with unit tests; treescrollcheck.py runs in CI. --- .github/workflows/build.yml | 3 + src/editor/tests/editor_tests.cpp | 31 +++++++ src/mockup/main_screen.cpp | 67 +++++++++++++-- src/mockup/main_screen.h | 10 +++ src/ui/list_scroll.hh | 53 ++++++++++++ tests/README.md | 9 ++ tests/treescrollcheck.py | 138 ++++++++++++++++++++++++++++++ 7 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 src/ui/list_scroll.hh create mode 100644 tests/treescrollcheck.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 86a2c0f..422ee53 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -325,6 +325,9 @@ jobs: - name: Object inspector scrolls if: runner.os == 'Linux' run: LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s "-screen 0 1600x960x24" python3 tests/inspectorscrollcheck.py build/splashedit + - name: Scene panel scrolls + if: runner.os == 'Linux' + run: LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s "-screen 0 1600x960x24" python3 tests/treescrollcheck.py build/splashedit - name: Viewport trigger, interact and nav overlays if: runner.os == 'Linux' run: LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s "-screen 0 1600x960x24" python3 tests/gizmocheck.py build/splashedit diff --git a/src/editor/tests/editor_tests.cpp b/src/editor/tests/editor_tests.cpp index 67a6222..14cc9b1 100644 --- a/src/editor/tests/editor_tests.cpp +++ b/src/editor/tests/editor_tests.cpp @@ -63,6 +63,7 @@ #include "ui/dialog_gate.hh" #include "ui/window_chrome.hh" #include "ui/ui_scale.hh" +#include "ui/list_scroll.hh" #include "ui/scrub.hh" #include "ui/colour_hex.hh" #include "budget.hh" @@ -12354,6 +12355,35 @@ void testSpriteDirectionSets() { } } +// The Scene panel's rows scroll (#227): clamped to the rows, a row brought +// into view moves the scroll as little as it can, the thumb spans the view +// in proportion and its drag covers the whole scroll, and a dragged row +// scrolls the list only near or past an edge. +void testListScroll() { + CHECK(ui::maxScroll(500, 800) == 0); + CHECK(ui::maxScroll(2000, 800) == 1200); + CHECK(ui::clampScroll(-10, 2000, 800) == 0); + CHECK(ui::clampScroll(5000, 2000, 800) == 1200); + CHECK(ui::clampScroll(300, 500, 800) == 0); // rows collapsed shorter than the view: back to the top + CHECK(ui::scrollToShow(100, 300, 326, 800) == 100); // in view: left alone + CHECK(ui::scrollToShow(400, 300, 326, 800) == 300); // above: its top at the view's top + CHECK(ui::scrollToShow(0, 1900, 1926, 800) == 1126); // below: its bottom at the view's bottom + CHECK(ui::scrollToShow(0, 100, 1000, 800) == 100); // taller than the view: its top + const ui::Thumb none = ui::thumb(0, 500, 800); + CHECK(none.top == 0 && none.height == 800); + const ui::Thumb top = ui::thumb(0, 2000, 800), end = ui::thumb(1200, 2000, 800); + CHECK(std::fabs(top.height - 320) < 1e-3f && top.top == 0); + CHECK(std::fabs(end.top + end.height - 800) < 1e-3f); + CHECK(ui::thumb(0, 1e6f, 800).height == ui::kMinThumb); + CHECK(std::fabs(ui::thumbDragScroll(800 - 320, 2000, 800) - 1200) < 1e-3f); + CHECK(ui::thumbDragScroll(10, 500, 800) == 0); + CHECK(ui::edgeScrollSpeed(400, 100, 900, 26, 900) == 0); + CHECK(ui::edgeScrollSpeed(100, 100, 900, 26, 900) == -900); + CHECK(ui::edgeScrollSpeed(50, 100, 900, 26, 900) == -900); // past the edge: full speed + CHECK(std::fabs(ui::edgeScrollSpeed(887, 100, 900, 26, 900) - 450) < 1e-3f); + CHECK(ui::edgeScrollSpeed(120, 100, 130, 26, 900) == 0); // a view too short to have edges +} + // Dragging a whole-number field moves it one per pixel at any size (#174): // a 300 px wide element grows by 5 for 5 px, not by 15; Shift takes 10 px // per step. Other fields keep growing with the value. @@ -13854,6 +13884,7 @@ int main(int argc, char** argv) { testGpuDumpParse(); testGpuDecode(); testGpuRaster(); + testListScroll(); if (g_failures) { std::fprintf(stderr, "editor_tests: %d of %d checks failed\n", g_failures, g_checks); return 1; diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index 29714e6..b4b2f1c 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -40,6 +40,7 @@ #include "ui/colour_hex.hh" #include "ui/file_dialog.h" #include "ui/icons.h" +#include "ui/list_scroll.hh" #include "ui/texture_image.h" #include "ui/widgets.h" #include "viewport/ps1view.h" @@ -1044,6 +1045,10 @@ struct TreeCtx { editor::Document& doc; ImRect panel; float y; + ImRect list{}; // the visible part of the rows: a row outside it takes no drop + // The selected object's row, in px from the top of the scrolled rows, when drawn. + std::optional selTop{}; + float contentTop = 0; // where the rows' top is drawn this frame (scrolled up past list.Min.y) bool renameShown = false; std::string filter{}; // lower case; empty = every object int shown = 0; // object rows drawn @@ -1079,6 +1084,7 @@ static bool isUnder(const editor::ObjectPath& p, const editor::ObjectPath& ances 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 (!c.list.Contains(m)) return; // the row is scrolled out from under the pointer if (isUnder(path, c.st.treeDragPath)) return; // onto itself or inside it if (c.doc.inInstance(path)) return; // an instance's content comes from its prefab const float t = (m.y - rr.Min.y) / rr.GetHeight(); @@ -1155,9 +1161,11 @@ static void treeObjects(TreeCtx& c, const std::vector& objs, edi ". Open the prefab to change it (Inspector > Prefab > Open).").c_str()); nameRect("tree." + o.name, rr); ++c.shown; - if (ImGui::IsRectVisible(rr.Min, rr.Max)) { + if (row.selected) c.selTop = rr.Min.y - c.contentTop; + if (rr.Overlaps(c.list)) { + // Only its visible part takes a dropped file. ImRect shown = rr; - shown.ClipWith(c.panel); + shown.ClipWith(c.list); c.st.treeRows.push_back({shown, path}); } if (c.st.treeDragging) { @@ -3387,10 +3395,38 @@ static void sceneTree(State& st, ImDrawList* dl, ImRect r, editor::Document& doc } ImRect foot(ImVec2(r.Min.x, r.Max.y - 40), r.Max); - nameRect("treelist.panel", ImRect(ImVec2(r.Min.x, y), ImVec2(r.Max.x, foot.Min.y))); // the rows' visible part - ImGui::PushClipRect(ImVec2(r.Min.x, y), ImVec2(r.Max.x, foot.Min.y), true); + // The rows scroll between the filter (or the prefab bar) and the footer. + // The wheel over them, the thumb, and a row dragged near an edge move them, + // clamped to last frame's rows before they are drawn and to this frame's after. + const ImRect list(ImVec2(r.Min.x, y), ImVec2(r.Max.x, foot.Min.y)); + nameRect("treelist.panel", list); // for --scroll-to + const float listH = list.GetHeight(); + const ImGuiIO& io = ImGui::GetIO(); + float& scroll = st.treeScroll; + bool thumbHot = false; + if (ui::maxScroll(st.treeContent, listH) > 0) { + if (ImGui::IsMouseHoveringRect(list.Min, list.Max) && !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId)) + scroll -= io.MouseWheel * 48.0f; + // Registered before the rows so it wins the press where they meet. + const ui::Thumb th = ui::thumb(scroll, st.treeContent, listH); + const ImRect thumbRect(ImVec2(r.Max.x - 7, list.Min.y + th.top), ImVec2(r.Max.x - 1, list.Min.y + th.top + th.height)); + nameRect("treethumb", thumbRect); + Hit t = interact("treethumb", thumbRect); + st.treeThumbHeld = t.held; + thumbHot = t.hovered || t.held; + if (t.held) scroll += ui::thumbDragScroll(io.MouseDelta.y, st.treeContent, listH); + if (st.treeDragging && r.Contains(io.MousePos)) + scroll += ui::edgeScrollSpeed(io.MousePos.y, list.Min.y, list.Max.y, size::row, 900.0f) * io.DeltaTime; + } else { + st.treeThumbHeld = false; + } + scroll = ui::clampScroll(scroll, st.treeContent, listH); + ImGui::PushClipRect(list.Min, list.Max, true); + y = list.Min.y - scroll; const editor::ObjectPath root; TreeCtx ctx{st, doc, r, y + size::row}; + ctx.list = list; + ctx.contentTop = y; 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}; @@ -3420,12 +3456,12 @@ static void sceneTree(State& st, ImDrawList* dl, ImRect r, editor::Document& doc // 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)) { + const bool inTree = list.Contains(m); + if (!ctx.hasDrop && inTree && (m.y < rr.Max.y || 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.dropInto = m.y < rr.Max.y; 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. @@ -3441,6 +3477,23 @@ static void sceneTree(State& st, ImDrawList* dl, ImRect r, editor::Document& doc } ImGui::PopClipRect(); + // The rows' height, with half a row under the last to drop at the end. + st.treeContent = ctx.y + size::row * 0.5f - ctx.contentTop; + // A selection made since last frame, here or anywhere else, or a new + // filter: its row is brought into view, once. + if (doc.selection() != st.treeSeenSelection || st.treeFilter != st.treeSeenFilter) { + if (ctx.selTop) scroll = ui::scrollToShow(scroll, *ctx.selTop, *ctx.selTop + size::row, listH); + st.treeSeenSelection = doc.selection(); + st.treeSeenFilter = st.treeFilter; + } + scroll = ui::clampScroll(scroll, st.treeContent, listH); + if (ui::maxScroll(st.treeContent, listH) > 0) { + // A thin bar at the right edge, as in the inspector; it can be dragged. + const ui::Thumb th = ui::thumb(scroll, st.treeContent, listH); + dl->AddRectFilled(ImVec2(r.Max.x - 5, list.Min.y + th.top + 2), ImVec2(r.Max.x - 2, list.Min.y + th.top + th.height - 2), + rgb(0xffffff, thumbHot ? 90 : 40), 2); + } + if (st.treeDragging) { const splash::Object* dragged = doc.object(st.treeDragPath); if (dragged) { diff --git a/src/mockup/main_screen.h b/src/mockup/main_screen.h index 7f9cf8c..38864a7 100644 --- a/src/mockup/main_screen.h +++ b/src/mockup/main_screen.h @@ -240,6 +240,16 @@ struct State { std::vector treeMenuPath; ImVec2 treeMenuPos; bool treeMenuRight = false; + // The Scene panel's rows scroll: how far up (px), the rows' height last + // frame (to clamp before they are drawn again), the thumb being dragged, and + // the selection and filter it last brought into view: a selection made + // anywhere (viewport, a new object, --select) is scrolled to once, the + // wheel is left alone otherwise. + float treeScroll = 0; + float treeContent = 0; + bool treeThumbHeld = false; + std::optional> treeSeenSelection; + std::string treeSeenFilter; // The title-bar menu that is open (index into File..Help), or -1. int menuOpen = -1; diff --git a/src/ui/list_scroll.hh b/src/ui/list_scroll.hh new file mode 100644 index 0000000..89ef25f --- /dev/null +++ b/src/ui/list_scroll.hh @@ -0,0 +1,53 @@ +// A scrolled list's arithmetic (the Scene panel's rows): how far it may +// scroll, the scroll that brings a row into view, the thumb, and the scroll +// while a row is dragged near an edge. Kept apart from the widgets (no +// ImGui) so editor_tests can check it. Positions are in px; `s` is how far +// the content is scrolled up, `content` its height and `view` the visible height. +#pragma once + +#include + +namespace ui { + +inline float maxScroll(float content, float view) { return std::max(0.0f, content - view); } + +inline float clampScroll(float s, float content, float view) { return std::clamp(s, 0.0f, maxScroll(content, view)); } + +// The scroll nearest `s` that shows the row from `top` to `bottom` (content +// px from the list's top) whole; a row taller than the view shows its top. +inline float scrollToShow(float s, float top, float bottom, float view) { + if (top < s) return top; + if (bottom > s + view) return std::min(top, bottom - view); + return s; +} + +// The thumb at the view's right edge: its top (from the view's top) and height. +struct Thumb { + float top, height; +}; +constexpr float kMinThumb = 24; + +inline Thumb thumb(float s, float content, float view) { + const float m = maxScroll(content, view); + if (m <= 0 || view <= 0) return {0, view}; + const float h = std::min(view, std::max(kMinThumb, view * view / content)); + return {(view - h) * std::clamp(s / m, 0.0f, 1.0f), h}; +} + +// Dragging the thumb `dy` px scrolls the content this far. +inline float thumbDragScroll(float dy, float content, float view) { + const float travel = view - thumb(0, content, view).height; + return travel > 0 ? dy * maxScroll(content, view) / travel : 0; +} + +// A row dragged with the pointer at `y`: within `zone` of the view's top +// (`viewTop`) or bottom, or past them, the list scrolls, up to `maxSpeed` +// px a second at the edge and beyond; 0 elsewhere. Negative scrolls up. +inline float edgeScrollSpeed(float y, float viewTop, float viewBottom, float zone, float maxSpeed) { + if (zone <= 0 || viewBottom - viewTop < 2 * zone) return 0; + if (y < viewTop + zone) return -maxSpeed * std::min(1.0f, (viewTop + zone - y) / zone); + if (y > viewBottom - zone) return maxSpeed * std::min(1.0f, (y - (viewBottom - zone)) / zone); + return 0; +} + +} // namespace ui diff --git a/tests/README.md b/tests/README.md index 1f98c73..875bb8c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -851,6 +851,15 @@ tree's rows are `tree.`). Needs a display. python3 inspectorscrollcheck.py +`treescrollcheck.py` adds 80 objects (Filler 1 to Filler 80) to a copy of the courtyard example, more +than the Scene panel fits. The last row starts below the visible rows (`treelist.panel`); the wheel +over them brings it into view and a click selects it (the run prints `selected: `); a row +scrolled up under the filter field (`treesearch`), or one under the Assets footer, does not take a +click there; a row dragged to the bottom edge and held scrolls the list; and `--select` of the last +object scrolls its row into view at startup. Needs a display. + + python3 treescrollcheck.py + `gizmocheck.py` adds a Trigger (Gate) and an Interactable (Lever) to a copy of the courtyard example and shoots the scene view twice, with the viewport's overlay toggle (`vp.gizmos`) on and off. Inside the viewport (`vp.view`), each pixel the "on" shot moved towards an overlay's colour counts for that diff --git a/tests/treescrollcheck.py b/tests/treescrollcheck.py new file mode 100644 index 0000000..c1572d1 --- /dev/null +++ b/tests/treescrollcheck.py @@ -0,0 +1,138 @@ +# The Scene panel's rows scroll when the scene has more objects than the +# panel fits (#227). A copy of the courtyard gets 80 more objects, Filler 1 +# to Filler 80. The last row starts below the panel; the wheel over the rows +# brings it into view and a click on it selects it, as does dragging the +# thumb to the bottom; a row scrolled up under +# the filter field does not take a click meant for the field; and --select of +# the last object scrolls its row into view at startup; a row dragged to the +# list's bottom edge scrolls it. Rects come from the editor's list of named +# controls, as the last frame drew them; the list's visible part is +# treelist.panel. +# usage: python3 treescrollcheck.py (needs a display: xvfb-run -a) +import json, os, re, shutil, subprocess, sys, tempfile + +editor = os.path.abspath(sys.argv[1]) +here = os.path.dirname(os.path.abspath(__file__)) +example = os.path.join(here, '..', 'examples', 'courtyard') +fails = 0 +ENV = dict(os.environ, SPLASHEDIT_REDUX='/nonexistent/pcsx-redux', SPLASHEDIT_PSXSPLASH='/nonexistent/psxsplash.ps-exe') +H = 960 +N = 80 +LAST = 'Filler %d' % N + + +def check(name, ok, detail=''): + global fails + print(('ok ' if ok else 'FAIL ') + name + ('' if ok else ': ' + str(detail))) + fails += not ok + + +def project_copy(): + project = os.path.join(tempfile.mkdtemp(), 'courtyard') + shutil.copytree(example, project) + scene = os.path.join(project, 'courtyard.scene') + s = json.load(open(scene, encoding='utf-8')) + for k in range(1, N + 1): + s['objects'].append({'name': 'Filler %d' % k, 'active': True, 'transform': {'position': [k * 0.5, 0, 0], 'rotation': [0, 0, 0, 1], + 'scale': [1, 1, 1]}, 'components': [], 'children': []}) + json.dump(s, open(scene, 'w', encoding='utf-8'), indent=2) + return project + + +def run(args, select=None): + """Runs the editor on a fresh copy with `args`; returns the output lines, + every named control's rect and the selected object's name.""" + project = project_copy() + cmd = [editor, '--size', '1600x%d' % H, '--project', project, '--scene', os.path.join(project, 'courtyard.scene')] + if select: + cmd += ['--select', select] + cmd += ['--wait', '5'] + args + ['--wait', '5', '--screenshot', os.path.join(project, 'shot.png')] + r = subprocess.run(cmd, capture_output=True, text=True, env=ENV, timeout=120) + lines = (r.stdout + r.stderr).strip().splitlines() + missing = [l for l in lines if 'no such control drawn' in l or 'no object named' in l] + check('every control the script names was drawn', not missing, missing) + rects, selected = {}, None + for l in lines: + m = re.match(r'control (.+) at (-?\d+),(-?\d+) (-?\d+),(-?\d+)$', l) + if m: + rects[m.group(1)] = tuple(int(v) for v in m.groups()[1:]) + if l.startswith('selected: '): + selected = l[len('selected: '):] + return lines, rects, selected + + +def inside(row, panel): + return row is not None and panel is not None and row[1] >= panel[1] and row[3] <= panel[3] + + +lines, rects, selected = run([]) +panel = rects.get('treelist.panel') +check('the rows list is drawn', panel is not None, sorted(rects)[:20]) +last = rects.get('tree.' + LAST) +check('the last row starts below the list (the scene overflows it)', last is not None and panel is not None and last[1] > panel[3], + (last, panel)) + +# The wheel over the rows, far enough to reach the end, then a click on the last row. +mx = (panel[0] + panel[2]) // 2 if panel else 150 +my = (panel[1] + panel[3]) // 2 if panel else 400 +WHEEL = ['--wheel', '%d,%d,-60' % (mx, my), '--wait', '3'] +lines, rects, selected = run(WHEEL + ['--click', '@tree.' + LAST, '--wait', '3']) +panel = rects.get('treelist.panel') +last = rects.get('tree.' + LAST) +check('the wheel brings the last row into the list', inside(last, panel), (last, panel)) +check('a click on it selects it', selected == LAST, selected) +first = rects.get('tree.Environment') +check('the first rows are scrolled up out of the list', first is not None and panel is not None and first[3] <= panel[1], (first, panel)) + +# The thumb dragged to the bottom shows the last row too. +lines, rects, selected = run(['--drag', '@treethumb,0,0,0,%d' % (H - 200), '--wait', '3']) +panel = rects.get('treelist.panel') +last = rects.get('tree.' + LAST) +check('dragging the thumb to the bottom brings the last row into the list', inside(last, panel), (last, panel)) + +# Scrolled a little, some row lies under the filter field above the list: +# a click there is the field's, not the row's. +lines, rects, selected = run(['--wheel', '%d,%d,-3' % (mx, my), '--wait', '3']) +panel = rects.get('treelist.panel') +search = rects.get('treesearch') +hidden = None +for name, rr in sorted(rects.items()): + if name.startswith('tree.') and panel and rr[3] <= panel[1] and search and rr[1] <= search[3] - 4 and rr[3] >= search[1] + 4: + hidden = (name, rr) +check('a row is scrolled up under the filter field', hidden is not None, (search, panel)) +if hidden: + cx = (hidden[1][0] + hidden[1][2]) // 2 + cy = (max(hidden[1][1], search[1]) + min(hidden[1][3], search[3])) // 2 + lines, rects, selected = run(['--wheel', '%d,%d,-3' % (mx, my), '--wait', '3', '--click', '%d,%d' % (cx, cy), '--wait', '3'], + select='Sun') + check('a click over it leaves the selection alone', selected == 'Sun', (selected, hidden[0], cx, cy)) +# Under the Project footer, below the list, too. +lines, rects, selected = run([], select='Sun') +panel = rects.get('treelist.panel') +below = None +for name, rr in sorted(rects.items()): + if name.startswith('tree.') and panel and rr[1] >= panel[3] + 4 and rr[3] <= panel[3] + 36: + below = (name, rr) + break +check('a row lies under the footer', below is not None, panel) +if below: + lines, rects, selected = run(['--click', '%d,%d' % ((below[1][0] + below[1][2]) // 2, (below[1][1] + below[1][3]) // 2), '--wait', '3'], + select='Sun') + check('a click on the footer does not select the row under it', selected == 'Sun', (selected, below[0])) + +# A row dragged to the list's bottom edge, and held there, scrolls the list. +lines, rects, selected = run(['--drag-hold', '%d,%d,%d,%d' % (mx, panel[1] + 90, mx, panel[3] - 4), '--wait', '40'], select='Sun') +panel = rects.get('treelist.panel') +first = rects.get('tree.Environment') +check('dragging a row to the bottom edge scrolls the list', first is not None and panel is not None and first[3] <= panel[1], + (first, panel)) + +# Selected at startup (as a viewport click or Ctrl F would), the last row is scrolled to. +lines, rects, selected = run([], select=LAST) +panel = rects.get('treelist.panel') +last = rects.get('tree.' + LAST) +check('--select of the last object scrolls its row into view', inside(last, panel), (last, panel)) +check('and it is the selection', selected == LAST, selected) + +print('%d failure%s' % (fails, '' if fails == 1 else 's')) +sys.exit(1 if fails else 0) From 90f28d0c7c405bf0ca5dd2831b8f0044dedecd92 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 18:13:46 -0700 Subject: [PATCH 3/4] Scripted runs: name the viewport's object markers The markers the viewport draws for objects with neither mesh nor children (and the lightbulbs for lights) are now the named controls vp.marker., so a scripted check can click one with --click @vp.marker. and select that object the way a user picks it in the view. Nothing is drawn differently. --- src/mockup/main_screen.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index b4b2f1c..b0f4396 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -3544,10 +3544,12 @@ static void selectionOutline(ImDrawList* dl, viewport::Ps1View& view, ImVec2 mn, } // A clickable marker for an object without geometry. Returns true when clicked. -static bool sceneIcon(ImDrawList* dl, viewport::Ps1View& view, ImVec2 mn, ImVec2 sz, viewport::Vec3 p, const char* ic, ImU32 col) { +static bool sceneIcon(ImDrawList* dl, viewport::Ps1View& view, ImVec2 mn, ImVec2 sz, viewport::Vec3 p, const char* ic, ImU32 col, + const std::string& name = {}) { ImVec2 c; if (!view.project(p, mn, sz, &c)) return false; if (!ImRect(mn, mn + sz).Contains(c)) return false; + if (!name.empty()) nameRect("vp.marker." + name, ImRect(c - ImVec2(13, 13), c + ImVec2(13, 13))); Hit h = interact("marker", ImRect(c - ImVec2(13, 13), c + ImVec2(13, 13))); dl->AddCircleFilled(c, 13, rgb(0x0e1014, 200), 24); // The ring brightens on hover. @@ -5364,7 +5366,7 @@ static void viewportPanel(State& st, ImDrawList* dl, ImRect r, editor::Document& else if (!fo.object->mesh && fo.object->children.empty() && !fo.object->script) ic = icon::square, col = kind::folder; if (!ic) continue; ImGui::PushID((int)i); - if (sceneIcon(dl, view, mn, sz, pos, ic, col) && i < paths.size()) doc.select(pickTarget(doc, paths[i])); + if (sceneIcon(dl, view, mn, sz, pos, ic, col, fo.object->name) && i < paths.size()) doc.select(pickTarget(doc, paths[i])); ImGui::PopID(); } if (previewing) previewCamera(dl, view, mn, sz, view.preview()); From 86d67efd806df7d6ffe66902c0f102f8c514ffed Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 20 Oct 2026 18:13:53 -0700 Subject: [PATCH 4/4] Scene panel: open closed groups over a selection made elsewhere An object picked in the viewport, added, or given with --select while a group above it is closed had no row to scroll to. The tree now opens every closed group above it, the scene row included, and scrolls to the row on the next frame. It tries once per selection: a selection made by clicking a row in the tree never opens anything, and a group the user closes again afterwards stays closed while the selection is unchanged. treescrollcheck.py picks a child of a closed group from its viewport marker. --- src/mockup/main_screen.cpp | 22 +++++++++++++++++++--- src/mockup/main_screen.h | 1 + tests/README.md | 6 ++++-- tests/treescrollcheck.py | 24 +++++++++++++++++++++++- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/mockup/main_screen.cpp b/src/mockup/main_screen.cpp index b0f4396..c2e1cc6 100644 --- a/src/mockup/main_screen.cpp +++ b/src/mockup/main_screen.cpp @@ -1049,6 +1049,7 @@ struct TreeCtx { // The selected object's row, in px from the top of the scrolled rows, when drawn. std::optional selTop{}; float contentTop = 0; // where the rows' top is drawn this frame (scrolled up past list.Min.y) + bool selectedHere = false; // a row was clicked to select it this frame bool renameShown = false; std::string filter{}; // lower case; empty = every object int shown = 0; // object rows drawn @@ -1177,15 +1178,18 @@ static void treeObjects(TreeCtx& c, const std::vector& objs, edi if (c.filter.empty()) c.doc.toggleExpanded(path); } else { c.doc.select(path); + c.selectedHere = true; } } if (!c.st.treeDragging && h.hovered && ImGui::IsMouseDoubleClicked(0) && !(row.hasChildren && chevronClicked(rr, row.depth))) { c.doc.select(path); + c.selectedHere = true; 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.selectedHere = true; c.st.treeMenu = 1; c.st.treeMenuPath = path; c.st.treeMenuPos = ImGui::GetIO().MousePos; @@ -3481,10 +3485,22 @@ static void sceneTree(State& st, ImDrawList* dl, ImRect r, editor::Document& doc st.treeContent = ctx.y + size::row * 0.5f - ctx.contentTop; // A selection made since last frame, here or anywhere else, or a new // filter: its row is brought into view, once. + // Selected elsewhere under closed groups, the groups open first and the + // row is scrolled to when next drawn; a group closed afterwards stays closed. if (doc.selection() != st.treeSeenSelection || st.treeFilter != st.treeSeenFilter) { - if (ctx.selTop) scroll = ui::scrollToShow(scroll, *ctx.selTop, *ctx.selTop + size::row, listH); - st.treeSeenSelection = doc.selection(); - st.treeSeenFilter = st.treeFilter; + const std::optional& sel = doc.selection(); + if (sel && !ctx.selTop && !ctx.selectedHere && !st.treeRevealPending && doc.object(*sel)) { + for (size_t n = 0; n < sel->size(); ++n) { + const editor::ObjectPath up(sel->begin(), sel->begin() + static_cast(n)); + if (!doc.expanded(up)) doc.toggleExpanded(up); + } + st.treeRevealPending = true; + } else { + if (ctx.selTop) scroll = ui::scrollToShow(scroll, *ctx.selTop, *ctx.selTop + size::row, listH); + st.treeSeenSelection = sel; + st.treeSeenFilter = st.treeFilter; + st.treeRevealPending = false; + } } scroll = ui::clampScroll(scroll, st.treeContent, listH); if (ui::maxScroll(st.treeContent, listH) > 0) { diff --git a/src/mockup/main_screen.h b/src/mockup/main_screen.h index 38864a7..3b4cf27 100644 --- a/src/mockup/main_screen.h +++ b/src/mockup/main_screen.h @@ -248,6 +248,7 @@ struct State { float treeScroll = 0; float treeContent = 0; bool treeThumbHeld = false; + bool treeRevealPending = false; // the selection's groups were opened; scroll to its row next frame std::optional> treeSeenSelection; std::string treeSeenFilter; diff --git a/tests/README.md b/tests/README.md index 875bb8c..a76879c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -855,8 +855,10 @@ tree's rows are `tree.`). Needs a display. than the Scene panel fits. The last row starts below the visible rows (`treelist.panel`); the wheel over them brings it into view and a click selects it (the run prints `selected: `); a row scrolled up under the filter field (`treesearch`), or one under the Assets footer, does not take a -click there; a row dragged to the bottom edge and held scrolls the list; and `--select` of the last -object scrolls its row into view at startup. Needs a display. +click there; a row dragged to the bottom edge and held scrolls the list; `--select` of the last +object scrolls its row into view at startup; and with the last group (Deep Group) closed and the +list back at the top, picking its child from its viewport marker (`vp.marker.Deep Child`) opens the +group and scrolls to the row, while closing the group again by hand keeps it closed. Needs a display. python3 treescrollcheck.py diff --git a/tests/treescrollcheck.py b/tests/treescrollcheck.py index c1572d1..c8cdbbf 100644 --- a/tests/treescrollcheck.py +++ b/tests/treescrollcheck.py @@ -4,7 +4,8 @@ # brings it into view and a click on it selects it, as does dragging the # thumb to the bottom; a row scrolled up under # the filter field does not take a click meant for the field; and --select of -# the last object scrolls its row into view at startup; a row dragged to the +# the last object scrolls its row into view at startup, and an object picked +# in the viewport under a closed group opens it and is scrolled to; a row dragged to the # list's bottom edge scrolls it. Rects come from the editor's list of named # controls, as the last frame drew them; the list's visible part is # treelist.panel. @@ -35,6 +36,11 @@ def project_copy(): for k in range(1, N + 1): s['objects'].append({'name': 'Filler %d' % k, 'active': True, 'transform': {'position': [k * 0.5, 0, 0], 'rotation': [0, 0, 0, 1], 'scale': [1, 1, 1]}, 'components': [], 'children': []}) + # Last of all, a group holding one object, clear of the fillers in the view. + child = {'name': 'Deep Child', 'active': True, 'transform': {'position': [-3, 2, 3], 'rotation': [0, 0, 0, 1], 'scale': [1, 1, 1]}, + 'components': [], 'children': []} + s['objects'].append({'name': 'Deep Group', 'active': True, 'transform': {'position': [0, 0, 0], 'rotation': [0, 0, 0, 1], + 'scale': [1, 1, 1]}, 'components': [], 'children': [child]}) json.dump(s, open(scene, 'w', encoding='utf-8'), indent=2) return project @@ -134,5 +140,21 @@ def inside(row, panel): check('--select of the last object scrolls its row into view', inside(last, panel), (last, panel)) check('and it is the selection', selected == LAST, selected) +# Deep Group closed from its chevron, the list wheeled back to the top, then +# Deep Child picked from its marker in the viewport: the group opens and the +# row is scrolled to. Closed again by hand, it stays closed. +COLLAPSE = ['--scroll-to', '@tree.Deep Group', '--click', '@tree.Deep Group,-105,0', '--wait', '3', + '--wheel', '%d,%d,60' % (mx, my), '--wait', '3'] +lines, rects, selected = run(COLLAPSE) +check('closing Deep Group hides its child row', 'tree.Deep Child' not in rects, rects.get('tree.Deep Child')) +lines, rects, selected = run(COLLAPSE + ['--click', '@vp.marker.Deep Child', '--wait', '5']) +panel = rects.get('treelist.panel') +deep = rects.get('tree.Deep Child') +check('picked in the viewport, Deep Child is the selection', selected == 'Deep Child', selected) +check('its closed group opens and its row is scrolled into view', inside(deep, panel), (deep, panel)) +lines, rects, selected = run(COLLAPSE + ['--click', '@vp.marker.Deep Child', '--wait', '5', '--click', '@tree.Deep Group,-105,0', '--wait', '5']) +check('closed again by hand with the selection unchanged, the group stays closed', + 'tree.Deep Child' not in rects and selected == 'Deep Child', (rects.get('tree.Deep Child'), selected)) + print('%d failure%s' % (fails, '' if fails == 1 else 's')) sys.exit(1 if fails else 0)