Skip to content

The settings window

NuclearMeltdown edited this page Oct 23, 2026 · 21 revisions

The settings window

src/ui/settings_host.cpp, src/ui/settings_window.cpp, src/ui/ui_surface_win32.cpp, src/app_ui.cpp

The settings can be drawn over the picture or given a window of their own, which can then be moved to a second monitor or set beside the preview. The switch is under Settings → General.

The separate window is the default as of 2.0. It has a graphics device of its own (Direct3D 11 or Vulkan, whichever the main window runs on), so it is fully decoupled from the preview — nothing it does can affect the preview's pacing, and the preview's size and position do not constrain it. The embedded panel stays, and not only for taste: a window capture in OBS cannot see a second window.

What the settings contain depends on the source

Eleven tabs of controls before a capture card has been chosen is not an offer, it is an obstacle. Until a device is selected there are only General, Source with the device picker, and About. The other eight tabs are not rendered at all. A no device entry in the dropdown makes that state reachable without editing the configuration by hand.

Once a card is running, controls that cannot apply are absent rather than disabled. A greyed-out control still has to be read and dismissed.

Shown when
Composite filter the source is analogue
Native pixel grid the source is analogue and 576 lines or fewer
Deinterlacing the source has fields
HDR source curve, source peak the source is not analogue

Two of those deliberately do not key on analogue versus digital, because the obvious rule is wrong in both directions:

  • Deinterlacing is keyed on whether there are fields. 1080i and 480i exist over HDMI. Hiding the field controls for every digital source would break the people who need them most.
  • The native pixel grid needs both. It counts samples along one analogue line, so a digital source has nothing for it to undo — and above 576 lines something has scaled on the way, which puts the edges it looks for somewhere other than where they started. Knowing the line count does not rescue it: that is a statement about vertical, and this grid is horizontal.

And one that used to be here and is not

Scanlines and the CRT mask were hidden above 576 lines until 3.7. The reasoning was that the shader's own gate switches them off up there anyway, so nothing was lost — and that turned out to be half wrong twice over. The mask counts output pixels and never looks at the source at all. The scanlines do have a gate, but it is not 576: it is whether the window has two output rows per picture line, and a 4K screen has that at 720p.

What was lost was the answer to "does this program do scanlines?", which issue 1 asked twice from behind a 720p card. The section stays put now, and when there is genuinely no room it says so with the figure it is working from:

No room: the window shows 1.3 rows per picture line, 2 are needed. Make the window bigger — or is the line count above right?

A control that explains its own silence beats one that is missing. The principle above still holds: absent rather than disabled is for controls that cannot apply, and a control that can apply the moment you resize the window is not one of them.

The HDR tab is likewise split rather than hidden. The source half — which transfer curve arrives, how bright the source peaks — is meaningless for an analogue decoder and goes away. The display half stays, because it is not about the source at all: with the desktop in HDR mode an SDR picture still has to be written into the HDR container at a chosen white level, or it comes out too dim or too bright. That is as true of a SNES as of anything else.

Automatic looks at the picture, not just at the card

SetAnalogueSource used to ask only whether the card exposes an analogue decoder. On a hybrid card that is the wrong question: both inputs sit on one board, the decoder is reported either way, and a console at 1080p60 over the digital input was being called analogue.

The picture answers it. No standard in kStandards produces more than 576 lines, so a taller source did not come out of the decoder no matter what the card can do.

The first run

A welcome screen, not the settings. It is the same screen as "no signal" — icon, wordmark, one line underneath — but the line says which key opens the settings instead of reporting that something is missing. Nothing is missing the first time.

From the second run onward the old behaviour returns and a missing device opens the settings directly, because by then it is a problem rather than a greeting. The state is read from whether a configuration file existed at startup: Config::Load returns the same false for "no file" and for "broken file", and the difference is that error is empty.

The second window used to share one Direct3D device with the preview, and that sharing turned out to be the whole story behind a stutter which reached the desktop's own mouse cursor. It has a device of its own now.

This page is mostly a post-mortem, because everything here was found by measurement and several of the intermediate fixes were wrong. The three balancing acts below are kept even though the last of them is superseded: they are how the shared device was identified as the cause in the first place.

How the settings are arranged

Eleven tabs, grouped by what a setting affects rather than by what it technically is. In screen order:

General language, the settings window, appearance, tray, shortcuts, log
Source the card and the signal coming out of it
Picture what is done to that signal for viewing
HDR the whole subject, including what the outputs write
Audio playback, delay, levels, recording sources
Display the picture window: graphics API, behaviour, volume readout, fullscreen
Recording everything that leaves qBlank as a file or a camera
Encoder ffmpeg, which encoder, and what it is told
Keys
Profiles
About version, copyright, licence, links, and the updates (the Updates tab until 5.4.2)

That distinction did real work. The switches deciding whether a recording, a screenshot or the virtual camera keeps its full range used to sit under Display — three tabs away from any of the three things they govern.

General was split off Display in 5.2 for the same reason. Display had grown into everything that was not about the signal: the language next to the fullscreen behaviour, the tray next to the volume readout. Now Display holds only what concerns the picture window, and General holds the program itself. General comes first and is there without a device, because the language is the first thing someone may need to change.

Graphics in Display is a choice between Direct3D 11 and Vulkan. It applies at the next start. While the choice differs from the renderer that is running, a Restart button sits next to it. The restart saves the settings, ends the program and starts it again only once the card has been released. See Latency for what Vulkan changes.

The tab strip

ImGui's own tab bar squeezes the labels down to "…" once they stop fitting side by side. Eleven tabs at 150 % scaling do not fit side by side in a window of reasonable width, so labels like Recording read Rec….

Since 5.2 the strip is drawn by hand (SettingsWindow::DrawTabStrip):

  • One row while everything fits, with the labels at their own widths.
  • Otherwise as few rows as will do, with the tabs spread evenly over them and each row stretched to the full width, the way the Windows tab control does it.
  • No label is ever cut.
  • The first time the settings window opens, it is widened to fit all tabs in one row, if the screen has the room.

The layout follows a resize live. While a window border is dragged, Windows runs its own modal loop. qBlank keeps the picture going from there, but up to 5.1 the settings were not drawn in it, and the last frame sat stretched across the new size until the mouse was let go. Now App::DrawSettingsFrame() lays the dialog out and draws it again inside that loop, on every size change. Only when the size has actually changed, so moving the window still costs nothing. ImGui lays out from scratch every frame anyway, which is why a different UI framework would not have done this better.

Recording and Encoder are separate because they answer different questions. Where does the file go, how big, how often is a decision about the recording; which chip encodes it and how hard it works is a decision about the machine. One is set once and the other every session. ffmpeg lives with the encoder for the same reason: it is what the encoder runs on, not a property of the recording.

HDR earns a tab rather than a section, because it is not a display setting. Its source curve belongs to the card, its tone mapping to the screen, and three of its switches decide what the recorder, the screenshots and the virtual camera write. A subject that reaches across four tabs is a subject.

Inside the Picture tab

It is the fullest tab by a distance, so its sections are ordered rather than merely listed: at the top what somebody reaches for on a source they have just plugged in, at the bottom what stands right on its own.

Scaling filter, sharpening, aspect ratio, rotation, line doubling
Picture controls brightness, contrast, saturation, hue
Fields deinterlacing and field order
Crop
Native pixel grid undoing the card's fixed sampling rate
Composite filter
Cathode ray tube source lines, scanlines and mask
Compare every filter off on one side, see Freeze and compare
Colour range and matrix

Two pairings carry the middle. Crop and the native pixel grid sit together because they count the same thing — pixels of the source, one saying which to throw away and the other how many the console really drew. Colour is last because it measures itself: range and matrix are decided from the picture, so reaching for them means the measurement was wrong, and that is the rare case.

The controls that moved furthest were the ones that had never been placed at all. Aspect ratio, rotation and line doubling had no heading of their own and so fell under Cathode ray tube by accident, which made three settings that reach every recording and screenshot look like nostalgia. They belong to Scaling, and the square-pixel help text already assumed as much: it spoke of "the scaling filter chosen above" while pointing four sections up the page.

What the order cannot fix is that the composite filter and the CRT section end up as neighbours. One recovers what the transmission spoiled and the other lays something back on top that was never in the signal; side by side they read as two matters of taste, and one of them is not. Nothing inside a single tab separates them — that would take a tab of its own.

Tab persistence

Which tab is open is state ImGui keeps per context, and the settings are drawn into a different context depending on whether they live in their own window. So SettingsWindow tracks it explicitly:

if (!tabRestored_) {                     // once per run, from the configuration
  tabRestored_ = true;
  activeTab_ = cfg().app.settingsTab;
  wantTab_ = activeTab_;
}
ImGuiContext* nowContext = ImGui::GetCurrentContext();
if (nowContext != tabContext_) {         // and on every context switch
  tabContext_ = nowContext;
  wantTab_ = activeTab_;
}

Without the second half, switching to the separate window dropped you back on the Source tab every time.

Search

src/ui/settings_search.cpp, SettingsWindow::DrawSearchField()

A field above the tabs; Ctrl+F puts the cursor in it from anywhere in the window. While it holds text the results take the tabs' place. Enter, a click or the arrow keys pick one: its tab opens, the view scrolls to the control and the control flashes for a second and a half. It lands a third of the way down rather than at the top edge, so the heading above it and the line it depends on stay in view.

When the settings are drawn over the picture, Ctrl+F counts only while they have the focus. Over the picture the keys belong to the app, and someone may have bound that one.

Every setting is known three times: by the name on screen, by its name in the other language, and by the words of someone who does not know the name. The third is what the search is for. Whoever types Gamma does not know it is called Brightness here, and whoever knows barely needs a search. So Gamma, Stretch, Input lag, Bitrate, YPbPr and PAL60 all find something. The table, kEntries, has 157 rows in tab order; which tab and section a control belongs to is read off its position.

Each word of the query is matched on its own, and the best of these counts:

Score Match
100 the whole word
80 the beginning of a word, or of several run together
60 inside a word: the back half of a compound, rand in Bildrand
30 / 20 one typo, or two from seven letters on
20 the letters in order, with gaps: hlgkt finds Helligkeit

A typo is counted by optimal string alignment, Levenshtein plus swapping two neighbours, because that is the typo people actually make (Kontarst). It is also tried against the word's beginnings around the query's length, so a typo in a word still being typed finds it too. Pieces shorter than five letters count inside a word only at its end: in the middle they turn up in words that have nothing to do with them, crop in microphone.

Both sides go through the same normalisation, so Größe, Groesse and grosse are one word. Dots, slashes and apostrophes join rather than split, because H.264, A/V and BT.601 are typed as one word. Words like the, for or der are dropped. A section's name narrows a search (recording sources gain) but never finds anything on its own, since every control in that section would match.

Order. What matches the label as it reads comes first, up to twelve. Guesses — a synonym, the other language, a typo — follow under Related, at most six, and when there is nothing but guesses they are headed Did you mean. A guess scoring under half the best hit is dropped: those are letters that happen to fit. Every result shows Tab › Section on the right, because two controls share a name now and then.

Hidden controls are still found. Every control the search knows draws an anchor (Anchor()) right after itself, and the jump waits for it. A control hidden for the current source never draws one, so after eight frames the field says so instead — "X" is hidden right now – it depends on the source or another setting — rather than leaving the reader to search the tab by eye. Without a video device only Source and Updates exist; the jump then goes to the device list, with Select a video device first.

Right click resets a slider

Right click on a slider sets it back to the value it ships with. The defaults come from default-constructed configuration structs (kImage, kAudio, kRecord, kApp in settings_window.cpp), so there is no second list to go stale.

The tooltip saying so, Right click: default, appears only when the slider is off its default, and only after the mouse has rested on it for a moment. Otherwise it would hang on every slider the mouse crosses on its way down the page.

Volume is left out on purpose. Its default is 100 %, and a stray click should not blow anyone's ears off.

The stutter: three causes, each hiding the next

The symptom was that having the settings in their own window made everything stutter — including the system mouse cursor, which is what said it was not an ordinary frame-rate problem.

1. Presenting inside another window's frame

The dialog's frame was drawn and presented from inside the preview's own frame. Presenting a second swap chain part-way through another window's frame flushes everything already queued for it.

It runs after the preview's present now.

2. Drawing 235 times a second for a 25 fps source

The preview redrew on every wake-up of the message loop, and a second window on screen produces a steady stream of messages.

Measured: the whole pipeline — video shaders, colour work, readbacks for the recorder and the camera, a present — ran 235 times a second to show a source delivering 25.

Drawing is paced by the picture now: a new frame, a field falling due, or a floor of thirty a second so meters and toasts keep moving. See Latency for the loop.

This fix was wrong the first time. The first version paced against a fixed 33 ms clock, which beats against 25 fps and produced a slow visible pulse. Pacing has to be against the picture, not against a timer.

3. SetMaximumFrameLatency is device-wide

This is the one that actually cost the milliseconds.

Superseded. The window now creates a Direct3D device of its own, which removes this coupling rather than balancing it — see A device of its own below. The measurement is kept because it is what pointed at the shared device in the first place.

SetMaximumFrameLatency(1) is what makes the preview's latency as short as it is — and it applies to the device, not to a swap chain. With one frame of queue and two swap chains, each present waited for the other's frame to retire.

Measured: the dialog's present cost 8–14 ms and the preview's 3 ms. At a queue of three, both are under a millisecond.

The fix at the time was D3DContext::SetFrameLatency(UINT), which raised the queue to three only while the dialog was on screen, because the rest of the time the short queue is the point.

The dialog's swap chain also uses BufferCount = 3 and ALLOW_TEARING where supported (swapchainFlags_ / presentFlags_).

Getting back into Windows' modal drag loop

Dragging a window puts Windows into a loop of its own that does not return until the mouse is released. qBlank's own loop stops running and the preview stops with it — measured at 1.24 seconds frozen during a drag.

The only way back in is from a message the window receives while that loop is running.

WM_TIMER does not work

The obvious answer, and it fails:

case WM_ENTERSIZEMOVE:
  ::SetTimer(hwnd, 1, 16, nullptr);

WM_TIMER is the lowest priority message there is, and Windows only generates one when the queue is otherwise empty. During a drag the queue never is. Measured: at an interval of 8 ms it fired seven times in a second.

The timer is still set, but only as a floor for when the mouse is held still.

WM_MOVING and WM_SIZING do

case WM_MOVING:
case WM_SIZING:
  self->PumpModalFrame();
  break;   // and on to DefWindowProc, which does the actual moving

Windows sends these continuously while the window is being dragged or resized — once per mouse movement — and unlike WM_TIMER they are real messages that cannot be starved by the flood of mouse input causing the problem in the first place.

Measured after: 125 pumps a second against the timer's seven.

The ceiling: three tries, and the answer was to have none

Rendering on every WM_MOVING puts a whole frame — shaders, readbacks, two presents — between each mouse movement and the window following it, which trades one kind of stutter for another. That was the second wrong fix: the preview ran beautifully and the window lagged behind the cursor. So a ceiling went in:

if (lastModalTick_ != 0 && now - lastModalTick_ < 30) return;   // wrong

That number was visible. Dragging the window dropped the preview from 60 to 33 frames — not because anything was overloaded, but because it had been decided here.

The obvious repair was to pace against the source's measured frame rate. It made things worse, and for two reasons that multiplied:

  • PAL delivers 25 frames and the preview shows 50 deinterlaced fields. Pacing on the frame rate throws away half of what there is to show.
  • GetTickCount advances in 15.6 ms steps. Every threshold rounds up to the next multiple: 40 ms becomes 46.8, which is 21.4 frames a second. That is what was measured. The old 30 ms rounded to 31.2 — the 32 seen before.

Any number is wrong here. Whether there is something new to show is known only to the loop upstairs, which has decided it for the ordinary case all along, so PumpModalFrame now decides nothing:

void SettingsHost::PumpModalFrame() {
  if (!onFrame_ || inFrameCallback_) return;
  inFrameCallback_ = true;
  onFrame_();
  inFrameCallback_ = false;
}

The callback in App applies the same three conditions as the main loop — a new picture, a field falling due, a slow floor (see Latency). The capture card's event is auto-reset, so testing it with a zero wait is the same consumption the main loop performs, and that loop is not running at the time.

The WM_TIMER floor went from 16 ms to 10, the smallest Windows accepts (USER_TIMER_MINIMUM). A wake-up without a new picture now costs only the question of whether there is one, so it may be asked more often than 60 Hz needs.

Measured during a drag: a preview frame costs 0.6 to 0.8 ms, and the preview runs at the source's rate rather than at a rate chosen here.

inFrameCallback_ guards against reentrancy, since the frame callback can itself pump messages.

A device of its own

The three fixes above balanced a shared Direct3D device. They worked, and the window still did not feel free — so the sharing itself went.

SettingsHost::Create now calls D3D11CreateDevice and keeps its own device and immediate context. Two things follow:

  • The preview keeps a frame queue of one, permanently. It no longer has to be raised to three while the dialog is open, which was the single biggest lever on its latency being given away for the dialog's sake.
  • Nothing serialises. Both windows used the same immediate context, so every ImGui draw call for the dialog went through the exact context the preview needed. Separate contexts, separate queues.

Measured with the settings window open and overlapping the preview: 0.5 to 1.6 ms. That was frame age as it was then defined, stopped at the start of the draw rather than at the present, so it is not directly comparable with the pipeline figure the overlay shows today. It stands against a preview that previously had to run at a queue of three to stay under a millisecond at all.

What it costs

A second font atlas. A texture belongs to the device that made it, so the two contexts cannot share one. LoadUiFont() is called again inside the host's context — without it the window comes up in ImGui's built-in font and looks like a different program.

And it removes a trap. The shared atlas had a sting: the DX11 backend keeps the atlas's texture id inside the atlas, so shutting down the second context called SetTexID(0) and released the texture the first one was still drawing with. Closing the settings window blanked the entire interface until restart. That was patched by rebuilding the texture on the way out; with two atlases there is nothing to patch.

Black patches on an interlaced source: fixed

Reported against 1.7: with the settings in their own window, the preview occasionally flickered black in places. Only with the analogue, interlaced source. Gone as of 1.9, confirmed on the hardware that showed it.

It was never reproduced by instrumentation here. Two attempts, both defeated by the instrument rather than by the bug: screen capture runs at about twenty frames a second against a sixty frame preview, so a single dropped frame is caught roughly one time in three, and on moving content a per-pixel "darker than both neighbours" test flags every moving edge in the picture.

What found it was the report itself — that it only happened on the interlaced source. That pointed at the field schedule rather than at the renderer, and the WAIT_TIMEOUT bug below was sitting there: with the dialog open, every second field was dropped. Fixing it made the flicker stop.

Ruled out along the way, each by reading rather than by guessing:

  • Scissor rectangles left behind by ImGui. The renderer's rasteriser state has ScissorEnable = FALSE, so a stale rectangle cannot clip the video.
  • Undefined back-buffer content under FLIP_DISCARD. D3DContext::BeginFrame clears the whole surface every frame.
  • Repeated SetMaximumFrameLatency calls. D3DContext::SetFrameLatency already returns early when the value has not changed.

The defect behind it: with the settings window open, the message queue never goes quiet, so the second field of every interlaced frame was being dropped — see Latency. On a still picture that is invisible; on moving interlaced content half the fields going missing is exactly the kind of thing that reads as flicker.

Worth recording that the cause was reached from which sources it happened on rather than from a reproduction. Two reproduction attempts had already failed, and both of them were measuring the wrong thing.

What is left, and whose it is

The settings window itself does not always move perfectly smoothly. The preview no longer cares what the window does — that part is fixed — but the window can still hitch as it follows the cursor.

The work is not the problem: 0.6 to 0.8 ms a frame, measured. What is left is the desktop's. Two overlapping windows have to be composed together by DWM, and one of them is repainting at the capture card's rate. Dragging the dialog onto another monitor, or off the preview, makes it go away — which is the test that says whose it is.

Three smaller things that were also wrong

No taskbar button

The window was created owned but without WS_EX_APPWINDOW, which is how Windows is asked for a taskbar button. Without one, a window has nowhere to go when it is minimised, and lands as a stub in a corner of the screen the way windows did before there was a taskbar.

MakeWindowAssociation is deliberately not called — it is per-factory, not per-window, and calling it for the second window would change the first one's Alt+Enter behaviour.

The shared font atlas released the wrong texture

Both windows used to share one ImFontAtlas — same glyphs, one copy on the GPU.

But the DX11 backend stores the atlas's texture id in the atlas. Closing the second window released the texture the first one was still drawing with, and the whole interface went blank until restart.

That was patched by rebuilding the texture on the way out. It is moot now: with a device of its own the window needs an atlas of its own, and neither can reach into the other.

The window jumped on every switch

Switching between embedded and separate destroys and rebuilds the window, so it came up wherever Windows decided each time.

Each form remembering its own position is not the answer — that was the first fix, and it produced two windows that each reappeared where it had last been, so switching modes still made the panel jump across the screen. What is wanted is a handover: the separate window opens exactly where the embedded panel was standing, and the other way round.

That handover has to be measured outer edge to outer edge:

RECT outer = {};
if (::GetWindowRect(host, &outer)) {          // including the title bar
  POINT inMain = {outer.left, outer.top};
  ::ScreenToClient(hwnd_, &inMain);
  config_.app.settingsPanelX = inMain.x;
  config_.app.settingsPanelY = inMain.y;
  config_.app.settingsPanelW = outer.right - outer.left;
  config_.app.settingsPanelH = outer.bottom - outer.top;
}

Mapping client to client — with AdjustWindowRect to convert between them — looks right and is not: the embedded panel counts its own ImGui title bar as part of its area, while a real window's client area starts below the Windows title bar. The difference between the two bars shifted the panel a few pixels on every switch, down one way and up the other. Outer edges assume nothing about what is inside.

The failsafe stays: a separate window dragged outside the main window has no sensible embedded position, so the panel is centred in the preview instead.

SettingsHost::placement() reads GetWindowPlacement — the restored rectangle, not the current one, so a window read while minimised or maximised is not remembered at the wrong size. App::RememberSettingsWindow() copies it into the configuration before Destroy(), and Create() checks the remembered position against the virtual screen before using it:

// A hundred pixels of title bar has to remain reachable.
if (where.x + 100 > desk.left && where.x < desk.right - 100 &&
    where.y >= desk.top && where.y < desk.bottom - 40) { x = where.x; y = where.y; }

A window remembered on a monitor that is no longer there does not come up somewhere nobody can reach it.

Minimum size

Dragged too small, the tab row wraps and the fields beside their labels are squeezed. The separate window now stops where the embedded panel always has: kSettingsMinWidth × kSettingsMinHeight (620 × 460) inside, in settings_host.h, shared by both.

WindowSpec::minWidth / minHeight carry it to the platform. WM_GETMINMAXINFO turns it into a whole-window size for the monitor's scaling (ToolMinSize: MulDiv by the DPI, then AdjustWindowRectExForDpi). That message arrives before WM_NCCREATE attaches the window's state, so it cannot catch a smaller size remembered from before; Window::Create brings that up to the minimum with one SetWindowPos after creation.

Moving to a monitor with other scaling

Up to 5.0.0 the DPI scale was read once, at startup. A window dragged from a 100 % monitor to a 150 % one kept its old sizes and font and looked small.

Since 5.0.1 WM_DPICHANGED becomes a window event carrying the new scale. The main window and the separate settings window take the rectangle Windows suggests, so they stay the same size to the eye, and restyle before their next frame. The event can arrive in the middle of a frame (a SetWindowPos from the UI that lands the window on another monitor sends it right away), and restyling there would change sizes under a half-built frame.

The startup dialog does not follow. It is seen once.

Font and sizes

The font is loaded at its 100 % size and scaled through FontScaleDpi. Since 1.92 ImGui rasterizes glyphs for the size they end up at, so a scaled font is as sharp as one loaded at that size.

ApplyImGuiTheme starts from a fresh ImGuiStyle() every time. ScaleAllSizes multiplies whatever is there, so on top of the previous call it scaled the fields the theme does not set once more with every monitor or theme change.

Fixed widths in the settings (number fields, buttons, the release notes box, the status card) were plain pixels. They now go through Px(), which multiplies by the style's current scale.

The embedded panel

The panel's default and minimum size scale too. When the main window changes monitors, the panel grows or shrinks by the same factor and keeps its place in it. At 150 % the unscaled minimum of 620 × 460 was too narrow for the tab row.

This turned up an older bug: the remembered panel size was never restored. SetNextWindowSize keeps only the last call, and the FirstUseEver default came after the restore. It goes first now.

Is ImGui the wrong framework for this?

Reasonable question, and the answer is no — the problems above were all Direct3D and Win32 problems, not ImGui ones.

ImGui does assume it is called once per frame from whatever thread owns the device, which is exactly the assumption that makes the modal-loop problem visible. But any immediate-mode or retained-mode UI sharing a device with a latency-tuned swap chain hits the same three issues: the present ordering, the device-wide frame latency, and Windows' modal loop. A different framework would have hidden the first, made no difference to the second, and hit the third identically.

Swapping it out has no upside either. Qt would multiply the size of a 2.7 MB program, and the other small ones (Nuklear, microui) do less than ImGui does. ImGui draws into the same swap chain as the picture, with no second window and no second compositor pass. What it lacks (right-to-left text, screen readers, a native look) is nothing qBlank needs. Since 1.92 it also loads every glyph the font has on demand, so a language with its own script is a matter of a font, not a framework.

Clone this wiki locally