Debugging
ZenitUI has three independent tools for understanding what's happening at runtime:
Debug::dump*— structured dumps of a node, a tree, the theme, and the parts of a widget. Compiled in only whenZENITUI_DEBUGis defined.Logger— a structured logging system with categories, source locations, and severity levels. Always compiled in, but only emits when something goes wrong or a custom logger is installed.Mocks.hpp— a headless test harness that replaces the three backend interfaces with mocks, letting you drive the framework through a scripted sequence of frames and pointer/keyboard events.
This document covers all three, plus the workflow of writing tests and debugging common problems.
1. The debug macro
Debug.hpp wraps its entire implementation in:
#ifdef ZENITUI_DEBUG
// ... full implementation ...
#else
// ... inline no-op stubs with the same signatures ...
#endif
So including Debug.hpp and calling Debug::dumpTree(*node) always
compiles. But if ZENITUI_DEBUG isn't defined, the call compiles to
nothing (an empty inline function with the same signature). No linker
errors, no runtime overhead.
To enable the debug functions, define the macro before including
Debug.hpp (or globally, via the build system):
#define ZENITUI_DEBUG
#include "Debug.hpp"
Because Debug.hpp is included by UI.hpp, the macro must be defined
in the build flags (-DZENITUI_DEBUG) or at the very top of the file
that includes UI.hpp. Defining it after including UI.hpp has no
effect — the header has already been processed with the stub branch.
In practice:
- Debug builds:
-DZENITUI_DEBUG. - Release builds: undefined (or explicitly
-UZENITUI_DEBUG).
The macros are orthogonal to NDEBUG — you can have a build with
NDEBUG (asserts off) and ZENITUI_DEBUG (dumps on), or vice versa.
They serve different purposes.
2. Debug::dump* functions
Four functions, all in namespace ZenitUI::Debug, all writing to a
std::ostream& (default std::cerr).
2.1 dumpStyle(node, os)
Prints a single node's identity, state, rect, computed style, and the list of matching rules.
=== Node ===
tag : Button
classes : .btn-primary
state : H
rect : (340, 280 120x40)
style : size=auto x auto bg=#0079F1FF color=#FFFFFFFF fs=20 padding=[0 0 0 0] margin=[0 0 0 0] radius=8px opacity=1 pos=static ...
matches : 3 rule(s)
spec=0.1.0] .btn-primary
-> { background=#0079F1FF, radius=8px, ... }
spec=0.1.0] .btn-primary:hover
-> { background=#3296FFFF, scale=1.15 }
spec=0.1.1] Button.btn-primary
-> { min-width=80px, padding=[1.5vh 2vw 1.5vh 2vw] }
Sections:
- Identity: tag, id (if set), classes.
- State: a compact code —
Hovered,Pressed,Focused,Checked,Disabled.-if none. - Rect:
(x, y widthxheight). - Style:
computedStyleToString— a compact summary of every non-default property. - Matches: every
ThemeRulethat matched this node, with its specificity (ids.classes.tags), the selector string, and the declarations from that rule.
The style summary is generated by computedStyleToString, which only
prints non-default values. So a node with no styling shows a bare
size=auto x auto bg=#00000000 ... with most fields omitted.
The matches section is the most useful for cascade debugging: it
shows you exactly which rules applied, in what order, and what each
one contributed. If a property isn't what you expect, look here.
2.2 dumpTree(node, os, maxDepth, depth)
Prints the tree recursively, one line per node.
- Stack#root [800x600 @ 0,0]
- Button.btn-primary [120x40 @ 340,280] {H}
- Text.btn-text [80x20 @ 360,290]
- ScrollView.demo-content [600x560 @ 180,20]
- VStack.card [560x200 @ 200,40]
- Text.card-title [100x24 @ 220,60]
- Text.card-desc [560x48 @ 220,92]
- VStack.card [560x180 @ 200,260]
...
Format: indent - [#id] Tag.class1.class2 [WxH @ X,Y] {state}.
Parameters:
maxDepth: if>= 0, stops recursion at that depth. Default-1(unlimited).depth: internal, starts at 0. Don't pass.
dumpTree traverses the logical tree, not the render tree. So a
portal appears as a child of its logical parent, not at the root where
it's drawn. See Portals §8 for the discussion.
The state block is only shown if the node has any flag set ({H},
{PF}, etc.). Nodes with no flags show nothing.
2.3 dumpTheme(os)
Prints the global Theme:
=== Theme ===
Rules : 47
Keyframes: 3
--- Keyframes ---
pulse (3 frames)
knobGlow (3 frames)
knobPop (3 frames)
--- Rules ---
[ 0] spec= 0.0.1 VStack
{ gap=2vh }
[ 1] spec= 0.1.0 .btn-primary
{ background=#0079F1FF, radius=8px, padding=[1.5vh 2vw 1.5vh 2vw] }
[ 2] spec= 0.1.0 .btn-primary:hover
{ background=#3296FFFF, scale=1.15 }
...
Three sections:
- Summary: total rule count and keyframe count.
- Keyframes: name and frame count for each
@keyframes. - Rules: each rule with its index, specificity, selector string, and declarations.
The rule index is the order of declaration in the theme. Lower indices were declared earlier. When two rules have the same specificity, the later one wins — so a rule with a higher index overrides one with a lower index.
Useful for:
- Checking what styles are loaded after
loadStyleFile. - Verifying that a
@keyframesblock parsed correctly. - Debugging specificity issues (which rule wins).
2.4 dumpParts(node, os)
Prints the ::parts that have matching rules for a node.
=== Parts of Slider ===
::track (2 matching rule(s))
::fill (1 matching rule(s))
::knob (3 matching rule(s))
dumpParts checks a hardcoded list of candidate names:
static const char* candidates[] = {
"track", "fill", "knob", "box", "mark", "cursor"
};
For each candidate, it counts matching ThemeRules (rules whose part
field equals the name and whose chain matches the node). If the count
is non-zero, it's printed.
This is a coverage check, not a full dump. It tells you which parts
have styles, but not what those styles are. For the full rules, use
dumpStyle (which lists all matching rules, including ::part rules)
or dumpTheme.
Note: the candidate list is hardcoded. If you write a custom widget
with a part name not in the list, dumpParts won't report it. The
list is intentionally small to keep the output readable.
2.5 Output target
All four functions take a std::ostream& defaulting to std::cerr.
To dump to a file:
std::ofstream log("dump.txt");
Debug::dumpTree(*root, log);
Debug::dumpTheme(log);
To dump to stdout:
Debug::dumpStyle(*node, std::cout);
The functions don't flush. If you're writing to a file, flush or close the stream before reading it.
2.6 Performance
dumpStyle and dumpTree are cheap — they iterate a node's rules and
children. dumpTheme is O(rules). All are synchronous and blocking.
Calling them every frame produces a lot of output. They're meant for
one-shot debugging, not continuous logging. For per-frame diagnostics,
use Logger instead.
3. Logger
Logger is the framework's structured logging system. It's always
compiled in (unlike Debug), and it's used by the framework itself to
report warnings (unknown properties, invalid values, misuse of APIs).
3.1 The message
struct LogMessage {
LogLevel level;
std::string category; // "StyleParser", "Layout", "Animations", ...
std::string file; // source file (e.g. "assets/game.zstyle")
int line; // 0 if not available
int column; // 0 if not available
std::string text;
};
enum class LogLevel { Debug, Info, Warning, Error };
Five fields, all optional except level and text. The file / line
/ column are populated when the logging call site knows its source
location (e.g. the style parser, which has parsed a .zstyle file and
knows the current line).
3.2 The interface
class ILogger {
public:
virtual ~ILogger() = default;
virtual void log(const LogMessage& msg) = 0;
};
One method. Implementations are free to do anything with the message.
3.3 The default logger
ConsoleLogger writes to stderr:
[WARN ] [StyleParser] assets/game.zstyle(45:10): unknown property 'foo'. Ignored.
[ERROR] [StyleParser] assets/game.zstyle: impossibile aprire il file
Format: [LEVEL] [category] file(line:col): text.
If file is empty, the file part is omitted. If line == 0, only the
file name is shown (no line/column). If both are empty, only the level
and category.
The level is padded to 5 characters for alignment (DEBUG, INFO,
WARN, ERROR).
3.4 Installing a custom logger
Logger::set(&myLogger);
Logger::set takes an ILogger*. Passing nullptr restores the
default ConsoleLogger. The user is responsible for the lifetime of a
custom logger — Logger doesn't own it.
A custom logger might:
- Filter by category or level.
- Route to
std::coutfor info,std::cerrfor warnings. - Collect messages into a buffer for later display.
- Forward to a game engine's logging system.
- Write to a file.
Example: a logger that writes only errors to a file:
class FileErrorLogger : public ILogger {
public:
FileErrorLogger(const char* path) : file(path) {}
void log(const LogMessage& msg) override {
if (msg.level != LogLevel::Error &&
msg.level != LogLevel::Warning) return;
file << "[" << (int)msg.level << "] "
<< msg.category << ": " << msg.text << "\n";
}
private:
std::ofstream file;
};
3.5 Free functions
Five free functions in namespace ZenitUI:
inline void logDebug(const std::string& c, const std::string& f, int l, int col, const std::string& t);
inline void logInfo (const std::string& c, const std::string& f, int l, int col, const std::string& t);
inline void logWarn (const std::string& c, const std::string& f, int l, int col, const std::string& t);
inline void logError(const std::string& c, const std::string& f, int l, int col, const std::string& t);
Each constructs a LogMessage and forwards to Logger::get().log(msg).
Parameters:
c: category (a short string like"StyleParser").f: file (source file or asset path). Empty if not applicable.l: line. 0 if not applicable.col: column. 0 if not applicable.t: text.
Typical call sites:
// In the style parser, with full location
logWarn("StyleParser", loc.file, loc.line, loc.col,
"unknown property '" + k + "'. Ignored.");
// In Layout::addChild, without location
logWarn("Layout", "", 0, 0,
"addChild called on a node not managed by shared_ptr. "
"Use X::create() and put this logic in onBuild().");
3.6 Categories used in the framework
| Category | Where | Typical messages |
|---|---|---|
StyleParser |
StyleParser.hpp, StyleAttr.hpp |
Unknown properties, invalid values, missing transition shorthand, keyframe auto-fills, unreadable files. |
Layout |
Layout.hpp |
addChild misuse (constructor call), missing shared_ptr. |
There's no strict registry of categories. New code can use any string.
The convention is PascalCase, one or two words, describing the
subsystem.
3.7 What gets logged
The framework logs in these situations:
- Unknown property in
.zstyle:StyleParserwarns, continues. - Invalid value in
.zstyle:StyleParserwarns, ignores the property. transition-delaywithouttransition: warning, ignored.animation-delaywithoutanimation: warning, ignored.- Keyframe auto-fill: warning per filled property per keyframe.
- File not found: error, parse aborted.
addChildon amake_sharednode: warning, call ignored.
The framework doesn't log on:
- Missing assets (font, texture, effect) — silent fallback.
- Unknown selector states (
:foo) — silently ignored. - Missing filter shaders — silent fallback to unfiltered.
- Style resolution errors that are recoverable — silent.
The philosophy: log things the user probably didn't intend, but stay silent about fallbacks that are legitimate. A missing font is often fine (the default is used); a typo'd property is usually a bug.
3.8 Logging from user code
There's no requirement to use Logger in user code. It's available if
you want:
logInfo("MyGame", "", 0, 0, "player spawned");
But std::printf is simpler for most cases. Logger is most useful
when you want the message to go through the same sink as the framework's
messages (e.g. a game console overlay).
3.9 Performance
logImpl constructs a LogMessage (a struct with four strings), then
calls the logger. The strings are copied. For a call that doesn't
actually log (e.g. debug level filtered out by a custom logger), the
copy is wasted.
There's no "level check before formatting" optimization. If you log in a hot path, check the level first or use a custom logger that doesn't copy.
In practice, the framework doesn't log in hot paths. The typical warning (unknown property) fires once at parse time. There's no per-frame logging.
4. Test infrastructure
tests/Mocks.hpp provides a complete headless test fixture. You can
drive the framework through a full frame loop without opening a window,
and assert on observable behavior (callbacks fired, states set, rects
computed).
4.1 MockRenderer
Implements IRenderer with counting and minimal state:
class MockRenderer : public IRenderer {
public:
int fillRectCount{0};
int drawTextCount{0};
int pushClipCount{0};
int popClipCount{0};
float charWidth{8.0f}; // deterministic text measurement
std::vector<Rect> clipStack;
Rect currentClip{0, 0, 1e9f, 1e9f};
// ... method implementations ...
bool supports(Feature) const override { return false; }
};
Key features:
- Counting:
fillRectCount,drawTextCount,pushClipCount,popClipCountincrement on the corresponding calls. Useful for asserting "this node was drawn" or "text was measured". - Deterministic text:
measureTextreturnscharWidth * lengthfor the width andsizefor the height. So a 10-char string at font size 20 measures80 x 20. No font required. - Clip stack tracking: the renderer maintains a
clipStackand the intersectedcurrentClip.pushClipintersects,popCliprestores. This lets tests verify that clipping works without a real GPU. - No effects:
supports(Feature::Effects)returnsfalse, so filters are skipped in tests unless explicitly enabled.
The mock doesn't actually draw pixels. It just records that draw calls were made. Tests that need to verify visual output would need a real backend with framebuffer readback.
4.2 MockPlatform
Implements IPlatform with scriptable input:
class MockPlatform : public IPlatform {
public:
Vec2 viewport{800.0f, 600.0f};
double currentTime{0.0};
ModifierState mods;
PointerState nextPointer;
InputEvents nextEvents;
// ... scripted helpers ...
void moveMouse(Vec2 pos);
void pressLeft(Vec2 pos);
void releaseLeft(Vec2 pos);
void holdLeft(Vec2 pos);
void pressRight(Vec2 pos);
void releaseRight(Vec2 pos);
void pressKey(int key);
void typeChar(int c);
};
The pointer state is set via helper methods, then read by the framework
on the next beginFrame. The mock clears pressed / released
after each read, so edge events only fire once.
Helper semantics:
pressLeft(pos)— setsdown = true,pressed = true,released = false. The next frame sees a press.holdLeft(pos)—down = true,pressed = false,released = false. Subsequent frames of a held button.releaseLeft(pos)—down = false,pressed = false,released = true.moveMouse(pos)— no buttons, no edges.
So a click sequence is:
platform.pressLeft({50, 50});
env.frame(root); // framework sees press
platform.releaseLeft({50, 50});
env.frame(root); // framework sees release
Or, more concisely, using the click helper (see §4.4).
4.3 MockAssetProvider
Implements IAssetProvider returning empty handles for everything:
class MockAssetProvider : public IAssetProvider {
public:
FontHandle getFont(std::string_view) override { return {}; }
TextureHandle getTexture(std::string_view) override { return {}; }
EffectHandle getEffect(std::string_view) override { return {}; }
};
This means no fonts, textures, or effects are loaded. The framework falls back to defaults:
- Text uses the default font (which in the mock renderer has
deterministic measurement via
charWidth). - Background textures are missing (nothing drawn).
- Effects are unsupported (the mock renderer returns
falseforsupports(Effects)).
For tests that need a specific asset, you'd extend the provider.
4.4 The Env fixture
struct Env {
MockRenderer renderer;
MockPlatform platform;
MockAssetProvider assets;
Env(); // installs the mocks in UIContext, calls reset()
~Env(); // nulls the UIContext pointers
void reset(); // clears Theme, UIContext state, mock counters
void frame(std::shared_ptr<Layout> root, float dt = 1.0f / 60.0f);
void click(std::shared_ptr<Layout> root, Vec2 pos);
};
Construction installs the three mocks in UIContext and calls
reset(). Destruction nulls the pointers (so subsequent tests don't
use dangling references).
reset() clears:
Theme(rules, keyframes, root).UIContextfocus, capture, targets, flags, portals.MockPlatformstate (viewport, time, pointer, events).MockRenderercounters and clip state.Metrics::viewportto{1920, 1080}(a sensible default for tests).
frame(root, dt) runs a single frame:
void frame(std::shared_ptr<Layout> root, float dt = 1.0f / 60.0f) {
auto& ctx = UIContext::get();
ctx.beginFrame(dt);
root->updateTree(dt);
root->measure(platform.viewport.x, platform.viewport.y);
root->arrange({0, 0, platform.viewport.x, platform.viewport.y});
root->draw();
}
This is the same sequence as runFrame + renderFrame, but inlined
without the renderer's beginFrame / endFrame (which the mock
doesn't need).
click(root, pos) does a full press-and-release sequence:
void click(std::shared_ptr<Layout> root, Vec2 pos) {
platform.pressLeft(pos);
frame(root);
platform.releaseLeft(pos);
frame(root);
}
Two frames — one for the press, one for the release. Callbacks fire on the appropriate frame.
4.5 Writing a test
The test framework is defined in tests/TestFramework.hpp (not shown,
but the API is used throughout the test files). Tests use a simple
macro-based syntax:
TEST(SuiteName, test_name) {
Env env;
// setup
// actions
CHECK(condition);
CHECK_NEAR(a, b, tolerance);
}
Test files are tests/test_*.cpp. Each file includes TestFramework.hpp
and (optionally) Mocks.hpp.
test_main.cpp:
int main() {
ZenitUI::Metrics::viewport = { 1920.0f, 1080.0f };
return Test::run();
}
Sets the viewport for tests that use vw / vh before running all
registered tests.
4.6 Example: click test
TEST(Interaction_click, simple_click_increments) {
Env env;
int count = 0;
auto root = std::make_shared<Layout>(LayoutType::Stack);
root->size(Percent(100), Percent(100));
auto btn = std::make_shared<Layout>(LayoutType::Stack);
btn->size(Px(100), Px(50));
btn->setInteractive(true);
btn->setBlocksRaycast(true);
btn->onClick = [&count]() { count++; };
root->addChild(btn);
env.frame(root); // layout settles
env.click(root, {50, 25});
CHECK(count == 1);
}
Steps:
- Create the fixture (installs mocks, resets state).
- Build a tree.
- Run one frame so layout is computed.
- Simulate a click.
- Assert the callback fired.
The env.frame(root) before the click is required — without it, the
button's rect is {0, 0, 0, 0} (no measure/arrange has run), and the
hit-test at (50, 25) won't find it.
4.7 Example: hover test
TEST(Interaction_hover, enter_fires_on_move) {
Env env;
int enters = 0;
auto btn = std::make_shared<Layout>(LayoutType::Stack);
btn->size(Px(100), Px(50));
btn->setInteractive(true);
btn->setBlocksRaycast(true);
btn->onHoverEnter = [&enters]() { enters++; };
auto root = std::make_shared<Layout>(LayoutType::Stack);
root->size(Percent(100), Percent(100));
root->addChild(btn);
env.frame(root);
CHECK(enters == 0);
env.platform.moveMouse({50, 25});
env.frame(root);
CHECK(enters == 1);
env.platform.moveMouse({500, 500});
env.frame(root);
// enters still 1 (no exit handler, so no change in enters)
}
The pattern: moveMouse sets the pointer position, frame runs the
frame with that position, and the framework updates the hover flags.
4.8 Example: keyboard test
TEST(Interaction_focus, tab_cycles_through_focusables) {
Env env;
auto root = std::make_shared<Layout>(LayoutType::Stack);
root->size(Percent(100), Percent(100));
auto a = std::make_shared<Layout>(LayoutType::Stack);
a->size(Px(100), Px(50));
a->setInteractive(true);
a->setBlocksRaycast(true);
a->setFocusable(true);
auto b = std::make_shared<Layout>(LayoutType::Stack);
b->size(Px(100), Px(50));
b->setInteractive(true);
b->setBlocksRaycast(true);
b->setFocusable(true);
root->addChild(a);
root->addChild(b);
env.frame(root);
env.platform.pressKey(Key::Tab);
env.frame(root);
CHECK(UIContext::get().hasFocus(a.get()));
env.platform.pressKey(Key::Tab);
env.frame(root);
CHECK(UIContext::get().hasFocus(b.get()));
env.platform.pressKey(Key::Tab);
env.frame(root);
CHECK(UIContext::get().hasFocus(a.get())); // wrap
}
Each pressKey sets nextEvents.keys, which is consumed by the next
pollInputEvents call. So the Tab event is seen on the following
frame.
4.9 Example: style test
TEST(Parser_basic, hover_state) {
Theme t;
ZMarkup::loadStyleString("Toggle:hover { background: blue; }", t);
CHECK(t.rules.size() == 1);
CHECK(t.rules[0].chain.size() == 1);
CHECK(t.rules[0].chain[0].requireHover);
}
Parser tests don't need Env — they operate on a local Theme. This
is faster and simpler for testing the parsing layer.
4.10 What can be tested
- Style parsing: parse a string, check
Theme::rules,Style::customProps, etc. - Style resolution: build a node, run a frame, check
node->getStyle(). - Layout: build a tree, run measure/arrange, check
getRect(). - Interaction: simulate clicks/keys, check callbacks and state.
- Animation: run N frames, check that a property interpolated.
- Media queries: set
Metrics::viewport, check that rules match. - ZMarkup: build a string, check the tree.
4.11 What can't be tested with the mocks
- Visual output: the mock renderer doesn't draw pixels. Tests can't verify colors, shapes, or text rendering.
- Text measurement accuracy: the mock uses
charWidth * length, which is a rough approximation. Tests that depend on precise text size should use a real backend. - Effects: the mock returns
falseforsupports(Effects), so filters and shaders are skipped. - Render targets: the mock's
createTargetreturns an invalid handle, so any code that relies on a valid target won't work. - Performance: the mocks are fast, but they don't measure real rendering performance.
For these, you need a real backend. The Raylib backend can be used headlessly (no window) with an offscreen framebuffer, but that's not currently set up.
5. Debugging workflow
5.1 "My widget doesn't appear"
Debug::dumpTree(*root, std::cout);— is the widget in the tree?- If yes:
Debug::dumpStyle(*widget, std::cout);— does it have a non-zero rect? Are the expected rules matched? - If the rect is
{0,0,0,0}: check the parent's size and the widget'swidth/height/ intrinsic size. - If the rect is fine but nothing is drawn: check
supports(Effects)and the filter chain (a missing shader silently draws nothing for filtered nodes).
5.2 "My styles don't apply"
Debug::dumpTheme(std::cout);— is the rule loaded?Debug::dumpStyle(*node, std::cout);— is the rule matching?- If the rule isn't in
matches: check the selector. Specificity? Media query? State flags? - If the rule matches but the value is wrong: check the cascade
order. Inline styles beat CSS. Later same-specificity rules beat
earlier ones.
!importantdoesn't exist. - Check
customPropsif usingvar().
5.3 "My click doesn't fire"
- Is the node
interactive?setInteractive(true). - Does it
config.blocksRaycast = 1? Check the parent chain. - Is there a node on top that's consuming? Use
UIContext::get().topmostConsumerafter a frame. - Is the press and release on the same node? A drag-out-drag-in might not fire.
- Is
onClickeven set? Check with a breakpoint or a log.
5.4 "My layout is wrong"
Debug::dumpTree(*root, std::cout);— check the rects.- Is the parent the right size? Check recursively.
- Is the child's intrinsic size what you expect?
computeIntrinsicSizemight be returning 0. - Is
grow/shrinkdistributing space correctly? Check the container's main-axis size vs. the sum of children. - Is
justify/items-*doing what you expect? Check the align values.
5.5 "My animation doesn't run"
- Is the
animation/transitionproperty parsed? CheckdumpStyleoutput. - Is the keyframe name defined? Check
dumpTheme. - Is the state correct?
:hoverneedsstate.hovered = 1,:checkedneedssetChecked(true). - Is the animation ticked?
tickCssruns every frame inupdate. - For imperative: is
addAnimation+playAnimationcalled? Is the name correct?
5.6 "My filter doesn't work"
- Is the shader loaded?
assets.loadEffect(name, path). - Is the filter registered?
FilterRegistry::get().add(def). - Is the name in
.zstylematching? CheckdumpStyle. - Is the backend supporting effects?
supports(Feature::Effects). - Is the node inside another target?
renderer->inTarget().
6. Logging integration
6.1 Custom logger for tests
Tests can install a logger that fails on any error:
class FailingLogger : public ILogger {
public:
void log(const LogMessage& msg) override {
if (msg.level == LogLevel::Error) {
std::cerr << "UNEXPECTED ERROR: " << msg.text << "\n";
std::abort();
}
}
};
This makes tests fail on unexpected errors, catching silent bugs.
6.2 Custom logger for production
A production logger might:
- Write to a rolling file.
- Forward errors to a crash reporter.
- Filter out
Debugmessages. - Prefix messages with a timestamp.
The Logger::set(&custom) call is the only integration point.
6.3 Logging in a game loop
If you log every frame, the console fills up. Filter aggressively:
class FrameLogger : public ILogger {
public:
void log(const LogMessage& msg) override {
if (msg.level == LogLevel::Error)
console.Print(msg.text);
// drop warnings in release
}
};
Or accumulate and show only on a keypress (a "debug overlay").
7. Common tasks
7.1 Dumping the tree on a key press
if (IsKeyPressed(KEY_F1)) {
Debug::dumpTree(*root, std::cout);
}
if (IsKeyPressed(KEY_F2)) {
Debug::dumpTheme(std::cout);
}
Requires ZENITUI_DEBUG to be defined. In a release build, the calls
are no-ops (the stubs compile to nothing).
7.2 Highlighting a specific node
There's no built-in "highlight" in the renderer. To visually debug a node's position, add a debug border:
node->getInlineBase().borderColor = Colors::Red;
node->getInlineBase().borderWidth = Px(1);
Or, temporarily, add a child that draws a red rect. A future extension might add a debug overlay.
7.3 Tracing style resolution
Add a breakpoint in StyleResolver::resolveFor, or log inside:
logDebug("Style", "", 0, 0,
"resolving " + node.getStyleTag() + " with " +
std::to_string(matches.size()) + " matches");
This requires temporarily modifying the framework source. A less invasive approach: dump the theme once at startup and inspect the rules manually.
7.4 Tracing input
Log in InputController::fireCallbacks:
if (node.onClick) {
logDebug("Input", "", 0, 0, "click on " + node.getStyleTag());
}
Again, requires modifying the source.
7.5 Inspecting UIContext
UIContext::get() is a singleton. You can inspect it from anywhere:
auto& ctx = UIContext::get();
std::cout << "pointer: " << ctx.pointer.pos.x << "," << ctx.pointer.pos.y << "\n";
std::cout << "topmost: " << (ctx.topmostConsumer ? ctx.topmostConsumer->getStyleTag() : "none") << "\n";
std::cout << "focused: " << (ctx.focusedNode.lock() ? "yes" : "no") << "\n";
Useful for understanding why a click isn't reaching a node.
8. Pitfalls
ZENITUI_DEBUG must be defined before including Debug.hpp.
Defining it after the include has no effect. The stub branch is already active. To enable debug functions in a specific file:
#define ZENITUI_DEBUG
#include "Debug.hpp"
Or use the compiler flag: -DZENITUI_DEBUG.
dumpTree prints the logical tree, not the visual tree.
Portals appear where they're declared, not where they're drawn. Absolute-positioned nodes appear in their parent's children list, not at their screen position. The rects are correct, but the tree structure doesn't reflect z-order or portal destinations.
dumpStyle only shows ::part rules that match the node.
The matches list includes ::part rules (since they match the node
with a state), but dumpStyle doesn't distinguish them from regular
rules. Look for the ::part suffix in the selector string.
dumpParts uses a hardcoded candidate list.
If your widget defines a part not in the list ("track", "fill",
"knob", "box", "mark", "cursor"), dumpParts won't report it. Use
dumpStyle instead, or extend the list.
The Logger doesn't filter by level.
The default ConsoleLogger prints everything. If you want to filter,
install a custom logger. There's no built-in level setting.
Missing assets are silent.
resolveFont, resolveBgTexture, resolveEffectIfNeeded, and
FilterRegistry::applyFilter all fall back silently when the name
isn't found. This is intentional (the framework works without assets),
but it means a typo'd font name produces no error — just the default
font.
The mock renderer's measureText is a rough approximation.
charWidth * length doesn't account for kerning, variable-width
fonts, or even spaces (a space has the same width as a letter in the
mock). Tests that rely on precise text measurement should use a real
backend.
env.frame doesn't call renderFrame's beginFrame / endFrame.
The mock renderer's beginFrame / endFrame are no-ops, so it
doesn't matter. If you extend the mock to do per-frame setup, you'd
need to add those calls.
The Env destructor nulls the UIContext pointers.
If a test creates an Env inside a scope and then uses a Layout from
that scope after it, the Layout's destructor might try to use the
renderer (via ~Layout's destroyTarget). With the pointer nulled,
this is safe — the destructor checks for null.
But if the Env is alive and the Layout is destroyed after the
Env, the Layout uses the (still valid) mock renderer. If the
Env was destroyed, the pointer is null and the check saves it.
Tests don't reset Theme automatically.
Env::reset() clears Theme::get(), but a test that creates an Env
outside a loop and reuses it across test cases should call env.reset()
between cases. Most tests create a fresh Env per case (the fixture
is cheap), so this isn't an issue.
dumpTheme can be very large.
A stylesheet with hundreds of rules produces hundreds of lines of output. Redirect to a file or filter with a custom logger.
Debug functions don't flush.
std::cerr is unbuffered (or line-buffered) by default. std::cout
is buffered. If you're writing to a file, flush explicitly.
The Debug stubs take the same parameters.
The no-op stubs in the #else branch have the same signatures, so
calls compile in both modes. This is intentional — you can leave
debug calls in the code without conditionals.
But the stubs don't evaluate their arguments. So
Debug::dumpTree(*node) compiles to nothing — node isn't
dereferenced. If node were a nullptr, the release build wouldn't
crash (the stub ignores it), but the debug build would. A subtle
difference, but usually irrelevant.
Logger is a singleton.
Logger::get() returns a static instance. Logger::set(&custom)
replaces the current logger. This is process-wide — all tests, all
subsystems share the same logger.
If you install a custom logger in one test and forget to restore it,
subsequent tests use it. Use Logger::set(nullptr) to restore the
default, or scope the custom logger with an RAII wrapper.
The mock's text measurement uses the string's length, not its size.
measureText does charWidth * (float)s.size(). For ASCII, this is
the character count. For multi-byte UTF-8, this is the byte count,
which overestimates. Since the framework only handles ASCII currently,
this isn't an issue in practice.
The Env fixture doesn't set Metrics::viewport to the platform's
viewport.
Env::reset() sets Metrics::viewport = {1920, 1080} (a default) and
platform.viewport = {800, 600}. The first beginFrame overwrites
Metrics::viewport with platform.viewport. So before the first
frame, Metrics::viewport is {1920, 1080}, and after it's
{800, 600}. If a test reads Metrics::viewport before calling
frame, it gets the wrong value.
The reason for the discrepancy: Metrics::viewport is a global, and
some tests (e.g. test_value_parsers.cpp) read it before setting up
a mock. The default {1920, 1080} is a sensible choice for those
tests. Once Env runs a frame, the platform's viewport takes over.
Tests share global state.
Theme, UIContext, Metrics are all singletons. Tests that modify
them affect subsequent tests unless reset. Env::reset() handles the
common cases (Theme::clear(), UIContext fields), but a test that
sets Metrics::viewport without an Env leaves it modified.
In practice, test_main.cpp sets Metrics::viewport = {1920, 1080}
at the start, and tests that need a specific viewport set it
explicitly. The framework doesn't restore between tests, so ordering
matters. Keep this in mind when a test passes alone but fails in a
suite.