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:
co-authored by
Claude Sonnet 4.6
parent
c1029a25df
commit
0e5d103e73
@@ -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})
|
||||
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user