-
Notifications
You must be signed in to change notification settings - Fork 16.6k
feat: introduce layered tray icons on macos #48738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: introduce layered tray icons on macos #48738
Conversation
Introduces an alternative way of setting tray icons on macos, where instead of providing one image (that has to me a template image or not), we can provide an array of layers, which will be composed over each other in order, where come can be template, some not, and the behavior when rendered by the NSStatusItem is preserved and the template parts still tint white/black depending on the effective appearance, but the colored parts stayed colored.
|
💖 Thanks for opening this pull request! 💖 Semantic PR titlesWe use semantic commit messages to streamline the release process. Before your pull request can be merged, you should update your pull request title to start with a semantic prefix. Examples of commit messages with semantic prefixes:
Commit signingThis repo enforces commit signatures for all incoming PRs. PR tipsThings that will help get your PR across the finish line:
We get a lot of pull requests on this repo, so please be patient and we will get back to you as soon as we can. |
|
hey @martin-podlubny - thanks for this! We'll do our best to get some eyes on it before too long :) |
docs/api/tray.md
Outdated
| * `image` ([NativeImage](native-image.md) | string | Object) | ||
| * `layers` ([NativeImage](native-image.md) | string)[] _macOS_ - An array of images to be composited as layers for the tray icon. Each image will be drawn on top of the previous one. This allows composing template images with non-template images (e.g., colored badges or indicators). Template images will automatically adapt to appear correctly regardless of the effective appearance where the tray icon is rendered, which can vary per screen. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: our bespoke Markdown-to-types parser doesn't get the syntax that you wrote (although it's human-readable).
https://github.com/electron/electron/actions/runs/18940338093/attempts/2#summary-54089737711
I think with the current API, [NativeImage](native-image.md) | [NativeImage](native-image.md)[] | string might work but it unfortunately doesn't handle the platform notice very well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I see. And also see I can run npm run create-typescript-definitions locally to see what comes out 🎉
Tbh, I'm not sure what's the best way to surface this functionality and, by extension, the generated types. I'd be happy to take an array of layers rather than an object with a layers property if that makes types easier. That being said, I gave it a shot, and didn't come up with a neat way to achieve that either 🤔
Expand to see various attempts at taking in layers as an array.
I tried massaging the markdown to get
setImage(image: NativeImage | string | (NativeImage | string)[]): void;but couldn't in a neat way. The naive md of
#### `tray.setImage(image)`
* `image` ([NativeImage](native-image.md) | string | ([NativeImage](native-image.md) | string)[] _macOS_)// the layers would be one NativeImage, or an array of strings.
setImage(image: (NativeImage) | (string) | ((NativeImage) | (string)[])): void;The above isn't great.. With enough parentheses I can get it to allow an array of layers:
#### `tray.setImage(image)`
* `image` ([NativeImage](native-image.md) | string | (([NativeImage](native-image.md) | string))[] _macOS_)setImage(image: (NativeImage) | (string) | (((NativeImage) | (string))[])): void;The extra parens make this harder to read, but could work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another way I could make this work was by introducing additional type along the lines of LayeredTrayImage. So far, this is my favorite way to surface this new feature. But again, there might be reasons to avoid doing this I'm not aware of.
#### `tray.setImage(image)`
* `image` ([NativeImage](native-image.md) | string | [LayeredTrayImage](structures/layered-tray-image.md) _macOS_)# LayeredTrayImage Object
* `layers` ([NativeImage](native-image.md) | string)[] - An array of images that will be composed into a single tray icon. Each image in the array will be drawn on top of the previous one, allowing you to compose template images with non-template images (e.g., colored badges or indicators). The array can contain a mix of `NativeImage` instances and string paths.// electron.d.ts
interface LayeredTrayImage {
// Docs: https://electronjs.org/docs/api/structures/layered-tray-image
/**
* An array of images that will be composed into a single tray icon. Each image in
* the array will be drawn on top of the previous one, allowing you to compose
* template images with non-template images (e.g., colored badges or indicators).
* The array can contain a mix of `NativeImage` instances and string paths.
*/
layers: Array<(NativeImage) | (string)>;
}
setImage(image: (NativeImage) | (string) | (LayeredTrayImage)): void;Have a look pls, let me know what you think 🙏
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👆 did that in 3b8649e
b8141f1 to
3fb7c8f
Compare
What?
This PR is an implementation of a macos-only feature I call "layered tray icons", where a tray icon can be composed of multiple images, (some template, some regular), and still behave sensibly across macos menubars of different colors.
The main idea is composing icons with mixed rendering modes:
So you can have an apple icon that changes with the menubar, but a red notification dot that stays red.
Result:
Why?
On macos, the menubar is colored (light or dark) depending on the wallpaper color (not the OS appearance theme). This is the "effective appearance".
At runtime, with multiple displays, each display can have a different wallpaper, so it's possible to have one
Trayinstance that gets rendered on both light and dark menubars simultaneously. The "solution" for this is to use template images, but they cannot have colors (only black+alpha).The moment you'd like an icon that adapts to different menubars and also has some colored indicator, I don't think there's a way to achieve that until now (see issue #25478).
How?
It's all based on the fact that
NSImagecan have a drawing handler, which provides access to the appearance context at draw time. In other words, when macos comes to draw our tray icon, the underlyingNSImageknows where it's being rendered and can adapt on the fly.High-level approach
Pre-render light and dark versions: For each layer in the input, we check if it's a template image. Template layers are manually tinted (black for light mode, white for dark mode), while non-template layers are used as-is. This gives us two complete composited images: one for light menubars, one for dark menubars.
Create an adaptive NSImage: We create a final
NSImagewith adrawingHandlerthat:NSAppearance.currentDrawingAppearanceto determine the effective appearance of the context where it's being drawn.Let macos do the rest: The beauty of this is that the drawing handler is invoked by macOS wherever the tray icon needs to be rendered. Since macos controls when and where the
NSImageis drawn, the handler automatically has access to the correct appearance context for each menubar.Why this works across multiple displays
When you have multiple displays with different wallpapers (and thus different menubar colors), macos will call our drawing handler separately for each menubar rendering. Each invocation provides the correct
NSAppearance.currentDrawingAppearancefor that specific menubar, so the sameNSImageinstance automatically renders the light version on light menubars and the dark version on dark menubars.The drawing handler is a property of the
NSImageobject itself, not tied to any particular display or menubar. This means:macos handles all of this for us by simply calling our handler with the right context whenever a repaint is needed.
Other considerations
NativeImageinterface remains untouched -- in fact, we leverage all the neat reading and dealing with various hires/lowres representations of images and whether the image is a template image or not.Trayinterface remains untouched -- only extended, meaning even if there were some problems with this approach, the consumers of the existing API should not be affected.@1x,@2x,@3xetc) they offer are composed by producing all unique representations that appear across layers, and for each choosing the most appropriate representations for each. In other words, if you're missing a@2xfor one of your layers, the@1xrep will be used for that layer in the final@2xcomposition. The intended use is for all the layers to come with the same set of representations.Previews
Basic usecase -- a template apple with a red indicator
Basic use case - a template icon with a colored indicator overlay.
Result:
The apple shape changes color based on the menubar theme, while the red dot keeps its color.
Complex usecase -- 5-Layer composition
Going complex with 5 layers - mix of template and color elements.
Result:
Note
Note, the red and blue indicators in the 5-layer example are deliberately overlapping with the apple and each other. It's to demonstrate that the layers can have alpha (50% alpha in this case) and render over each other just fine.
Video Demonstration:
preview-full-res-cycling-h265.mp4
The video shows the progressive layer composition, including:
Note on Different-Sized Layers: The API handles layers of different dimensions through defensive programming (automatically centering smaller layers on the larger canvas). This is not an intended feature to rely on, just a safety mechanism. The bellpeppers example (44x100px) demonstrates this defensive behavior when mixed with standard 44x44px layers.
Note
The above screenshots and the video were made with a minimal PoC application
using a local build of electron with the layered images feature. I put it in
its own repo at martin-podlubny/electron-poc-macos-layered-tray-icons.
Alternatives considered
coloredTemplateSingle image, marked with
coloredTemplate, where on the fly I'd split up the image into black-ish and colored parts, treating black as template and preserving colored.After playing with CoreImage, Metal, and various other ways to actually process pixels on the fly, I concluded it'd rather let the user pre-split the images for me. So I abandoned this idea.
explicit
darkandlightUser would provide dark and light variant of a tray icon, and we'd then still use the NSImage's drawingHandler to draw the correct one on the fly.
With the above, I had to get into the game of how to deal with template images being passed in for either the light or dark variant. And I thought it's a shame to no longer get that sweet benefit of template images, where you only produce one variant (the black one) and the framework+OS end up doing the right thing.
I then realized that what I'd really want as a consumer is the ability to define which parts of my tray icon are template, and which are the indicators (so colored). Hence the idea of arbitrary layers, where some can be tempalte images.
Extending tray vs. extending native image
At some point during prototyping this I had all this functionality of an image that is part-template and part-nontemplate as an extension to the
NativeImage. Where you could construct a native image out of layers and it would behave the same. But I realized that native images are used for a lot more than just tray icons, and I didn't want to deal with all the potential cases and places outside of a tray icon where a more complicated native image could cause problems.So I chose to limit this functionality to just the tray -- which is where I initially set out to solve the problem/need for colored indicators in tray icons on macos.
macos only vs. all platforms
While in theory layered images could be neat to have on Windows too, the whole premise of this feature in its current form revolves around how neat but limiting template images on macos are -- a template image is automagically colored based on the effective appearance of the menubar(s) it appears in, but cannot have colors.
With template images existing only on macos, for the I chose to only deal with macos. However, I suspect if this proves to be useful on macos, somebody might come along and open a PR for layered icons in Windows trays too!
tray.getEffectiveAppearance()andon('update-icon-for-effective-appearance')Issue #25478 actually discusses exposing the effective appearance (of a menubar? trayicon?) as a means of solving the same problem of wanting a tray icon that looks good both on light and dark menubars but comes with a colored indicator (red dot).
So I had a stab at that and did manage to expose the effective appearance of our tray menubar item. Under the hood, our
Traybecomes aNSStatusItem, which has abuttonprop, and that button seems to know it'seffectiveAppearance.So I exposed it via
tray.getEffectiveAppearance(), and any changes to it viaon('update-icon-for-effective-appearance'), but... It gets very complicated very quickly the moment we bring back the problem that:Meaning there is no single
tray.getEffectiveAppearance()that makes sense, because that oneTray->NSStatusItem->buttonsort of has multiple effective appearances.With
on('update-icon-for-effective-appearance')I was able to achieve the correct outcome (a black/white icon with a red dot indicator), but for janky reasons:The effective appearance of that menubar item would change whenever macos needed to re-draw the icon on multiple displays. The even firing for every "about to be drawn" menubar allowed me to swap in the correct icon, macos rendering and caching it, and then repeating it for my N screens. Thanks to this caching, the outcome was that all the menubars had the icon of a correct color, but only just because of timing, caching, and imho undocumented behavior of how NSStatusItem is drawn.
If anybody's interested, I got a branch on my fork called macos-tray-status-item-effective-appearance. But TL;DR based on my stab at this is -- it's messy because on JS side we have one
Tray, while on macos that ends up rendered on multiple menubars with potentially different effective appearances.Checklist
npm testpassesRelease Notes
Notes: Added layered tray icons feature on macos.