Skip to content

菜单与托盘 ​

菜单由 *mygo.MenuItem 模板构建,可用作菜单栏、上下文菜单、Dock 菜单和托盘图标的菜单。

模板 ​

go
menu := mygo.NewMenu([]*mygo.MenuItem{
	{Role: mygo.RoleAppMenu},
	{Label: "File", Submenu: []*mygo.MenuItem{
		{Label: "New Note", Accelerator: "CmdOrCtrl+N", Click: func(item *mygo.MenuItem, win *mygo.Window) {
			newNote()
		}},
		{Label: "Export…", Accelerator: "CmdOrCtrl+E", Click: exportNotes},
		mygo.Separator(),
		{Role: mygo.RoleClose},
	}},
	{Role: mygo.RoleEditMenu},
	{Label: "View", Submenu: []*mygo.MenuItem{
		{ID: "sidebar", Label: "Show Sidebar", Type: mygo.MenuItemCheckbox, Checked: true, Click: toggleSidebar},
		mygo.Separator(),
		{Label: "List", Type: mygo.MenuItemRadio, Checked: true},
		{Label: "Grid", Type: mygo.MenuItemRadio},
		mygo.Separator(),
		{Role: mygo.RoleToggleDevTools},
		{Role: mygo.RoleToggleFullScreen},
	}},
	{Role: mygo.RoleWindowMenu},
})

一个 MenuItem 包含:

  • Label,以及 Accelerator,即它的快捷键(见快捷键);
  • Click,在菜单项被选择时于主线程调用,参数为该菜单项,以及在 Linux 和 Windows 上被点击的菜单栏或上下文菜单所属的窗口。其他菜单使用获得焦点的窗口,或 nil;
  • Type:MenuItemNormal、MenuItemCheckbox、MenuItemRadio、MenuItemSeparator 或 MenuItemSubmenu;带有 Submenu 的项是子菜单,未特别设置时其他项为普通项。相邻的单选按钮项构成一组,组内勾选一项会取消勾选其他项。复选框和单选按钮项会在 Click 运行前切换 Checked;
  • Disabled、Hidden 和 ToolTip;
  • Icon,显示在标签旁的图像(见图标);
  • ID,用于通过 Menu.ItemByID 查找该项;
  • Role,内置行为,自带标签和快捷键。

角色 ​

角色提供用户对标准菜单项的期待行为,带有通常的标签和快捷键,可用 Label 和 Accelerator 覆盖:

  • 编辑:RoleUndo、RoleRedo、RoleCut、RoleCopy、RolePaste、RoleDelete、RoleSelectAll,macOS 上还有 RolePasteAndMatchStyle。它们的按键首先交给内容,就像浏览器中一样:在 Windows 的页面里,WebView2 用页面自身的事件和剪贴板的所有格式进行剪切、复制和粘贴,而从菜单中选择"粘贴"则会把剪贴板的文本以键入的方式输入,因为页面可能无法读取剪贴板(当某个编辑角色收到页面无用的按键时也是如此,例如 Ctrl+Shift+C 对应的 Copy);
  • 格式化:RoleBold、RoleItalic、RoleUnderline、RoleStrikethrough、RoleAlignLeft、RoleAlignCenter 和 RoleAlignRight,用于格式化原生 UI 的富文本编辑器或可编辑页面(document.execCommand)中的选区;Paste and Match Style 会把纯文本粘贴进富文本编辑器;
  • 页面:RoleReload、RoleForceReload、RoleToggleDevTools(在原生 UI 窗口中,打开其检查器)、RoleResetZoom、RoleZoomIn、RoleZoomOut;
  • 窗口:RoleToggleFullScreen、RoleMinimize、RoleZoom、RoleClose;
  • 文档:RoleNew、RoleOpen、RoleOpenRecent、RoleSave、RoleSaveAs、RoleDuplicate、RoleRename、RoleMoveTo、RoleRevert;
  • 应用:RoleQuit,macOS 上还有 RoleAbout、RoleHide、RoleHideOthers、RoleUnhide、RoleFront、RoleServices、RoleStartSpeaking、RoleStopSpeaking;
  • 整个菜单:RoleFileMenu、RoleEditMenu、RoleFormatMenu(Bold、Italic、Underline、Strikethrough 和各种对齐)、RoleViewMenu 和 RoleWindowMenu,macOS 上还有 RoleAppMenu(以应用命名的菜单)、RoleWindow(列出窗口的菜单)和 RoleHelp(带搜索框的菜单)。

带有 macOS 角色的项在其他平台上会被省略,因此一套模板适用于所有平台。

修改菜单项 ​

对正在使用的菜单中的项,请用它们的方法进行修改,以便原生菜单随之更新:SetLabel、SetEnabled、SetVisible、SetChecked、SetAccelerator 和 SetIcon:

go
item := menu.ItemByID("sidebar")
item.SetChecked(false)

Menu.Append 和 Menu.Insert 用于添加菜单项。

图标 ​

菜单项的 Icon 是显示在其标签旁的图像,例如 VPN 托盘菜单中每个服务器对应的国旗。NewMenuIcon 从一张 PNG 图像创建一个图标,保留其自身颜色;若数据不是 PNG 图像则返回错误;每个图标只需创建一次,然后交给所有需要显示它的菜单项:

go
//go:embed flags/jp.png
var japanPNG []byte

japan, err := mygo.NewMenuIcon(japanPNG, false)
if err != nil {
	log.Fatal(err)
}
servers := mygo.NewMenu([]*mygo.MenuItem{
	{ID: "tokyo", Label: "Tokyo", Type: mygo.MenuItemRadio, Checked: true, Icon: japan},
	{ID: "osaka", Label: "Osaka", Type: mygo.MenuItemRadio, Icon: japan},
	{ID: "direct", Label: "Direct", Type: mygo.MenuItemRadio},
})

图像会被放入 16×16 点的方框中,保持纵横比;请提供至少 32×32 像素,最好是 48×48 的图像,以便在高分辨率显示器以及 150% 和 200% 缩放下保持清晰。复选框或单选按钮项会同时显示其勾选标记。SetIcon 可在正在使用的菜单中替换某项的图标,传 nil 则移除它:

go
servers.ItemByID("tokyo").SetIcon(warning)
servers.ItemByID("osaka").SetIcon(nil)

NewMenuIcon 的第二个参数 template(模板图),会让黑色加透明的图像采用菜单文本的颜色,就像 macOS 为其模板图像着色一样:在深色菜单和高亮背景上变为白色。Linux 则按原样显示模板图标。

无论有多少项显示某个图像、它们的菜单打开或重建多少次,包括同一图像创建的多个图标,MyGo 对每个图像只解码和缩放一次;当应用和菜单都不再持有某个图标时,它就释放由此生成的内容;Windows 10 菜单的位图随其菜单一起释放。不创建图标的应用不会链接绘制图标的代码。

  • macOS 在所有菜单上,把图标(一个 NSImage)显示在勾选标记列之后,包括菜单栏自身的项,它们会在菜单栏中显示图标。
  • Windows 11 的菜单由 MyGo 绘制,会在勾选标记与标签之间给图标留出单独的一列,与系统菜单一致,并按菜单的显示缩放进行缩放。Windows 10 把它们绘制为菜单项的位图(32 位,含 alpha 通道),位于勾选标记列中:被勾选的项会以带勾选边框的图标取代勾选标记。菜单栏自身的项不显示图标。
  • Linux 在菜单栏的下级菜单和上下文菜单(GTK)的菜单项中显示图标;菜单栏自身的项不显示。托盘图标的菜单由面板绘制,面板通过 DBusMenu(icon-data)获取图标:GNOME Shell 的 AppIndicator 扩展和 KDE Plasma 会显示它们,其他面板可能不显示。

菜单栏 ​

go
mygo.App.SetMenu(menu)

在 macOS 上,菜单栏属于应用。在应用设置自己的菜单之前,会得到一个默认菜单栏,包含应用菜单、File、Edit、View 和 Window 菜单:请在你的菜单中保留 RoleAppMenu 和 RoleEditMenu,它们使 Cmd+Q 以及复制粘贴可用。

在 Linux 和 Windows 上,菜单栏属于窗口。App.SetMenu 把菜单给每一个没有自己菜单的窗口,Window.SetMenu 给某个窗口设置自己的菜单;窗口默认没有菜单,带有文档的应用除外,它们会得到 File 和 Edit 菜单。App.SetMenu(nil) 移除菜单。

在 Windows 上,菜单栏和菜单跟随浅色或深色外观,与标题栏一致(见深色模式)。在 Windows 11 上,它们看起来与系统自身的菜单一样,如同文件资源管理器的菜单:更宽松的项目、圆角高亮,以及颜色更淡的快捷键。

设置了 AutoHideMenuBar 的窗口会隐藏菜单栏,把空间留给页面。单独按 Alt 或 F10 会显示菜单栏并让键盘焦点进入其中(Windows),或打开它的第一个菜单(Linux);用户选择某项或离开后,菜单栏再次隐藏。其快捷键始终有效:

go
mygo.NewWindow(mygo.WindowOptions{URL: "/", AutoHideMenuBar: true})

Windows 上没有标题栏的窗口——无边框的或标题栏隐藏的——也没有空间放菜单栏:Alt 和 F10 会以从左上角弹出的方式打开菜单,位于页面自绘的标题栏下方。在 Linux 上,标题栏隐藏的窗口会像 AutoHideMenuBar 那样隐藏菜单栏,使窗口按钮保持在窗口顶部、页面的标题栏内;无边框窗口则在页面上方显示菜单栏。

全屏的窗口把整个屏幕留给页面:其菜单栏保持隐藏,直到退出全屏。Alt 和 F10 仍能打开它的菜单——在 Windows 上从左上角弹出,在 Linux 上则在菜单栏中打开,菜单打开期间菜单栏会显示。

上下文菜单 ​

Menu.Popup 在窗口内鼠标所在位置显示菜单,并在其关闭后返回;PopupAt 在页面的指定位置显示菜单。可以在绑定的方法中显示它,由页面在右键时调用:

go
// ShowNoteMenu 显示某条笔记的上下文菜单。
func (n *Notes) ShowNoteMenu(ctx context.Context, id int64) {
	menu := mygo.NewMenu([]*mygo.MenuItem{
		{Label: "Rename", Click: func(*mygo.MenuItem, *mygo.Window) { n.startRename(id) }},
		{Label: "Delete", Click: func(*mygo.MenuItem, *mygo.Window) { n.Delete(id) }},
	})
	menu.Popup(mygo.CallerWindow(ctx))
}
ts
note.addEventListener("contextmenu", (e) => {
  e.preventDefault();
  Notes.showNoteMenu(id);
});

页面若没有自己的处理函数,会显示 webview 的标准上下文菜单,包含针对文本的复制和粘贴,开发构建中还有"检查元素"。显示原生 UI的窗口通过 ContextMenu 为元素提供上下文菜单,其构建方式与其界面的其他部分相同。

webview 的菜单 ​

Page.OnContextMenu 会在该标准菜单即将打开时修改它,就像 Electron 的 context-menu 事件一样。事件会告知被右键点击的内容(LinkURL、LinkText、ImageURL、MediaURL、SelectionText、Editable、FrameURL),其 Menu 包含 webview 自身的菜单项。你可以隐藏、重命名、禁用或移除它们,并添加应用自己的菜单项,它们与其他菜单中的项工作方式相同:

go
page.OnContextMenu(func(e *mygo.ContextMenuEvent) {
	e.Menu.Remove(mygo.ContextMenuReload, mygo.ContextMenuInspect)
	if e.LinkURL != "" {
		e.Menu.Append(mygo.Separator(), &mygo.MenuItem{
			Label: "Open in Browser",
			Click: func(*mygo.MenuItem, *mygo.Window) { go mygo.Shell.OpenExternal(e.LinkURL) },
		})
	}
})

e.PreventDefault() 不显示任何菜单;把 e.Menu 设为应用自己的菜单,则改为显示那个菜单。webview 的菜单项无论被移到何处,仍执行 webview 原本的动作,除非应用为某项设置了 Click,此时改由它执行。若页面自己的 contextmenu 处理函数调用了 preventDefault,则不打开任何菜单,监听器也不会被调用。

webview 的菜单项在所有提供它们的平台上具有相同的 ID:

ID菜单项
ContextMenuBack、ContextMenuForward、ContextMenuReload、ContextMenuStop页面的导航
ContextMenuInspect"检查元素",在带 DevTools 的构建中
ContextMenuUndo、ContextMenuRedo、ContextMenuCut、ContextMenuCopy、ContextMenuPaste、ContextMenuPasteAndMatchStyle、ContextMenuDelete、ContextMenuSelectAll编辑
ContextMenuSpellingGuess针对拼写错误单词给出的每个建议词
ContextMenuOpenLink、ContextMenuOpenLinkInNewWindow、ContextMenuDownloadLink、ContextMenuCopyLink链接
ContextMenuOpenImageInNewWindow、ContextMenuDownloadImage、ContextMenuCopyImage、ContextMenuCopyImageURL图像
ContextMenuOpenMediaInNewWindow、ContextMenuDownloadMedia、ContextMenuCopyMediaURL、ContextMenuMediaControls、ContextMenuMediaLoop、ContextMenuMediaFullScreen、ContextMenuPictureInPicture视频与音频
ContextMenuOpenFrameInNewWindow、ContextMenuLookUp、ContextMenuSearchWeb、ContextMenuShare、ContextMenuEmoji、ContextMenuPrint其余项

其他菜单项以平台自身的名称作为 ID:macOS 上是 WebKit 的标识符(WKMenuItemIdentifierTranslate),Linux 上是标准动作的昵称(unicode),Windows 上是 WebView2 的名称(createQrCode)。分隔符以及平台没有命名的项没有 ID。

各平台的做法:

  • macOS 在 WebKit 菜单打开时原地修改它,因此"查找"、"翻译"、"服务"和拼写建议继续可用。WebKit 不提供被点击内容的信息:MyGo 的运行时从页面自身文档中的 contextmenu 事件上报这些信息,因此在 frame 中点击不会报告任何内容,FrameURL 保持为空。
  • Linux 原地修改 WebKitGTK 的菜单。WebKitGTK 会告知链接、图像、媒体以及元素是否可编辑,选区则由页面提供。应用的菜单项不显示快捷键或图标,单选按钮项显示为复选框,且只有 WebKitGTK 标准动作的项可以重命名。FrameURL 保持为空。
  • Windows 的一切信息都来自 WebView2(1.0.1185 及更新版本;旧版运行时从不调用监听器)。应用修改过的菜单由 MyGo 绘制,与其其他弹出菜单一样,其中 WebView2 的菜单项仍执行各自的动作;未做修改的菜单就是 WebView2 自身的。WebView2 不为拼写建议提供单词,所以 MyGo 绘制的菜单会省略它们;它也会省略 data: 图像的 URL,此时由页面的 contextmenu 事件提供。

应用修改过的菜单会自动去掉位于开头、结尾或紧接另一个之后的分隔符——因为移除两个分隔符之间的项后就会留下这种情形。

Dock 菜单 ​

在 macOS 上,App.Dock.SetMenu 会在右键点击 Dock 图标打开的菜单中,把菜单项添加在标准项之上。与 App.SetMenu 一样,它可以在 main 中、App.Run 之前调用:

go
mygo.App.Dock.SetMenu(mygo.NewMenu([]*mygo.MenuItem{
	{Label: "New Window", Click: func(*mygo.MenuItem, *mygo.Window) { openMainWindow() }},
}))

托盘图标 ​

托盘图标在 macOS 上位于菜单栏,在 Windows 和 Linux 上位于通知区域。请在应用就绪后创建它:

go
//go:embed resources/tray.png
var trayIcon []byte

mygo.App.WhenReady(func() {
	tray, err := mygo.NewTray(mygo.TrayOptions{
		Icon:           trayIcon,
		IconIsTemplate: true, // macOS 会为其着色以匹配菜单栏
		ToolTip:        "Notes",
		Menu: mygo.NewMenu([]*mygo.MenuItem{
			{Label: "Open Notes", Click: func(*mygo.MenuItem, *mygo.Window) { openMainWindow() }},
			mygo.Separator(),
			{Role: mygo.RoleQuit},
		}),
	})
	if err != nil {
		log.Println("tray:", err)
	}
	_ = tray
})

图标是 PNG 格式,最好是 16×16 点(高分辨率显示器用 32×32 的图像);模板图标——透明底黑色——可让 macOS 为浅色和深色菜单栏分别着色。带菜单的托盘在点击时显示菜单。没有菜单时,点击送达 OnClick,OnRightClick 收到右键点击,在 macOS 上还包括 Control 点击,因为 Control 点击在那里就是次级点击。用 SetIcon、SetMenu、SetToolTip 以及 macOS 上的 SetTitle(图标旁的文本)修改托盘;Destroy 将其移除。

托盘菜单的项可以显示图标,例如每个服务器旁的国旗,并像任何菜单的项一样变化:菜单下次打开时会显示它们的状态。

显示变化数字的标题,例如网速,应保持宽度不变,否则该项会时大时小,带动其图标和旁边的项移动。在 macOS 上,标题中的数字宽度都相同,因此把数值补齐到固定字符数即可保持宽度:用数字空格(figure space,U+2007)填充,它与数字等宽,而普通空格更窄。

go
// "  9.0 MB/s"、" 12.5 MB/s"、"128.0 MB/s":宽度都相同。
func speed(mbps float64) string {
	return strings.ReplaceAll(fmt.Sprintf("%5.1f", mbps), " ", "\u2007") + " MB/s"
}

tray.SetTitle(speed(rate))

只有数字是等宽的:字母保持各自的宽度,因此会切换单位的标题(9 KB/s,随后 1.2 MB/s)会随单位改变宽度。是否保持宽度稳定由应用决定,例如按上文那样始终以同一单位显示数值。

一个应用可以显示多个图标,例如菜单栏应用把每项测量值放在自己的条目中。在 macOS 上,新图标出现在已有图标的左侧,用户可以按住 ⌘ 拖动图标来移动它们:给每个图标一个在应用图标中唯一的 ID,那么每当它重新创建——下次启动时或 Destroy 之后——都会回到用户放置的位置。在 Windows 上,由用户选择在任务栏(而非溢出区)中显示哪些图标,带 ID 的图标会保持这一选择,无论应用以何种顺序创建图标。

模板图标会自动跟随菜单栏。彩色图标——例如按各自颜色绘制每项测量值的图标——需要针对它所显示的栏来绘制颜色:Tray.Dark 报告该栏是否为深色(macOS 菜单栏,受系统外观或其下方壁纸的色调影响,或 Windows 任务栏),OnAppearanceChange 在其变化时通知,以便重新设置图标:

go
draw := func() { tray.SetIcon(meterIcon(tray.Dark()), false) }
tray.OnAppearanceChange(draw)
draw()

Tray.PopUpMenu 以编程方式打开用 SetMenu 设置的菜单。在 macOS 上,AppKit 把它放在状态项下方,并在菜单打开期间高亮其按钮。若只想在右键时显示菜单,同时保留主点击可用,可以在 OnRightClick 中设置菜单,调用 PopUpMenu,待其返回后用 SetMenu(nil) 清除菜单。

托盘图标不会让应用保持运行:最后一个窗口关闭时应用就退出(退出)。若想把窗口"关闭到托盘",请改为隐藏它,这样窗口和应用都得以保留。退出流程(如托盘菜单中的 RoleQuit)在窗口关闭之前开始,因此 OnClose 能够区分它触发的关闭与用户触发的关闭:

go
var quitting bool // OnBeforeQuit 和 OnClose 在主线程运行
mygo.App.OnBeforeQuit(func(*mygo.QuitEvent) { quitting = true })

win.OnClose(func(e *mygo.CloseEvent) {
	if !quitting {
		e.PreventDefault()
		win.Hide()
	}
})
// 没有菜单时点击图标,或点击其菜单中的某一项,可让窗口重新显示。
tray.OnClick(win.Show)

在 Linux 上,托盘图标使用 AppIndicator,需要 libayatana-appindicator3:它们显示菜单;带 OnClick 监听器的图标会响应激活(需要包含 activate 信号的库版本;旧版本不会报告点击)。GNOME 的 AppIndicator 扩展在双击时发送该信号,并在双击时间间隔之后的单击时打开菜单。缺少该库时 NewTray 返回错误。

常驻菜单栏的应用在 macOS 上通常会去掉其 Dock 图标:

go
mygo.App.SetActivationPolicy(mygo.ActivationPolicyAccessory)

关于打开原生 UI 窗口的图标,参见菜单栏示例。它在 macOS 和 Windows 上不设置托盘菜单,用 OnClick 切换一个用 Tray.Bounds 定位的无边框窗口,并在 OnBlur 或 Escape 时将其隐藏。在 Linux 上,通过 AppIndicator 菜单项打开同一个窗口。

快捷键 ​

快捷键是由 + 连接的带修饰键的按键,例如 CmdOrCtrl+N 或 Alt+Shift+F4,不区分大小写。

修饰键:

修饰键按键
CmdOrCtrl(CommandOrControl)macOS 上为 Command,其他平台为 Control
Cmd(Command)、Super、Meta、WinmacOS 上为 Command,其他平台为 Windows 键或 Super 键
Ctrl(Control)Control
Alt(Option、AltGr)Alt,macOS 上为 Option
ShiftShift

按键:

  • 单个字符:字母、数字或 ,、/ 之类的符号(+ 用 Plus);
  • F1 到 F24;
  • Enter(Return)、Tab、Space、Backspace、Delete(Del)、Insert(Ins)、Escape(Esc);
  • Up、Down、Left、Right、Home、End、PageUp、PageDown;
  • Num0 到 Num9、NumDec、NumAdd、NumSub、NumMult、NumDiv(数字小键盘);
  • VolumeUp、VolumeDown、VolumeMute、MediaPlayPause、MediaNextTrack、MediaPreviousTrack、MediaStop、PrintScreen、CapsLock、NumLock、ScrollLock。

注册全局快捷键使用同样的语法。