diff --git a/v3/pkg/application/dialogs_taskdialog_test.go b/v3/pkg/application/dialogs_taskdialog_test.go new file mode 100644 index 00000000000..71bec09a006 --- /dev/null +++ b/v3/pkg/application/dialogs_taskdialog_test.go @@ -0,0 +1,82 @@ +package application + +import ( + "sync" + "testing" +) + +func TestTaskDialogButtonCallbackStorage(t *testing.T) { + callbacks := make(map[int32]func()) + var mu sync.Mutex + + quitCalled := false + cancelCalled := false + + callbacks[100] = func() { quitCalled = true } + callbacks[101] = func() { cancelCalled = true } + + mu.Lock() + if cb, ok := callbacks[100]; ok && cb != nil { + cb() + } + mu.Unlock() + + if !quitCalled { + t.Error("callback for button 100 should have been called") + } + if cancelCalled { + t.Error("callback for button 101 should NOT have been called yet") + } + + mu.Lock() + if cb, ok := callbacks[101]; ok && cb != nil { + cb() + } + mu.Unlock() + + if !cancelCalled { + t.Error("callback for button 101 should have been called") + } +} + +func TestTaskDialogButtonIDMapping(t *testing.T) { + buttons := []*Button{ + {Label: "Quit", IsDefault: true}, + {Label: "Cancel", IsCancel: true}, + {Label: "Retry"}, + } + + const customButtonBase = 100 + for i, btn := range buttons { + id := int32(customButtonBase + i) + expectedID := int32(100 + i) + if id != expectedID { + t.Errorf("button %q: id = %d, want %d", btn.Label, id, expectedID) + } + if btn.Label == "Quit" && !btn.IsDefault { + t.Error("Quit button should be default") + } + if btn.Label == "Cancel" && !btn.IsCancel { + t.Error("Cancel button should be cancel") + } + } +} + +func TestTaskDialogDefaultButtonSelection(t *testing.T) { + buttons := []*Button{ + {Label: "No", IsDefault: false}, + {Label: "Yes", IsDefault: true}, + } + + var defaultButtonID int32 + const customButtonBase = 100 + for i, btn := range buttons { + if btn.IsDefault { + defaultButtonID = int32(customButtonBase + i) + } + } + + if defaultButtonID != 101 { + t.Errorf("defaultButtonID = %d, want 101", defaultButtonID) + } +} diff --git a/v3/pkg/application/dialogs_windows.go b/v3/pkg/application/dialogs_windows.go index 73084d098e0..e59b79e6ec1 100644 --- a/v3/pkg/application/dialogs_windows.go +++ b/v3/pkg/application/dialogs_windows.go @@ -31,6 +31,9 @@ type windowsDialog struct { } func (m *windowsDialog) show() { + if len(m.dialog.Buttons) > 0 && showTaskDialog(m.dialog) { + return + } title := w32.MustStringToUTF16Ptr(m.dialog.Title) message := w32.MustStringToUTF16Ptr(m.dialog.Message) @@ -47,7 +50,6 @@ func (m *windowsDialog) show() { } if m.UseAppIcon || m.dialog.Icon != nil { - // 3 is the application icon button, err = w32.MessageBoxWithIcon(parentWindow, message, title, 3, windows.MB_OK|windows.MB_USERICON) if err != nil { globalApplication.handleFatalError(err) @@ -58,13 +60,11 @@ func (m *windowsDialog) show() { globalApplication.handleFatalError(err) } } - // This maps MessageBox return values to strings responses := []string{"", "Ok", "Cancel", "Abort", "Retry", "Ignore", "Yes", "No", "", "", "Try Again", "Continue"} result := "Error" if int(button) < len(responses) { result = responses[button] } - // Check if there's a callback for the button pressed for _, buttonInDialog := range m.dialog.Buttons { if buttonInDialog.Label == result { if buttonInDialog.Callback != nil { diff --git a/v3/pkg/application/dialogs_windows_taskdialog.go b/v3/pkg/application/dialogs_windows_taskdialog.go new file mode 100644 index 00000000000..b7c8eba9795 --- /dev/null +++ b/v3/pkg/application/dialogs_windows_taskdialog.go @@ -0,0 +1,171 @@ +//go:build windows + +package application + +import ( + "sync" + "syscall" + "unsafe" +) + +const ( + tdfAllowDialogCancellation = 0x0008 +) + +const ( + tdcbfOkButton = 0x0001 + tdcbfCancelButton = 0x0008 +) + +const ( + tdiInformationIcon = 65534 + tdiErrorIcon = 65531 + tdiWarningIcon = 65533 +) + +type taskDialogButton struct { + nButtonID int32 + pszButtonText *uint16 +} + +type taskDialogConfig struct { + cbSize uint32 + hwndParent uintptr + hInstance uintptr + dwFlags uint32 + dwCommonButtons uint32 + pszWindowTitle *uint16 + _ uintptr + pszMainIcon *uint16 + pszMainInstruction *uint16 + pszContent *uint16 + cButtons uint32 + pButtons uintptr + nDefaultButton int32 + cRadioButtons uint32 + pRadioButtons uintptr + nDefaultRadioButton int32 + pszVerificationText *uint16 + pszExpandedInfo *uint16 + pszExpandedCtrlText *uint16 + pszCollapsedCtrlText *uint16 + _ uintptr + pszFooterIcon *uint16 + pszFooter *uint16 + pfCallback uintptr + lpCallbackData uintptr + cxWidth uint32 +} + +var ( + lazyComctl32 = syscall.NewLazyDLL("comctl32.dll") + procTaskDialogIndirect = lazyComctl32.NewProc("TaskDialogIndirect") + taskDialogCallbackMutex sync.Mutex + taskDialogButtonCallback map[int32]func() +) + +func init() { + taskDialogButtonCallback = make(map[int32]func()) +} + +func taskDialogAvailable() bool { + return procTaskDialogIndirect.Find() == nil +} + +func showTaskDialog(dialog *MessageDialog) bool { + if !taskDialogAvailable() { + return false + } + + var parentWindow uintptr + if dialog.window != nil { + if nativeWindow := dialog.window.NativeWindow(); nativeWindow != nil { + parentWindow = uintptr(nativeWindow) + } + } + + cfg := taskDialogConfig{ + cbSize: uint32(unsafe.Sizeof(taskDialogConfig{})), + hwndParent: parentWindow, + dwFlags: tdfAllowDialogCancellation, + } + + if dialog.Title != "" { + cfg.pszWindowTitle = syscall.StringToUTF16Ptr(dialog.Title) + } + + if dialog.Message != "" { + cfg.pszMainInstruction = syscall.StringToUTF16Ptr(dialog.Message) + } + + switch dialog.DialogType { + case InfoDialogType: + cfg.pszMainIcon = makeIntResource(tdiInformationIcon) + case ErrorDialogType: + cfg.pszMainIcon = makeIntResource(tdiErrorIcon) + case WarningDialogType: + cfg.pszMainIcon = makeIntResource(tdiWarningIcon) + case QuestionDialogType: + cfg.pszMainIcon = makeIntResource(tdiInformationIcon) + } + + if len(dialog.Buttons) == 0 { + cfg.dwCommonButtons = tdcbfOkButton + } + + var buttons []taskDialogButton + const customButtonBase = 100 + + taskDialogCallbackMutex.Lock() + for id := range taskDialogButtonCallback { + delete(taskDialogButtonCallback, id) + } + + for i, btn := range dialog.Buttons { + id := int32(customButtonBase + i) + buttons = append(buttons, taskDialogButton{ + nButtonID: id, + pszButtonText: syscall.StringToUTF16Ptr(btn.Label), + }) + if btn.Callback != nil { + taskDialogButtonCallback[id] = btn.Callback + } + if btn.IsDefault { + cfg.nDefaultButton = id + } + } + taskDialogCallbackMutex.Unlock() + + if len(buttons) > 0 { + cfg.cButtons = uint32(len(buttons)) + cfg.pButtons = uintptr(unsafe.Pointer(&buttons[0])) + } + + var buttonPressed int32 + ret, _, _ := procTaskDialogIndirect.Call( + uintptr(unsafe.Pointer(&cfg)), + uintptr(unsafe.Pointer(&buttonPressed)), + 0, + 0, + ) + + if ret != 0 { + return false + } + + if buttonPressed >= customButtonBase { + taskDialogCallbackMutex.Lock() + if cb, ok := taskDialogButtonCallback[buttonPressed]; ok && cb != nil { + taskDialogCallbackMutex.Unlock() + cb() + return true + } + taskDialogCallbackMutex.Unlock() + } + + return true +} + +func makeIntResource(id uint16) *uint16 { + return (*uint16)(unsafe.Pointer(uintptr(id))) +}