Windows tray menus (#2181)

* Add example

* Add windows systray

* Add gitkeep

* use windows.GUID
This commit is contained in:
Lea Anthony
2022-12-06 20:55:56 +11:00
committed by GitHub
parent 0581ad03b1
commit b84a2e5255
38 changed files with 5234 additions and 2 deletions
+147
View File
@@ -0,0 +1,147 @@
//go:build windows
package menu
import (
"github.com/wailsapp/wails/v2/pkg/menu"
)
// MenuManager manages the menus for the application
var MenuManager = NewManager()
type radioGroup []*menu.MenuItem
// Click updates the radio group state based on the item clicked
func (g *radioGroup) Click(item *menu.MenuItem) {
for _, radioGroupItem := range *g {
if radioGroupItem != item {
radioGroupItem.Checked = false
}
}
}
type processedMenu struct {
// the menu we processed
menu *menu.Menu
// updateMenuItemCallback is called when the menu item needs to be updated in the UI
updateMenuItemCallback func(*menu.MenuItem)
// items is a map of all menu items in this menu
items map[*menu.MenuItem]struct{}
// radioGroups tracks which radiogroup a menu item belongs to
radioGroups map[*menu.MenuItem][]*radioGroup
}
func newProcessedMenu(topLevelMenu *menu.Menu, updateMenuItemCallback func(*menu.MenuItem)) *processedMenu {
result := &processedMenu{
updateMenuItemCallback: updateMenuItemCallback,
menu: topLevelMenu,
items: make(map[*menu.MenuItem]struct{}),
radioGroups: make(map[*menu.MenuItem][]*radioGroup),
}
result.process(topLevelMenu.Items)
return result
}
func (p *processedMenu) process(items []*menu.MenuItem) {
var currentRadioGroup radioGroup
for index, item := range items {
// Save the reference to the top level menu for this item
p.items[item] = struct{}{}
// If this is a radio item, add it to the radio group
if item.Type == menu.RadioType {
currentRadioGroup = append(currentRadioGroup, item)
}
// If this is not a radio item, or we are processing the last item in the menu,
// then we need to add the current radio group to the map if it has items
if item.Type != menu.RadioType || index == len(items)-1 {
if len(currentRadioGroup) > 0 {
p.addRadioGroup(currentRadioGroup)
currentRadioGroup = nil
}
}
// Process the submenu
if item.SubMenu != nil {
p.process(item.SubMenu.Items)
}
}
}
func (p *processedMenu) processClick(item *menu.MenuItem) {
// If this item is not in our menu, then we can't process it
if _, ok := p.items[item]; !ok {
return
}
// If this is a radio item, then we need to update the radio group
if item.Type == menu.RadioType {
// Get the radio groups for this item
radioGroups := p.radioGroups[item]
// Iterate each radio group this item belongs to and set the checked state
// of all items apart from the one that was clicked to false
for _, thisRadioGroup := range radioGroups {
thisRadioGroup.Click(item)
for _, thisRadioGroupItem := range *thisRadioGroup {
p.updateMenuItemCallback(thisRadioGroupItem)
}
}
}
if item.Type == menu.CheckboxType {
p.updateMenuItemCallback(item)
}
}
func (p *processedMenu) addRadioGroup(r radioGroup) {
for _, item := range r {
p.radioGroups[item] = append(p.radioGroups[item], &r)
}
}
type Manager struct {
menus map[*menu.Menu]*processedMenu
}
func NewManager() *Manager {
return &Manager{
menus: make(map[*menu.Menu]*processedMenu),
}
}
func (m *Manager) AddMenu(menu *menu.Menu, updateMenuItemCallback func(*menu.MenuItem)) {
m.menus[menu] = newProcessedMenu(menu, updateMenuItemCallback)
}
func (m *Manager) ProcessClick(item *menu.MenuItem) {
// if menuitem is a checkbox, then we need to toggle the state
if item.Type == menu.CheckboxType {
item.Checked = !item.Checked
}
// Set the radio item to checked
if item.Type == menu.RadioType {
item.Checked = true
}
for _, thisMenu := range m.menus {
thisMenu.processClick(item)
}
if item.Click != nil {
item.Click(&menu.CallbackData{
MenuItem: item,
})
}
}
func (m *Manager) RemoveMenu(data *menu.Menu) {
delete(m.menus, data)
}
+297
View File
@@ -0,0 +1,297 @@
//go:build windows
package menu_test
import (
"github.com/stretchr/testify/require"
platformMenu "github.com/wailsapp/wails/v2/internal/platform/menu"
"github.com/wailsapp/wails/v2/pkg/menu"
"testing"
)
func TestManager_ProcessClick_Checkbox(t *testing.T) {
checkbox := menu.Label("Checkbox").SetChecked(false)
menu1 := &menu.Menu{
Items: []*menu.MenuItem{
checkbox,
},
}
menu2 := &menu.Menu{
Items: []*menu.MenuItem{
checkbox,
},
}
menuWithNoCheckbox := &menu.Menu{
Items: []*menu.MenuItem{
menu.Label("No Checkbox"),
},
}
clicked := false
tests := []struct {
name string
inputs []*menu.Menu
startState bool
expectedState bool
expectedMenuUpdates map[*menu.Menu][]*menu.MenuItem
click func(*menu.CallbackData)
}{
{
name: "should callback menu checkbox state when clicked (false -> true)",
inputs: []*menu.Menu{menu1},
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
menu1: {checkbox},
},
startState: false,
expectedState: true,
},
{
name: "should callback multiple menus when checkbox state when clicked (false -> true)",
inputs: []*menu.Menu{menu1, menu2},
startState: false,
expectedState: true,
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
menu1: {checkbox},
menu2: {checkbox},
},
},
{
name: "should callback only for the menus that the checkbox is in (false -> true)",
inputs: []*menu.Menu{menu1, menuWithNoCheckbox},
startState: false,
expectedState: true,
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
menu1: {checkbox},
},
},
{
name: "should callback menu checkbox state when clicked (true->false)",
inputs: []*menu.Menu{menu1},
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
menu1: {checkbox},
},
startState: true,
expectedState: false,
},
{
name: "should callback multiple menus when checkbox state when clicked (true->false)",
inputs: []*menu.Menu{menu1, menu2},
startState: true,
expectedState: false,
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
menu1: {checkbox},
menu2: {checkbox},
},
},
{
name: "should callback only for the menus that the checkbox is in (true->false)",
inputs: []*menu.Menu{menu1, menuWithNoCheckbox},
startState: true,
expectedState: false,
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
menu1: {checkbox},
},
},
{
name: "should callback no menus if checkbox not in them",
inputs: []*menu.Menu{menuWithNoCheckbox},
startState: false,
expectedState: false,
expectedMenuUpdates: nil,
},
{
name: "should call Click on the checkbox",
inputs: []*menu.Menu{menu1, menu2},
startState: false,
expectedState: true,
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
menu1: {checkbox},
menu2: {checkbox},
},
click: func(data *menu.CallbackData) {
clicked = true
},
},
}
for _, tt := range tests {
menusUpdated := map[*menu.Menu][]*menu.MenuItem{}
clicked = false
var checkMenuItemStateInMenu func(menu *menu.Menu)
checkMenuItemStateInMenu = func(menu *menu.Menu) {
for _, item := range menusUpdated[menu] {
if item == checkbox {
require.Equal(t, tt.expectedState, item.Checked)
}
if item.SubMenu != nil {
checkMenuItemStateInMenu(item.SubMenu)
}
}
}
t.Run(tt.name, func(t *testing.T) {
m := platformMenu.NewManager()
checkbox.SetChecked(tt.startState)
checkbox.Click = tt.click
for _, thisMenu := range tt.inputs {
thisMenu := thisMenu
m.AddMenu(thisMenu, func(menuItem *menu.MenuItem) {
menusUpdated[thisMenu] = append(menusUpdated[thisMenu], menuItem)
})
}
m.ProcessClick(checkbox)
// Check the item has the correct state in all the menus
for thisMenu := range menusUpdated {
require.EqualValues(t, tt.expectedMenuUpdates[thisMenu], menusUpdated[thisMenu])
}
if tt.click != nil {
require.Equal(t, true, clicked)
}
})
}
}
func TestManager_ProcessClick_RadioGroups(t *testing.T) {
radio1 := menu.Radio("Radio1", false, nil, nil)
radio2 := menu.Radio("Radio2", false, nil, nil)
radio3 := menu.Radio("Radio3", false, nil, nil)
radio4 := menu.Radio("Radio4", false, nil, nil)
radio5 := menu.Radio("Radio5", false, nil, nil)
radio6 := menu.Radio("Radio6", false, nil, nil)
radioGroupOne := &menu.Menu{
Items: []*menu.MenuItem{
radio1,
radio2,
radio3,
},
}
radioGroupTwo := &menu.Menu{
Items: []*menu.MenuItem{
radio4,
radio5,
radio6,
},
}
radioGroupThree := &menu.Menu{
Items: []*menu.MenuItem{
radio1,
radio2,
radio3,
},
}
clicked := false
tests := []struct {
name string
inputs []*menu.Menu
startState map[*menu.MenuItem]bool
selected *menu.MenuItem
expectedMenuUpdates map[*menu.Menu][]*menu.MenuItem
click func(*menu.CallbackData)
expectedState map[*menu.MenuItem]bool
}{
{
name: "should only set the clicked radio item",
inputs: []*menu.Menu{radioGroupOne},
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
radioGroupOne: {radio1, radio2, radio3},
},
startState: map[*menu.MenuItem]bool{
radio1: true,
radio2: false,
radio3: false,
},
selected: radio2,
expectedState: map[*menu.MenuItem]bool{
radio1: false,
radio2: true,
radio3: false,
},
},
{
name: "should not affect other radio groups or menus",
inputs: []*menu.Menu{radioGroupOne, radioGroupTwo},
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
radioGroupOne: {radio1, radio2, radio3},
},
startState: map[*menu.MenuItem]bool{
radio1: true,
radio2: false,
radio3: false,
radio4: true,
radio5: false,
radio6: false,
},
selected: radio2,
expectedState: map[*menu.MenuItem]bool{
radio1: false,
radio2: true,
radio3: false,
radio4: true,
radio5: false,
radio6: false,
},
},
{
name: "menus with the same radio group should be updated",
inputs: []*menu.Menu{radioGroupOne, radioGroupThree},
expectedMenuUpdates: map[*menu.Menu][]*menu.MenuItem{
radioGroupOne: {radio1, radio2, radio3},
radioGroupThree: {radio1, radio2, radio3},
},
startState: map[*menu.MenuItem]bool{
radio1: true,
radio2: false,
radio3: false,
},
selected: radio2,
expectedState: map[*menu.MenuItem]bool{
radio1: false,
radio2: true,
radio3: false,
},
},
}
for _, tt := range tests {
menusUpdated := map[*menu.Menu][]*menu.MenuItem{}
clicked = false
t.Run(tt.name, func(t *testing.T) {
m := platformMenu.NewManager()
for item, value := range tt.startState {
item.SetChecked(value)
}
tt.selected.Click = tt.click
for _, thisMenu := range tt.inputs {
thisMenu := thisMenu
m.AddMenu(thisMenu, func(menuItem *menu.MenuItem) {
menusUpdated[thisMenu] = append(menusUpdated[thisMenu], menuItem)
})
}
m.ProcessClick(tt.selected)
require.Equal(t, tt.expectedMenuUpdates, menusUpdated)
// Check the items have the correct state in all the menus
for item, expectedValue := range tt.expectedState {
require.Equal(t, expectedValue, item.Checked)
}
if tt.click != nil {
require.Equal(t, true, clicked)
}
})
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build windows
package menu
import "github.com/wailsapp/wails/v2/internal/platform/win32"
type Menu struct {
menu win32.HMENU
}
+33
View File
@@ -0,0 +1,33 @@
//go:build windows
package platform
import (
"github.com/wailsapp/wails/v2/internal/platform/systray"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/options"
)
import "github.com/samber/lo"
type SysTray interface {
// SetTitle sets the title of the tray menu
SetTitle(title string)
SetTooltip(tooltip string) error
Show() error
Hide() error
Run() error
Close()
SetMenu(menu *menu.Menu) error
SetIcons(lightModeIcon, darkModeIcon *options.SystemTrayIcon) error
Update() error
OnLeftClick(func())
OnRightClick(func())
OnLeftDoubleClick(func())
OnRightDoubleClick(func())
OnMenuClose(func())
OnMenuOpen(func())
}
func NewSysTray() SysTray {
return lo.Must(systray.New())
}
@@ -0,0 +1,222 @@
//go:build windows
package systray
import (
"errors"
"fmt"
platformMenu "github.com/wailsapp/wails/v2/internal/platform/menu"
"github.com/wailsapp/wails/v2/internal/platform/win32"
"github.com/wailsapp/wails/v2/pkg/menu"
)
type RadioGroupMember struct {
ID int
MenuItem *menu.MenuItem
}
type RadioGroup []*RadioGroupMember
func (r *RadioGroup) Add(id int, item *menu.MenuItem) {
*r = append(*r, &RadioGroupMember{
ID: id,
MenuItem: item,
})
}
func (r *RadioGroup) Bounds() (int, int) {
p := *r
return p[0].ID, p[len(p)-1].ID
}
func (r *RadioGroup) MenuID(item *menu.MenuItem) int {
for _, member := range *r {
if member.MenuItem == item {
return member.ID
}
}
panic("RadioGroup.MenuID: item not found:")
}
type PopupMenu struct {
menu win32.PopupMenu
parent win32.HWND
menuMapping map[int]*menu.MenuItem
checkboxItems map[*menu.MenuItem][]int
radioGroups map[*menu.MenuItem][]*RadioGroup
menuData *menu.Menu
currentMenuID int
onMenuClose func()
onMenuOpen func()
}
func (p *PopupMenu) buildMenu(parentMenu win32.PopupMenu, inputMenu *menu.Menu) error {
var currentRadioGroup RadioGroup
for _, item := range inputMenu.Items {
if item.Hidden {
continue
}
var ret bool
p.currentMenuID++
itemID := p.currentMenuID
p.menuMapping[itemID] = item
flags := win32.MF_STRING
if item.Disabled {
flags = flags | win32.MF_GRAYED
}
if item.Checked {
flags = flags | win32.MF_CHECKED
}
//if item.BarBreak {
// flags = flags | win32.MF_MENUBARBREAK
//}
if item.IsSeparator() {
flags = flags | win32.MF_SEPARATOR
}
if item.IsCheckbox() {
p.checkboxItems[item] = append(p.checkboxItems[item], itemID)
}
if item.IsRadio() {
currentRadioGroup.Add(itemID, item)
} else {
if len(currentRadioGroup) > 0 {
for _, radioMember := range currentRadioGroup {
currentRadioGroup := currentRadioGroup
p.radioGroups[radioMember.MenuItem] = append(p.radioGroups[radioMember.MenuItem], &currentRadioGroup)
}
currentRadioGroup = RadioGroup{}
}
}
if item.SubMenu != nil {
flags = flags | win32.MF_POPUP
submenu := win32.CreatePopupMenu()
err := p.buildMenu(submenu, item.SubMenu)
if err != nil {
return err
}
itemID = int(submenu)
}
var menuText = item.Label
if item.Accelerator != nil {
shortcut := win32.AcceleratorToShortcut(item.Accelerator)
menuText = fmt.Sprintf("%s\t%s", menuText, shortcut)
// Popup Menus don't appear to support accelerators and I'm not
// sure they make sense either
}
ret = parentMenu.Append(uintptr(flags), uintptr(itemID), menuText)
if ret == false {
return errors.New("AppendMenu failed")
}
}
if len(currentRadioGroup) > 0 {
for _, radioMember := range currentRadioGroup {
currentRadioGroup := currentRadioGroup
p.radioGroups[radioMember.MenuItem] = append(p.radioGroups[radioMember.MenuItem], &currentRadioGroup)
}
currentRadioGroup = RadioGroup{}
}
return nil
}
func (p *PopupMenu) Update() error {
p.menu = win32.CreatePopupMenu()
p.menuMapping = make(map[int]*menu.MenuItem)
p.currentMenuID = win32.MenuItemMsgID
err := p.buildMenu(p.menu, p.menuData)
if err != nil {
return err
}
p.updateRadioGroups()
return nil
}
func NewPopupMenu(parent win32.HWND, inputMenu *menu.Menu) (*PopupMenu, error) {
result := &PopupMenu{
parent: parent,
menuData: inputMenu,
checkboxItems: make(map[*menu.MenuItem][]int),
radioGroups: make(map[*menu.MenuItem][]*RadioGroup),
}
err := result.Update()
platformMenu.MenuManager.AddMenu(inputMenu, result.UpdateMenuItem)
return result, err
}
func (p *PopupMenu) ShowAtCursor() error {
x, y, ok := win32.GetCursorPos()
if ok == false {
return errors.New("GetCursorPos failed")
}
if win32.SetForegroundWindow(p.parent) == false {
return errors.New("SetForegroundWindow failed")
}
if p.onMenuOpen != nil {
p.onMenuOpen()
}
if p.menu.Track(win32.TPM_LEFTALIGN, x, y-5, p.parent) == false {
return errors.New("TrackPopupMenu failed")
}
if p.onMenuClose != nil {
p.onMenuClose()
}
if win32.PostMessage(p.parent, win32.WM_NULL, 0, 0) == 0 {
return errors.New("PostMessage failed")
}
return nil
}
func (p *PopupMenu) ProcessCommand(cmdMsgID int) {
item := p.menuMapping[cmdMsgID]
platformMenu.MenuManager.ProcessClick(item)
}
func (p *PopupMenu) Destroy() {
p.menu.Destroy()
}
func (p *PopupMenu) UpdateMenuItem(item *menu.MenuItem) {
if item.IsCheckbox() {
for _, itemID := range p.checkboxItems[item] {
p.menu.Check(uintptr(itemID), item.Checked)
}
return
}
if item.IsRadio() && item.Checked == true {
p.updateRadioGroup(item)
}
}
func (p *PopupMenu) updateRadioGroups() {
for menuItem := range p.radioGroups {
if menuItem.Checked {
p.updateRadioGroup(menuItem)
}
}
}
func (p *PopupMenu) updateRadioGroup(item *menu.MenuItem) {
for _, radioGroup := range p.radioGroups[item] {
thisMenuID := radioGroup.MenuID(item)
startID, endID := radioGroup.Bounds()
p.menu.CheckRadio(startID, endID, thisMenuID)
}
}
func (p *PopupMenu) OnMenuOpen(fn func()) {
p.onMenuOpen = fn
}
func (p *PopupMenu) OnMenuClose(fn func()) {
p.onMenuClose = fn
}
@@ -0,0 +1,432 @@
//go:build windows
/*
* Based on code originally from https://github.com/tadvi/systray. Copyright (C) 2019 The Systray Authors. All Rights Reserved.
*/
package systray
import (
"errors"
"github.com/samber/lo"
"github.com/wailsapp/wails/v2/internal/platform/win32"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/options"
"syscall"
"unsafe"
)
var (
user32 = syscall.MustLoadDLL("user32.dll")
DefWindowProc = user32.MustFindProc("DefWindowProcW")
RegisterClassEx = user32.MustFindProc("RegisterClassExW")
CreateWindowEx = user32.MustFindProc("CreateWindowExW")
windowClasses = map[string]win32.HINSTANCE{}
)
type Systray struct {
id uint32
mhwnd win32.HWND // main window handle
hwnd win32.HWND
hinst win32.HINSTANCE
lclick func()
rclick func()
ldblclick func()
rdblclick func()
onMenuClose func()
onMenuOpen func()
appIcon win32.HICON
lightModeIcon win32.HICON
darkModeIcon win32.HICON
currentIcon win32.HICON
menu *PopupMenu
quit chan struct{}
icon *options.SystemTrayIcon
}
func (p *Systray) Close() {
err := p.Stop()
if err != nil {
println(err.Error())
}
}
func (p *Systray) Update() error {
// Delete old menu
if p.menu != nil {
p.menu.Destroy()
}
return p.menu.Update()
}
// SetTitle is unused on Windows
func (p *Systray) SetTitle(_ string) {}
func New() (*Systray, error) {
ni := &Systray{}
ni.lclick = func() {
if ni.menu != nil {
_ = ni.menu.ShowAtCursor()
}
}
ni.rclick = func() {
if ni.menu != nil {
_ = ni.menu.ShowAtCursor()
}
}
MainClassName := "WailsSystray"
ni.hinst, _ = RegisterWindow(MainClassName, ni.WinProc)
ni.mhwnd = win32.CreateWindowEx(
win32.WS_EX_CONTROLPARENT,
win32.MustStringToUTF16Ptr(MainClassName),
win32.MustStringToUTF16Ptr(""),
win32.WS_OVERLAPPEDWINDOW|win32.WS_CLIPSIBLINGS,
win32.CW_USEDEFAULT,
win32.CW_USEDEFAULT,
win32.CW_USEDEFAULT,
win32.CW_USEDEFAULT,
0,
0,
0,
unsafe.Pointer(nil))
if ni.mhwnd == 0 {
return nil, errors.New("create main win failed")
}
NotifyIconClassName := "NotifyIconForm"
_, err := RegisterWindow(NotifyIconClassName, ni.WinProc)
if err != nil {
return nil, err
}
hwnd, _, _ := CreateWindowEx.Call(
0,
uintptr(unsafe.Pointer(win32.MustStringToUTF16Ptr(NotifyIconClassName))),
0,
0,
0,
0,
0,
0,
uintptr(win32.HWND_MESSAGE),
0,
0,
0)
if hwnd == 0 {
return nil, errors.New("create notify win failed")
}
ni.hwnd = win32.HWND(hwnd) // Important to keep this inside struct.
nid := win32.NOTIFYICONDATA{
HWnd: win32.HWND(hwnd),
UFlags: win32.NIF_MESSAGE | win32.NIF_STATE,
DwState: win32.NIS_HIDDEN,
DwStateMask: win32.NIS_HIDDEN,
UCallbackMessage: win32.NotifyIconMessageId,
}
nid.CbSize = uint32(unsafe.Sizeof(nid))
if !win32.ShellNotifyIcon(win32.NIM_ADD, &nid) {
return nil, errors.New("shell notify create failed")
}
nid.UVersion = win32.NOTIFYICON_VERSION
if !win32.ShellNotifyIcon(win32.NIM_SETVERSION, &nid) {
return nil, errors.New("shell notify version failed")
}
ni.appIcon = win32.LoadIconWithResourceID(0, uintptr(win32.IDI_APPLICATION))
ni.lightModeIcon = ni.appIcon
ni.darkModeIcon = ni.appIcon
ni.id = nid.UID
return ni, nil
}
func (p *Systray) HWND() win32.HWND {
return p.hwnd
}
func (p *Systray) SetMenu(popupMenu *menu.Menu) (err error) {
p.menu, err = NewPopupMenu(p.hwnd, popupMenu)
p.menu.OnMenuClose(p.onMenuClose)
p.menu.OnMenuOpen(p.onMenuOpen)
return
}
func (p *Systray) Stop() error {
nid := p.newNotifyIconData()
win32.PostQuitMessage(0)
if !win32.ShellNotifyIcon(win32.NIM_DELETE, &nid) {
return errors.New("shell notify delete failed")
}
return nil
}
func (p *Systray) OnLeftClick(fn func()) {
if fn != nil {
p.lclick = fn
}
}
func (p *Systray) OnRightClick(fn func()) {
if fn != nil {
p.rclick = fn
}
}
func (p *Systray) OnLeftDoubleClick(fn func()) {
if fn != nil {
p.ldblclick = fn
}
}
func (p *Systray) OnRightDoubleClick(fn func()) {
if fn != nil {
p.rdblclick = fn
}
}
func (p *Systray) OnMenuClose(fn func()) {
if fn != nil {
p.onMenuClose = fn
}
}
func (p *Systray) OnMenuOpen(fn func()) {
if fn != nil {
p.onMenuOpen = fn
}
}
func (p *Systray) SetTooltip(tooltip string) error {
nid := p.newNotifyIconData()
nid.UFlags = win32.NIF_TIP
copy(nid.SzTip[:], win32.MustUTF16FromString(tooltip))
if !win32.ShellNotifyIcon(win32.NIM_MODIFY, &nid) {
return errors.New("shell notify tooltip failed")
}
return nil
}
func (p *Systray) ShowMessage(title, msg string, bigIcon bool) error {
nid := p.newNotifyIconData()
if bigIcon == true {
nid.DwInfoFlags = win32.NIIF_USER
}
nid.CbSize = uint32(unsafe.Sizeof(nid))
nid.UFlags = win32.NIF_INFO
copy(nid.SzInfoTitle[:], win32.MustUTF16FromString(title))
copy(nid.SzInfo[:], win32.MustUTF16FromString(msg))
if !win32.ShellNotifyIcon(win32.NIM_MODIFY, &nid) {
return errors.New("shell notify tooltip failed")
}
return nil
}
func (p *Systray) newNotifyIconData() win32.NOTIFYICONDATA {
nid := win32.NOTIFYICONDATA{
UID: p.id,
HWnd: p.hwnd,
}
nid.CbSize = uint32(unsafe.Sizeof(nid))
return nid
}
func (p *Systray) Show() error {
return p.setVisible(true)
}
func (p *Systray) Hide() error {
return p.setVisible(false)
}
func (p *Systray) setVisible(visible bool) error {
nid := p.newNotifyIconData()
nid.UFlags = win32.NIF_STATE
nid.DwStateMask = win32.NIS_HIDDEN
if !visible {
nid.DwState = win32.NIS_HIDDEN
}
if !win32.ShellNotifyIcon(win32.NIM_MODIFY, &nid) {
return errors.New("shell notify tooltip failed")
}
return nil
}
func (p *Systray) SetIcons(lightModeIcon, darkModeIcon *options.SystemTrayIcon) error {
var newLightModeIcon, newDarkModeIcon win32.HICON
if lightModeIcon != nil && lightModeIcon.Data != nil {
newLightModeIcon = p.getIcon(lightModeIcon.Data)
}
if darkModeIcon != nil && darkModeIcon.Data != nil {
newDarkModeIcon = p.getIcon(darkModeIcon.Data)
}
p.lightModeIcon, _ = lo.Coalesce(newLightModeIcon, newDarkModeIcon, p.appIcon)
p.darkModeIcon, _ = lo.Coalesce(newDarkModeIcon, newLightModeIcon, p.appIcon)
return p.updateIcon()
}
func (p *Systray) getIcon(icon []byte) win32.HICON {
result, err := win32.CreateHIconFromPNG(icon)
if err != nil {
result = p.appIcon
}
return result
}
func (p *Systray) setIcon(hicon win32.HICON) error {
nid := p.newNotifyIconData()
nid.UFlags = win32.NIF_ICON
if hicon == 0 {
nid.HIcon = 0
} else {
nid.HIcon = hicon
}
if !win32.ShellNotifyIcon(win32.NIM_MODIFY, &nid) {
return errors.New("shell notify icon failed")
}
return nil
}
func (p *Systray) WinProc(hwnd win32.HWND, msg uint32, wparam, lparam uintptr) uintptr {
switch msg {
case win32.NotifyIconMessageId:
switch lparam {
case win32.WM_LBUTTONUP:
if p.lclick != nil {
println("left click")
p.lclick()
}
case win32.WM_RBUTTONUP:
if p.rclick != nil {
println("right click")
p.rclick()
}
case win32.WM_LBUTTONDBLCLK:
if p.ldblclick != nil {
p.ldblclick()
}
case win32.WM_RBUTTONDBLCLK:
if p.rdblclick != nil {
p.rdblclick()
}
default:
//println(win32.WMMessageToString(lparam))
}
case win32.WM_SETTINGCHANGE:
settingChanged := win32.UTF16PtrToString(lparam)
if settingChanged == "ImmersiveColorSet" {
err := p.updateIcon()
if err != nil {
println("update icon failed", err.Error())
}
}
return 0
case win32.WM_COMMAND:
cmdMsgID := int(wparam & 0xffff)
switch cmdMsgID {
default:
p.menu.ProcessCommand(cmdMsgID)
}
default:
//msg := int(wparam & 0xffff)
//println(win32.WMMessageToString(uintptr(msg)))
}
result, _, _ := DefWindowProc.Call(uintptr(hwnd), uintptr(msg), wparam, lparam)
return result
}
func (p *Systray) Run() error {
var msg win32.MSG
for {
rt := win32.GetMessage(&msg)
switch int(rt) {
case 0:
return nil
case -1:
return errors.New("run failed")
}
if win32.IsDialogMessage(p.hwnd, &msg) == 0 {
win32.TranslateMessage(&msg)
win32.DispatchMessage(&msg)
}
}
}
func (p *Systray) updateIcon() error {
var newIcon win32.HICON
if win32.IsCurrentlyDarkMode() {
newIcon = p.darkModeIcon
} else {
newIcon = p.lightModeIcon
}
if p.currentIcon == newIcon {
return nil
}
p.currentIcon = newIcon
return p.setIcon(newIcon)
}
func (p *Systray) updateTheme() {
//win32.SetTheme(p.hwnd, win32.IsCurrentlyDarkMode())
}
func RegisterWindow(name string, proc win32.WindowProc) (win32.HINSTANCE, error) {
instance, exists := windowClasses[name]
if exists {
return instance, nil
}
hinst := win32.GetModuleHandle(0)
if hinst == 0 {
return 0, errors.New("get module handle failed")
}
hicon := win32.LoadIconWithResourceID(0, uintptr(win32.IDI_APPLICATION))
if hicon == 0 {
return 0, errors.New("load icon failed")
}
hcursor := win32.LoadCursorWithResourceID(0, uintptr(win32.IDC_ARROW))
if hcursor == 0 {
return 0, errors.New("load cursor failed")
}
hi := win32.HINSTANCE(hinst)
var wc win32.WNDCLASSEX
wc.CbSize = uint32(unsafe.Sizeof(wc))
wc.LpfnWndProc = syscall.NewCallback(proc)
wc.HInstance = win32.HINSTANCE(hinst)
wc.HIcon = hicon
wc.HCursor = hcursor
wc.HbrBackground = win32.COLOR_BTNFACE + 1
wc.LpszClassName = win32.MustStringToUTF16Ptr(name)
atom, _, e := RegisterClassEx.Call(uintptr(unsafe.Pointer(&wc)))
if atom == 0 {
println(e.Error())
return 0, errors.New("register class failed")
}
windowClasses[name] = hi
return hi, nil
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package win32
import "unsafe"
func GetCursorPos() (x, y int, ok bool) {
pt := POINT{}
ret, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
return int(pt.X), int(pt.Y), ret != 0
}
+41
View File
@@ -0,0 +1,41 @@
//go:build windows
package win32
import (
"unsafe"
)
func CreateIconFromResourceEx(presbits uintptr, dwResSize uint32, isIcon bool, version uint32, cxDesired int, cyDesired int, flags uint) (uintptr, error) {
icon := 0
if isIcon {
icon = 1
}
r, _, err := procCreateIconFromResourceEx.Call(
presbits,
uintptr(dwResSize),
uintptr(icon),
uintptr(version),
uintptr(cxDesired),
uintptr(cyDesired),
uintptr(flags),
)
if r == 0 {
return 0, err
}
return r, nil
}
// CreateHIconFromPNG creates a HICON from a PNG file
func CreateHIconFromPNG(pngData []byte) (HICON, error) {
icon, err := CreateIconFromResourceEx(
uintptr(unsafe.Pointer(&pngData[0])),
uint32(len(pngData)),
true,
0x00030000,
0,
0,
LR_DEFAULTSIZE)
return HICON(icon), err
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
//go:build windows
package win32
type Menu HMENU
type PopupMenu Menu
func CreatePopupMenu() PopupMenu {
ret, _, _ := procCreatePopupMenu.Call(0, 0, 0, 0)
return PopupMenu(ret)
}
func (m Menu) Destroy() bool {
ret, _, _ := procDestroyMenu.Call(uintptr(m))
return ret != 0
}
func (p PopupMenu) Destroy() bool {
return Menu(p).Destroy()
}
func (p PopupMenu) Track(flags uint, x, y int, wnd HWND) bool {
ret, _, _ := procTrackPopupMenu.Call(
uintptr(p),
uintptr(flags),
uintptr(x),
uintptr(y),
0,
uintptr(wnd),
0,
)
return ret != 0
}
func (p PopupMenu) Append(flags uintptr, id uintptr, text string) bool {
return Menu(p).Append(flags, id, text)
}
func (m Menu) Append(flags uintptr, id uintptr, text string) bool {
ret, _, _ := procAppendMenuW.Call(
uintptr(m),
flags,
id,
MustStringToUTF16uintptr(text),
)
return ret != 0
}
func (p PopupMenu) Check(id uintptr, checked bool) bool {
return Menu(p).Check(id, checked)
}
func (m Menu) Check(id uintptr, check bool) bool {
var checkState uint = MF_UNCHECKED
if check {
checkState = MF_CHECKED
}
return CheckMenuItem(HMENU(m), id, checkState) != 0
}
func (m Menu) CheckRadio(startID int, endID int, selectedID int) bool {
ret, _, _ := procCheckMenuRadioItem.Call(
uintptr(m),
uintptr(startID),
uintptr(endID),
uintptr(selectedID),
MF_BYCOMMAND)
return ret != 0
}
func CheckMenuItem(menu HMENU, id uintptr, flags uint) uint {
ret, _, _ := procCheckMenuItem.Call(
uintptr(menu),
id,
uintptr(flags),
)
return uint(ret)
}
func (p PopupMenu) CheckRadio(startID, endID, selectedID int) bool {
return Menu(p).CheckRadio(startID, endID, selectedID)
}
+51
View File
@@ -0,0 +1,51 @@
//go:build windows
package win32
import "golang.org/x/sys/windows"
type NOTIFYICONDATA struct {
CbSize uint32
HWnd HWND
UID uint32
UFlags uint32
UCallbackMessage uint32
HIcon HICON
SzTip [128]uint16
DwState uint32
DwStateMask uint32
SzInfo [256]uint16
UVersion uint32
SzInfoTitle [64]uint16
DwInfoFlags uint32
GuidItem windows.GUID
HBalloonIcon HICON
}
type WNDCLASSEX struct {
CbSize uint32
Style uint32
LpfnWndProc uintptr
CbClsExtra int32
CbWndExtra int32
HInstance HINSTANCE
HIcon HICON
HCursor HCURSOR
HbrBackground HBRUSH
LpszMenuName *uint16
LpszClassName *uint16
HIconSm HICON
}
type MSG struct {
HWnd HWND
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Pt POINT
}
type POINT struct {
X, Y int32
}
+191
View File
@@ -0,0 +1,191 @@
//go:build windows
package win32
import (
"golang.org/x/sys/windows/registry"
"unsafe"
)
type DWMWINDOWATTRIBUTE int32
const DwmwaUseImmersiveDarkModeBefore20h1 DWMWINDOWATTRIBUTE = 19
const DwmwaUseImmersiveDarkMode DWMWINDOWATTRIBUTE = 20
const DwmwaBorderColor DWMWINDOWATTRIBUTE = 34
const DwmwaCaptionColor DWMWINDOWATTRIBUTE = 35
const DwmwaTextColor DWMWINDOWATTRIBUTE = 36
const DwmwaSystemBackdropType DWMWINDOWATTRIBUTE = 38
const SPI_GETHIGHCONTRAST = 0x0042
const HCF_HIGHCONTRASTON = 0x00000001
const WCA_ACCENT_POLICY WINDOWCOMPOSITIONATTRIB = 19
type ACCENT_STATE DWORD
const (
ACCENT_DISABLED ACCENT_STATE = 0
ACCENT_ENABLE_GRADIENT ACCENT_STATE = 1
ACCENT_ENABLE_TRANSPARENTGRADIENT ACCENT_STATE = 2
ACCENT_ENABLE_BLURBEHIND ACCENT_STATE = 3
ACCENT_ENABLE_ACRYLICBLURBEHIND ACCENT_STATE = 4 // RS4 1803
ACCENT_ENABLE_HOSTBACKDROP ACCENT_STATE = 5 // RS5 1809
ACCENT_INVALID_STATE ACCENT_STATE = 6
)
type ACCENT_POLICY struct {
AccentState ACCENT_STATE
AccentFlags DWORD
GradientColor DWORD
AnimationId DWORD
}
type WINDOWCOMPOSITIONATTRIBDATA struct {
Attrib WINDOWCOMPOSITIONATTRIB
PvData unsafe.Pointer
CbData uintptr
}
type WINDOWCOMPOSITIONATTRIB DWORD
// BackdropType defines the type of translucency we wish to use
type BackdropType int32
const (
BackdropTypeAuto BackdropType = 0
BackdropTypeNone BackdropType = 1
BackdropTypeMica BackdropType = 2
BackdropTypeAcrylic BackdropType = 3
BackdropTypeTabbed BackdropType = 4
)
func dwmSetWindowAttribute(hwnd HWND, dwAttribute DWMWINDOWATTRIBUTE, pvAttribute unsafe.Pointer, cbAttribute uintptr) {
ret, _, err := procDwmSetWindowAttribute.Call(
uintptr(hwnd),
uintptr(dwAttribute),
uintptr(pvAttribute),
cbAttribute)
if ret != 0 {
_ = err
// println(err.Error())
}
}
func SupportsThemes() bool {
// We can't support Windows versions before 17763
return IsWindowsVersionAtLeast(10, 0, 17763)
}
func SupportsCustomThemes() bool {
return IsWindowsVersionAtLeast(10, 0, 17763)
}
func SupportsBackdropTypes() bool {
return IsWindowsVersionAtLeast(10, 0, 22621)
}
func SupportsImmersiveDarkMode() bool {
return IsWindowsVersionAtLeast(10, 0, 18985)
}
func SetTheme(hwnd HWND, useDarkMode bool) {
if SupportsThemes() {
attr := DwmwaUseImmersiveDarkModeBefore20h1
if SupportsImmersiveDarkMode() {
attr = DwmwaUseImmersiveDarkMode
}
var winDark int32
if useDarkMode {
winDark = 1
}
dwmSetWindowAttribute(hwnd, attr, unsafe.Pointer(&winDark), unsafe.Sizeof(winDark))
}
}
func EnableBlurBehind(hwnd HWND) {
var accent = ACCENT_POLICY{
AccentState: ACCENT_ENABLE_ACRYLICBLURBEHIND,
AccentFlags: 0x2,
}
var data WINDOWCOMPOSITIONATTRIBDATA
data.Attrib = WCA_ACCENT_POLICY
data.PvData = unsafe.Pointer(&accent)
data.CbData = unsafe.Sizeof(accent)
SetWindowCompositionAttribute(hwnd, &data)
}
func SetWindowCompositionAttribute(hwnd HWND, data *WINDOWCOMPOSITIONATTRIBDATA) bool {
if procSetWindowCompositionAttribute != nil {
ret, _, _ := procSetWindowCompositionAttribute.Call(
uintptr(hwnd),
uintptr(unsafe.Pointer(data)),
)
return ret != 0
}
return false
}
func EnableTranslucency(hwnd HWND, backdrop BackdropType) {
if SupportsBackdropTypes() {
dwmSetWindowAttribute(hwnd, DwmwaSystemBackdropType, unsafe.Pointer(&backdrop), unsafe.Sizeof(backdrop))
} else {
println("Warning: Translucency type unavailable on Windows < 22621")
}
}
func SetTitleBarColour(hwnd HWND, titleBarColour int32) {
dwmSetWindowAttribute(hwnd, DwmwaCaptionColor, unsafe.Pointer(&titleBarColour), unsafe.Sizeof(titleBarColour))
}
func SetTitleTextColour(hwnd HWND, titleTextColour int32) {
dwmSetWindowAttribute(hwnd, DwmwaTextColor, unsafe.Pointer(&titleTextColour), unsafe.Sizeof(titleTextColour))
}
func SetBorderColour(hwnd HWND, titleBorderColour int32) {
dwmSetWindowAttribute(hwnd, DwmwaBorderColor, unsafe.Pointer(&titleBorderColour), unsafe.Sizeof(titleBorderColour))
}
func SetWindowTheme(hwnd HWND, appName string, subIdList string) uintptr {
var subID uintptr
if subIdList != "" {
subID = MustStringToUTF16uintptr(subIdList)
}
ret, _, _ := procSetWindowTheme.Call(
uintptr(hwnd),
MustStringToUTF16uintptr(appName),
subID,
)
return ret
}
func IsCurrentlyDarkMode() bool {
key, err := registry.OpenKey(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.QUERY_VALUE)
if err != nil {
return false
}
defer key.Close()
AppsUseLightTheme, _, err := key.GetIntegerValue("AppsUseLightTheme")
if err != nil {
return false
}
return AppsUseLightTheme == 0
}
type highContrast struct {
CbSize uint32
DwFlags uint32
LpszDefaultScheme *int16
}
func IsCurrentlyHighContrastMode() bool {
var result highContrast
result.CbSize = uint32(unsafe.Sizeof(result))
res, _, err := procSystemParametersInfo.Call(SPI_GETHIGHCONTRAST, uintptr(result.CbSize), uintptr(unsafe.Pointer(&result)), 0)
if res == 0 {
_ = err
return false
}
r := result.DwFlags&HCF_HIGHCONTRASTON == HCF_HIGHCONTRASTON
return r
}
+139
View File
@@ -0,0 +1,139 @@
//go:build windows
package win32
import (
"fmt"
"github.com/samber/lo"
"golang.org/x/sys/windows"
"syscall"
"unsafe"
)
func LoadIconWithResourceID(instance HINSTANCE, res uintptr) HICON {
ret, _, _ := procLoadIcon.Call(
uintptr(instance),
res)
return HICON(ret)
}
func LoadCursorWithResourceID(instance HINSTANCE, res uintptr) HCURSOR {
ret, _, _ := procLoadCursor.Call(
uintptr(instance),
res)
return HCURSOR(ret)
}
func RegisterClassEx(wndClassEx *WNDCLASSEX) ATOM {
ret, _, _ := procRegisterClassEx.Call(uintptr(unsafe.Pointer(wndClassEx)))
return ATOM(ret)
}
func RegisterClass(className string, wndproc uintptr, instance HINSTANCE) error {
classNamePtr, err := syscall.UTF16PtrFromString(className)
if err != nil {
return err
}
icon := LoadIconWithResourceID(instance, IDI_APPLICATION)
var wc WNDCLASSEX
wc.CbSize = uint32(unsafe.Sizeof(wc))
wc.Style = CS_HREDRAW | CS_VREDRAW
wc.LpfnWndProc = wndproc
wc.HInstance = instance
wc.HbrBackground = COLOR_WINDOW + 1
wc.HIcon = icon
wc.HCursor = LoadCursorWithResourceID(0, IDC_ARROW)
wc.LpszClassName = classNamePtr
wc.LpszMenuName = nil
wc.HIconSm = icon
if ret := RegisterClassEx(&wc); ret == 0 {
return syscall.GetLastError()
}
return nil
}
func CreateWindow(className string, instance HINSTANCE, parent HWND, exStyle, style uint) HWND {
classNamePtr := lo.Must(syscall.UTF16PtrFromString(className))
result := CreateWindowEx(
exStyle,
classNamePtr,
nil,
style,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
parent,
0,
instance,
nil)
if result == 0 {
errStr := fmt.Sprintf("Error occurred in CreateWindow(%s, %v, %d, %d)", className, parent, exStyle, style)
panic(errStr)
}
return result
}
func CreateWindowEx(exStyle uint, className, windowName *uint16,
style uint, x, y, width, height int, parent HWND, menu HMENU,
instance HINSTANCE, param unsafe.Pointer) HWND {
ret, _, _ := procCreateWindowEx.Call(
uintptr(exStyle),
uintptr(unsafe.Pointer(className)),
uintptr(unsafe.Pointer(windowName)),
uintptr(style),
uintptr(x),
uintptr(y),
uintptr(width),
uintptr(height),
uintptr(parent),
uintptr(menu),
uintptr(instance),
uintptr(param))
return HWND(ret)
}
func MustStringToUTF16Ptr(input string) *uint16 {
ret, err := syscall.UTF16PtrFromString(input)
if err != nil {
panic(err)
}
return ret
}
func MustStringToUTF16uintptr(input string) uintptr {
ret, err := syscall.UTF16PtrFromString(input)
if err != nil {
panic(err)
}
return uintptr(unsafe.Pointer(ret))
}
func MustUTF16FromString(input string) []uint16 {
ret, err := syscall.UTF16FromString(input)
if err != nil {
panic(err)
}
return ret
}
func UTF16PtrToString(input uintptr) string {
return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(input)))
}
func SetForegroundWindow(wnd HWND) bool {
ret, _, _ := procSetForegroundWindow.Call(
uintptr(wnd),
)
return ret != 0
}