mirror of
https://github.com/wavetermdev/wails.git
synced 2026-08-05 13:53:43 -07:00
Windows tray menus (#2181)
* Add example * Add windows systray * Add gitkeep * use windows.GUID
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package menu
|
||||
|
||||
import "github.com/wailsapp/wails/v2/internal/platform/win32"
|
||||
|
||||
type Menu struct {
|
||||
menu win32.HMENU
|
||||
}
|
||||
@@ -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], ¤tRadioGroup)
|
||||
}
|
||||
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], ¤tRadioGroup)
|
||||
}
|
||||
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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user