GUI Integration

Running a Capy coroutine on a GUI framework’s event loop, and resuming on the GUI thread to update widgets.

What This Example Shows

  • Wrapping a GUI toolkit’s "run this on the main thread" primitive as an executor

  • Moving slow work off the GUI thread with run, and coming back

  • Awaiting an operation the toolkit completes on a thread of its own

  • Why widget access stays on the GUI thread with no hop written by hand

Source Code

#include <boost/capy.hpp>
#include <boost/capy/ex/frame_allocator.hpp>

#include <condition_variable>
#include <coroutine>
#include <cstdlib>
#include <deque>
#include <functional>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <utility>

namespace capy = boost::capy;

// The thread checks are the point of this example, so they have to
// survive a release build.  assert() would compile out under NDEBUG.
void check(bool ok, char const* what)
{
    if(ok)
        return;
    std::cerr << "FAILED: " << what << std::endl;
    // abort() rather than exit(): a failing check can fire on a pool
    // thread, and exit() would run static destructors alongside the
    // still-running threads.
    std::abort();
}

//----------------------------------------------------------
// The stand-in toolkit
//----------------------------------------------------------

// Stands in for QApplication, GtkApplication, or wxApp.  It owns the
// thread it is constructed on, runs the event loop on that thread, and
// takes work from any thread.  Every real toolkit offers that last
// operation: QMetaObject::invokeMethod, g_idle_add,
// wxEvtHandler::CallAfter, PostMessage.
class gui_app : public capy::execution_context
{
    std::thread::id const gui_thread_ = std::this_thread::get_id();
    std::mutex m_;
    std::condition_variable cv_;
    std::deque<std::coroutine_handle<>> queue_;
    bool quit_ = false;

public:
    class executor_type;

    gui_app()
        : execution_context(this)
    {
    }

    ~gui_app()
    {
        shutdown();
        destroy();
    }

    gui_app(gui_app const&) = delete;
    gui_app& operator=(gui_app const&) = delete;

    // Run a coroutine on the GUI thread.  Callable from any thread and
    // never blocks the caller.
    void post_to_gui_thread(std::coroutine_handle<> h)
    {
        {
            std::lock_guard<std::mutex> lock(m_);
            queue_.push_back(h);
        }
        cv_.notify_one();
    }

    // Ask the loop to return once it has drained its queue.
    void quit()
    {
        {
            std::lock_guard<std::mutex> lock(m_);
            quit_ = true;
        }
        cv_.notify_one();
    }

    // The event loop.  It blocks while the queue is empty, so an
    // operation still running on another thread cannot end the loop.
    void run()
    {
        check(on_gui_thread(), "run() called off the GUI thread");
        for(;;)
        {
            std::coroutine_handle<> h;
            {
                std::unique_lock<std::mutex> lock(m_);
                cv_.wait(lock,
                    [this]{ return !queue_.empty() || quit_; });
                if(queue_.empty())
                    return;  // quit requested, nothing left to run
                h = queue_.front();
                queue_.pop_front();
            }
            capy::safe_resume(h);
        }
    }

    bool on_gui_thread() const noexcept
    {
        return std::this_thread::get_id() == gui_thread_;
    }

    executor_type get_executor() noexcept;
};

// Wraps post_to_gui_thread as an Executor.  This is the whole binding
// between Capy and the toolkit.
class gui_app::executor_type
{
    friend class gui_app;
    gui_app* app_ = nullptr;

    explicit executor_type(gui_app& app) noexcept
        : app_(&app)
    {
    }

public:
    executor_type() = default;

    capy::execution_context& context() const noexcept
    {
        return *app_;
    }

    void on_work_started() const noexcept {}
    void on_work_finished() const noexcept {}

    std::coroutine_handle<> dispatch(capy::continuation& c) const
    {
        if(app_->on_gui_thread())
            return c.h;  // resume inline by symmetric transfer
        app_->post_to_gui_thread(c.h);
        return std::noop_coroutine();
    }

    void post(capy::continuation& c) const
    {
        app_->post_to_gui_thread(c.h);
    }

    bool operator==(executor_type const& other) const noexcept
    {
        return app_ == other.app_;
    }
};

inline
gui_app::executor_type
gui_app::get_executor() noexcept
{
    return executor_type{*this};
}

static_assert(capy::Executor<gui_app::executor_type>);

// Stands in for QLabel, GtkLabel, or wxStaticText.  Real widgets are
// not thread-safe, and touching one off the GUI thread is undefined
// behavior that a real toolkit usually fails to diagnose.  This one
// diagnoses it.
class label
{
    gui_app& app_;
    std::string text_;

public:
    explicit label(gui_app& app) noexcept
        : app_(app)
    {
    }

    void set_text(std::string text)
    {
        check(app_.on_gui_thread(), "set_text off the GUI thread");
        text_ = std::move(text);
        std::cout << "[gui] label: " << text_ << "\n";
    }

    std::string const& text() const
    {
        check(app_.on_gui_thread(), "text() off the GUI thread");
        return text_;
    }
};

//----------------------------------------------------------
// Work that leaves the GUI thread
//----------------------------------------------------------

// A task meant to run somewhere other than the GUI thread.  It fails
// the program if it finds itself on the GUI thread, which would mean
// the work never left it.
capy::task<std::string>
count_rows(gui_app& app)
{
    check(!app.on_gui_thread(), "count_rows ran on the GUI thread");
    co_return "42";
}

//----------------------------------------------------------
// A completion from the toolkit's own thread
//----------------------------------------------------------

// Stands in for QMessageBox, GtkDialog, or wxMessageDialog.  A toolkit
// reports the user's answer on whichever thread it chooses, so a plain
// thread is the honest stand-in.  It is not the GUI thread, and it is
// not a thread Capy scheduled.
class dialog
{
    gui_app& app_;
    std::thread thread_;

public:
    explicit dialog(gui_app& app) noexcept
        : app_(app)
    {
    }

    // Joins the toolkit's thread, so no answer outlives main.
    ~dialog()
    {
        if(thread_.joinable())
            thread_.join();
    }

    // Show the dialog.  Returns at once; the answer arrives later, on
    // the toolkit's thread.
    void show(std::function<void(std::string)> on_closed)
    {
        // The stand-in delivers one answer at a time.  A previous
        // thread has already posted its answer by the time the
        // coroutine can ask again, so this join does not block.
        if(thread_.joinable())
            thread_.join();
        thread_ = std::thread(
            [this, cb = std::move(on_closed)]
            {
                check(!app_.on_gui_thread(),
                    "dialog answered on the GUI thread");
                cb("OK");  // the user chose OK
            });
    }
};

// An IoAwaitable for an operation the toolkit completes.  The protocol
// is the subject of the IoAwaitable page; one line of it matters here.
struct show_dialog
{
    dialog& dialog_;
    capy::io_env const* env_ = nullptr;
    capy::continuation cont_ = {};
    std::string answer_ = {};

    bool await_ready() const noexcept
    {
        return false;
    }

    std::coroutine_handle<> await_suspend(
        std::coroutine_handle<> h, capy::io_env const* env)
    {
        env_ = env;
        cont_.h = h;
        dialog_.show([this](std::string answer)
        {
            answer_ = std::move(answer);
            // The toolkit's thread must not resume the coroutine
            // itself.  Handing the continuation to the executor is what
            // puts the resumption back on the GUI thread.  This is also
            // the last read of *this: the executor may resume the
            // coroutine -- and then destroy this awaitable -- before
            // this call returns.
            env_->executor.post(cont_);
        });
        return std::noop_coroutine();
    }

    std::string await_resume()
    {
        return std::move(answer_);
    }
};

capy::task<>
refresh(gui_app& app, label& status,
    capy::thread_pool& pool, dialog& confirm)
{
    // Started on the GUI executor, so the body runs on the GUI thread
    // and touching the widget is safe.
    status.set_text("Loading...");

    // run() starts count_rows on the pool and posts this coroutine
    // back through the executor it was started with.
    auto rows = co_await capy::run(pool.get_executor())(count_rows(app));

    // Back on the GUI thread, without a hop written by hand.
    status.set_text("Loaded " + rows + " rows");

    // The toolkit answers on its own thread.  show_dialog posts the
    // continuation through this coroutine's executor, so the resumption
    // lands on the GUI thread again.
    auto answer = co_await show_dialog{confirm};

    status.set_text("Confirmed: " + answer);
}

int main()
{
    // The GUI thread is whichever thread constructs the app.
    gui_app app;
    label status(app);
    capy::thread_pool pool(1);
    dialog confirm(app);

    capy::run_async(app.get_executor(), [&app]
    {
        // The task completed on the GUI thread, so this handler runs
        // there too.
        check(app.on_gui_thread(), "handler off the GUI thread");
        app.quit();
    })(refresh(app, status, pool, confirm));

    app.run();
    pool.join();

    // The updates are sequential, so the last one proves all of them.
    check(status.text() == "Confirmed: OK", "wrong final text");
    std::cout << "[gui] event loop finished\n";
    return 0;
}

Build

add_executable(gui_integration gui_integration.cpp)
target_link_libraries(gui_integration PRIVATE Boost::capy)

Walkthrough

A Toolkit Stand-In

The program links no GUI library. It uses a stand-in instead. The binding to a real toolkit is a handful of lines that differ per toolkit, and the contract underneath is the same everywhere. That contract is what this page proves.

// Stands in for QApplication, GtkApplication, or wxApp.  It owns the
// thread it is constructed on, runs the event loop on that thread, and
// takes work from any thread.  Every real toolkit offers that last
// operation: QMetaObject::invokeMethod, g_idle_add,
// wxEvtHandler::CallAfter, PostMessage.
class gui_app : public capy::execution_context
{
    std::thread::id const gui_thread_ = std::this_thread::get_id();
    std::mutex m_;
    std::condition_variable cv_;
    std::deque<std::coroutine_handle<>> queue_;
    bool quit_ = false;

public:
    class executor_type;

    gui_app()
        : execution_context(this)
    {
    }

    ~gui_app()
    {
        shutdown();
        destroy();
    }

    gui_app(gui_app const&) = delete;
    gui_app& operator=(gui_app const&) = delete;

    // Run a coroutine on the GUI thread.  Callable from any thread and
    // never blocks the caller.
    void post_to_gui_thread(std::coroutine_handle<> h)
    {
        {
            std::lock_guard<std::mutex> lock(m_);
            queue_.push_back(h);
        }
        cv_.notify_one();
    }

    // Ask the loop to return once it has drained its queue.
    void quit()
    {
        {
            std::lock_guard<std::mutex> lock(m_);
            quit_ = true;
        }
        cv_.notify_one();
    }

    // The event loop.  It blocks while the queue is empty, so an
    // operation still running on another thread cannot end the loop.
    void run()
    {
        check(on_gui_thread(), "run() called off the GUI thread");
        for(;;)
        {
            std::coroutine_handle<> h;
            {
                std::unique_lock<std::mutex> lock(m_);
                cv_.wait(lock,
                    [this]{ return !queue_.empty() || quit_; });
                if(queue_.empty())
                    return;  // quit requested, nothing left to run
                h = queue_.front();
                queue_.pop_front();
            }
            capy::safe_resume(h);
        }
    }

    bool on_gui_thread() const noexcept
    {
        return std::this_thread::get_id() == gui_thread_;
    }

    executor_type get_executor() noexcept;
};

gui_app records the thread it is constructed on, holds a queue of coroutine handles, and exposes post_to_gui_thread. That last operation is the one primitive every toolkit already provides. Qt names it QMetaObject::invokeMethod, GTK g_idle_add, wxWidgets wxEvtHandler::CallAfter, Win32 PostMessage.

The Event Loop

// The event loop.  It blocks while the queue is empty, so an
// operation still running on another thread cannot end the loop.
void run()
{
    check(on_gui_thread(), "run() called off the GUI thread");
    for(;;)
    {
        std::coroutine_handle<> h;
        {
            std::unique_lock<std::mutex> lock(m_);
            cv_.wait(lock,
                [this]{ return !queue_.empty() || quit_; });
            if(queue_.empty())
                return;  // quit requested, nothing left to run
            h = queue_.front();
            queue_.pop_front();
        }
        capy::safe_resume(h);
    }
}

The loop blocks while the queue is empty. A GUI loop must do this: the background operation is still running, and an empty queue does not mean the program is finished. quit() sets the flag that lets the loop return once the queue drains.

Resumption goes through safe_resume rather than h.resume(). This saves and restores the thread-local frame allocator around each resumption. See TLS Preservation.

Wrapping the Primitive as an Executor

// Wraps post_to_gui_thread as an Executor.  This is the whole binding
// between Capy and the toolkit.
class gui_app::executor_type
{
    friend class gui_app;
    gui_app* app_ = nullptr;

    explicit executor_type(gui_app& app) noexcept
        : app_(&app)
    {
    }

public:
    executor_type() = default;

    capy::execution_context& context() const noexcept
    {
        return *app_;
    }

    void on_work_started() const noexcept {}
    void on_work_finished() const noexcept {}

    std::coroutine_handle<> dispatch(capy::continuation& c) const
    {
        if(app_->on_gui_thread())
            return c.h;  // resume inline by symmetric transfer
        app_->post_to_gui_thread(c.h);
        return std::noop_coroutine();
    }

    void post(capy::continuation& c) const
    {
        app_->post_to_gui_thread(c.h);
    }

    bool operator==(executor_type const& other) const noexcept
    {
        return app_ == other.app_;
    }
};

This class is the entire binding between Capy and the toolkit. post forwards to post_to_gui_thread. dispatch resumes inline when the caller is already on the GUI thread, and otherwise posts.

static_assert(capy::Executor<gui_app::executor_type>);

The static_assert confirms the class satisfies the concept before anything tries to run on it.

A Widget That Checks Its Thread

// Stands in for QLabel, GtkLabel, or wxStaticText.  Real widgets are
// not thread-safe, and touching one off the GUI thread is undefined
// behavior that a real toolkit usually fails to diagnose.  This one
// diagnoses it.
class label
{
    gui_app& app_;
    std::string text_;

public:
    explicit label(gui_app& app) noexcept
        : app_(app)
    {
    }

    void set_text(std::string text)
    {
        check(app_.on_gui_thread(), "set_text off the GUI thread");
        text_ = std::move(text);
        std::cout << "[gui] label: " << text_ << "\n";
    }

    std::string const& text() const
    {
        check(app_.on_gui_thread(), "text() off the GUI thread");
        return text_;
    }
};

label stands in for QLabel, GtkLabel, or wxStaticText. Real widgets are not thread-safe. Touching one off the GUI thread is undefined behavior, and a real toolkit rarely tells you. This one tells you, on every access:

// The thread checks are the point of this example, so they have to
// survive a release build.  assert() would compile out under NDEBUG.
void check(bool ok, char const* what)
{
    if(ok)
        return;
    std::cerr << "FAILED: " << what << std::endl;
    // abort() rather than exit(): a failing check can fire on a pool
    // thread, and exit() would run static destructors alongside the
    // still-running threads.
    std::abort();
}

The check is the program’s reason to exist, so it must survive a release build. assert would compile out under NDEBUG.

Work That Leaves the GUI Thread

Slow work must not run on the GUI thread, or the interface stops repainting. Put it in a task of its own:

// A task meant to run somewhere other than the GUI thread.  It fails
// the program if it finds itself on the GUI thread, which would mean
// the work never left it.
capy::task<std::string>
count_rows(gui_app& app)
{
    check(!app.on_gui_thread(), "count_rows ran on the GUI thread");
    co_return "42";
}

The check is the second half of the proof. The widget checks say the updates happen on the GUI thread; this one says the work does not.

Where the Coroutine Resumes

capy::task<>
refresh(gui_app& app, label& status,
    capy::thread_pool& pool, dialog& confirm)
{
    // Started on the GUI executor, so the body runs on the GUI thread
    // and touching the widget is safe.
    status.set_text("Loading...");

    // run() starts count_rows on the pool and posts this coroutine
    // back through the executor it was started with.
    auto rows = co_await capy::run(pool.get_executor())(count_rows(app));

    // Back on the GUI thread, without a hop written by hand.
    status.set_text("Loaded " + rows + " rows");

    // The toolkit answers on its own thread.  show_dialog posts the
    // continuation through this coroutine's executor, so the resumption
    // lands on the GUI thread again.
    auto answer = co_await show_dialog{confirm};

    status.set_text("Confirmed: " + answer);
}

This is the question a GUI developer needs answered. count_rows runs on the pool. The line after the first co_await touches a widget, so it must run on the GUI thread. It does, and the source contains no hop. The second co_await is the toolkit’s dialog, covered next.

run is what moves the work. It starts the inner task on the executor you name, and posts the awaiting coroutine back through the executor that coroutine was started with. The whole subtree under count_rows runs on the pool; only the boundary crosses back.

Three contracts combine to put that boundary on the GUI thread:

  • run_async builds one io_env holding the executor it was given, and passes the task a pointer to it.

  • task propagates that pointer into every co_await in its body. A task completes by symmetric transfer and never posts, so nothing in the chain substitutes a different executor.

  • run records the caller’s executor and posts the caller back through it when the inner task completes. That post runs on a pool thread and lands in the GUI queue.

This is the same-executor invariant seen from a GUI application. Start the task on the GUI executor, and every line of the body runs on the GUI thread, whatever thread the awaited work ran on.

Two conditions carry the invariant, and both are contracts rather than magic.

The executor must resume handles on one thread only. A GUI loop does, by construction.

Every awaitable in the chain must honor the IoAwaitable requirement to resume through env→executor. Capy’s own types do: when_all and when_any return control to the caller through the caller’s executor, and run does the same at an executor boundary. An awaitable that resumes the handle directly from a foreign thread breaks the invariant, and no other part of the library can repair it. The next section shows one that honors it.

A Completion From the Toolkit’s Own Thread

run covers work you hand to Capy. A toolkit also completes operations of its own, and it reports them on whichever thread it chose. A modal dialog is the common case:

// Stands in for QMessageBox, GtkDialog, or wxMessageDialog.  A toolkit
// reports the user's answer on whichever thread it chooses, so a plain
// thread is the honest stand-in.  It is not the GUI thread, and it is
// not a thread Capy scheduled.
class dialog
{
    gui_app& app_;
    std::thread thread_;

public:
    explicit dialog(gui_app& app) noexcept
        : app_(app)
    {
    }

    // Joins the toolkit's thread, so no answer outlives main.
    ~dialog()
    {
        if(thread_.joinable())
            thread_.join();
    }

    // Show the dialog.  Returns at once; the answer arrives later, on
    // the toolkit's thread.
    void show(std::function<void(std::string)> on_closed)
    {
        // The stand-in delivers one answer at a time.  A previous
        // thread has already posted its answer by the time the
        // coroutine can ask again, so this join does not block.
        if(thread_.joinable())
            thread_.join();
        thread_ = std::thread(
            [this, cb = std::move(on_closed)]
            {
                check(!app_.on_gui_thread(),
                    "dialog answered on the GUI thread");
                cb("OK");  // the user chose OK
            });
    }
};

Awaiting that needs an IoAwaitable. Bridging a Foreign Awaitable covers the protocol. One line of it decides thread affinity:

// An IoAwaitable for an operation the toolkit completes.  The protocol
// is the subject of the IoAwaitable page; one line of it matters here.
struct show_dialog
{
    dialog& dialog_;
    capy::io_env const* env_ = nullptr;
    capy::continuation cont_ = {};
    std::string answer_ = {};

    bool await_ready() const noexcept
    {
        return false;
    }

    std::coroutine_handle<> await_suspend(
        std::coroutine_handle<> h, capy::io_env const* env)
    {
        env_ = env;
        cont_.h = h;
        dialog_.show([this](std::string answer)
        {
            answer_ = std::move(answer);
            // The toolkit's thread must not resume the coroutine
            // itself.  Handing the continuation to the executor is what
            // puts the resumption back on the GUI thread.  This is also
            // the last read of *this: the executor may resume the
            // coroutine -- and then destroy this awaitable -- before
            // this call returns.
            env_->executor.post(cont_);
        });
        return std::noop_coroutine();
    }

    std::string await_resume()
    {
        return std::move(answer_);
    }
};

The toolkit’s thread does not resume the coroutine. It posts the continuation through env→executor, and the executor resumes it on the GUI thread. That is the second condition above, honored in one call.

The widget update after this co_await carries the same thread check as every other access. Resume the handle directly instead of posting, and the check fires.

show_dialog does not watch env→stop_token, so a stop request cannot interrupt a dialog already showing. Exercise 2 adds that.

Starting and Stopping the Loop

int main()
{
    // The GUI thread is whichever thread constructs the app.
    gui_app app;
    label status(app);
    capy::thread_pool pool(1);
    dialog confirm(app);

    capy::run_async(app.get_executor(), [&app]
    {
        // The task completed on the GUI thread, so this handler runs
        // there too.
        check(app.on_gui_thread(), "handler off the GUI thread");
        app.quit();
    })(refresh(app, status, pool, confirm));

    app.run();
    pool.join();

    // The updates are sequential, so the last one proves all of them.
    check(status.text() == "Confirmed: OK", "wrong final text");
    std::cout << "[gui] event loop finished\n";
    return 0;
}

The GUI thread is whichever thread constructs gui_app. Real toolkits impose the same rule on their application object.

run_async is called on that thread, so dispatch resumes the task inline and the body reaches its co_await before app.run() is called. Nothing is lost if the pool finishes first: the continuation waits in the queue, and the loop picks it up when it starts.

The completion handler runs on the GUI thread, because the task completed there. It calls quit(), the loop drains and returns, and main checks the final text. The program needs no sleep and no timeout to exit.

Substituting a Real Toolkit

The stand-in exists to prove the contract, not to model a toolkit. Five pieces change when the toolkit is real, and the rest does not:

  • post_to_gui_thread forwards to the toolkit’s own post operation, and the receiving side calls safe_resume on the handle.

  • gui_app::run disappears. The toolkit already owns a loop, such as QApplication::exec, g_main_loop_run, or wxApp::OnRun.

  • gui_app::quit forwards to the toolkit’s own quit, such as QCoreApplication::quit, g_main_loop_quit, or wxAppConsole::ExitMainLoop.

  • label becomes a real widget. Its thread check is the part the toolkit does not do for you.

  • dialog becomes the toolkit’s own dialog, and its callback the toolkit’s completion notification. The gui_app& it holds only for the thread check goes away.

executor_type, show_dialog, count_rows, and refresh are unchanged. Those four are the pattern worth copying.

Output

[gui] label: Loading...
[gui] label: Loaded 42 rows
[gui] label: Confirmed: OK
[gui] event loop finished

Exercises

  1. Break the invariant on purpose. Make post resume the handle inline instead of queueing it, and watch the widget check fire

  2. Make show_dialog cancellable. Pass a std::stop_token to run_async, request the stop before the dialog is awaited, and complete early in await_suspend when the token reports one

  3. Give dialog a Cancel answer, and skip the update when the user chooses it

  4. Add a second background step and a second widget, and check that both updates land on the GUI thread