Add QGuiApplication API to set a number-badge in the Dock/task bar

The API is supported on macOS, iOS, and Windows. On Android no official
API exists for badging the application icon, and we don't want to take
on dependencies like ShortcutBadger [1].

The macOS and iOS implementations are trivial. The same goes for
the WinRT based implementation on Windows, but this API is only
available for applications that have a so called "package identity",
and does not seem to be stable for Windows 10.

To cover the cases where this API is not available we fall back to
drawing the badge manually, and set it as an overlay icon on the task
bar using ITaskbarList3. The look of this badge has been tweaked to
match the Windows 11/10 styles, and will pick up the user's choice of
dark/light mode and accent color if available.

[1] https://github.com/leolin310148/ShortcutBadger/

[ChangeLog][QtGui] Added QGuiApplication::setBadgeNumber() to inform
the user about e.g. the number of unread e-mail or queued tasks.
The badge will be overlaid on the application's icon in the Dock
on macOS, the home screen icon on iOS, or the task bar on Windows.

Task-number: QTBUG-94009
Change-Id: I6447d55177f9987b0dfcd93caf63c6167f7224c7
Reviewed-by: Axel Spoerl <axel.spoerl@qt.io>
Reviewed-by: Oliver Wolff <oliver.wolff@qt.io>
Reviewed-by: Volker Hilsheimer <volker.hilsheimer@qt.io>
This commit is contained in:
Tor Arne Vestbø 2022-08-24 17:18:44 +02:00
parent 846cda7eb0
commit 7e4c7d50a7
12 changed files with 235 additions and 0 deletions

View File

@ -739,6 +739,29 @@ QString QGuiApplication::applicationDisplayName()
return QGuiApplicationPrivate::displayName ? *QGuiApplicationPrivate::displayName : applicationName();
}
/*!
Sets the application's badge to \a number.
Useful for providing feedback to the user about the number
of unread messages or similar.
The badge will be overlaid on the application's icon in the Dock
on \macos, the home screen icon on iOS, or the task bar on Windows.
If the number is outside the range supported by the platform, the
number will be clamped to the supported range. If the number does
not fit within the badge, the number may be visually elided.
Setting the number to 0 will clear the badge.
\since 6.5
\sa applicationName
*/
void QGuiApplication::setBadgeNumber(qint64 number)
{
QGuiApplicationPrivate::platformIntegration()->setApplicationBadge(number);
}
/*!
\property QGuiApplication::desktopFileName
\brief the base name of the desktop entry for this application

View File

@ -58,6 +58,8 @@ public:
static void setApplicationDisplayName(const QString &name);
static QString applicationDisplayName();
Q_SLOT void setBadgeNumber(qint64 number);
static void setDesktopFileName(const QString &name);
static QString desktopFileName();

View File

@ -569,6 +569,20 @@ void QPlatformIntegration::setApplicationIcon(const QIcon &icon) const
Q_UNUSED(icon);
}
/*!
\since 6.5
Should set the application's badge to \a number.
If the number is 0 the badge should be cleared.
\sa QGuiApplication::setBadge()
*/
void QPlatformIntegration::setApplicationBadge(qint64 number)
{
Q_UNUSED(number);
}
#if QT_CONFIG(vulkan) || defined(Q_QDOC)
/*!

View File

@ -188,6 +188,7 @@ public:
virtual QOpenGLContext::OpenGLModuleType openGLModuleType();
#endif
virtual void setApplicationIcon(const QIcon &icon) const;
virtual void setApplicationBadge(qint64 number);
virtual void beep() const;
virtual void quit() const;

View File

@ -90,6 +90,7 @@ public:
void clearToolbars();
void setApplicationIcon(const QIcon &icon) const override;
void setApplicationBadge(qint64 number) override;
void beep() const override;
void quit() const override;

View File

@ -438,6 +438,11 @@ void QCocoaIntegration::setApplicationIcon(const QIcon &icon) const
NSApp.applicationIconImage = [NSImage imageFromQIcon:icon withSize:fallbackSize];
}
void QCocoaIntegration::setApplicationBadge(qint64 number)
{
NSApp.dockTile.badgeLabel = number ? [NSString stringWithFormat:@"%" PRId64, number] : nil;
}
void QCocoaIntegration::beep() const
{
NSBeep();

View File

@ -58,6 +58,8 @@ public:
void beep() const override;
void setApplicationBadge(qint64 number) override;
static QIOSIntegration *instance();
// -- QPlatformNativeInterface --

View File

@ -270,6 +270,11 @@ void QIOSIntegration::beep() const
#endif
}
void QIOSIntegration::setApplicationBadge(qint64 number)
{
UIApplication.sharedApplication.applicationIconBadgeNumber = number;
}
// ---------------------------------------------------------
void *QIOSIntegration::nativeResourceForWindow(const QByteArray &resource, QWindow *window)

View File

@ -107,6 +107,7 @@ enum WindowsEventType // Simplify event types
PointerActivateWindowEvent = WindowEventFlag + 24,
DpiScaledSizeEvent = WindowEventFlag + 25,
DpiChangedAfterParentEvent = WindowEventFlag + 27,
TaskbarButtonCreated = WindowEventFlag + 28,
MouseEvent = MouseEventFlag + 1,
MouseWheelEvent = MouseEventFlag + 2,
CursorEvent = MouseEventFlag + 3,
@ -162,6 +163,14 @@ enum ProcessDpiAwareness
inline QtWindows::WindowsEventType windowsEventType(UINT message, WPARAM wParamIn, LPARAM lParamIn)
{
static const UINT WM_TASKBAR_BUTTON_CREATED = []{
UINT message = RegisterWindowMessage(L"TaskbarButtonCreated");
// In case the application is run elevated, allow the
// TaskbarButtonCreated message through.
ChangeWindowMessageFilter(message, MSGFLT_ADD);
return message;
}();
switch (message) {
case WM_PAINT:
case WM_ERASEBKGND:
@ -312,6 +321,8 @@ inline QtWindows::WindowsEventType windowsEventType(UINT message, WPARAM wParamI
return QtWindows::NonClientPointerEvent;
if (message >= WM_POINTERUPDATE && message <= WM_POINTERHWHEEL)
return QtWindows::PointerEvent;
if (message == WM_TASKBAR_BUTTON_CREATED)
return QtWindows::TaskbarButtonCreated;
return QtWindows::UnknownEvent;
}

View File

@ -1022,6 +1022,7 @@ bool QWindowsContext::windowsProc(HWND hwnd, UINT message,
const bool darkModeChanged = darkMode != QWindowsContextPrivate::m_darkMode;
QWindowsContextPrivate::m_darkMode = darkMode;
auto integration = QWindowsIntegration::instance();
integration->updateApplicationBadge();
if (integration->darkModeHandling().testFlag(QWindowsApplication::DarkModeStyle)) {
QWindowsTheme::instance()->refresh();
QWindowSystemInterface::handleThemeChange();
@ -1286,6 +1287,11 @@ bool QWindowsContext::windowsProc(HWND hwnd, UINT message,
return true;
}
#endif // !defined(QT_NO_SESSIONMANAGER)
case QtWindows::TaskbarButtonCreated:
// Apply application badge if this is the first time we have a taskbar
// button, or after Explorer restart.
QWindowsIntegration::instance()->updateApplicationBadge();
break;
default:
break;
}

View File

@ -48,6 +48,11 @@
#include <QtCore/qdebug.h>
#include <QtCore/qvariant.h>
#include <QtCore/qoperatingsystemversion.h>
#include <QtCore/private/qfunctions_win_p.h>
#include <wrl.h>
#include <limits.h>
#if !defined(QT_NO_OPENGL)
@ -56,6 +61,13 @@
#include "qwindowsopengltester.h"
#if QT_CONFIG(cpp_winrt)
# include <winrt/Windows.UI.Notifications.h>
# include <winrt/Windows.Data.Xml.Dom.h>
# include <winrt/Windows.Foundation.h>
# include <winrt/Windows.UI.ViewManagement.h>
#endif
#include <memory>
static inline void initOpenGlBlacklistResources()
@ -635,6 +647,153 @@ void QWindowsIntegration::beep() const
MessageBeep(MB_OK); // For QApplication
}
void QWindowsIntegration::setApplicationBadge(qint64 number)
{
// Clamp to positive numbers, as the Windows API doesn't support negative numbers
number = qMax(0, number);
// Persist, so we can re-apply it on setting changes and Explorer restart
m_applicationBadgeNumber = number;
static const bool isWindows11 = QOperatingSystemVersion::current() >= QOperatingSystemVersion::Windows11;
#if QT_CONFIG(cpp_winrt)
// We prefer the native BadgeUpdater API, that allows us to set a number directly,
// but it requires that the application has a package identity, and also doesn't
// seem to work in all cases on < Windows 11.
if (isWindows11 && qt_win_hasPackageIdentity()) {
using namespace winrt::Windows::UI::Notifications;
auto badgeXml = BadgeUpdateManager::GetTemplateContent(BadgeTemplateType::BadgeNumber);
badgeXml.SelectSingleNode(L"//badge/@value").NodeValue(winrt::box_value(winrt::to_hstring(number)));
BadgeUpdateManager::CreateBadgeUpdaterForApplication().Update(BadgeNotification(badgeXml));
return;
}
#endif
// Fallback for non-packaged apps, Windows 10, or Qt builds without WinRT/C++ support
if (!number) {
// Clear badge
setApplicationBadge(QImage());
return;
}
const bool isDarkMode = QWindowsContext::isDarkMode();
QColor badgeColor;
QColor textColor;
#if QT_CONFIG(cpp_winrt)
if (isWindows11) {
// Match colors used by BadgeUpdater
static const auto fromUIColor = [](winrt::Windows::UI::Color &&color) {
return QColor(color.R, color.G, color.B, color.A);
};
using namespace winrt::Windows::UI::ViewManagement;
const auto settings = UISettings();
badgeColor = fromUIColor(settings.GetColorValue(isDarkMode ?
UIColorType::AccentLight2 : UIColorType::Accent));
textColor = fromUIColor(settings.GetColorValue(UIColorType::Background));
}
#endif
if (!badgeColor.isValid()) {
// Fall back to basic badge colors, based on Windows 10 look
badgeColor = isDarkMode ? Qt::black : QColor(220, 220, 220);
badgeColor.setAlphaF(0.5f);
textColor = isDarkMode ? Qt::white : Qt::black;
}
const auto devicePixelRatio = qApp->devicePixelRatio();
static const QSize iconBaseSize(16, 16);
QImage image(iconBaseSize * devicePixelRatio,
QImage::Format_ARGB32_Premultiplied);
image.fill(Qt::transparent);
QPainter painter(&image);
QRect badgeRect = image.rect();
QPen badgeBorderPen = Qt::NoPen;
if (!isWindows11) {
QColor badgeBorderColor = textColor;
badgeBorderColor.setAlphaF(0.5f);
badgeBorderPen = badgeBorderColor;
badgeRect.adjust(1, 1, -1, -1);
}
painter.setBrush(badgeColor);
painter.setPen(badgeBorderPen);
painter.setRenderHint(QPainter::Antialiasing);
painter.drawEllipse(badgeRect);
auto pixelSize = qCeil(10.5 * devicePixelRatio);
// Unlike the BadgeUpdater API we're limited by a square
// badge, so adjust the font size when above two digits.
const bool textOverflow = number > 99;
if (textOverflow)
pixelSize *= 0.8;
QFont font = painter.font();
font.setPixelSize(pixelSize);
font.setWeight(isWindows11 ? QFont::Medium : QFont::DemiBold);
painter.setFont(font);
painter.setRenderHint(QPainter::TextAntialiasing, devicePixelRatio > 1);
painter.setPen(textColor);
auto text = textOverflow ? u"99+"_s : QString::number(number);
painter.translate(textOverflow ? 1 : 0, textOverflow ? 0 : -1);
painter.drawText(image.rect(), Qt::AlignCenter, text);
painter.end();
setApplicationBadge(image);
}
void QWindowsIntegration::setApplicationBadge(const QImage &image)
{
QComHelper comHelper;
using Microsoft::WRL::ComPtr;
ComPtr<ITaskbarList3> taskbarList;
CoCreateInstance(CLSID_TaskbarList, nullptr,
CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&taskbarList));
if (!taskbarList) {
// There may not be any windows with a task bar button yet,
// in which case we'll apply the badge once a window with
// a button has been created.
return;
}
const auto hIcon = image.toHICON();
// Apply the icon to all top level windows, since the badge is
// set on an application level. If one of the windows go away
// the other windows will take over in showing the badge.
const auto topLevelWindows = QGuiApplication::topLevelWindows();
for (auto *topLevelWindow : topLevelWindows) {
auto hwnd = reinterpret_cast<HWND>(topLevelWindow->winId());
taskbarList->SetOverlayIcon(hwnd, hIcon, L"");
}
DestroyIcon(hIcon);
// FIXME: Update icon when the application scale factor changes.
// Doing so in response to screen DPI changes is too soon, as the
// task bar is not yet ready for an updated icon, and will just
// result in a blurred icon even if our icon is high-DPI.
}
void QWindowsIntegration::updateApplicationBadge()
{
// The system color settings have changed, or we are reacting
// to a task bar button being created for the fist time or after
// Explorer had crashed and re-started. In any case, re-apply the
// badge so that everything is up to date.
setApplicationBadge(m_applicationBadgeNumber);
}
#if QT_CONFIG(vulkan)
QPlatformVulkanInstance *QWindowsIntegration::createPlatformVulkanInstance(QVulkanInstance *instance) const
{

View File

@ -91,6 +91,10 @@ public:
void beep() const override;
void setApplicationBadge(qint64 number) override;
void setApplicationBadge(const QImage &image);
void updateApplicationBadge();
#if QT_CONFIG(sessionmanager)
QPlatformSessionManager *createPlatformSessionManager(const QString &id, const QString &key) const override;
#endif
@ -106,6 +110,8 @@ private:
QScopedPointer<QWindowsIntegrationPrivate> d;
static QWindowsIntegration *m_instance;
qint64 m_applicationBadgeNumber = 0;
};
QT_END_NAMESPACE