It turns out, I got the timeline wrong.
The first bit of work I did on Packet Sender was to test light mode.
See, Dan Nagle, creator and maintainer of Packet Sender, had implemented light and dark mode. But he only uses dark mode personally. Because of this, he missed the fact that some parts of the dark mode theme was hanging on after the app switched to light mode.
Having fixed the bug, he wanted me to manually test the app going through as much UI as possible to make sure that going from dark mode to light mode 100% converted the app to light mode.
I did not know this until quite recently, but it turns out that there is a setting you can toggle. I thought Packet Sender followed whatever the operating system is set to; that turned out to be the truth but not the whole truth.
Whut?
What I mean is, since I didn't know there was a setting already built into Packet Sender I could toggle. In order to do the manual tests, I would switch the operating system's (read: macOS–I'm an Apple guy) mode and then see how Packet Sender responded.
In order to affect the mode change, one had to restart Packet Sender. Upon launch, Packet Sender would read what the OS was set to and would apply the correct theme.
What I know that Packet Sender didn't do was update itself when the OS changed themes, say if you had set your machine up to automagically transition from dark to light mode or vice versa based on the time of day or the amount of light in your environment.
It was my personal belief that we were so close to having Packet Sender do this–the hard work of having themes and applying them was already in the code base–all we had to do was know when the OS had changed modes and then we could update ourselves.
Dan told me in a phone call that he had a different QT app for a client where he solved this problem. He said he solved it with a thread.
I fed this information in to Grok because this was early in my work with Packet Sender, I wasn't familiar with some of the more modern C++ syntax/idioms and this was my first time working with Qt. Grok gave me code to use a QThread to solve this problem, but the code would crash.
It turns out that listening for light/dark is not a background-compute problem. It is a GUI-state problem.
The OS theme is shared with AppKit and with every widget. A helper thread that reads appearance and then calls setPalette() is racing the toolkit.
On macOS, that race is undefined and often a hard crash.
The solution I ended up going with was to use a QTimer instead.
A timer (or a colorSchemeChanged() slot–more on that anon) on the main thread is the same work with a critical section: the event loop.
A QTimer parented to qApp fires on the main event loop. Each tick runs in the same thread that owns the widgets. Checking “is it dark now?” and calling applyTheme() is then serialized with paint, resize, and other theme events. No second actor is mid-scribble on QPalette. And, as stated above, on macOS, you also stay on the thread AppKit allows.
As an addendum, polling every few seconds is inelegant.
Qt 6.5+ has QStyleHints::colorSchemeChanged(), and widgets already get QEvent::ApplicationPaletteChange / ThemeChange / PaletteChange. But polling it is correct because it does not cross the thread boundary.
Dan’s paid thread solved “notice the OS changed.” It did not solve “apply that change legally.” This assumes, of course, that Dan actually use a thread in his client work and not a QTimer and was misremembering which object he used.
To be clear, I'm not throwing shade on Dan here. He's a working programmer and unless he has a photographic memory, it'd be easy to confuse the two on recall, especially in a post-work day telephone call.
There is a logical follow up question: why not use QStyleHints::colorSchemeChanged()? Several reasons:
- I wasn't sure which version of Qt we are using everywhere, so I didn't want to add code that worked on some OSes but not others.
- I knew this "old way" would be safe.
- Even though I poll once every three seconds, it's not a hard operation on the machine. e.g. I made an engineering trade off.
- It seems like Grok warned me at the time there were some quirks with
QStyleHints::colorSchemeChanged(), but as that work was done ~7 months ago at the time of this writing, the reason is lost to me.
Here's how I ultimately solved that problem:
void setupThemePolling(QApplication *app, MainWindow *mainWindow, bool debugMode = false)
{
QTimer *themePollTimer = new QTimer(app); // parent = app so it auto-deletes
themePollTimer->setInterval(3000);
static bool lastDark = Settings::useDark();
QObject::connect(themePollTimer, &QTimer::timeout, [app, mainWindow, debugMode, &lastDark]() {
bool current = Settings::useDark();
if (current != lastDark) {
QDEBUG() << "[THEME POLL] Change detected:" << (current ? "Dark" : "Light");
applyTheme(current, debugMode, app, mainWindow);
lastDark = current;
}
});
themePollTimer->start();
if(debugMode) {
QDEBUG() << "[THEME POLL] Timer started - polling every 3 seconds";
}
}
which is called from here:
MainWindow w;
setupThemePolling(&a, &w, debugMode);
w.show();
return a.exec();
If you found this post useful, consider supporting my Open Source software work directly by donating directly at https://www.paypal.com/paypalme/realprofessortom
Top comments (0)