App icon badge (proposed)
The recommendation: a Toolkit duty
Do not make a day-part-badge crate. Add a defaulted Toolkit::set_badge duty plus a small
day::badge facade, the way set_appearance and set_app_menu already work.
There are three reasons, in order of weight.
A badge is app chrome, and app chrome is already a duty. set_app_menu, set_toolbar,
set_window_title, and set_appearance are all Toolkit duties today. A Dock badge sits in exactly
that category: it decorates the running application, it is per-toolkit, and it has no meaning
outside a running app. Parts are for headless OS services that work in a plain main with no Day
runtime (docs/clipboard.md is explicit about this); a badge has nothing to say in that context.
The handle a badge needs is the one the toolkit already holds. Windows attaches an overlay icon
to an HWND. macOS needs NSApplication’s dock tile. A part reaching those would need a day-core
edge for WindowHandle and still could not get at the HWND cleanly, which is a heavier dependency
than any existing part takes and buys nothing.
A defaulted duty costs nothing where the platform cannot do it. set_appearance is implemented
by 4 of 9 backends; the other five inherit fn set_appearance(&mut self, _dark: Option<bool>) {}
and answer Cap::Appearance = Unsupported. Badge support is at least as uneven, so the same shape
carries it without forcing nine implementations.
The counter-argument is that on iOS the badge is part of the notification system
(UNUserNotificationCenter.setBadgeCount, gated on the .badge authorization option), and on
Android it is only reachable through notifications. That is an argument for folding it into
day-part-local-notify. It loses because on macOS, Linux, and Windows the badge has no relationship
to notifications at all, and a desktop app wanting a Dock count should not compile alarm receivers
and boot re-arm to get one. The platforms that plumb it through notifications are an implementation
detail a duty can hide, which is what a duty is for.
What each platform will actually accept
This is the part that decides the API, because the payload differs more than the availability does.
| target | count | text | dot | native API |
|---|---|---|---|---|
| macos-appkit | ✓ | ✓ | ✓ | NSApp.dockTile.badgeLabel — an arbitrary String |
| ios-uikit | ✓ | – | – | UNUserNotificationCenter.setBadgeCount (iOS 16+); number only |
| linux-gtk / linux-qt | ✓ | – | ✓ | com.canonical.Unity.LauncherEntry D-Bus signal (count, count-visible) |
| web-dom | ✓ | – | ✓ | navigator.setAppBadge(n?) / clearAppBadge() |
| windows-xaml | ~ | – | ~ | ITaskbarList3::SetOverlayIcon — an image, not a number |
| android-mdc | – | – | ~ | none: the launcher derives a dot from posted notifications |
| harmony-arkui | ? | ? | ? | notificationManager.setBadgeNumber — likely ArkTS-only, needs investigation |
Four findings follow from that table.
macOS is the only platform that takes arbitrary text. badgeLabel is a String, so "beta" or
"99+" render literally. Everywhere else the payload is a number or nothing.
Android cannot set a badge at all. There is no AOSP API. The launcher dot is derived from active
notifications, and Notification.setNumber is honored only by some launchers. The OEM broadcast
workarounds (the ShortcutBadger approach) are per-vendor and break often. The answer is
Unsupported on every badge cap, with the docs pointing at day-part-local-notify’s
Notification::badge(n), which is the correct Android path and already ships.
Windows takes a picture, not a number. The unpackaged Win32 XAML host can only set an overlay
HICON, so a count means rendering digits into an icon at runtime. A packaged MSIX build could use
BadgeUpdateManager (1–99 plus a fixed glyph set) instead, but day pack also produces an
unpackaged NSIS installer, so the backend cannot assume it. It is marked ~ because the work is
real and deferred past v1.
Linux depends on the shell, not the toolkit. The Unity launcher protocol is a plain D-Bus signal
naming the app’s .desktop id, so GTK and Qt behave identically, but KDE Plasma, Dash-to-Dock, and
Docky honor it while stock GNOME Shell ignores it. Cap cannot see which shell is running, so this
reports Emulated: the call is made and may do nothing.
The API
The API is a Badge value, an imperative setter, and per-payload capabilities.
use day::badge::{self, Badge};
badge::set(Badge::Count(7)); // the portable case
badge::set(Badge::Text("99+")); // macOS Dock only — ignored elsewhere
badge::set(Badge::Dot); // "something is waiting", no number
badge::set(Badge::None); // clear it
pub enum Badge {
/// Clear the badge.
None,
/// A count. Zero clears, matching every platform's own convention.
Count(u32),
/// Short arbitrary text. Only macOS renders it; see `Cap::BadgeText` before using it.
Text(String),
/// An indicator with no value.
Dot,
}
There are three capabilities, following the split Cap::TextEditable / TextSelectable /
TextSpellCheck already uses, because one flag cannot express “counts yes, text no”:
Cap::BadgeCount // Badge::Count is honored
Cap::BadgeText // Badge::Text renders as written (macOS only)
Cap::BadgeDot // Badge::Dot is honored
// Cap::BadgeImage — reserved, see "deferred" below
Setting is fire-and-forget and never invents a fallback. set returns nothing; an unsupported
payload is ignored, and the app probes the cap first. This mirrors set_appearance exactly, whose
own doc says “probe before showing a theme picker — on Unsupported backends the call is ignored.”
The alternative, silently degrading Text("beta") to Count(1), would put a wrong number on a
user’s icon, which is worse than nothing. An app that wants a fallback writes it:
let b = if capability(Cap::BadgeText) == Support::Native {
Badge::Text(label)
} else {
Badge::Count(unread)
};
badge::set(b);
Persistence differs per platform
An iOS badge is a property of the installed app and survives termination: an app that exits
without clearing leaves an outdated number on the home screen, so a WillTerminate handler
(docs/lifecycle.md) is usually wanted. A macOS Dock badge dies with the process. The web badge
persists for the installed PWA. This is documented here because it is the one behavior that
differs and cannot be probed.
The iOS permission coupling
setBadgeCount needs the .badge authorization option, which is part of the notification grant. So
on iOS a badge is invisible until the user has allowed notifications, and the duty should declare
uses = ["notifications"] through the same permission machinery day-part-local-notify uses
(docs/permissions.md). Two subsystems declaring the same permission is fine; the app grants once.
A naming collision to resolve first
badge is already taken in the piece vocabulary: NavItem::badge is the count on a sidebar
row (crates/day-pieces/src/nav.rs), and Decorate::overlay_aligned’s docs describe corner
badges. Those are in-window annotations and have nothing to do with the app icon.
The recommendation is to name the new surface app_badge at every layer
(Toolkit::set_app_badge, Cap::AppBadgeCount, day::app_badge::set), so a reader grepping
badge can tell which one a call site means.
Deferred, with the reason
Custom graphics. macOS can host an arbitrary view on the Dock tile (NSDockTile.contentView)
and Windows overlay icons are images by nature, so a Badge::Image is real on two targets. It needs
a per-platform native image type and an encode path that Day does not have at this layer;
day-piece-remote-image decodes bytes into a widget, which is a different thing. Cap::BadgeImage
is reserved so the enum can grow without a breaking change.
Progress. The same Unity D-Bus protocol carries a progress double, macOS can draw a progress
bar on the Dock tile, and Windows has ITaskbarList3::SetProgressValue. That is a separate
feature and does not belong in Badge.
Phasing
Cap::AppBadge{Count,Text,Dot}+ the defaulted duty + theday::app_badgefacade + the AppKit, UIKit, and web-dom arms. Those three are small and cover the platforms with a real API:badgeLabel,setBadgeCount,setAppBadge. Android answersUnsupportedfrom the default and its doc points atNotification::badge.- Linux, over the Unity D-Bus signal, reusing the std-only D-Bus approach docs/notify.md
specifies for
org.freedesktop.Notificationsrather than adding a D-Bus crate. ReportsEmulated, because whether it shows depends on the shell. - HarmonyOS, once the ArkTS-versus-NDK question is settled, and Windows, which needs the render-digits-to-an-icon path or a packaged-only implementation.
Phase 1 stands on its own: it is three small arms, it needs no new crate, and one defaulted
method makes every other backend report Unsupported.
Verification note
A badge is drawn by the Dock, the launcher, or the home screen, outside the app’s own window.
snapshot_window cannot capture it and a dayscript cannot assert it, exactly like a notification
banner (docs/notify.md). Scripts can assert that set was called and what the caps report; that a
number actually appeared on the icon needs a person looking at a device, and the CI gallery should
not imply otherwise.