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/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..c2e1cc6 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,11 @@ 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 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 @@ -1079,6 +1085,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 +1162,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) { @@ -1169,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; @@ -3358,8 +3370,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,9 +3399,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); - 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}; @@ -3418,12 +3460,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. @@ -3439,6 +3481,35 @@ 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. + // 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) { + 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) { + // 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) { @@ -3489,10 +3560,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. @@ -5309,7 +5382,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()); diff --git a/src/mockup/main_screen.h b/src/mockup/main_screen.h index 7f9cf8c..3b4cf27 100644 --- a/src/mockup/main_screen.h +++ b/src/mockup/main_screen.h @@ -240,6 +240,17 @@ 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; + bool treeRevealPending = false; // the selection's groups were opened; scroll to its row next frame + 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..a76879c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -851,6 +851,17 @@ 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; `--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 + `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..c8cdbbf --- /dev/null +++ b/tests/treescrollcheck.py @@ -0,0 +1,160 @@ +# 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, 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. +# 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': []}) + # 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 + + +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) + +# 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)