mirror of
https://github.com/electron/electron.git
synced 2026-04-10 03:01:51 -04:00
feat: add menuItem.badge support on macOS
Adds a `badge` property to `MenuItem` on macOS, backed by AppKit's `NSMenuItemBadge` API (macOS 14+). The property accepts a `MenuItemBadge` object with a `type` (`alerts`, `updates`, `new-items`, or `none`) and an optional `count` or custom `content` string. The badge can be set at construction time or updated dynamically after the item has been added to a menu.
This commit is contained in:
@@ -63,10 +63,17 @@ See [`Menu`](menu.md) for examples.
|
||||
* `afterGroupContaining` string[] (optional) - Provides a means for a single context menu to declare
|
||||
the placement of their containing group after the containing group of the item
|
||||
with the specified id.
|
||||
* `badge` Object (optional) _macOS_
|
||||
* `type` string (optional) - Can be one of `alerts`, `updates`, `new-items` or `none`. Default is `none`.
|
||||
* `count` number (optional) - The number of items the badge displays. Cannot be used with `type: 'none'`.
|
||||
* `content` string (optional) - A custom string to display in the badge. Only usable with `type: 'none'`.
|
||||
|
||||
> [!NOTE]
|
||||
> `acceleratorWorksWhenHidden` is specified as being macOS-only because accelerators always work when items are hidden on Windows and Linux. The option is exposed to users to give them the option to turn it off, as this is possible in native macOS development.
|
||||
|
||||
> [!NOTE]
|
||||
> If you use one of the predefined badge types on macOS (not 'none'), the system localizes and pluralizes the string for you. If you create your own custom badge string, you need to localize and pluralize that string yourself.
|
||||
|
||||
### Instance Properties
|
||||
|
||||
The following properties are available on instances of `MenuItem`:
|
||||
@@ -181,3 +188,9 @@ A `number` indicating an item's sequential unique id.
|
||||
#### `menuItem.menu`
|
||||
|
||||
A [`Menu`](menu.md) that the item is a part of.
|
||||
|
||||
#### `menuItem.badge` _macOS_
|
||||
|
||||
An [`MenuItemBadge`](structures/menu-item-badge.md) indicating the badge for the menu item.
|
||||
|
||||
This property can be dynamically changed.
|
||||
|
||||
5
docs/api/structures/menu-item-badge.md
Normal file
5
docs/api/structures/menu-item-badge.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# MenuItemBadge Object
|
||||
|
||||
* `type` string (optional) - Can be one of `alerts`, `updates`, `new-items` or `none`. Default is `none`.
|
||||
* `count` number (optional) - The number of items the badge displays. Cannot be used with `type: 'none'`.
|
||||
* `content` string (optional) - A custom string to display in the badge. Only usable with `type: 'none'`.
|
||||
@@ -111,6 +111,7 @@ auto_filenames = {
|
||||
"docs/api/structures/media-access-permission-request.md",
|
||||
"docs/api/structures/memory-info.md",
|
||||
"docs/api/structures/memory-usage-details.md",
|
||||
"docs/api/structures/menu-item-badge.md",
|
||||
"docs/api/structures/mime-typed-buffer.md",
|
||||
"docs/api/structures/mouse-input-event.md",
|
||||
"docs/api/structures/mouse-wheel-input-event.md",
|
||||
|
||||
@@ -38,6 +38,24 @@ const MenuItem = function (this: any, options: any) {
|
||||
this.overrideProperty('acceleratorWorksWhenHidden', true);
|
||||
this.overrideProperty('registerAccelerator', roles.shouldRegisterAccelerator(this.role));
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
let badgeValue = options.badge;
|
||||
Object.defineProperty(this, 'badge', {
|
||||
get: () => badgeValue,
|
||||
set: (newValue) => {
|
||||
badgeValue = newValue;
|
||||
// Update native badge if this item is already in a menu
|
||||
if (this.menu) {
|
||||
const index = this.menu.getIndexOfCommandId(this.commandId);
|
||||
if (index !== -1 && badgeValue) {
|
||||
this.menu.setBadge(index, badgeValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
enumerable: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!MenuItem.types.includes(this.type)) {
|
||||
throw new Error(`Unknown menu item type: ${this.type}`);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,9 @@ Menu.prototype.insert = function (pos, item) {
|
||||
if (item.type === 'palette' || item.type === 'header') {
|
||||
this.setCustomType(pos, item.type);
|
||||
}
|
||||
if (process.platform === 'darwin' && item.badge) {
|
||||
this.setBadge(pos, item.badge);
|
||||
}
|
||||
|
||||
// Make menu accessible to items.
|
||||
item.overrideReadOnlyProperty('menu', this);
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
#include "shell/common/gin_converters/gurl_converter.h"
|
||||
#include "shell/common/gin_converters/image_converter.h"
|
||||
#include "shell/common/gin_converters/optional_converter.h"
|
||||
#include "shell/common/gin_helper/dictionary.h"
|
||||
#include "shell/common/gin_helper/object_template_builder.h"
|
||||
#include "shell/common/node_includes.h"
|
||||
#include "ui/base/models/image_model.h"
|
||||
@@ -28,6 +27,7 @@
|
||||
namespace gin {
|
||||
|
||||
using SharingItem = electron::ElectronMenuModel::SharingItem;
|
||||
using Badge = electron::ElectronMenuModel::Badge;
|
||||
|
||||
template <>
|
||||
struct Converter<SharingItem> {
|
||||
@@ -44,6 +44,28 @@ struct Converter<SharingItem> {
|
||||
}
|
||||
};
|
||||
|
||||
template <>
|
||||
struct Converter<Badge> {
|
||||
static bool FromV8(v8::Isolate* isolate,
|
||||
v8::Local<v8::Value> val,
|
||||
Badge* out) {
|
||||
gin_helper::Dictionary dict;
|
||||
if (!ConvertFromV8(isolate, val, &dict))
|
||||
return false;
|
||||
|
||||
std::string type_str;
|
||||
if (dict.Get("type", &type_str)) {
|
||||
out->type = base::UTF8ToUTF16(type_str);
|
||||
} else {
|
||||
out->type = u"none";
|
||||
}
|
||||
|
||||
dict.GetOptional("count", &(out->count));
|
||||
dict.GetOptional("content", &(out->content));
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace gin
|
||||
|
||||
#endif
|
||||
@@ -252,6 +274,21 @@ void Menu::SetCustomType(int index, const std::u16string& customType) {
|
||||
model_->SetCustomType(index, customType);
|
||||
}
|
||||
|
||||
#if BUILDFLAG(IS_MAC)
|
||||
void Menu::SetBadge(int index, const gin_helper::Dictionary& badge_dict) {
|
||||
ElectronMenuModel::Badge badge;
|
||||
std::string type_str;
|
||||
if (badge_dict.Get("type", &type_str)) {
|
||||
badge.type = base::UTF8ToUTF16(type_str);
|
||||
} else {
|
||||
badge.type = u"none";
|
||||
}
|
||||
badge_dict.GetOptional("count", &badge.count);
|
||||
badge_dict.GetOptional("content", &badge.content);
|
||||
model_->SetBadge(index, std::move(badge));
|
||||
}
|
||||
#endif
|
||||
|
||||
void Menu::Clear() {
|
||||
model_->Clear();
|
||||
}
|
||||
@@ -292,8 +329,12 @@ void Menu::FillObjectTemplate(v8::Isolate* isolate,
|
||||
.SetMethod("setToolTip", &Menu::SetToolTip)
|
||||
.SetMethod("setRole", &Menu::SetRole)
|
||||
.SetMethod("setCustomType", &Menu::SetCustomType)
|
||||
#if BUILDFLAG(IS_MAC)
|
||||
.SetMethod("setBadge", &Menu::SetBadge)
|
||||
#endif
|
||||
.SetMethod("clear", &Menu::Clear)
|
||||
.SetMethod("getItemCount", &Menu::GetItemCount)
|
||||
.SetMethod("getIndexOfCommandId", &Menu::GetIndexOfCommandId)
|
||||
.SetMethod("popupAt", &Menu::PopupAt)
|
||||
.SetMethod("closePopupAt", &Menu::ClosePopupAt)
|
||||
.SetMethod("_getAcceleratorTextAt", &Menu::GetAcceleratorTextAtForTesting)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "shell/browser/event_emitter_mixin.h"
|
||||
#include "shell/browser/ui/electron_menu_model.h"
|
||||
#include "shell/common/gin_helper/constructible.h"
|
||||
#include "shell/common/gin_helper/dictionary.h"
|
||||
#include "shell/common/gin_helper/self_keep_alive.h"
|
||||
#include "ui/base/mojom/menu_source_type.mojom-forward.h"
|
||||
|
||||
@@ -128,6 +129,9 @@ class Menu : public gin::Wrappable<Menu>,
|
||||
void SetToolTip(int index, const std::u16string& toolTip);
|
||||
void SetRole(int index, const std::u16string& role);
|
||||
void SetCustomType(int index, const std::u16string& customType);
|
||||
#if BUILDFLAG(IS_MAC)
|
||||
void SetBadge(int index, const gin_helper::Dictionary& badge);
|
||||
#endif
|
||||
void Clear();
|
||||
int GetIndexOfCommandId(int command_id) const;
|
||||
int GetItemCount() const;
|
||||
|
||||
@@ -117,6 +117,30 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert a Badge to an NSMenuItemBadge.
|
||||
NSMenuItemBadge* CreateBadge(const electron::ElectronMenuModel::Badge& badge)
|
||||
API_AVAILABLE(macos(14.0)) {
|
||||
NSString* badgeType = base::SysUTF16ToNSString(badge.type);
|
||||
|
||||
if ([badgeType isEqualToString:@"alerts"]) {
|
||||
if (badge.count.has_value())
|
||||
return [NSMenuItemBadge alertsWithCount:badge.count.value()];
|
||||
} else if ([badgeType isEqualToString:@"updates"]) {
|
||||
if (badge.count.has_value())
|
||||
return [NSMenuItemBadge updatesWithCount:badge.count.value()];
|
||||
} else if ([badgeType isEqualToString:@"new-items"]) {
|
||||
if (badge.count.has_value())
|
||||
return [NSMenuItemBadge newItemsWithCount:badge.count.value()];
|
||||
} else if ([badgeType isEqualToString:@"none"]) {
|
||||
if (badge.content.has_value()) {
|
||||
NSString* content = base::SysUTF8ToNSString(badge.content.value());
|
||||
return [[NSMenuItemBadge alloc] initWithString:content];
|
||||
}
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// This class stores a base::WeakPtr<electron::ElectronMenuModel> as an
|
||||
@@ -341,14 +365,12 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) {
|
||||
electron::ElectronMenuModel::ItemType type = model->GetTypeAt(index);
|
||||
std::u16string customType = model->GetCustomTypeAt(index);
|
||||
|
||||
// The sectionHeaderWithTitle menu item is only available in macOS 14.0+.
|
||||
if (@available(macOS 14, *)) {
|
||||
if (customType == u"header") {
|
||||
item = [NSMenuItem sectionHeaderWithTitle:label];
|
||||
}
|
||||
}
|
||||
|
||||
// If the menu item has an icon, set it.
|
||||
ui::ImageModel icon = model->GetIconAt(index);
|
||||
if (icon.IsImage())
|
||||
item.image = icon.GetImage().ToNSImage();
|
||||
@@ -356,6 +378,15 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) {
|
||||
std::u16string toolTip = model->GetToolTipAt(index);
|
||||
item.toolTip = base::SysUTF16ToNSString(toolTip);
|
||||
|
||||
if (@available(macOS 14, *)) {
|
||||
electron::ElectronMenuModel::Badge badge;
|
||||
if (model->GetBadgeAt(index, &badge)) {
|
||||
NSMenuItemBadge* nsBadge = CreateBadge(badge);
|
||||
if (nsBadge)
|
||||
item.badge = nsBadge;
|
||||
}
|
||||
}
|
||||
|
||||
if (role == u"services") {
|
||||
std::u16string title = u"Services";
|
||||
NSString* sub_label = l10n_util::FixUpWindowsStyleLabel(title);
|
||||
@@ -517,6 +548,15 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) {
|
||||
} else {
|
||||
item.image = nil;
|
||||
}
|
||||
|
||||
if (@available(macOS 14, *)) {
|
||||
electron::ElectronMenuModel::Badge badge;
|
||||
if (model->GetBadgeAt(index, &badge)) {
|
||||
item.badge = CreateBadge(badge);
|
||||
} else {
|
||||
item.badge = nil;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)refreshMenuTree:(NSMenu*)menu {
|
||||
|
||||
@@ -12,6 +12,15 @@ namespace electron {
|
||||
ElectronMenuModel::SharingItem::SharingItem() = default;
|
||||
ElectronMenuModel::SharingItem::SharingItem(SharingItem&&) = default;
|
||||
ElectronMenuModel::SharingItem::~SharingItem() = default;
|
||||
|
||||
ElectronMenuModel::Badge::Badge() = default;
|
||||
ElectronMenuModel::Badge::Badge(Badge&&) = default;
|
||||
ElectronMenuModel::Badge::Badge(const Badge&) = default;
|
||||
ElectronMenuModel::Badge& ElectronMenuModel::Badge::operator=(const Badge&) =
|
||||
default;
|
||||
ElectronMenuModel::Badge& ElectronMenuModel::Badge::operator=(Badge&&) =
|
||||
default;
|
||||
ElectronMenuModel::Badge::~Badge() = default;
|
||||
#endif
|
||||
|
||||
bool ElectronMenuModel::Delegate::GetAcceleratorForCommandId(
|
||||
@@ -115,6 +124,21 @@ bool ElectronMenuModel::GetSharingItemAt(size_t index,
|
||||
void ElectronMenuModel::SetSharingItem(SharingItem item) {
|
||||
sharing_item_.emplace(std::move(item));
|
||||
}
|
||||
|
||||
void ElectronMenuModel::SetBadge(size_t index, Badge badge) {
|
||||
int command_id = GetCommandIdAt(index);
|
||||
badges_[command_id] = std::move(badge);
|
||||
}
|
||||
|
||||
bool ElectronMenuModel::GetBadgeAt(size_t index, Badge* badge) const {
|
||||
int command_id = GetCommandIdAt(index);
|
||||
const auto iter = badges_.find(command_id);
|
||||
if (iter != badges_.end()) {
|
||||
*badge = iter->second;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
void ElectronMenuModel::MenuWillClose() {
|
||||
|
||||
@@ -33,6 +33,19 @@ class ElectronMenuModel : public ui::SimpleMenuModel {
|
||||
std::optional<std::vector<GURL>> urls;
|
||||
std::optional<std::vector<base::FilePath>> file_paths;
|
||||
};
|
||||
|
||||
struct Badge {
|
||||
Badge();
|
||||
Badge(Badge&&);
|
||||
Badge(const Badge&);
|
||||
Badge& operator=(const Badge&);
|
||||
Badge& operator=(Badge&&);
|
||||
~Badge();
|
||||
|
||||
std::u16string type; // "alerts", "updates", "new-items", or "none"
|
||||
std::optional<int> count;
|
||||
std::optional<std::string> content;
|
||||
};
|
||||
#endif
|
||||
|
||||
class Delegate : public ui::SimpleMenuModel::Delegate {
|
||||
@@ -105,6 +118,9 @@ class ElectronMenuModel : public ui::SimpleMenuModel {
|
||||
return sharing_item_;
|
||||
}
|
||||
|
||||
// Set/Get the Badge of a menu item.
|
||||
void SetBadge(size_t index, Badge badge);
|
||||
bool GetBadgeAt(size_t index, Badge* badge) const;
|
||||
#endif
|
||||
|
||||
// ui::SimpleMenuModel:
|
||||
@@ -123,6 +139,7 @@ class ElectronMenuModel : public ui::SimpleMenuModel {
|
||||
|
||||
#if BUILDFLAG(IS_MAC)
|
||||
std::optional<SharingItem> sharing_item_;
|
||||
base::flat_map<int, Badge> badges_; // command id -> badge
|
||||
#endif
|
||||
|
||||
base::flat_map<int, std::u16string> toolTips_; // command id -> tooltip
|
||||
|
||||
2
typings/internal-electron.d.ts
vendored
2
typings/internal-electron.d.ts
vendored
@@ -166,6 +166,7 @@ declare namespace Electron {
|
||||
commandsMap: Record<string, MenuItem>;
|
||||
groupsMap: Record<string, MenuItem[]>;
|
||||
getItemCount(): number;
|
||||
getIndexOfCommandId(commandId: number): number;
|
||||
popupAt(window: BaseWindow, frame: WebFrameMain | undefined, x: number, y: number, positioning: number, sourceType: Required<Electron.PopupOptions>['sourceType'], callback: () => void): void;
|
||||
closePopupAt(id: number): void;
|
||||
setSublabel(index: number, label: string): void;
|
||||
@@ -173,6 +174,7 @@ declare namespace Electron {
|
||||
setIcon(index: number, image: string | NativeImage): void;
|
||||
setRole(index: number, role: string): void;
|
||||
setCustomType(index: number, customType: string): void;
|
||||
setBadge(index: number, badge: MenuItemBadge): void;
|
||||
insertItem(index: number, commandId: number, label: string): void;
|
||||
insertCheckItem(index: number, commandId: number, label: string): void;
|
||||
insertRadioItem(index: number, commandId: number, label: string, groupId: number): void;
|
||||
|
||||
Reference in New Issue
Block a user