Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions src/editor/tests/editor_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
93 changes: 83 additions & 10 deletions src/mockup/main_screen.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<float> 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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1155,9 +1162,11 @@ static void treeObjects(TreeCtx& c, const std::vector<splash::Object>& 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) {
Expand All @@ -1169,15 +1178,18 @@ static void treeObjects(TreeCtx& c, const std::vector<splash::Object>& 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;
Expand Down Expand Up @@ -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;

Expand All @@ -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};
Expand Down Expand Up @@ -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<int>(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.
Expand All @@ -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<editor::ObjectPath>& 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<std::ptrdiff_t>(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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand Down
11 changes: 11 additions & 0 deletions src/mockup/main_screen.h
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ struct State {
std::vector<int> 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<std::vector<int>> treeSeenSelection;
std::string treeSeenFilter;

// The title-bar menu that is open (index into File..Help), or -1.
int menuOpen = -1;
Expand Down
53 changes: 53 additions & 0 deletions src/ui/list_scroll.hh
Original file line number Diff line number Diff line change
@@ -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 <algorithm>

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
11 changes: 11 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,17 @@ tree's rows are `tree.<object name>`). Needs a display.

python3 inspectorscrollcheck.py <splashedit>

`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: <name>`); 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 <splashedit>

`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
Expand Down
Loading
Loading