feat(udpscope): BSP pane tree with split, close and hit-testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-27 19:42:09 +02:00
co-authored by Claude Sonnet 4.6
parent c1029a25df
commit 0e5d103e73
4 changed files with 443 additions and 0 deletions
+1
View File
@@ -81,6 +81,7 @@ endif()
# ── Core library: everything except main.cpp, so tests can link it ──────────── # ── Core library: everything except main.cpp, so tests can link it ────────────
set(CORE_SOURCES set(CORE_SOURCES
Decimate.cpp Decimate.cpp
PaneTree.cpp
) )
add_library(udpscope_core STATIC ${CORE_SOURCES}) add_library(udpscope_core STATIC ${CORE_SOURCES})
+157
View File
@@ -0,0 +1,157 @@
#include "PaneTree.h"
#include <algorithm>
#include <cmath>
namespace udpscope {
PaneTree::PaneTree() : root_(new PaneNode()) {}
void PaneTree::setRoot(std::unique_ptr<PaneNode> 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<Placed>& leaves,
std::vector<Splitter>& 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<Placed>& leaves,
std::vector<Splitter>& 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<PaneNode> first(new PaneNode());
first->signals = std::move(leaf->signals);
first->profilePane = leaf->profilePane;
std::unique_ptr<PaneNode> 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<PaneNode> 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<Splitter>& 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 */
+116
View File
@@ -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 <memory>
#include <string>
#include <vector>
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<Assignment> 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<PaneNode> 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<PaneNode> node);
/**
* @brief Walk the tree, producing every leaf's rectangle and every split's
* drag zone.
*/
void layout(const Rect& area,
std::vector<Placed>& leaves,
std::vector<Splitter>& 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<Splitter>& 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<Placed>& leaves,
std::vector<Splitter>& 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<PaneNode> root_;
};
} /* namespace udpscope */
+169
View File
@@ -0,0 +1,169 @@
#include "PaneTree.h"
#include <gtest/gtest.h>
using namespace udpscope;
namespace {
const Rect kScreen{0.0, 0.0, 1000.0, 600.0};
std::vector<PaneTree::Placed> leavesOf(const PaneTree& tree, const Rect& area) {
std::vector<PaneTree::Placed> leaves;
std::vector<PaneTree::Splitter> 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<PaneTree::Placed> out;
std::vector<PaneTree::Splitter> 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<PaneTree::Placed> leaves;
std::vector<PaneTree::Splitter> 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);
}