Skip to content

Your first applet

(New here? Development basics explains what you write vs. what the host provides, and where every library comes from.)

This walks the hello applet — the smallest complete Caliper applet on ABI epoch 2. It is the canonical starting point: one manifest, one macro, three lifecycle methods. Continue to examples/sine_scope/ for a small ML-oriented example. The in-tree applets demonstrate larger combinations of services; no single exemplar should be treated as covering the entire service catalog.

The whole applet lives in examples/hello/:

examples/hello/
├── hello.caliper.toml   # the manifest — identity + what the host must provide
├── hello.cpp            # the applet — one class + one macro
└── CMakeLists.txt       # the build — links caliper::sdk + caliper::ui_stack

1. The manifest

The host reads hello.caliper.toml before it loads any of your code. It declares who the applet is, which ABI epoch it was compiled against, and which framework services it needs to run:

[applet]
id      = "dev.caliper.hello"
name    = "Hello"
version = "0.1.0"
summary = "Epoch-2 fixture applet: sugar demo + loader-test substrate."
tag     = "Demo"

[compat]
abi_epoch = 2
min_host  = "0.6.0"

[services]
required = ["caliper.ui.v1", "caliper.log.v1"]

required services are a gate: if the host cannot provide caliper.ui.v1 or caliper.log.v1, it refuses to load the applet rather than handing you a half-working Host. The id, version, and services here must agree with the fields you pass to the CALIPER_APPLET macro (below) — the loader verifies the two and rejects the applet if they drift. See reference/manifest.md for the full schema.

2. The macro

One include and one class is the entire C++ surface. #include <caliper/caliper.hpp> pulls in the sugar layer (the Applet/Host/Frame types) and the pinned ImGui/ImPlot stack — there is no wrapper to learn, you program raw ImGui:

// Epoch-2 fixture applet (PLATFORM.md §13.1): loader-test substrate and the
// "hello world" of the sugar layer. Kept deliberately tiny.
#include <caliper/caliper.hpp>
#include <cmath>
#include <cstdlib>
#include <vector>

class HelloApplet final : public caliper::Applet {
public:
    bool on_init(caliper::Host& host) override {
        host_ = &host;
        crash_on_frame_ = std::getenv("CALIPER_HELLO_CRASH") != nullptr;
        // Test hook: fail launch cleanly (initialize() returns false) so hosts
        // can exercise the failed-load path without a crash. on_cleanup is NOT
        // called for a false return (the loader destroys the raw instance), so
        // this logs nothing.
        if (std::getenv("CALIPER_HELLO_INIT_FAIL") != nullptr) return false;
        host.log_info("hello.on_init");
        return true;
    }

    void on_frame(const caliper::Frame& f) override {
        if (crash_on_frame_) {           // test hook: fault before any ImGui call
            volatile int* p = nullptr;
            *p = 1;
        }
        ImGui::SetNextWindowPos({40, 60}, ImGuiCond_FirstUseEver);
        ImGui::SetNextWindowSize({520, 360}, ImGuiCond_FirstUseEver);
        ImGui::Begin("Hello, Caliper");
        ImGui::Text("ABI epoch %d applet via CALIPER_APPLET macro", CALIPER_ABI_EPOCH);
        ImGui::Text("framebuffer: %d x %d px   dpi_scale: %.1f",
                    f.fb_width, f.fb_height, f.dpi_scale);

        // Input: one button that owns the animation. Its label is an
        // expression over the state it controls, so it reads "Pause" while
        // running and "Play" while stopped; the click flips that state. A
        // button returns true only on the frame it is clicked, and the state
        // it toggles lives in the applet — not ImGui.
        if (ImGui::Button(playing_ ? "Pause" : "Play")) playing_ = !playing_;

        // The animation runs on phase the applet accumulates itself, advanced
        // only while playing. f.time_sec (monotonic wall-clock) can't be
        // paused — it keeps ticking whatever the applet does — so owning the
        // phase is what makes Pause possible at all.
        if (playing_) phase_ += (float)f.delta_sec;

        if (ImPlot::BeginPlot("sine", {-1, 220})) {
            static std::vector<float> xs(256), ys(256);
            for (int i = 0; i < 256; i++) {
                xs[i] = i / 255.0f * 6.28318f;
                ys[i] = std::sin(xs[i] + phase_);
            }
            ImPlot::PlotLine("sin", xs.data(), ys.data(), 256);
            ImPlot::EndPlot();
        }
        ImGui::End();
    }

    void on_cleanup() override {
        if (host_) host_->log_info("hello.on_cleanup");
    }

private:
    caliper::Host* host_ = nullptr;
    bool crash_on_frame_ = false;
    float phase_ = 0.0f;      // animation phase the applet owns (so Pause works)
    bool  playing_ = true;    // Play/Pause state — starts running
};

CALIPER_APPLET(HelloApplet,
    .id       = "dev.caliper.hello",
    .version  = "0.1.0",
    .name     = "Hello",
    .summary  = "Epoch-2 fixture applet: sugar demo + loader-test substrate.",
    .tag      = "Demo",
    .services = {CALIPER_UI_V1, CALIPER_LOG_V1})

The CALIPER_APPLET(HelloApplet, ...) macro at the bottom is the whole ABI boundary of the dylib. It generates the descriptor the loader looks for (caliper_applet_descriptor), the five exception-safe C bridge functions, and the ui::connect() call that shares the host's ImGui/ImPlot contexts and allocators with your dylib — so ImGui:: and ImPlot:: calls land in the host's single UI world. Field order is fixed: id, version, name, summary, tag, services.

3. The lifecycle: on_initon_frameon_cleanup

Your class overrides three methods from caliper::Applet:

  • on_init(Host& host) runs once when the applet is opened. The Host& is valid for your whole lifetime — keep the pointer. Do setup here, and log through the host (host.log_info(...)), never printf (see howto/debug-an-applet.md for why). Return false to abort loading.

  • on_frame(const Frame& f) runs every frame. Everything visible happens here, and nothing slow — you share the frame thread with the host and every other applet. f.fb_width/f.fb_height are physical pixels; f.dpi_scale converts to the logical units ImGui sizes in; drive animation from f.time_sec/f.delta_sec, never a wall-clock sleep (§3a shows why a pausable animation accumulates its own phase from f.delta_sec rather than reading f.time_sec directly).

  • on_cleanup() runs when the applet closes — symmetric with on_init: persist, release, log. After it returns the host destroys your object; do not touch host services afterwards.

Hello also reads CALIPER_HELLO_CRASH in on_init and, when set, faults inside on_frame before any ImGui call. That is a deliberate test hook the loader's crash-quarantine tests use — not something your own applets need.

3a. Input: the Play/Pause button

The button above the plot is the smallest complete lesson in ImGui IO, and it turns on three ideas you will use in every applet:

  • A widget call both draws and reports. ImGui::Button(...) draws the button and returns true on the single frame it was clicked. There is no callback and no event queue — you check the return value inline:

    if (ImGui::Button(playing_ ? "Pause" : "Play")) playing_ = !playing_;
    
    This is immediate mode: the UI is a function of your state, re-issued every frame, and input comes back as the return value of the call that drew it.

  • The label is derived from state, every frame. There is one button, not a Play button and a Pause button — its label is an expression over playing_, recomputed on every on_frame. Because the whole UI is rebuilt each frame from your state, a widget that reflects state costs nothing extra: you just compute what to show. The click flips the same bool the label reads, so the button relabels itself the very next frame.

  • The state lives in your applet, not in ImGui. ImGui does not remember "paused" for you — playing_ is a member of HelloApplet. The widget reads and writes your field; ImGui only owns pixels and the click. That is why the state is a bool on the class, initialised in the header, not a static inside on_frame.

The subtle part is why the animation can be paused at all. Earlier the sine was drawn from f.time_sec — the host's monotonic wall-clock, which keeps advancing no matter what the applet does, so there is nothing you could freeze. Pause only becomes possible once the applet owns the time: Hello accumulates its own phase_, advanced by f.delta_sec only while playing:

if (playing_) phase_ += (float)f.delta_sec;   // ...then draw sin(x + phase_)

That is the general shape of interactive animation in a Caliper applet — derive what you draw from state you control, and let the widgets edit that state. The same three ideas scale straight up to sliders (SliderFloat returns an edited value), checkboxes, and the live training controls the ML applets use.

4. Build it

The CMakeLists.txt links the two SDK targets and drops the dylib plus a copy of the manifest into build/applets/, where the host scans for applets:

cmake -B build
cmake --build build --target hello_applet
ls build/applets/            # libhello.dylib + hello.caliper.toml

In-tree the build links caliper::sdk and caliper::ui_stack directly; an out-of-tree applet swaps those two lines for a find_package/CPM fetch of a tagged SDK release under the same target names — nothing else changes.

5. See it in the app

Hello appears on the launcher landing page as a card you can open. The host's epoch-2 loader discovers it by reading its <stem>.caliper.toml manifest and the caliper_applet_descriptor export — the two signals every applet you build this way must ship. If the card is missing, check that both are present (the dylib and its manifest sit side by side in build/applets/); if the card shows an [unavailable] line instead, that line is the loader's refusal reason (see the refusal reference).