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.
#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
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
#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
#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
// 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();
}// 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
#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 ImGui | Qt | wxWidgets | |
|---|---|---|---|
| Mode | Immediate | Retained | Retained |
| Look | Custom | Cross-platform | Native |
| License | MIT | GPL/LGPLv3/Commercial | wxWindows (permissive) |
| Setup effort | Low | Medium | Medium |
| Best for | Tools, debug UIs | Desktop apps, mobile | Native desktop |
| Mobile | No | Yes (Qt Quick) | Limited |
| Learning curve | Low | High (MOC, signals) | Medium |
| Widgets | Basic | Full suite | Full suite |
| 3D integration | Yes (render loop) | Via QOpenGL | Via wxGLCanvas |
CMake Setup
# 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.
// 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 loopThread 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:
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 type | When chosen | Behaviour |
|---|---|---|
AutoConnection | default | Direct if same thread, Queued if cross-thread |
DirectConnection | explicit | Slot called synchronously in emitter's thread |
QueuedConnection | explicit / cross-thread | Event posted; slot runs in receiver's thread |
BlockingQueuedConnection | explicit | Emitter 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.
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:
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.
// 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):
// 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 textureDear 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.
// 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.
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:
// 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 sizeGTK4 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.
#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);
}# 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).
// 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);
});// 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.
# 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// 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"# 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:
# .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