diff --git a/Client/udpscope/CMakeLists.txt b/Client/udpscope/CMakeLists.txt index e523cfa..0b7a988 100644 --- a/Client/udpscope/CMakeLists.txt +++ b/Client/udpscope/CMakeLists.txt @@ -81,6 +81,7 @@ endif() # ── Core library: everything except main.cpp, so tests can link it ──────────── set(CORE_SOURCES Decimate.cpp + PaneTree.cpp ) add_library(udpscope_core STATIC ${CORE_SOURCES}) diff --git a/Client/udpscope/PaneTree.cpp b/Client/udpscope/PaneTree.cpp new file mode 100644 index 0000000..26600b9 --- /dev/null +++ b/Client/udpscope/PaneTree.cpp @@ -0,0 +1,157 @@ +#include "PaneTree.h" + +#include +#include + +namespace udpscope { + +PaneTree::PaneTree() : root_(new PaneNode()) {} + +void PaneTree::setRoot(std::unique_ptr node) { + if (node) { root_ = std::move(node); } +} + +double PaneTree::clampRatio(double ratio, double extent) { + if (extent <= 2.0 * kMinPaneSize) { + return 0.5; /* Too small to honour the minimum on both sides. */ + } + const double lo = kMinPaneSize / extent; + return std::min(std::max(ratio, lo), 1.0 - lo); +} + +void PaneTree::layoutNode(PaneNode* node, const Rect& r, + std::vector& leaves, + std::vector& splitters) { + if (node == nullptr) { return; } + if (node->leaf) { + leaves.push_back(Placed{node, r}); + return; + } + + if (node->orient == Orient::Columns) { + const double ratio = clampRatio(node->ratio, r.w); + const double wA = r.w * ratio; + layoutNode(node->a.get(), Rect{r.x, r.y, wA, r.h}, leaves, splitters); + layoutNode(node->b.get(), Rect{r.x + wA, r.y, r.w - wA, r.h}, leaves, splitters); + splitters.push_back(Splitter{ + node, + Rect{r.x + wA - kSplitterGrab * 0.5, r.y, kSplitterGrab, r.h}, + Orient::Columns}); + } else { + const double ratio = clampRatio(node->ratio, r.h); + const double hA = r.h * ratio; + layoutNode(node->a.get(), Rect{r.x, r.y, r.w, hA}, leaves, splitters); + layoutNode(node->b.get(), Rect{r.x, r.y + hA, r.w, r.h - hA}, leaves, splitters); + splitters.push_back(Splitter{ + node, + Rect{r.x, r.y + hA - kSplitterGrab * 0.5, r.w, kSplitterGrab}, + Orient::Rows}); + } +} + +void PaneTree::layout(const Rect& area, + std::vector& leaves, + std::vector& splitters) const { + leaves.clear(); + splitters.clear(); + layoutNode(root_.get(), area, leaves, splitters); +} + +void PaneTree::splitLeaf(PaneNode* leaf, Orient orient) { + if (leaf == nullptr || !leaf->leaf) { return; } + + /* Move the existing content into a new first child; the second is empty. */ + std::unique_ptr first(new PaneNode()); + first->signals = std::move(leaf->signals); + first->profilePane = leaf->profilePane; + + std::unique_ptr second(new PaneNode()); + + leaf->leaf = false; + leaf->orient = orient; + leaf->ratio = 0.5; + leaf->signals.clear(); + leaf->a = std::move(first); + leaf->b = std::move(second); +} + +PaneNode* PaneTree::findParent(PaneNode* node, const PaneNode* child) { + if (node == nullptr || node->leaf) { return nullptr; } + if (node->a.get() == child || node->b.get() == child) { return node; } + if (PaneNode* p = findParent(node->a.get(), child)) { return p; } + return findParent(node->b.get(), child); +} + +void PaneTree::closeLeaf(PaneNode* leaf) { + if (leaf == nullptr || !leaf->leaf) { return; } + + PaneNode* parent = findParent(root_.get(), leaf); + if (parent == nullptr) { + return; /* The root is the only leaf; a scope with no pane is useless. */ + } + + std::unique_ptr survivor = + (parent->a.get() == leaf) ? std::move(parent->b) : std::move(parent->a); + + /* Collapse the parent into the survivor in place, so the parent pointer + * held by any caller stays valid. */ + parent->leaf = survivor->leaf; + parent->signals = std::move(survivor->signals); + parent->profilePane = survivor->profilePane; + parent->orient = survivor->orient; + parent->ratio = survivor->ratio; + parent->a = std::move(survivor->a); + parent->b = std::move(survivor->b); +} + +void PaneTree::setRatio(PaneNode* split, double ratio) { + if (split != nullptr && !split->leaf) { + split->ratio = std::min(std::max(ratio, 0.0), 1.0); + } +} + +size_t PaneTree::countLeaves(const PaneNode* node) { + if (node == nullptr) { return 0; } + if (node->leaf) { return 1; } + return countLeaves(node->a.get()) + countLeaves(node->b.get()); +} + +size_t PaneTree::leafCount() const { return countLeaves(root_.get()); } + +const PaneTree::Splitter* PaneTree::hitTestSplitter( + const std::vector& splitters, double px, double py) const { + for (const Splitter& s : splitters) { + if (s.rect.contains(px, py)) { return &s; } + } + return nullptr; +} + +Handle PaneTree::hitTestHandle(const Rect& pane, double px, double py) { + if (!pane.contains(px, py)) { return Handle::None; } + + const double relX = px - pane.x; + const double relY = py - pane.y; + const double midY = pane.h * 0.5; + const double midX = pane.w * 0.5; + const double half = kHandleSize * 0.5; + + /* Close sits in the top-right corner and wins over the edge handles. */ + if (relX >= pane.w - kHandleSize && relY <= kHandleSize) { + return Handle::Close; + } + if (relX <= kHandleSize && std::abs(relY - midY) <= half * 3.0) { + return Handle::Left; + } + if (relX >= pane.w - kHandleSize && std::abs(relY - midY) <= half * 3.0) { + return Handle::Right; + } + if (relY <= kHandleSize && std::abs(relX - midX) <= half * 3.0) { + return Handle::Top; + } + if (relY >= pane.h - kHandleSize && std::abs(relX - midX) <= half * 3.0) { + return Handle::Bottom; + } + return Handle::None; +} + +} /* namespace udpscope */ diff --git a/Client/udpscope/PaneTree.h b/Client/udpscope/PaneTree.h new file mode 100644 index 0000000..84fa6dc --- /dev/null +++ b/Client/udpscope/PaneTree.h @@ -0,0 +1,116 @@ +/** + * @file PaneTree.h + * @brief Binary-space-partition layout of the plot area. + * + * Framework-free: no ImGui, no UDPS. The geometry and the hit-testing are the + * fiddly part of the pane UI and are unit-tested without a window. + */ +#pragma once + +#include "Types.h" + +#include +#include +#include + +namespace udpscope { + +/** Direction a node splits its rectangle in. */ +enum class Orient { Columns, Rows }; + +/** Vertical scaling strategy for one trace. */ +enum class VMode { Auto, Range, Manual }; + +struct VScale { + VMode mode = VMode::Auto; + double div = 1.0; /**< Units per division, Manual only. */ + double offset = 0.0; /**< Centre value, Manual only. */ +}; + +/** One signal drawn in one pane. Signals are named, never indexed. */ +struct Assignment { + std::string signalName; + Color color; + float lineWidth = 1.5f; + VScale vs; +}; + +/** Smallest a pane may be squeezed to, in pixels. */ +constexpr double kMinPaneSize = 80.0; + +/** Thickness of the splitter drag zone and of the inset handles, in pixels. */ +constexpr double kSplitterGrab = 6.0; +constexpr double kHandleSize = 18.0; + +/** What the pointer is over inside a pane. */ +enum class Handle { None, Left, Right, Top, Bottom, Close }; + +struct PaneNode { + bool leaf = true; + + /* leaf only */ + std::vector signals; + bool profilePane = false; /**< Holds vector signals, not time series. */ + + /* split only */ + Orient orient = Orient::Columns; + double ratio = 0.5; /**< First child's share of the parent. */ + std::unique_ptr a, b; +}; + +class PaneTree { +public: + struct Placed { PaneNode* leaf; Rect rect; }; + struct Splitter { PaneNode* node; Rect rect; Orient orient; }; + + PaneTree(); + + PaneNode* root() { return root_.get(); } + const PaneNode* root() const { return root_.get(); } + + /** Replaces the whole tree, e.g. when loading a session. */ + void setRoot(std::unique_ptr node); + + /** + * @brief Walk the tree, producing every leaf's rectangle and every split's + * drag zone. + */ + void layout(const Rect& area, + std::vector& leaves, + std::vector& splitters) const; + + /** Turn a leaf into a split; the original content stays in the first child. */ + void splitLeaf(PaneNode* leaf, Orient orient); + + /** Replace the leaf's parent with its sibling. No-op on the last leaf. */ + void closeLeaf(PaneNode* leaf); + + void setRatio(PaneNode* split, double ratio); + + size_t leafCount() const; + + /** @return the splitter under the point, or nullptr. */ + const Splitter* hitTestSplitter(const std::vector& splitters, + double px, double py) const; + + /** + * @brief Which inset handle of @p pane the point is over. + * + * Handles sit inside the pane so they never overlap the splitter drag zone, + * and every pane has all four regardless of whether it touches a window + * edge — a pane in the middle of a 3x3 touches none. + */ + static Handle hitTestHandle(const Rect& pane, double px, double py); + +private: + static void layoutNode(PaneNode* node, const Rect& r, + std::vector& leaves, + std::vector& splitters); + static size_t countLeaves(const PaneNode* node); + static PaneNode* findParent(PaneNode* node, const PaneNode* child); + static double clampRatio(double ratio, double extent); + + std::unique_ptr root_; +}; + +} /* namespace udpscope */ diff --git a/Client/udpscope/tests/PaneTreeTest.cpp b/Client/udpscope/tests/PaneTreeTest.cpp new file mode 100644 index 0000000..087a0e2 --- /dev/null +++ b/Client/udpscope/tests/PaneTreeTest.cpp @@ -0,0 +1,169 @@ +#include "PaneTree.h" + +#include + +using namespace udpscope; + +namespace { + +const Rect kScreen{0.0, 0.0, 1000.0, 600.0}; + +std::vector leavesOf(const PaneTree& tree, const Rect& area) { + std::vector leaves; + std::vector splitters; + tree.layout(area, leaves, splitters); + return leaves; +} + +} /* namespace */ + +TEST(PaneTree, StartsAsOneEmptyLeafFillingTheArea) { + PaneTree tree; + EXPECT_EQ(tree.leafCount(), 1u); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 1u); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0); + EXPECT_DOUBLE_EQ(leaves[0].rect.h, 600.0); + EXPECT_TRUE(leaves[0].leaf->signals.empty()); +} + +TEST(PaneTree, SplittingIntoColumnsHalvesTheWidth) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 500.0); + EXPECT_DOUBLE_EQ(leaves[1].rect.w, 500.0); + EXPECT_DOUBLE_EQ(leaves[0].rect.h, 600.0); + EXPECT_DOUBLE_EQ(leaves[1].rect.x, 500.0); +} + +TEST(PaneTree, SplittingIntoRowsHalvesTheHeight) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Rows); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + EXPECT_DOUBLE_EQ(leaves[0].rect.h, 300.0); + EXPECT_DOUBLE_EQ(leaves[1].rect.y, 300.0); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0); +} + +// The pane being split keeps its content; the new pane is the empty one. +TEST(PaneTree, SplitKeepsTheOriginalContentInTheFirstChild) { + PaneTree tree; + tree.root()->signals.push_back(Assignment{"Voltage", Color{}, 1.5f, VScale{}}); + tree.splitLeaf(tree.root(), Orient::Columns); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + ASSERT_EQ(leaves[0].leaf->signals.size(), 1u); + EXPECT_EQ(leaves[0].leaf->signals[0].signalName, "Voltage"); + EXPECT_TRUE(leaves[1].leaf->signals.empty()); +} + +TEST(PaneTree, ClosingALeafGivesItsSpaceToTheSibling) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + leaves[1].leaf->signals.push_back(Assignment{"Keep", Color{}, 1.5f, VScale{}}); + + tree.closeLeaf(leaves[0].leaf); + + EXPECT_EQ(tree.leafCount(), 1u); + leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 1u); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 1000.0); + ASSERT_EQ(leaves[0].leaf->signals.size(), 1u); + EXPECT_EQ(leaves[0].leaf->signals[0].signalName, "Keep"); +} + +TEST(PaneTree, RefusesToCloseTheLastLeaf) { + PaneTree tree; + tree.closeLeaf(tree.root()); + EXPECT_EQ(tree.leafCount(), 1u); +} + +// A pane in the middle of a 3x3 touches no window edge. It must still be +// splittable, which is why handles are inset inside the pane rather than +// keyed on the window border. +TEST(PaneTree, AnInteriorPaneIsStillSplittable) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Rows); // top / bottom + auto leaves = leavesOf(tree, kScreen); + tree.splitLeaf(leaves[1].leaf, Orient::Rows); // 3 rows + leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 3u); + + PaneNode* middle = leaves[1].leaf; + tree.splitLeaf(middle, Orient::Columns); + leaves = leavesOf(tree, kScreen); + tree.splitLeaf(leaves[2].leaf, Orient::Columns); + + EXPECT_EQ(tree.leafCount(), 5u); +} + +TEST(PaneTree, LayoutReportsOneSplitterPerSplitNode) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + auto leaves = leavesOf(tree, kScreen); + tree.splitLeaf(leaves[0].leaf, Orient::Rows); + + std::vector out; + std::vector splitters; + tree.layout(kScreen, out, splitters); + + EXPECT_EQ(out.size(), 3u); + EXPECT_EQ(splitters.size(), 2u); +} + +TEST(PaneTree, RatioSurvivesALayoutRoundTrip) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + tree.setRatio(tree.root(), 0.25); + + const auto leaves = leavesOf(tree, kScreen); + ASSERT_EQ(leaves.size(), 2u); + EXPECT_DOUBLE_EQ(leaves[0].rect.w, 250.0); + EXPECT_DOUBLE_EQ(leaves[1].rect.w, 750.0); +} + +TEST(PaneTree, RatioIsClampedSoNeitherPaneGoesBelowTheMinimum) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + tree.setRatio(tree.root(), 0.001); + + const auto leaves = leavesOf(tree, kScreen); + EXPECT_GE(leaves[0].rect.w, kMinPaneSize); + EXPECT_GE(leaves[1].rect.w, kMinPaneSize); +} + +TEST(PaneTree, HitTestFindsTheSplitterBetweenTwoPanes) { + PaneTree tree; + tree.splitLeaf(tree.root(), Orient::Columns); + + std::vector leaves; + std::vector splitters; + tree.layout(kScreen, leaves, splitters); + ASSERT_EQ(splitters.size(), 1u); + + const PaneTree::Splitter* hit = tree.hitTestSplitter(splitters, 500.0, 300.0); + ASSERT_NE(hit, nullptr); + EXPECT_EQ(hit->orient, Orient::Columns); + + EXPECT_EQ(tree.hitTestSplitter(splitters, 100.0, 300.0), nullptr); +} + +TEST(PaneTree, HitTestFindsInsetSplitHandlesAndTheCloseButton) { + const Rect pane{0.0, 0.0, 400.0, 300.0}; + + EXPECT_EQ(PaneTree::hitTestHandle(pane, 8.0, 150.0), Handle::Left); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 392.0, 150.0), Handle::Right); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 8.0), Handle::Top); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 292.0), Handle::Bottom); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 392.0, 8.0), Handle::Close); + EXPECT_EQ(PaneTree::hitTestHandle(pane, 200.0, 150.0), Handle::None); +}