Skip to content
C++
Domain Track
Difficulty 3/5

GUI Development in C++

C++ GUI frameworks — Qt, Dear ImGui, wxWidgets, and FLTK. Signals/slots, immediate mode vs retained mode, and cross-platform considerations.

TL;DR

Qt for full-featured desktop apps (signals/slots, QML). Dear ImGui for dev tools and debug UIs (immediate-mode, minimal setup). wxWidgets for native look-and-feel without Qt's licensing.


Dear ImGui — Immediate Mode

No retained widget tree — draw UI every frame. Ideal for tools, debug overlays, game editors.

cpp
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"

// Setup (once)
ImGui::CreateContext();
ImGui_ImplGlfw_InitForOpenGL(window, true);
ImGui_ImplOpenGL3_Init("#version 330");

// Per-frame render loop
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();

// === Your UI code ===
static float value = 0.5f;
static int   count = 0;
static char  buf[128] = "hello";

ImGui::Begin("My Window");

ImGui::Text("Frame: %d", count++);
ImGui::SliderFloat("Value", &value, 0.0f, 1.0f);
ImGui::InputText("Name", buf, sizeof(buf));

if (ImGui::Button("Click me"))
    std::println("clicked! value={}", value);

ImGui::Separator();
ImGui::ColorEdit3("Color", color);

ImGui::End();
// =====================

ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());

Tables and plots

cpp
if (ImGui::BeginTable("data", 3, ImGuiTableFlags_Borders)) {
    ImGui::TableSetupColumn("ID");
    ImGui::TableSetupColumn("Name");
    ImGui::TableSetupColumn("Value");
    ImGui::TableHeadersRow();

    for (auto& row : data) {
        ImGui::TableNextRow();
        ImGui::TableSetColumnIndex(0); ImGui::Text("%d", row.id);
        ImGui::TableSetColumnIndex(1); ImGui::Text("%s", row.name.c_str());
        ImGui::TableSetColumnIndex(2); ImGui::Text("%.2f", row.value);
    }
    ImGui::EndTable();
}

// ImPlot (companion library)
if (ImPlot::BeginPlot("Temperature")) {
    ImPlot::PlotLine("°C", x_data.data(), y_data.data(), n);
    ImPlot::EndPlot();
}

Qt — Full Desktop Framework

cpp
#include <QApplication>
#include <QMainWindow>
#include <QPushButton>
#include <QVBoxLayout>
#include <QLabel>
#include <QLineEdit>

int main(int argc, char* argv[]) {
    QApplication app{argc, argv};

    QMainWindow window;
    window.setWindowTitle("Qt Example");

    auto* central = new QWidget;
    auto* layout  = new QVBoxLayout(central);

    auto* label  = new QLabel("Enter your name:");
    auto* edit   = new QLineEdit;
    auto* button = new QPushButton("Greet");
    auto* output = new QLabel;

    layout->addWidget(label);
    layout->addWidget(edit);
    layout->addWidget(button);
    layout->addWidget(output);

    window.setCentralWidget(central);

    // Signals and slots
    QObject::connect(button, &QPushButton::clicked,
        [&]() {
            output->setText("Hello, " + edit->text() + "!");
        });

    window.resize(400, 300);
    window.show();
    return app.exec();
}

Custom QObject with signals

cpp
#include <QObject>

class Counter : public QObject {
    Q_OBJECT

    int count_ = 0;

public:
    explicit Counter(QObject* parent = nullptr) : QObject{parent} {}

    int count() const { return count_; }

public slots:
    void increment() {
        ++count_;
        emit countChanged(count_);
    }

signals:
    void countChanged(int newCount);
};

// Connect to lambda
Counter c;
QObject::connect(&c, &Counter::countChanged,
    [](int n) { std::println("count = {}", n); });
c.increment();  // prints "count = 1"

QML Integration

cpp
// main.cpp
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>

int main(int argc, char* argv[]) {
    QGuiApplication app{argc, argv};
    QQmlApplicationEngine engine;

    // Expose C++ object to QML
    Counter counter;
    engine.rootContext()->setContextProperty("counter", &counter);
    engine.load(QUrl{"qrc:/main.qml"});

    return app.exec();
}
qml
// main.qml
import QtQuick
import QtQuick.Controls

ApplicationWindow {
    visible: true; width: 400; height: 200

    Column {
        anchors.centerIn: parent; spacing: 10

        Text { text: "Count: " + counter.count }
        Button {
            text: "Increment"
            onClicked: counter.increment()
        }
    }
}

wxWidgets — Native Look and Feel

cpp
#include <wx/wx.h>

class MyFrame : public wxFrame {
    wxStaticText* label_;

public:
    MyFrame() : wxFrame{nullptr, wxID_ANY, "wxWidgets Demo",
                         wxDefaultPosition, wxSize{400, 300}}
    {
        auto* panel  = new wxPanel{this};
        auto* sizer  = new wxBoxSizer{wxVERTICAL};

        label_          = new wxStaticText{panel, wxID_ANY, "Hello!"};
        auto* button    = new wxButton{panel, wxID_ANY, "Click"};
        auto* input     = new wxTextCtrl{panel, wxID_ANY};

        sizer->Add(label_,  0, wxALL, 10);
        sizer->Add(input,   0, wxEXPAND | wxALL, 10);
        sizer->Add(button,  0, wxALL, 10);
        panel->SetSizer(sizer);

        button->Bind(wxEVT_BUTTON, [this, input](wxCommandEvent&) {
            label_->SetLabel("Hello, " + input->GetValue() + "!");
        });
    }
};

class MyApp : public wxApp {
public:
    bool OnInit() override {
        (new MyFrame())->Show();
        return true;
    }
};

wxIMPLEMENT_APP(MyApp);

Framework Comparison

Dear ImGuiQtwxWidgets
ModeImmediateRetainedRetained
LookCustomCross-platformNative
LicenseMITGPL/LGPLv3/CommercialwxWindows (permissive)
Setup effortLowMediumMedium
Best forTools, debug UIsDesktop apps, mobileNative desktop
MobileNoYes (Qt Quick)Limited
Learning curveLowHigh (MOC, signals)Medium
WidgetsBasicFull suiteFull suite
3D integrationYes (render loop)Via QOpenGLVia wxGLCanvas

CMake Setup

cmake
# Dear ImGui (via FetchContent)
FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git GIT_TAG v1.90.4)
FetchContent_MakeAvailable(imgui)

add_library(imgui_lib STATIC
    ${imgui_SOURCE_DIR}/imgui.cpp
    ${imgui_SOURCE_DIR}/imgui_draw.cpp
    ${imgui_SOURCE_DIR}/imgui_widgets.cpp
    ${imgui_SOURCE_DIR}/imgui_tables.cpp
    ${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
    ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp
)
target_include_directories(imgui_lib PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends)
target_link_libraries(myapp PRIVATE imgui_lib glfw GL)

# Qt
find_package(Qt6 REQUIRED COMPONENTS Widgets Qml Quick)
set_target_properties(myapp PROPERTIES AUTOMOC ON AUTORCC ON AUTOUIC ON)
target_link_libraries(myapp PRIVATE Qt6::Widgets Qt6::Qml Qt6::Quick)

# wxWidgets
find_package(wxWidgets REQUIRED COMPONENTS core base)
include(${wxWidgets_USE_FILE})
target_link_libraries(myapp PRIVATE ${wxWidgets_LIBRARIES})

Qt signals/slots deep dive

Qt's signal/slot mechanism is type-safe, introspection-aware, and thread-aware — but its subtleties trip up every C++ developer sooner or later. The new pointer-to-member syntax (&Class::signal) gives compile-time checking that the old SIGNAL()/SLOT() macros never offered.

cpp
// Connection syntax options (Qt 5/6)
// 1. New syntax — compile-time checked
QObject::connect(sender, &Sender::valueChanged,
                 receiver, &Receiver::onValueChanged);

// 2. Lambda — ideal for one-off connections
QObject::connect(button, &QPushButton::clicked,
                 [this](bool checked) { handleClick(checked); });

// 3. Explicit connection type
QObject::connect(sender, &Sender::dataReady,
                 receiver, &Receiver::process,
                 Qt::QueuedConnection);   // cross-thread: posts to receiver's event loop

Thread affinity determines which thread's event loop processes the slot. Every QObject lives on the thread that created it. Moving an object to a worker thread:

cpp
QThread* thread = new QThread(this);
Worker*  worker = new Worker;          // no parent — must be movable

worker->moveToThread(thread);

// Signals emitted from worker's thread will use QueuedConnection automatically
connect(thread, &QThread::started,  worker, &Worker::doWork);
connect(worker, &Worker::finished,  thread, &QThread::quit);
connect(worker, &Worker::finished,  worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QObject::deleteLater);

thread->start();

Connection type rules:

Connection typeWhen chosenBehaviour
AutoConnectiondefaultDirect if same thread, Queued if cross-thread
DirectConnectionexplicitSlot called synchronously in emitter's thread
QueuedConnectionexplicit / cross-threadEvent posted; slot runs in receiver's thread
BlockingQueuedConnectionexplicitEmitter blocks until slot returns (avoid deadlock)

Qt Model/View architecture

QAbstractItemModel separates data from presentation — one model can feed a list, table, and tree simultaneously. Implement five pure-virtual methods and the framework handles the rest.

cpp
class FileListModel : public QAbstractListModel {
    Q_OBJECT
    std::vector<QString> files_;

public:
    explicit FileListModel(QObject* parent = nullptr)
        : QAbstractListModel{parent} {}

    // --- Required overrides ---
    int rowCount(const QModelIndex& parent = {}) const override {
        return parent.isValid() ? 0 : (int)files_.size();
    }

    QVariant data(const QModelIndex& idx, int role = Qt::DisplayRole) const override {
        if (!idx.isValid() || idx.row() >= (int)files_.size())
            return {};
        if (role == Qt::DisplayRole)
            return files_[idx.row()];
        if (role == Qt::DecorationRole)
            return QIcon::fromTheme("text-x-generic");
        return {};
    }

    // --- Mutation ---
    void addFile(const QString& path) {
        beginInsertRows({}, files_.size(), files_.size());
        files_.push_back(path);
        endInsertRows();                // notifies all attached views
    }
};

// Wire up to views
FileListModel* model = new FileListModel(this);
model->addFile("/home/user/main.cpp");

QListView*  listView  = new QListView;
QTableView* tableView = new QTableView;
listView->setModel(model);   // both views share one model instance
tableView->setModel(model);

Custom item delegate — paint thumbnails, progress bars, or any custom widget inside a cell:

cpp
class StarDelegate : public QStyledItemDelegate {
public:
    void paint(QPainter* p, const QStyleOptionViewItem& opt,
               const QModelIndex& idx) const override
    {
        int stars = idx.data(Qt::UserRole).toInt();
        for (int i = 0; i < 5; ++i) {
            QIcon icon = (i < stars)
                ? QIcon::fromTheme("starred")
                : QIcon::fromTheme("non-starred");
            icon.paint(p, opt.rect.adjusted(i*20, 0, (i-4)*20, 0));
        }
    }

    QSize sizeHint(const QStyleOptionViewItem&,
                   const QModelIndex&) const override { return {100, 24}; }
};

listView->setItemDelegate(new StarDelegate(listView));

Dear ImGui docking and custom rendering backends

ImGui's docking branch turns your tool into a fully dockable IDE-style layout with near-zero boilerplate. Enable it once at context creation; each window then becomes a draggable, dockable panel.

cpp
// Context setup — enable docking (and optionally viewports)
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;  // windows outside main OS window

// Per-frame: create a fullscreen dockspace before any other windows
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->Pos);
ImGui::SetNextWindowSize(ImGui::GetMainViewport()->Size);
ImGui::SetNextWindowViewport(ImGui::GetMainViewport()->ID);

ImGuiWindowFlags dockspace_flags =
    ImGuiWindowFlags_NoDocking |
    ImGuiWindowFlags_NoTitleBar  | ImGuiWindowFlags_NoCollapse |
    ImGuiWindowFlags_NoResize    | ImGuiWindowFlags_NoMove     |
    ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus;

ImGui::Begin("DockSpace", nullptr, dockspace_flags);
ImGui::DockSpace(ImGui::GetID("MainDock"), ImVec2{0,0},
                 ImGuiDockNodeFlags_PassthruCentralNode);
ImGui::End();

// Now open normal windows — they will be dockable
ImGui::Begin("Scene");  /* ... */  ImGui::End();
ImGui::Begin("Inspector"); /* ... */ ImGui::End();

Custom rendering backend (e.g., integrating ImGui into your own Vulkan renderer):

cpp
// Implement the three backend callbacks:
// ImGui_ImplXxx_Init()   — upload font atlas, create pipeline
// ImGui_ImplXxx_NewFrame()  — begin frame, handle events
// ImGui_ImplXxx_RenderDrawData(ImGui::GetDrawData()) — issue draw calls

// Font atlas customisation (before Init)
ImGuiIO& io = ImGui::GetIO();
io.Fonts->AddFontFromFileTTF("JetBrainsMono.ttf", 14.0f);
ImFontConfig cfg;
cfg.MergeMode = true;
cfg.GlyphRanges = io.Fonts->GetGlyphRangesChineseFull();
io.Fonts->AddFontFromFileTTF("NotoSansCJK.ttf", 14.0f, &cfg);
io.Fonts->Build();
// Upload io.Fonts->TexData* to your GPU texture

Dear ImGui with Vulkan backend — frame loop boilerplate

The Vulkan backend is the most involved setup but gives the lowest driver overhead. The key insight is that ImGui just records into a VkCommandBuffer you provide — it does not own the render pass.

cpp
// Dependencies: imgui + backends/imgui_impl_glfw.cpp + backends/imgui_impl_vulkan.cpp

// Initialisation
ImGui_ImplGlfw_InitForVulkan(window, true);

ImGui_ImplVulkan_InitInfo init_info{};
init_info.Instance       = vk_instance;
init_info.PhysicalDevice = vk_phys_device;
init_info.Device         = vk_device;
init_info.Queue          = graphics_queue;
init_info.QueueFamily    = graphics_family;
init_info.DescriptorPool = descriptor_pool;   // needs ImGui descriptor sets
init_info.RenderPass     = render_pass;
init_info.MinImageCount  = 2;
init_info.ImageCount     = swapchain_image_count;
ImGui_ImplVulkan_Init(&init_info);

// Upload font textures (once, inside a one-shot command buffer)
ImGui_ImplVulkan_CreateFontsTexture();

// Per-frame render (inside your command buffer recording)
ImGui_ImplVulkan_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();

// ... build UI ...

ImGui::Render();
ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), cmd_buffer);

// Multi-viewport support: after present
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
    ImGui::UpdatePlatformWindows();
    ImGui::RenderPlatformWindowsDefault();
}

// Cleanup
ImGui_ImplVulkan_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();

wxWidgets event table and sizers

wxWidgets uses a static event table macro system in addition to the modern Bind() API. The event table is useful for derived classes where you want a single dispatch point without virtual functions.

cpp
class MyFrame : public wxFrame {
    DECLARE_EVENT_TABLE()       // required macro in class body

    void OnOpen(wxCommandEvent& evt);
    void OnExit(wxCommandEvent& evt);
    void OnAbout(wxCommandEvent& evt);
};

// Source file — static event table
BEGIN_EVENT_TABLE(MyFrame, wxFrame)
    EVT_MENU(wxID_OPEN,  MyFrame::OnOpen)
    EVT_MENU(wxID_EXIT,  MyFrame::OnExit)
    EVT_MENU(wxID_ABOUT, MyFrame::OnAbout)
END_EVENT_TABLE()

Sizers replace absolute positioning with declarative layout that reflows on window resize:

cpp
// Nested sizer layout
auto* root = new wxBoxSizer{wxVERTICAL};

auto* toolbar_row = new wxBoxSizer{wxHORIZONTAL};
toolbar_row->Add(new wxButton{panel, ID_NEW,  "New"},  0, wxALL, 4);
toolbar_row->Add(new wxButton{panel, ID_OPEN, "Open"}, 0, wxALL, 4);
toolbar_row->AddStretchSpacer();      // pushes rest to right

auto* grid = new wxFlexGridSizer{/*rows=*/2, /*cols=*/2, /*vgap=*/6, /*hgap=*/6};
grid->AddGrowableCol(1);              // second column stretches
grid->Add(new wxStaticText{panel, wxID_ANY, "Name:"});
grid->Add(new wxTextCtrl{panel, wxID_ANY}, 1, wxEXPAND);
grid->Add(new wxStaticText{panel, wxID_ANY, "Email:"});
grid->Add(new wxTextCtrl{panel, wxID_ANY}, 1, wxEXPAND);

root->Add(toolbar_row, 0, wxEXPAND | wxALL, 4);
root->Add(grid,        1, wxEXPAND | wxALL, 8);
panel->SetSizer(root);
root->Fit(this);        // resize window to minimum required size

GTK4 with C++ (gtkmm 4)

gtkmm is the official C++ binding for GTK4, using signal-slot via libsigc++. The GLib main loop drives events; gtkmm wraps every GTK widget in an RAII class.

cpp
#include <gtkmm.h>

class MyWindow : public Gtk::ApplicationWindow {
    Gtk::Box   vbox_{Gtk::Orientation::VERTICAL, 8};
    Gtk::Entry entry_;
    Gtk::Button button_{"Greet"};
    Gtk::Label  label_;

public:
    MyWindow() {
        set_title("gtkmm Demo");
        set_default_size(400, 200);

        button_.signal_clicked().connect(
            sigc::mem_fun(*this, &MyWindow::on_button_clicked));

        vbox_.append(entry_);
        vbox_.append(button_);
        vbox_.append(label_);
        set_child(vbox_);
    }

private:
    void on_button_clicked() {
        label_.set_text("Hello, " + entry_.get_text() + "!");
    }
};

int main(int argc, char* argv[]) {
    auto app = Gtk::Application::create("com.example.myapp");
    return app->make_window_and_run<MyWindow>(argc, argv);
}
cmake
# CMakeLists.txt
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTKMM REQUIRED gtkmm-4.0)

target_include_directories(myapp PRIVATE ${GTKMM_INCLUDE_DIRS})
target_link_directories(myapp PRIVATE ${GTKMM_LIBRARY_DIRS})
target_link_libraries(myapp PRIVATE ${GTKMM_LIBRARIES})

Accessibility in desktop C++ GUIs

Accessibility is often the last feature added and the first thing that blocks enterprise sales. Each framework exposes an accessibility tree that screen readers (NVDA, JAWS, Orca) query via platform APIs (MSAA/UIA on Windows, ATK on Linux, NSAccessibility on macOS).

cpp
// Qt — accessibility roles and names are set per widget
QPushButton* btn = new QPushButton("Submit");
btn->setAccessibleName("Submit form");
btn->setAccessibleDescription("Submits the current form data to the server");
// Qt maps QAccessibleInterface to platform API automatically for standard widgets

// Custom widget: implement QAccessibleInterface
class MyWidgetInterface : public QAccessibleWidget {
public:
    using QAccessibleWidget::QAccessibleWidget;

    QAccessible::Role role() const override {
        return QAccessible::Button;
    }
    QString text(QAccessible::Text t) const override {
        if (t == QAccessible::Name) return "Custom button";
        return {};
    }
};
QAccessible::installFactory([](const QString& key, QObject* obj) {
    if (auto* w = qobject_cast<MyWidget*>(obj))
        return static_cast<QAccessibleInterface*>(new MyWidgetInterface(w));
    return static_cast<QAccessibleInterface*>(nullptr);
});
cpp
// wxWidgets — accessibility via wxAccessible
class MyAccessible : public wxAccessible {
public:
    wxAccStatus GetRole(int childId, wxAccRole* role) override {
        *role = wxROLE_SYSTEM_PUSHBUTTON;
        return wxACC_OK;
    }
    wxAccStatus GetName(int, wxString* name) override {
        *name = "Submit form";
        return wxACC_OK;
    }
};
myButton->SetAccessible(new MyAccessible(myButton));

Checklist for accessible desktop GUIs:

  • Every interactive widget has an accessible name and role.
  • Focus order follows logical reading order (Tab key works correctly).
  • Colour contrast ratio >= 4.5:1 for normal text (WCAG AA).
  • No information conveyed by colour alone.
  • Custom-drawn widgets implement the platform accessibility interface.

Testing GUIs

Headless testing prevents regressions without requiring a display — essential for CI pipelines. Qt Test provides a built-in framework; offscreen rendering avoids the need for a real GPU or Xvfb.

bash
# Qt offscreen platform — no display required
QT_QPA_PLATFORM=offscreen ./my_gui_tests

# Xvfb fallback for older setups
Xvfb :99 -screen 0 1280x720x24 &
export DISPLAY=:99
./my_gui_tests
cpp
// Qt Test — unit test a QWidget
#include <QtTest/QtTest>
#include "mywidget.h"

class TestMyWidget : public QObject {
    Q_OBJECT

private slots:
    void testButtonClick() {
        MyWidget w;
        w.show();
        QVERIFY(QTest::qWaitForWindowExposed(&w));

        QLabel* label  = w.findChild<QLabel*>("outputLabel");
        QLineEdit* edit = w.findChild<QLineEdit*>("nameEdit");

        QTest::keyClicks(edit, "World");               // simulate typing
        QTest::mouseClick(w.findChild<QPushButton*>(),
                          Qt::LeftButton);              // simulate click

        QCOMPARE(label->text(), QString("Hello, World!"));
    }

    void testKeyboardNav() {
        MyWidget w;
        w.show();
        QVERIFY(QTest::qWaitForWindowExposed(&w));
        QTest::keyClick(&w, Qt::Key_Tab);              // move focus
        QTest::keyClick(&w, Qt::Key_Return);           // activate focused widget
        // assert expected side-effect
    }
};

QTEST_MAIN(TestMyWidget)
#include "test_mywidget.moc"
cmake
# CMakeLists.txt for Qt Test
find_package(Qt6 REQUIRED COMPONENTS Test Widgets)
add_executable(test_mywidget test_mywidget.cpp)
set_target_properties(test_mywidget PROPERTIES AUTOMOC ON)
target_link_libraries(test_mywidget PRIVATE Qt6::Test Qt6::Widgets mywidget_lib)
add_test(NAME MyWidgetTests COMMAND test_mywidget)

CI integration — run tests in a Docker container with Qt and the offscreen platform:

yaml
# .github/workflows/gui-tests.yml (excerpt)
- name: Run GUI tests headless
  run: |
    sudo apt-get install -y qt6-base-dev libqt6test6
    cmake --build build --target test_mywidget
    QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure