Skip to content

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 when ZENITUI_DEBUG is 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 — H overed, P ressed, F ocused, C hecked, D isabled. - if none.
  • Rect: (x, y widthxheight).
  • Style: computedStyleToString — a compact summary of every non-default property.
  • Matches: every ThemeRule that 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 @keyframes block 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::cout for info, std::cerr for 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: StyleParser warns, continues.
  • Invalid value in .zstyle: StyleParser warns, ignores the property.
  • transition-delay without transition: warning, ignored.
  • animation-delay without animation: warning, ignored.
  • Keyframe auto-fill: warning per filled property per keyframe.
  • File not found: error, parse aborted.
  • addChild on a make_shared node: 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, popClipCount increment on the corresponding calls. Useful for asserting "this node was drawn" or "text was measured".
  • Deterministic text: measureText returns charWidth * length for the width and size for the height. So a 10-char string at font size 20 measures 80 x 20. No font required.
  • Clip stack tracking: the renderer maintains a clipStack and the intersected currentClip. pushClip intersects, popClip restores. This lets tests verify that clipping works without a real GPU.
  • No effects: supports(Feature::Effects) returns false, 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) — sets down = 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 false for supports(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).
  • UIContext focus, capture, targets, flags, portals.
  • MockPlatform state (viewport, time, pointer, events).
  • MockRenderer counters and clip state.
  • Metrics::viewport to {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:

  1. Create the fixture (installs mocks, resets state).
  2. Build a tree.
  3. Run one frame so layout is computed.
  4. Simulate a click.
  5. 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 false for supports(Effects), so filters and shaders are skipped.
  • Render targets: the mock's createTarget returns 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"

  1. Debug::dumpTree(*root, std::cout); — is the widget in the tree?
  2. If yes: Debug::dumpStyle(*widget, std::cout); — does it have a non-zero rect? Are the expected rules matched?
  3. If the rect is {0,0,0,0}: check the parent's size and the widget's width / height / intrinsic size.
  4. 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"

  1. Debug::dumpTheme(std::cout); — is the rule loaded?
  2. Debug::dumpStyle(*node, std::cout); — is the rule matching?
  3. If the rule isn't in matches: check the selector. Specificity? Media query? State flags?
  4. If the rule matches but the value is wrong: check the cascade order. Inline styles beat CSS. Later same-specificity rules beat earlier ones. !important doesn't exist.
  5. Check customProps if using var().

5.3 "My click doesn't fire"

  1. Is the node interactive? setInteractive(true).
  2. Does it config.blocksRaycast = 1? Check the parent chain.
  3. Is there a node on top that's consuming? Use UIContext::get().topmostConsumer after a frame.
  4. Is the press and release on the same node? A drag-out-drag-in might not fire.
  5. Is onClick even set? Check with a breakpoint or a log.

5.4 "My layout is wrong"

  1. Debug::dumpTree(*root, std::cout); — check the rects.
  2. Is the parent the right size? Check recursively.
  3. Is the child's intrinsic size what you expect? computeIntrinsicSize might be returning 0.
  4. Is grow / shrink distributing space correctly? Check the container's main-axis size vs. the sum of children.
  5. Is justify / items-* doing what you expect? Check the align values.

5.5 "My animation doesn't run"

  1. Is the animation / transition property parsed? Check dumpStyle output.
  2. Is the keyframe name defined? Check dumpTheme.
  3. Is the state correct? :hover needs state.hovered = 1, :checked needs setChecked(true).
  4. Is the animation ticked? tickCss runs every frame in update.
  5. For imperative: is addAnimation + playAnimation called? Is the name correct?

5.6 "My filter doesn't work"

  1. Is the shader loaded? assets.loadEffect(name, path).
  2. Is the filter registered? FilterRegistry::get().add(def).
  3. Is the name in .zstyle matching? Check dumpStyle.
  4. Is the backend supporting effects? supports(Feature::Effects).
  5. 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 Debug messages.
  • 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.