Martin Helmut Fieber

GUI Development with C++, SDL2, and Dear ImGui

Posted on — Updated

Top part of an application window with a menu and a panel named 'Some Panel'.
The final application that will be built in this post.

The many ways of GUI

Looking at all those tools, libraries, and many ways of developing GUIs in C++, it can become easily overwhelming. This at least happened to me; every time I set out to "finally solve how I will create GUIs in C++" I got blasted by all the possibilities. From very simplistic to huge monster like frameworks with own language extensions. In the end I never just created.

Over time though, I became more and more familiar with Dear ImGui, an immediate mode "GUI for C++ with minimal dependencies", creating tools for game development. Never taking it serious for "end-user" applications, I've got the final push to try it from a video from Yan Chernikov. Trying ImGui, in combination with SDL2 I just started learning, I was pleasantly surprised about the outcome.

Here it goes, a basic setup I started using for end-user applications. Of course, as many frameworks there are opinions, and this one is only mine.

Basic setup

For the setup of the application I use my C++ starter template I showed in the last post, Basic C++ setup with dependency management in CMake. The template for this can be found on GitHub: github.com/MartinHelmut/cpp-base-template or as a step-by-step explanation in the blog post.

The first thing I did was to rename src/some_library to src/core. This then contains the logging and debugging helper, also this is where I will create the Window and Application classes.

SDL2 and Dear ImGui

SDL2 will be added as dependency through FetchContent_Declare, same as the other dependencies in vendor/CMakeLists.txt.

# vendor/CMakeLists.txt
FetchContent_Declare(
  SDL2
  GIT_REPOSITORY "https://github.com/libsdl-org/SDL.git"
  GIT_TAG release-2.0.22
)
add_subdirectory(sdl)

# Other dependencies ...

The new file vendor/sdl/CMakeLists.txt will serve to show a message on fetching and make SDL2 available.

# vendor/sdl/CMakeLists.txt
message(STATUS "Fetching SDL ...")

FetchContent_MakeAvailable(SDL2)

Adding Dear ImGui as dependency works the same, by adding the content fetching in vendor/CMakeLists.txt. Instead of using a regular release I will use the "docking" branch, enabling a flexible way of moving and docking UI widgets.

# vendor/CMakeLists.txt
FetchContent_Declare(
  imgui
  GIT_REPOSITORY "https://github.com/ocornut/imgui.git"
  # docking-latest
  GIT_TAG cb8ead1f7198924f97e52973d115e1d4eaeda2f3
)
add_subdirectory(imgui)

# Other dependencies ...

As ImGui does not support CMake, there needs to be a bit more done inside the new file vendor/imgui/CMakeLists.txt. The whole file will set up ImGui as a library by adding all necessary source files, defining the including directory and linking SDL2 to it.

# vendor/imgui/CMakeLists.txt
message(STATUS "Fetching imgui ...")

set(CMAKE_CXX_STANDARD 20)

FetchContent_GetProperties(imgui)
if (NOT imgui_POPULATED)
  FetchContent_Populate(imgui)
endif ()

add_library(imgui
  ${imgui_SOURCE_DIR}/imgui.cpp
  ${imgui_SOURCE_DIR}/imgui.h
  ${imgui_SOURCE_DIR}/imconfig.h
  ${imgui_SOURCE_DIR}/imgui_demo.cpp
  ${imgui_SOURCE_DIR}/imgui_draw.cpp
  ${imgui_SOURCE_DIR}/imgui_internal.h
  ${imgui_SOURCE_DIR}/imgui_tables.cpp
  ${imgui_SOURCE_DIR}/imgui_widgets.cpp
  ${imgui_SOURCE_DIR}/imstb_rectpack.h
  ${imgui_SOURCE_DIR}/imstb_textedit.h
  ${imgui_SOURCE_DIR}/imstb_truetype.h
  ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl.h
  ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl.cpp
  ${imgui_SOURCE_DIR}/backends/imgui_impl_sdlrenderer.h
  ${imgui_SOURCE_DIR}/backends/imgui_impl_sdlrenderer.cpp)

target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR})
target_link_libraries(imgui PUBLIC SDL2::SDL2)

FetchContent_MakeAvailable(imgui)

Now, ImGui and SDL2 need to be added to the Core library.

# src/core/CMakeLists.txt
set(NAME "Core")

include(${PROJECT_SOURCE_DIR}/cmake/StaticAnalyzers.cmake)

add_library(${NAME} STATIC
  Core/Log.cpp Core/Log.hpp Core/Debug/Instrumentor.hpp
  Core/Application.cpp Core/Application.hpp
  Core/Window.cpp Core/Window.hpp)

target_include_directories(${NAME}
  PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(${NAME} PRIVATE cxx_std_20)
target_link_libraries(${NAME}
  PRIVATE project_warnings
  # Added SDL2::SDL2 and imgui here.
  PUBLIC fmt spdlog SDL2::SDL2 imgui)

Basic application setup

The basic application will run the main loop and handle properly stopping the application on close. There will also be an exit status in case of an issues.

// src/core/Core/Application.hpp
#pragma once

#include <SDL.h>

namespace App {

class Application {
 public:
  Application();
  ~Application();

  int run();
  void stop();

 private:
  int m_exit_status{0};
  bool m_running{true};
};

}  // namespace App

The basic implementation of Application.hpp looks like the following.

// src/core/Core/Application.cpp
#include "Application.hpp"
#include "Core/Log.hpp"

namespace App {

Application::Application() {
  unsigned int init_flags{
    SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_GAMECONTROLLER
  };
  if (init_flags) != 0) {
    APP_ERROR("Error: %s\n", SDL_GetError());
    m_exit_status = 1;
  }
}

Application::~Application() {
  SDL_Quit();
}

int App::Application::run() {
  if (m_exit_status == 1) {
    return m_exit_status;
  }

  m_running = true;
  while (m_running) {
    // ...
  }

  return m_exit_status;
}

void App::Application::stop() {
  m_running = false;
}

}  // namespace App

Step-by-step, the constructor will initialise SDL2 and catch possible issues on creation. The destructor will only quit SDL2 for now.

Application::Application() {
  unsigned int init_flags{
    SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_GAMECONTROLLER
  };
  if (SDL_Init(init_flags) != 0) {
    APP_ERROR("Error: %s\n", SDL_GetError());
    m_exit_status = 1;
  }
}

Application::~Application() {
  SDL_Quit();
}

Inside run will be the main loop and some error handling.

int App::Application::run() {
  if (m_exit_status == 1) {
    return m_exit_status;
  }

  m_running = true;
  while (m_running) {
    // ...
  }

  return m_exit_status;
}

Last but not least, calling stop will end the main loop.

void App::Application::stop() {
  m_running = false;
}

To actual run the application it needs to be added to the projects Main.cpp file.

// src/app/App/Main.cpp
#include "Core/Application.hpp"

int main() {
  App::Application app{};
  return app.run();
}

Returning the exit status from app.run() will serve as exit code of the application. Last step to make this work is to add the newly created files to CMake.

# src/core/CMakeLists.txt
set(NAME "Core")

include(${PROJECT_SOURCE_DIR}/cmake/StaticAnalyzers.cmake)

add_library(${NAME} STATIC
  Core/Log.cpp Core/Log.hpp Core/Debug/Instrumentor.hpp
  # Application files here:
  Core/Application.cpp Core/Application.hpp)

target_include_directories(${NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(${NAME} PRIVATE cxx_std_20)
target_link_libraries(${NAME}
  PRIVATE project_warnings
  PUBLIC fmt spdlog SDL2::SDL2 imgui)

Creating the window

Next is setting up the window for rendering. The Settings struct will define the base size of the window of 1280 times 720, I decided to pick.

// src/core/Core/Window.hpp
#pragma once

#include <string>
#include "SDL.h"

namespace App {

class Window {
 public:
  struct Settings {
    std::string title;
    const int width{1280};
    const int height{720};
  };

  explicit Window(const Settings& settings);
  ~Window();

  [[nodiscard]] SDL_Window* get_native_window() const;
  [[nodiscard]] SDL_Renderer* get_native_renderer() const;

 private:
  SDL_Window* m_window{nullptr};
  SDL_Renderer* m_renderer{nullptr};
};

}  // namespace App

Here the full implementation of Window.

// src/core/Core/Window.cpp
#include "Window.hpp"
#include "Core/Log.hpp"

namespace App {

Window::Window(const Settings& settings) {
  auto window_flags{
	static_cast<SDL_WindowFlags>(
	  SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI
	)
  };
  constexpr int window_center_flag{SDL_WINDOWPOS_CENTERED};

  m_window = SDL_CreateWindow(
      settings.title.c_str(),
      window_center_flag,
      window_center_flag,
      settings.width,
      settings.height,
      window_flags
  );

  auto renderer_flags{
    static_cast<SDL_RendererFlags>(
      SDL_RENDERER_PRESENTVSYNC | SDL_RENDERER_ACCELERATED
    )
  };
  m_renderer = SDL_CreateRenderer(
    m_window, -1, renderer_flags
  );

  if (m_renderer == nullptr) {
    APP_ERROR("Error creating SDL_Renderer!");
    return;
  }
}

Window::~Window() {
  SDL_DestroyRenderer(m_renderer);
  SDL_DestroyWindow(m_window);
}

SDL_Window* Window::get_native_window() const {
  return m_window;
}

SDL_Renderer* Window::get_native_renderer() const {
  return m_renderer;
}

}  // namespace App

One at a time, the biggest part is the constructor.

Window::Window(const Settings& settings) {
  auto window_flags{
	static_cast<SDL_WindowFlags>(
	  SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI
	)
  };
  constexpr int window_center_flag{SDL_WINDOWPOS_CENTERED};

  m_window = SDL_CreateWindow(
      settings.title.c_str(),
      window_center_flag,
      window_center_flag,
      settings.width,
      settings.height,
      window_flags
  );

  // ...
}

First step is to actually create a window — it should be resizable and work on high DPI displays, like Retina displays. Besides that, the window will be created centered on the screen with the provided settings for size and window title.

Besides the window, a renderer is needed to actually draw something into the window.

Window::Window(const Settings& settings) {
  // ...

  auto renderer_flags{
    static_cast<SDL_RendererFlags>(
      SDL_RENDERER_PRESENTVSYNC | SDL_RENDERER_ACCELERATED
    )
  };
  m_renderer = SDL_CreateRenderer(
    m_window, -1, renderer_flags
  );

  if (m_renderer == nullptr) {
    APP_ERROR("Error creating SDL_Renderer!");
    return;
  }
}

The renderer will use VSync and hardware acceleration. Both, renderer and window, will get cleaned up in the destructor.

Window::~Window() {
  SDL_DestroyRenderer(m_renderer);
  SDL_DestroyWindow(m_window);
}

For the setup of ImGui, Window will also expose the native window and renderer.

SDL_Window* Window::get_native_window() const {
  return m_window;
}

SDL_Renderer* Window::get_native_renderer() const {
  return m_renderer;
}

Not forgetting to add those new files to CMake again.

# src/core/CMakeLists.txt
set(NAME "Core")

include(${PROJECT_SOURCE_DIR}/cmake/StaticAnalyzers.cmake)

add_library(${NAME} STATIC
  Core/Log.cpp Core/Log.hpp Core/Debug/Instrumentor.hpp
  Core/Application.cpp Core/Application.hpp
  # New Window files
  Core/Window.cpp Core/Window.hpp)

target_include_directories(${NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(${NAME} PRIVATE cxx_std_20)
target_link_libraries(${NAME}
  PRIVATE project_warnings
  PUBLIC fmt spdlog SDL2::SDL2 imgui)

Now, the Application will create and manage the window holding a reference through a unique_ptr.

// src/core/Core/Application.hpp
// ...
  private:
    int m_exit_status{0};
    bool m_running{true};

    // Hold reference to window.
    std::unique_ptr<Window> m_window{nullptr};
// ...

The window will be created inside the Application constructor.

// src/core/Core/Application.cpp
Application::Application() {
  unsigned int init_flags{
    SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_GAMECONTROLLER
  };
  if (SDL_Init(init_flags) != 0) {
    APP_ERROR("Error: %s\n", SDL_GetError());
    m_exit_status = 1;
  }

  // Create new window with the title "Application".
  m_window = std::make_unique<Window>(
    Window::Settings{"Application"}
  );
}

The renderer set up inside the main loops run method.

// src/core/Core/Application.cpp

int App::Application::run() {
  // ...

  m_running = true;
  while (m_running) {
    SDL_SetRenderDrawColor(
      m_window->get_native_renderer(),
      // Gray clear color (rgba)
      100, 100, 100, 255
    );
    SDL_RenderClear(m_window->get_native_renderer());
    SDL_RenderPresent(m_window->get_native_renderer());
  }

  // ...
}

Showing something on the screen

To actually get something on screen, ImGui and the SDL2 ImGui backend and renderer need to be set up. The SDL2 renderer will, depending on the operating system, pick a different render backend — DirectX 10, 11 or 12 for Windows, Metal for Mac, and so on. You can see for yourself inside the SDL_render.c file if you want.

The imports are ImGui, the SDL2 ImGui implementation, and the ImGui SDL2 renderer.

// src/core/Core/Application.cpp
#include <backends/imgui_impl_sdl.h>
#include <backends/imgui_impl_sdlrenderer.h>
#include <imgui.h>

// ...

Now, the run method needs to create the ImGui context, initialise the SDL2 renderer, poll events, create a new frame and actually render something. But, everything step-by-step.

First thing is to create a new ImGui context and set the flags NavEnableKeyboard and ViewportsEnable for enabling keyboard navigation and multiple viewports.

// src/core/Core/Application.cpp
int App::Application::run() {
  if (m_exit_status == 1) {
    return m_exit_status;
  }

  // Setup Dear ImGui context
  IMGUI_CHECKVERSION();
  ImGui::CreateContext();
  ImGuiIO& io{ImGui::GetIO()};

  io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
  io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;

  m_running = true;
  while (m_running) {
    SDL_SetRenderDrawColor(
      m_window->get_native_renderer(),
      100, 100, 100, 255
    );
    SDL_RenderClear(m_window->get_native_renderer());
    SDL_RenderPresent(m_window->get_native_renderer());
  }

  return m_exit_status;
}

Next comes the SDL2 renderer.

// src/core/Core/Application.cpp
int App::Application::run() {
  // ...

  io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
  io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;

  // Setup Platform/Renderer backends
  ImGui_ImplSDL2_InitForSDLRenderer(
    m_window->get_native_window(),
    m_window->get_native_renderer()
  );
  ImGui_ImplSDLRenderer_Init(
    m_window->get_native_renderer()
  );

  m_running = true;
  while (m_running) {
    SDL_SetRenderDrawColor(
      m_window->get_native_renderer(),
      100, 100, 100, 255
    );
    SDL_RenderClear(m_window->get_native_renderer());
    SDL_RenderPresent(m_window->get_native_renderer());
  }

  return m_exit_status;
}

Inside the main loop will be the event polling — the SDL_QUIT event will stop the main loop by calling stop() from Application.

// src/core/Core/Application.cpp
int App::Application::run() {
  // ...

  m_running = true;
  while (m_running) {
    // Poll SDL events
    SDL_Event event{};
    while (SDL_PollEvent(&event) == 1) {
      ImGui_ImplSDL2_ProcessEvent(&event);
      // Listen for the quit event to stop the application
      if (event.type == SDL_QUIT) {
        stop();
      }
    }

    SDL_SetRenderDrawColor(
      m_window->get_native_renderer(),
      100, 100, 100, 255
    );
    SDL_RenderClear(m_window->get_native_renderer());
    SDL_RenderPresent(m_window->get_native_renderer());
  }

  return m_exit_status;
}

Finally, creating a new frame and calling the ImGui render method.

// src/core/Core/Application.cpp
int App::Application::run() {
  // ...

  m_running = true;
  while (m_running) {
    // ...

    // Start the Dear ImGui frame
    ImGui_ImplSDLRenderer_NewFrame();
    ImGui_ImplSDL2_NewFrame();
    ImGui::NewFrame();

    // Rendering
    ImGui::Render();

    SDL_SetRenderDrawColor(
      m_window->get_native_renderer(),
      100, 100, 100, 255
    );
    SDL_RenderClear(m_window->get_native_renderer());

    // Render data through the SDL renderer
    ImGui_ImplSDLRenderer_RenderDrawData(
      ImGui::GetDrawData()
    );
    SDL_RenderPresent(
      m_window->get_native_renderer()
    );
  }

  return m_exit_status;
}

The whole run method now looks like this:

// src/core/Core/Application.cpp
int App::Application::run() {
  if (m_exit_status == 1) {
    return m_exit_status;
  }

  IMGUI_CHECKVERSION();
  ImGui::CreateContext();
  ImGuiIO& io{ImGui::GetIO()};

  io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
  io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;

  ImGui_ImplSDL2_InitForSDLRenderer(
    m_window->get_native_window(),
    m_window->get_native_renderer()
  );
  ImGui_ImplSDLRenderer_Init(
    m_window->get_native_renderer()
  );

  m_running = true;
  while (m_running) {
    SDL_Event event{};
    while (SDL_PollEvent(&event) == 1) {
      ImGui_ImplSDL2_ProcessEvent(&event);

      if (event.type == SDL_QUIT) {
        stop();
      }
    }

    ImGui_ImplSDLRenderer_NewFrame();
    ImGui_ImplSDL2_NewFrame();
    ImGui::NewFrame();

    ImGui::Render();

    SDL_SetRenderDrawColor(
      m_window->get_native_renderer(),
      100, 100, 100, 255
    );
    SDL_RenderClear(m_window->get_native_renderer());
    ImGui_ImplSDLRenderer_RenderDrawData(
      ImGui::GetDrawData()
    );
    SDL_RenderPresent(
      m_window->get_native_renderer()
    );
  }

  return m_exit_status;
}

One last important thing, adding some cleanup functions inside the Application destructor.

// src/core/Core/Application.cpp
Application::~Application() {
  ImGui_ImplSDLRenderer_Shutdown();
  ImGui_ImplSDL2_Shutdown();
  ImGui::DestroyContext();
  SDL_Quit();
}

Using Litr, I can build and run the application with litr build,start. Without Litr, the same can be achieved by running CMake and the generated executable.

$ cmake -DCMAKE_BUILD_TYPE=Debug -B build/debug
$ cmake --build build/debug
$ cd build/debug/src/app && ./App
The $ is used to show a command will be entered.

This is how it looks like so far.

A basic application window in gray with a window title of Application.
Basic application window, not rendering anything, yet.

Rendering a widget

Let's get something rendering inside that window. For this I add a private variable to Application holding the information if the new widget should be visible.

// src/core/Core/Application.hpp

// ...

 private:
  int m_exit_status{0};
  bool m_running{true};
  std::unique_ptr<Window> m_window{nullptr};

  // Show "some panel", let's default to true.
  bool m_show_some_panel{true};

After creating the ImGui frame, before the ImGui render, the first widget "some panel" will be defined.

// src/core/Core/Application.cpp

  // ...

  while (m_running) {
    // ...

    ImGui_ImplSDLRenderer_NewFrame();
    ImGui_ImplSDL2_NewFrame();
    ImGui::NewFrame();

    // Render "some panel".
    if (m_show_some_panel) {
      ImGui::Begin("Some panel", &m_show_some_panel);
      ImGui::Text("Hello World");
      ImGui::End();
    }

    ImGui::Render();

    // ...
  }

  // ...

There it is, in all it's tiny glory — looking too small and not reacting to any click of the mouse. The reason for this is that the SDL2 renderer-scale is not yet adjusted to my Retina display on Mac. Let's fix it next.

An application window showing a very tiny widget with a blue title bar.
Seems like there is a small problem.

Handling high DPI displays

For high DPI displays, like Apples Retina display, I need to change the renderer scaling. For this I created a new public method get_scale in Window.

// src/core/Core/Window.hpp
class Window {
  // ...

 public:
  [[nodiscard]] float get_scale() const;

// ...

To implement get_scale, it is necessary to divide the render output size by the window size. The method will return one value, in that case x computed from width, where height is only needed for the computation.

// src/core/Core/Window.cpp
float Window::get_scale() const {
  int window_width{0};
  int window_height{0};
  SDL_GetWindowSize(
    m_window,
    &window_width, &window_height
  );

  int render_output_width{0};
  int render_output_height{0};
  SDL_GetRendererOutputSize(
    m_renderer,
    &render_output_width, &render_output_height
  );

  const auto scale_x{
    static_cast<float>(render_output_width) /
      static_cast<float>(window_width)
  };

  return scale_x;
}

The scale is then set up at the end of the Window constructor.

Window::Window(const Settings& settings) {
  // ...

  m_renderer = SDL_CreateRenderer(
    m_window, -1, renderer_flags
  );

  if (m_renderer == nullptr) {
    APP_ERROR("Error creating SDL_Renderer!");
    return;
  }

  // Set render scale for high DPI displays
  const float scale{get_scale()};
  SDL_RenderSetScale(m_renderer, scale, scale);
}

Building and running the application now shows a way better result then before.

An application window showing a good sized widget with a blue title bar saying 'Some Panel'.
Problem solved, the widget gets rendered in the right size.

Dockspace

The basics are up and running, dock spaces will now enable rearranging widgets and docking those to the window and each other, giving a user the option to freely define the application layout.

For this to work the ImGui flag DockingEnable needs to be set in run.

// src/core/Core/Application.cpp

int App::Application::run() {
  // ...

  io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
  io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;

  // Enable docking
  io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;

  // ...
}

Inside the main loop, after the frame creation, but before the widget, will the setup for the dock space be placed.

// src/core/Core/Application.cpp

int App::Application::run() {
  // ...

  m_running = true;
  while (m_running) {
    // ...

    ImGui::NewFrame();

    // This is all that needs to be added for this:
    ImGui::DockSpaceOverViewport();

    // The previously created widget
    if (m_show_some_panel) {
      ImGui::Begin("Some panel", &m_show_some_panel);
      ImGui::Text("Hello World");
      ImGui::End();
    }

    // ...
  }
}

This will create a full sized dock space, covering the whole window. Have a look at the following video to see the dock space in action.

Video showing how the docking space works.

Event handling

Customising the event handling from the previous implemented polling is not much more work; setting up a new method in Application called on_event.

// src/core/Core/Application.hpp
class Application {
 public:
  // ...

  // General event handler
  void on_event(const SDL_WindowEvent& event);

  // ...

}  // namespace App

The implementation will use a switch statement to handle different events.

// src/core/Core/Application.cpp
void Application::on_event(const SDL_WindowEvent& event) {
  switch (event.event) {
    // ...
  }
}

Furthermore, three specialised events that will be called from the generic event handler. The on_minimize and on_shown handler will set the m_minimized state of the application. This will be used to optimise the application when in idle mode. The on_close event will call stop.

// src/core/Core/Application.hpp
class Application {
 public:
  // ...

  void on_event(const SDL_WindowEvent& event);

  // Specialised events
  void on_minimize();
  void on_shown();
  void on_close();

  // ...

 private:
  // ...

  // Keep minimized state
  bool m_minimized{false};

}  // namespace App

From the generic event handler on_event the three specialised events are called.

// src/core/Core/Application.cpp
void Application::on_event(const SDL_WindowEvent& event) {
  switch (event.event) {
    case SDL_WINDOWEVENT_CLOSE:
      return on_close();
    case SDL_WINDOWEVENT_MINIMIZED:
      return on_minimize();
    case SDL_WINDOWEVENT_SHOWN:
      return on_shown();
  }
}

void Application::on_minimize() {
  m_minimized = true;
}

void Application::on_shown() {
  m_minimized = false;
}

void Application::on_close() {
  stop();
}

To get on_event hooked up is by adding it to the current event polling inside the main loop.

// src/core/Core/Application.cpp
int App::Application::run() {
  // ...

  m_running = true;
  while (m_running) {
    SDL_Event event{};
    while (SDL_PollEvent(&event) == 1) {
      ImGui_ImplSDL2_ProcessEvent(&event);

      if (event.type == SDL_QUIT) {
        stop();
      }

      // Capture events inside the window
      if (
        event.type == SDL_WINDOWEVENT &&
        event.window.windowID ==
          SDL_GetWindowID(m_window->get_native_window())
      ) {
        on_event(event.window);
      }
    }

    // ...
  }

  return m_exit_status;
}

Using the m_minimized state to optimise the application in idle mode by wrapping the viewport and dock space creation, as well as the widget, to not run when minimised.

// src/core/Core/Application.cpp

int App::Application::run() {
  // ...

  m_running = true;
  while (m_running) {
    // ...
    ImGui::NewFrame();

    if (!m_minimized) {
      ImGui::DockSpaceOverViewport();

      // ...

      if (m_show_some_panel) {
        // ...
      }
    }

    ImGui::Render();
    // ...
  }
}

Application menu

Inside the dock space, before the widget setup, is a good place to add the code for the application menu. This will serve to exit the application and show or hide the "some panel" widget.

// src/core/Core/Application.cpp

// ...
ImGui::DockSpace(ImGui::GetID("DockSpace"));

if (ImGui::BeginMainMenuBar()) {
  if (ImGui::BeginMenu("File")) {
    if (ImGui::MenuItem("Exit")) {
      stop();
    }
    ImGui::EndMenu();
  }
  if (ImGui::BeginMenu("View")) {
    ImGui::MenuItem(
      "Some Panel", nullptr, &m_show_some_panel
    );
    ImGui::EndMenu();
  }

  ImGui::EndMainMenuBar();
}

if (m_show_some_panel) {
  // ...
}

// ...

This will render the menu at the top of the window, enabling some basic application control.

The application window with the widget to the left, above a menu with 'File' and 'View'.
The application with the menu to exit the application or toggle the widget visibility.

Custom font

Making things look a little less "Debugger UI" like, adding a custom font can go a long way. I will use the amazing Open Source font Manrope. After downloading it all fonts will be placed relative to src/app/App/Main.cpp file inside a fonts folder. The full path of the folder is src/app/App/fonts.

The code for the font is placed inside Application after the ConfigFlags, but before the renderer setup.

// src/core/Core/Application.cpp

int App::Application::run() {
  // ...
  io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;

  // Get proper display scaling for high DPI displays
  const float font_scaling_factor{m_window->get_scale()};

  // Font size will be 18pt
  const float font_size{18.0F * font_scaling_factor};

  // Load font and set as default with proper scaling
  io.Fonts->AddFontFromFileTTF(
    "fonts/Manrope.ttf",
    font_size
  );
  io.FontDefault = io.Fonts->AddFontFromFileTTF(
    "fonts/Manrope.ttf",
    font_size
  );
  io.FontGlobalScale = 1.0F / font_scaling_factor;

  ImGui_ImplSDL2_InitForSDLRenderer(
    m_window->get_native_window(),
    m_window->get_native_renderer()
  );

  // ...
}

A final look at the application now shows the amazing new font, everything coming together and starting to look like an end-user application.

Video showing the final result.

Epilogue

And that is that! A base to continue when creating GUIs in C++ for me. And again, there is a more extended version of this on GitHub.

GitHub template: github.com/MartinHelmut/cpp-gui-template-sdl2

There is also an OpenGL version of the GitHub template with multi-viewport and detachable widgets: github.com/MartinHelmut/cpp-gui-template-sdl2-opengl

← Show all blog posts