From f2d6dba2cf73ac9c2cae96e619041078b30c7993 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Fri, 12 May 2023 20:56:07 +1000 Subject: [PATCH] [v3 windows] initial dialog support. Refactor button callback name --- v3/V3 Changes.md | 18 ++ v3/examples/dialogs/main.go | 1 + v3/examples/systray/main.go | 19 +- v3/internal/go-common-file-dialog/LICENSE | 21 ++ v3/internal/go-common-file-dialog/README.md | 31 +++ .../cfd/CommonFileDialog.go | 72 +++++ .../cfd/CommonFileDialog_nonWindows.go | 28 ++ .../cfd/CommonFileDialog_windows.go | 79 ++++++ .../go-common-file-dialog/cfd/DialogConfig.go | 120 ++++++++ .../go-common-file-dialog/cfd/errors.go | 7 + .../cfd/iFileOpenDialog.go | 201 ++++++++++++++ .../cfd/iFileSaveDialog.go | 92 +++++++ .../go-common-file-dialog/cfd/iShellItem.go | 53 ++++ .../cfd/iShellItemArray.go | 67 +++++ .../go-common-file-dialog/cfd/vtblCommon.go | 48 ++++ .../cfd/vtblCommonFunc.go | 227 +++++++++++++++ .../go-common-file-dialog/cfdutil/CFDUtil.go | 45 +++ .../go-common-file-dialog/util/util.go | 10 + .../go-common-file-dialog/util/util_test.go | 14 + v3/pkg/application/dialogs.go | 34 ++- v3/pkg/application/dialogs_darwin.go | 4 +- v3/pkg/application/dialogs_windows.go | 258 +++++++++--------- v3/pkg/application/menuitem_windows.go | 116 ++++++-- v3/pkg/application/popupmenu_windows.go | 213 +++++++++++++++ v3/pkg/application/systemtray.go | 2 + v3/pkg/application/systemtray_windows.go | 16 +- v3/pkg/w32/utils.go | 11 + v3/pkg/w32/window.go | 6 +- 28 files changed, 1650 insertions(+), 163 deletions(-) create mode 100644 v3/internal/go-common-file-dialog/LICENSE create mode 100644 v3/internal/go-common-file-dialog/README.md create mode 100644 v3/internal/go-common-file-dialog/cfd/CommonFileDialog.go create mode 100644 v3/internal/go-common-file-dialog/cfd/CommonFileDialog_nonWindows.go create mode 100644 v3/internal/go-common-file-dialog/cfd/CommonFileDialog_windows.go create mode 100644 v3/internal/go-common-file-dialog/cfd/DialogConfig.go create mode 100644 v3/internal/go-common-file-dialog/cfd/errors.go create mode 100644 v3/internal/go-common-file-dialog/cfd/iFileOpenDialog.go create mode 100644 v3/internal/go-common-file-dialog/cfd/iFileSaveDialog.go create mode 100644 v3/internal/go-common-file-dialog/cfd/iShellItem.go create mode 100644 v3/internal/go-common-file-dialog/cfd/iShellItemArray.go create mode 100644 v3/internal/go-common-file-dialog/cfd/vtblCommon.go create mode 100644 v3/internal/go-common-file-dialog/cfd/vtblCommonFunc.go create mode 100644 v3/internal/go-common-file-dialog/cfdutil/CFDUtil.go create mode 100644 v3/internal/go-common-file-dialog/util/util.go create mode 100644 v3/internal/go-common-file-dialog/util/util_test.go create mode 100644 v3/pkg/application/popupmenu_windows.go diff --git a/v3/V3 Changes.md b/v3/V3 Changes.md index f391029cf..9701d375b 100644 --- a/v3/V3 Changes.md +++ b/v3/V3 Changes.md @@ -59,6 +59,24 @@ TBD Dialogs are now available in JavaScript! +### Windows + +Dialog buttons in Windows are not configurable and are constant depending on the type of dialog. To trigger a callback when a button is pressed, create a button with the same name as the button you wish to have the callback attached to. +Example: Create a button with the label `Ok` and use `OnClick()` to set the callback method: +```go + dialog := app.QuestionDialog(). + SetTitle("Update"). + SetMessage("The cancel button is selected when pressing escape") + ok := dialog.AddButton("Ok") + ok.OnClick(func() { + // Do something + }) + no := dialog.AddButton("Cancel") + dialog.SetDefaultButton(ok) + dialog.SetCancelButton(no) + dialog.Show() +``` + ## Drag and Drop Native drag and drop can be enabled per-window. Simply set the `EnableDragAndDrop` window config option to `true` and the window will allow files to be dragged onto it. When this happens, the `events.FilesDropped` event will be emitted. The filenames can then be retrieved from the WindowEventContext using the `DroppedFiles()` method. This returns a slice of strings containing the filenames. diff --git a/v3/examples/dialogs/main.go b/v3/examples/dialogs/main.go index dce0ff2c4..2301fd983 100644 --- a/v3/examples/dialogs/main.go +++ b/v3/examples/dialogs/main.go @@ -20,6 +20,7 @@ func main() { ApplicationShouldTerminateAfterLastWindowClosed: true, }, }) + // Create a custom menu menu := app.NewMenu() menu.AddRole(application.AppMenu) diff --git a/v3/examples/systray/main.go b/v3/examples/systray/main.go index cbc002af5..3c7bdf8e7 100644 --- a/v3/examples/systray/main.go +++ b/v3/examples/systray/main.go @@ -36,12 +36,27 @@ func main() { myMenu := app.NewMenu() myMenu.Add("Hello World!").OnClick(func(ctx *application.Context) { println("Hello World!") - // app.InfoDialog().SetTitle("Hello World!").SetMessage("Hello World!").Show() + q := app.QuestionDialog().SetTitle("Ready?").SetMessage("Are you feeling ready?") + q.AddButton("Yes").OnClick(func() { + println("Awesome!") + }) + q.AddButton("No").SetAsDefault().OnClick(func() { + println("Boo!") + }) + q.Show() }) subMenu := myMenu.AddSubmenu("Submenu") subMenu.Add("Click me!").OnClick(func(ctx *application.Context) { + ctx.ClickedMenuItem().SetLabel("Clicked!") + }) + myMenu.AddSeparator() + myMenu.AddCheckbox("Checked", true).OnClick(func(ctx *application.Context) { + println("Checked: ", ctx.ClickedMenuItem().Checked()) + app.InfoDialog().SetTitle("Hello World!").SetMessage("Hello World!").Show() + }) + myMenu.Add("Enabled").OnClick(func(ctx *application.Context) { println("Click me!") - // ctx.ClickedMenuItem().SetLabel("Clicked!") + ctx.ClickedMenuItem().SetLabel("Disabled!").SetEnabled(false) }) myMenu.AddSeparator() myMenu.Add("Quit").OnClick(func(ctx *application.Context) { diff --git a/v3/internal/go-common-file-dialog/LICENSE b/v3/internal/go-common-file-dialog/LICENSE new file mode 100644 index 000000000..508b6978e --- /dev/null +++ b/v3/internal/go-common-file-dialog/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Harry Phillips + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/v3/internal/go-common-file-dialog/README.md b/v3/internal/go-common-file-dialog/README.md new file mode 100644 index 000000000..1cb5902d1 --- /dev/null +++ b/v3/internal/go-common-file-dialog/README.md @@ -0,0 +1,31 @@ +# Common File Dialog bindings for Golang + +[Project Home](https://github.com/harry1453/go-common-file-dialog) + +This library contains bindings for Windows Vista and +newer's [Common File Dialogs](https://docs.microsoft.com/en-us/windows/win32/shell/common-file-dialog), which is the +standard system dialog for selecting files or folders to open or save. + +The Common File Dialogs have to be accessed via +the [COM Interface](https://en.wikipedia.org/wiki/Component_Object_Model), normally via C++ or via bindings (like in C#) +. + +This library contains bindings for Golang. **It does not require CGO**, and contains empty stubs for non-windows +platforms (so is safe to compile and run on platforms other than windows, but will just return errors at runtime). + +This can be very useful if you want to quickly get a file selector in your Golang application. The `cfdutil` package +contains utility functions with a single call to open and configure a dialog, and then get the result from it. Examples +for this are in [`_examples/usingutil`](_examples/usingutil). Or, if you want finer control over the dialog's operation, +you can use the base package. Examples for this are in [`_examples/notusingutil`](_examples/notusingutil). + +This library is available under the MIT license. + +Currently supported features: + +* Open File Dialog (to open a single file) +* Open Multiple Files Dialog (to open multiple files) +* Open Folder Dialog +* Save File Dialog +* Dialog "roles" to allow Windows to remember different "last locations" for different types of dialog +* Set dialog Title, Default Folder and Initial Folder +* Set dialog File Filters diff --git a/v3/internal/go-common-file-dialog/cfd/CommonFileDialog.go b/v3/internal/go-common-file-dialog/cfd/CommonFileDialog.go new file mode 100644 index 000000000..58e97aa4e --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/CommonFileDialog.go @@ -0,0 +1,72 @@ +// Cross-platform. + +// Common File Dialogs +package cfd + +type Dialog interface { + // Show the dialog to the user. + // Blocks until the user has closed the dialog. + Show() error + // Sets the dialog's parent window. Use 0 to set the dialog to have no parent window. + SetParentWindowHandle(hwnd uintptr) + // Show the dialog to the user. + // Blocks until the user has closed the dialog and returns their selection. + // Returns an error if the user cancelled the dialog. + // Do not use for the Open Multiple Files dialog. Use ShowAndGetResults instead. + ShowAndGetResult() (string, error) + // Sets the title of the dialog window. + SetTitle(title string) error + // Sets the "role" of the dialog. This is used to derive the dialog's GUID, which the + // OS will use to differentiate it from dialogs that are intended for other purposes. + // This means that, for example, a dialog with role "Import" will have a different + // previous location that it will open to than a dialog with role "Open". Can be any string. + SetRole(role string) error + // Sets the folder used as a default if there is not a recently used folder value available + SetDefaultFolder(defaultFolder string) error + // Sets the folder that the dialog always opens to. + // If this is set, it will override the "default folder" behaviour and the dialog will always open to this folder. + SetFolder(folder string) error + // Gets the selected file or folder path, as an absolute path eg. "C:\Folder\file.txt" + // Do not use for the Open Multiple Files dialog. Use GetResults instead. + GetResult() (string, error) + // Sets the file name, I.E. the contents of the file name text box. + // For Select Folder Dialog, sets folder name. + SetFileName(fileName string) error + // Release the resources allocated to this Dialog. + // Should be called when the dialog is finished with. + Release() error +} + +type FileDialog interface { + Dialog + // Set the list of file filters that the user can select. + SetFileFilters(fileFilter []FileFilter) error + // Set the selected item from the list of file filters (set using SetFileFilters) by its index. Defaults to 0 (the first item in the list) if not called. + SetSelectedFileFilterIndex(index uint) error + // Sets the default extension applied when a user does not provide one as part of the file name. + // If the user selects a different file filter, the default extension will be automatically updated to match the new file filter. + // For Open / Open Multiple File Dialog, this only has an effect when the user specifies a file name with no extension and a file with the default extension exists. + // For Save File Dialog, this extension will be used whenever a user does not specify an extension. + SetDefaultExtension(defaultExtension string) error +} + +type OpenFileDialog interface { + FileDialog +} + +type OpenMultipleFilesDialog interface { + FileDialog + // Show the dialog to the user. + // Blocks until the user has closed the dialog and returns the selected files. + ShowAndGetResults() ([]string, error) + // Gets the selected file paths, as absolute paths eg. "C:\Folder\file.txt" + GetResults() ([]string, error) +} + +type SelectFolderDialog interface { + Dialog +} + +type SaveFileDialog interface { // TODO Properties + FileDialog +} diff --git a/v3/internal/go-common-file-dialog/cfd/CommonFileDialog_nonWindows.go b/v3/internal/go-common-file-dialog/cfd/CommonFileDialog_nonWindows.go new file mode 100644 index 000000000..3ab969850 --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/CommonFileDialog_nonWindows.go @@ -0,0 +1,28 @@ +//go:build !windows +// +build !windows + +package cfd + +import "fmt" + +var unsupportedError = fmt.Errorf("common file dialogs are only available on windows") + +// TODO doc +func NewOpenFileDialog(config DialogConfig) (OpenFileDialog, error) { + return nil, unsupportedError +} + +// TODO doc +func NewOpenMultipleFilesDialog(config DialogConfig) (OpenMultipleFilesDialog, error) { + return nil, unsupportedError +} + +// TODO doc +func NewSelectFolderDialog(config DialogConfig) (SelectFolderDialog, error) { + return nil, unsupportedError +} + +// TODO doc +func NewSaveFileDialog(config DialogConfig) (SaveFileDialog, error) { + return nil, unsupportedError +} diff --git a/v3/internal/go-common-file-dialog/cfd/CommonFileDialog_windows.go b/v3/internal/go-common-file-dialog/cfd/CommonFileDialog_windows.go new file mode 100644 index 000000000..69f46118e --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/CommonFileDialog_windows.go @@ -0,0 +1,79 @@ +//go:build windows +// +build windows + +package cfd + +import "github.com/go-ole/go-ole" + +func initialize() { + // Swallow error + _ = ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_DISABLE_OLE1DDE) +} + +// TODO doc +func NewOpenFileDialog(config DialogConfig) (OpenFileDialog, error) { + initialize() + + openDialog, err := newIFileOpenDialog() + if err != nil { + return nil, err + } + err = config.apply(openDialog) + if err != nil { + return nil, err + } + return openDialog, nil +} + +// TODO doc +func NewOpenMultipleFilesDialog(config DialogConfig) (OpenMultipleFilesDialog, error) { + initialize() + + openDialog, err := newIFileOpenDialog() + if err != nil { + return nil, err + } + err = config.apply(openDialog) + if err != nil { + return nil, err + } + err = openDialog.setIsMultiselect(true) + if err != nil { + return nil, err + } + return openDialog, nil +} + +// TODO doc +func NewSelectFolderDialog(config DialogConfig) (SelectFolderDialog, error) { + initialize() + + openDialog, err := newIFileOpenDialog() + if err != nil { + return nil, err + } + err = config.apply(openDialog) + if err != nil { + return nil, err + } + err = openDialog.setPickFolders(true) + if err != nil { + return nil, err + } + return openDialog, nil +} + +// TODO doc +func NewSaveFileDialog(config DialogConfig) (SaveFileDialog, error) { + initialize() + + saveDialog, err := newIFileSaveDialog() + if err != nil { + return nil, err + } + err = config.apply(saveDialog) + if err != nil { + return nil, err + } + return saveDialog, nil +} diff --git a/v3/internal/go-common-file-dialog/cfd/DialogConfig.go b/v3/internal/go-common-file-dialog/cfd/DialogConfig.go new file mode 100644 index 000000000..221dbef27 --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/DialogConfig.go @@ -0,0 +1,120 @@ +// Cross-platform. + +package cfd + +type FileFilter struct { + // The display name of the filter (That is shown to the user) + DisplayName string + // The filter pattern. Eg. "*.txt;*.png" to select all txt and png files, "*.*" to select any files, etc. + Pattern string +} + +type DialogConfig struct { + // The title of the dialog + Title string + // The role of the dialog. This is used to derive the dialog's GUID, which the + // OS will use to differentiate it from dialogs that are intended for other purposes. + // This means that, for example, a dialog with role "Import" will have a different + // previous location that it will open to than a dialog with role "Open". Can be any string. + Role string + // The default folder - the folder that is used the first time the user opens it + // (after the first time their last used location is used). + DefaultFolder string + // The initial folder - the folder that the dialog always opens to if not empty. + // If this is not empty, it will override the "default folder" behaviour and + // the dialog will always open to this folder. + Folder string + // The file filters that restrict which types of files the dialog is able to choose. + // Ignored by Select Folder Dialog. + FileFilters []FileFilter + // Sets the initially selected file filter. This is an index of FileFilters. + // Ignored by Select Folder Dialog. + SelectedFileFilterIndex uint + // The initial name of the file (I.E. the text in the file name text box) when the user opens the dialog. + // For the Select Folder Dialog, this sets the initial folder name. + FileName string + // The default extension applied when a user does not provide one as part of the file name. + // If the user selects a different file filter, the default extension will be automatically updated to match the new file filter. + // For Open / Open Multiple File Dialog, this only has an effect when the user specifies a file name with no extension and a file with the default extension exists. + // For Save File Dialog, this extension will be used whenever a user does not specify an extension. + // Ignored by Select Folder Dialog. + DefaultExtension string + // ParentWindowHandle is the handle (HWND) to the parent window of the dialog. + // If left as 0 / nil, the dialog will have no parent window. + ParentWindowHandle uintptr +} + +var defaultFilters = []FileFilter{ + { + DisplayName: "All Files (*.*)", + Pattern: "*.*", + }, +} + +func (config *DialogConfig) apply(dialog Dialog) (err error) { + if config.Title != "" { + err = dialog.SetTitle(config.Title) + if err != nil { + return + } + } + + if config.Role != "" { + err = dialog.SetRole(config.Role) + if err != nil { + return + } + } + + if config.Folder != "" { + err = dialog.SetFolder(config.Folder) + if err != nil { + return + } + } + + if config.DefaultFolder != "" { + err = dialog.SetDefaultFolder(config.DefaultFolder) + if err != nil { + return + } + } + + if config.FileName != "" { + err = dialog.SetFileName(config.FileName) + if err != nil { + return + } + } + + dialog.SetParentWindowHandle(config.ParentWindowHandle) + + if dialog, ok := dialog.(FileDialog); ok { + var fileFilters []FileFilter + if config.FileFilters != nil && len(config.FileFilters) > 0 { + fileFilters = config.FileFilters + } else { + fileFilters = defaultFilters + } + err = dialog.SetFileFilters(fileFilters) + if err != nil { + return + } + + if config.SelectedFileFilterIndex != 0 { + err = dialog.SetSelectedFileFilterIndex(config.SelectedFileFilterIndex) + if err != nil { + return + } + } + + if config.DefaultExtension != "" { + err = dialog.SetDefaultExtension(config.DefaultExtension) + if err != nil { + return + } + } + } + + return +} diff --git a/v3/internal/go-common-file-dialog/cfd/errors.go b/v3/internal/go-common-file-dialog/cfd/errors.go new file mode 100644 index 000000000..c097c8eb2 --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/errors.go @@ -0,0 +1,7 @@ +package cfd + +import "errors" + +var ( + ErrorCancelled = errors.New("cancelled by user") +) diff --git a/v3/internal/go-common-file-dialog/cfd/iFileOpenDialog.go b/v3/internal/go-common-file-dialog/cfd/iFileOpenDialog.go new file mode 100644 index 000000000..42f83814a --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/iFileOpenDialog.go @@ -0,0 +1,201 @@ +//go:build windows +// +build windows + +package cfd + +import ( + "github.com/go-ole/go-ole" + "github.com/google/uuid" + "syscall" + "unsafe" +) + +var ( + fileOpenDialogCLSID = ole.NewGUID("{DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7}") + fileOpenDialogIID = ole.NewGUID("{d57c7288-d4ad-4768-be02-9d969532d960}") +) + +type iFileOpenDialog struct { + vtbl *iFileOpenDialogVtbl + parentWindowHandle uintptr +} + +type iFileOpenDialogVtbl struct { + iFileDialogVtbl + + GetResults uintptr // func (ppenum **IShellItemArray) HRESULT + GetSelectedItems uintptr +} + +func newIFileOpenDialog() (*iFileOpenDialog, error) { + if unknown, err := ole.CreateInstance(fileOpenDialogCLSID, fileOpenDialogIID); err == nil { + return (*iFileOpenDialog)(unsafe.Pointer(unknown)), nil + } else { + return nil, err + } +} + +func (fileOpenDialog *iFileOpenDialog) Show() error { + return fileOpenDialog.vtbl.show(unsafe.Pointer(fileOpenDialog), fileOpenDialog.parentWindowHandle) +} + +func (fileOpenDialog *iFileOpenDialog) SetParentWindowHandle(hwnd uintptr) { + fileOpenDialog.parentWindowHandle = hwnd +} + +func (fileOpenDialog *iFileOpenDialog) ShowAndGetResult() (string, error) { + isMultiselect, err := fileOpenDialog.isMultiselect() + if err != nil { + return "", err + } + if isMultiselect { + // We should panic as this error is caused by the developer using the library + panic("use ShowAndGetResults for open multiple files dialog") + } + if err := fileOpenDialog.Show(); err != nil { + return "", err + } + return fileOpenDialog.GetResult() +} + +func (fileOpenDialog *iFileOpenDialog) ShowAndGetResults() ([]string, error) { + isMultiselect, err := fileOpenDialog.isMultiselect() + if err != nil { + return nil, err + } + if !isMultiselect { + // We should panic as this error is caused by the developer using the library + panic("use ShowAndGetResult for open single file dialog") + } + if err := fileOpenDialog.Show(); err != nil { + return nil, err + } + return fileOpenDialog.GetResults() +} + +func (fileOpenDialog *iFileOpenDialog) SetTitle(title string) error { + return fileOpenDialog.vtbl.setTitle(unsafe.Pointer(fileOpenDialog), title) +} + +func (fileOpenDialog *iFileOpenDialog) GetResult() (string, error) { + isMultiselect, err := fileOpenDialog.isMultiselect() + if err != nil { + return "", err + } + if isMultiselect { + // We should panic as this error is caused by the developer using the library + panic("use GetResults for open multiple files dialog") + } + return fileOpenDialog.vtbl.getResultString(unsafe.Pointer(fileOpenDialog)) +} + +func (fileOpenDialog *iFileOpenDialog) Release() error { + return fileOpenDialog.vtbl.release(unsafe.Pointer(fileOpenDialog)) +} + +func (fileOpenDialog *iFileOpenDialog) SetDefaultFolder(defaultFolderPath string) error { + return fileOpenDialog.vtbl.setDefaultFolder(unsafe.Pointer(fileOpenDialog), defaultFolderPath) +} + +func (fileOpenDialog *iFileOpenDialog) SetFolder(defaultFolderPath string) error { + return fileOpenDialog.vtbl.setFolder(unsafe.Pointer(fileOpenDialog), defaultFolderPath) +} + +func (fileOpenDialog *iFileOpenDialog) SetFileFilters(filter []FileFilter) error { + return fileOpenDialog.vtbl.setFileTypes(unsafe.Pointer(fileOpenDialog), filter) +} + +func (fileOpenDialog *iFileOpenDialog) SetRole(role string) error { + return fileOpenDialog.vtbl.setClientGuid(unsafe.Pointer(fileOpenDialog), StringToUUID(role)) +} + +// This should only be callable when the user asks for a multi select because +// otherwise they will be given the Dialog interface which does not expose this function. +func (fileOpenDialog *iFileOpenDialog) GetResults() ([]string, error) { + isMultiselect, err := fileOpenDialog.isMultiselect() + if err != nil { + return nil, err + } + if !isMultiselect { + // We should panic as this error is caused by the developer using the library + panic("use GetResult for open single file dialog") + } + return fileOpenDialog.vtbl.getResultsStrings(unsafe.Pointer(fileOpenDialog)) +} + +func (fileOpenDialog *iFileOpenDialog) SetDefaultExtension(defaultExtension string) error { + return fileOpenDialog.vtbl.setDefaultExtension(unsafe.Pointer(fileOpenDialog), defaultExtension) +} + +func (fileOpenDialog *iFileOpenDialog) SetFileName(initialFileName string) error { + return fileOpenDialog.vtbl.setFileName(unsafe.Pointer(fileOpenDialog), initialFileName) +} + +func (fileOpenDialog *iFileOpenDialog) SetSelectedFileFilterIndex(index uint) error { + return fileOpenDialog.vtbl.setSelectedFileFilterIndex(unsafe.Pointer(fileOpenDialog), index) +} + +func (fileOpenDialog *iFileOpenDialog) setPickFolders(pickFolders bool) error { + const FosPickfolders = 0x20 + if pickFolders { + return fileOpenDialog.vtbl.addOption(unsafe.Pointer(fileOpenDialog), FosPickfolders) + } else { + return fileOpenDialog.vtbl.removeOption(unsafe.Pointer(fileOpenDialog), FosPickfolders) + } +} + +const FosAllowMultiselect = 0x200 + +func (fileOpenDialog *iFileOpenDialog) isMultiselect() (bool, error) { + options, err := fileOpenDialog.vtbl.getOptions(unsafe.Pointer(fileOpenDialog)) + if err != nil { + return false, err + } + return options&FosAllowMultiselect != 0, nil +} + +func (fileOpenDialog *iFileOpenDialog) setIsMultiselect(isMultiselect bool) error { + if isMultiselect { + return fileOpenDialog.vtbl.addOption(unsafe.Pointer(fileOpenDialog), FosAllowMultiselect) + } else { + return fileOpenDialog.vtbl.removeOption(unsafe.Pointer(fileOpenDialog), FosAllowMultiselect) + } +} + +func (vtbl *iFileOpenDialogVtbl) getResults(objPtr unsafe.Pointer) (*iShellItemArray, error) { + var shellItemArray *iShellItemArray + ret, _, _ := syscall.Syscall(vtbl.GetResults, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(&shellItemArray)), + 0) + return shellItemArray, hresultToError(ret) +} + +func (vtbl *iFileOpenDialogVtbl) getResultsStrings(objPtr unsafe.Pointer) ([]string, error) { + shellItemArray, err := vtbl.getResults(objPtr) + if err != nil { + return nil, err + } + if shellItemArray == nil { + return nil, ErrorCancelled + } + defer shellItemArray.vtbl.release(unsafe.Pointer(shellItemArray)) + count, err := shellItemArray.vtbl.getCount(unsafe.Pointer(shellItemArray)) + if err != nil { + return nil, err + } + var results []string + for i := uintptr(0); i < count; i++ { + newItem, err := shellItemArray.vtbl.getItemAt(unsafe.Pointer(shellItemArray), i) + if err != nil { + return nil, err + } + results = append(results, newItem) + } + return results, nil +} + +func StringToUUID(str string) *ole.GUID { + return ole.NewGUID(uuid.NewSHA1(uuid.Nil, []byte(str)).String()) +} diff --git a/v3/internal/go-common-file-dialog/cfd/iFileSaveDialog.go b/v3/internal/go-common-file-dialog/cfd/iFileSaveDialog.go new file mode 100644 index 000000000..ddee7b246 --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/iFileSaveDialog.go @@ -0,0 +1,92 @@ +//go:build windows +// +build windows + +package cfd + +import ( + "github.com/go-ole/go-ole" + "unsafe" +) + +var ( + saveFileDialogCLSID = ole.NewGUID("{C0B4E2F3-BA21-4773-8DBA-335EC946EB8B}") + saveFileDialogIID = ole.NewGUID("{84bccd23-5fde-4cdb-aea4-af64b83d78ab}") +) + +type iFileSaveDialog struct { + vtbl *iFileSaveDialogVtbl + parentWindowHandle uintptr +} + +type iFileSaveDialogVtbl struct { + iFileDialogVtbl + + SetSaveAsItem uintptr + SetProperties uintptr + SetCollectedProperties uintptr + GetProperties uintptr + ApplyProperties uintptr +} + +func newIFileSaveDialog() (*iFileSaveDialog, error) { + if unknown, err := ole.CreateInstance(saveFileDialogCLSID, saveFileDialogIID); err == nil { + return (*iFileSaveDialog)(unsafe.Pointer(unknown)), nil + } else { + return nil, err + } +} + +func (fileSaveDialog *iFileSaveDialog) Show() error { + return fileSaveDialog.vtbl.show(unsafe.Pointer(fileSaveDialog), fileSaveDialog.parentWindowHandle) +} + +func (fileSaveDialog *iFileSaveDialog) SetParentWindowHandle(hwnd uintptr) { + fileSaveDialog.parentWindowHandle = hwnd +} + +func (fileSaveDialog *iFileSaveDialog) ShowAndGetResult() (string, error) { + if err := fileSaveDialog.Show(); err != nil { + return "", err + } + return fileSaveDialog.GetResult() +} + +func (fileSaveDialog *iFileSaveDialog) SetTitle(title string) error { + return fileSaveDialog.vtbl.setTitle(unsafe.Pointer(fileSaveDialog), title) +} + +func (fileSaveDialog *iFileSaveDialog) GetResult() (string, error) { + return fileSaveDialog.vtbl.getResultString(unsafe.Pointer(fileSaveDialog)) +} + +func (fileSaveDialog *iFileSaveDialog) Release() error { + return fileSaveDialog.vtbl.release(unsafe.Pointer(fileSaveDialog)) +} + +func (fileSaveDialog *iFileSaveDialog) SetDefaultFolder(defaultFolderPath string) error { + return fileSaveDialog.vtbl.setDefaultFolder(unsafe.Pointer(fileSaveDialog), defaultFolderPath) +} + +func (fileSaveDialog *iFileSaveDialog) SetFolder(defaultFolderPath string) error { + return fileSaveDialog.vtbl.setFolder(unsafe.Pointer(fileSaveDialog), defaultFolderPath) +} + +func (fileSaveDialog *iFileSaveDialog) SetFileFilters(filter []FileFilter) error { + return fileSaveDialog.vtbl.setFileTypes(unsafe.Pointer(fileSaveDialog), filter) +} + +func (fileSaveDialog *iFileSaveDialog) SetRole(role string) error { + return fileSaveDialog.vtbl.setClientGuid(unsafe.Pointer(fileSaveDialog), StringToUUID(role)) +} + +func (fileSaveDialog *iFileSaveDialog) SetDefaultExtension(defaultExtension string) error { + return fileSaveDialog.vtbl.setDefaultExtension(unsafe.Pointer(fileSaveDialog), defaultExtension) +} + +func (fileSaveDialog *iFileSaveDialog) SetFileName(initialFileName string) error { + return fileSaveDialog.vtbl.setFileName(unsafe.Pointer(fileSaveDialog), initialFileName) +} + +func (fileSaveDialog *iFileSaveDialog) SetSelectedFileFilterIndex(index uint) error { + return fileSaveDialog.vtbl.setSelectedFileFilterIndex(unsafe.Pointer(fileSaveDialog), index) +} diff --git a/v3/internal/go-common-file-dialog/cfd/iShellItem.go b/v3/internal/go-common-file-dialog/cfd/iShellItem.go new file mode 100644 index 000000000..6a747f4d9 --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/iShellItem.go @@ -0,0 +1,53 @@ +//go:build windows +// +build windows + +package cfd + +import ( + "github.com/go-ole/go-ole" + "syscall" + "unsafe" +) + +var ( + procSHCreateItemFromParsingName = syscall.NewLazyDLL("Shell32.dll").NewProc("SHCreateItemFromParsingName") + iidShellItem = ole.NewGUID("43826d1e-e718-42ee-bc55-a1e261c37bfe") +) + +type iShellItem struct { + vtbl *iShellItemVtbl +} + +type iShellItemVtbl struct { + iUnknownVtbl + BindToHandler uintptr + GetParent uintptr + GetDisplayName uintptr // func (sigdnName SIGDN, ppszName *LPWSTR) HRESULT + GetAttributes uintptr + Compare uintptr +} + +func newIShellItem(path string) (*iShellItem, error) { + var shellItem *iShellItem + pathPtr := ole.SysAllocString(path) + ret, _, _ := procSHCreateItemFromParsingName.Call( + uintptr(unsafe.Pointer(pathPtr)), + 0, + uintptr(unsafe.Pointer(iidShellItem)), + uintptr(unsafe.Pointer(&shellItem))) + return shellItem, hresultToError(ret) +} + +func (vtbl *iShellItemVtbl) getDisplayName(objPtr unsafe.Pointer) (string, error) { + var ptr *uint16 + ret, _, _ := syscall.Syscall(vtbl.GetDisplayName, + 2, + uintptr(objPtr), + 0x80058000, // SIGDN_FILESYSPATH + uintptr(unsafe.Pointer(&ptr))) + if err := hresultToError(ret); err != nil { + return "", err + } + defer ole.CoTaskMemFree(uintptr(unsafe.Pointer(ptr))) + return ole.LpOleStrToString(ptr), nil +} diff --git a/v3/internal/go-common-file-dialog/cfd/iShellItemArray.go b/v3/internal/go-common-file-dialog/cfd/iShellItemArray.go new file mode 100644 index 000000000..84f26fa20 --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/iShellItemArray.go @@ -0,0 +1,67 @@ +//go:build windows +// +build windows + +package cfd + +import ( + "github.com/go-ole/go-ole" + "syscall" + "unsafe" +) + +const ( + iidShellItemArrayGUID = "{b63ea76d-1f85-456f-a19c-48159efa858b}" +) + +var ( + iidShellItemArray *ole.GUID +) + +func init() { + iidShellItemArray, _ = ole.IIDFromString(iidShellItemArrayGUID) +} + +type iShellItemArray struct { + vtbl *iShellItemArrayVtbl +} + +type iShellItemArrayVtbl struct { + iUnknownVtbl + BindToHandler uintptr + GetPropertyStore uintptr + GetPropertyDescriptionList uintptr + GetAttributes uintptr + GetCount uintptr // func (pdwNumItems *DWORD) HRESULT + GetItemAt uintptr // func (dwIndex DWORD, ppsi **IShellItem) HRESULT + EnumItems uintptr +} + +func (vtbl *iShellItemArrayVtbl) getCount(objPtr unsafe.Pointer) (uintptr, error) { + var count uintptr + ret, _, _ := syscall.Syscall(vtbl.GetCount, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(&count)), + 0) + if err := hresultToError(ret); err != nil { + return 0, err + } + return count, nil +} + +func (vtbl *iShellItemArrayVtbl) getItemAt(objPtr unsafe.Pointer, index uintptr) (string, error) { + var shellItem *iShellItem + ret, _, _ := syscall.Syscall(vtbl.GetItemAt, + 2, + uintptr(objPtr), + index, + uintptr(unsafe.Pointer(&shellItem))) + if err := hresultToError(ret); err != nil { + return "", err + } + if shellItem == nil { + return "", ErrorCancelled + } + defer shellItem.vtbl.release(unsafe.Pointer(shellItem)) + return shellItem.vtbl.getDisplayName(unsafe.Pointer(shellItem)) +} diff --git a/v3/internal/go-common-file-dialog/cfd/vtblCommon.go b/v3/internal/go-common-file-dialog/cfd/vtblCommon.go new file mode 100644 index 000000000..21015c27c --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/vtblCommon.go @@ -0,0 +1,48 @@ +//go:build windows +// +build windows + +package cfd + +type comDlgFilterSpec struct { + pszName *int16 + pszSpec *int16 +} + +type iUnknownVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr +} + +type iModalWindowVtbl struct { + iUnknownVtbl + Show uintptr // func (hwndOwner HWND) HRESULT +} + +type iFileDialogVtbl struct { + iModalWindowVtbl + SetFileTypes uintptr // func (cFileTypes UINT, rgFilterSpec *COMDLG_FILTERSPEC) HRESULT + SetFileTypeIndex uintptr // func(iFileType UINT) HRESULT + GetFileTypeIndex uintptr + Advise uintptr + Unadvise uintptr + SetOptions uintptr // func (fos FILEOPENDIALOGOPTIONS) HRESULT + GetOptions uintptr // func (pfos *FILEOPENDIALOGOPTIONS) HRESULT + SetDefaultFolder uintptr // func (psi *IShellItem) HRESULT + SetFolder uintptr // func (psi *IShellItem) HRESULT + GetFolder uintptr + GetCurrentSelection uintptr + SetFileName uintptr // func (pszName LPCWSTR) HRESULT + GetFileName uintptr + SetTitle uintptr // func(pszTitle LPCWSTR) HRESULT + SetOkButtonLabel uintptr + SetFileNameLabel uintptr + GetResult uintptr // func (ppsi **IShellItem) HRESULT + AddPlace uintptr + SetDefaultExtension uintptr // func (pszDefaultExtension LPCWSTR) HRESULT + // This can only be used from a callback. + Close uintptr + SetClientGuid uintptr // func (guid REFGUID) HRESULT + ClearClientData uintptr + SetFilter uintptr +} diff --git a/v3/internal/go-common-file-dialog/cfd/vtblCommonFunc.go b/v3/internal/go-common-file-dialog/cfd/vtblCommonFunc.go new file mode 100644 index 000000000..a92100010 --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfd/vtblCommonFunc.go @@ -0,0 +1,227 @@ +//go:build windows +// +build windows + +package cfd + +import ( + "fmt" + "github.com/go-ole/go-ole" + "strings" + "syscall" + "unsafe" +) + +func hresultToError(hr uintptr) error { + if hr < 0 { + return ole.NewError(hr) + } + return nil +} + +func (vtbl *iUnknownVtbl) release(objPtr unsafe.Pointer) error { + ret, _, _ := syscall.Syscall(vtbl.Release, + 0, + uintptr(objPtr), + 0, + 0) + return hresultToError(ret) +} + +func (vtbl *iModalWindowVtbl) show(objPtr unsafe.Pointer, hwnd uintptr) error { + ret, _, _ := syscall.Syscall(vtbl.Show, + 1, + uintptr(objPtr), + hwnd, + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) setFileTypes(objPtr unsafe.Pointer, filters []FileFilter) error { + cFileTypes := len(filters) + if cFileTypes < 0 { + return fmt.Errorf("must specify at least one filter") + } + comDlgFilterSpecs := make([]comDlgFilterSpec, cFileTypes) + for i := 0; i < cFileTypes; i++ { + filter := &filters[i] + comDlgFilterSpecs[i] = comDlgFilterSpec{ + pszName: ole.SysAllocString(filter.DisplayName), + pszSpec: ole.SysAllocString(filter.Pattern), + } + } + ret, _, _ := syscall.Syscall(vtbl.SetFileTypes, + 2, + uintptr(objPtr), + uintptr(cFileTypes), + uintptr(unsafe.Pointer(&comDlgFilterSpecs[0]))) + return hresultToError(ret) +} + +// Options are: +// FOS_OVERWRITEPROMPT = 0x2, +// FOS_STRICTFILETYPES = 0x4, +// FOS_NOCHANGEDIR = 0x8, +// FOS_PICKFOLDERS = 0x20, +// FOS_FORCEFILESYSTEM = 0x40, +// FOS_ALLNONSTORAGEITEMS = 0x80, +// FOS_NOVALIDATE = 0x100, +// FOS_ALLOWMULTISELECT = 0x200, +// FOS_PATHMUSTEXIST = 0x800, +// FOS_FILEMUSTEXIST = 0x1000, +// FOS_CREATEPROMPT = 0x2000, +// FOS_SHAREAWARE = 0x4000, +// FOS_NOREADONLYRETURN = 0x8000, +// FOS_NOTESTFILECREATE = 0x10000, +// FOS_HIDEMRUPLACES = 0x20000, +// FOS_HIDEPINNEDPLACES = 0x40000, +// FOS_NODEREFERENCELINKS = 0x100000, +// FOS_OKBUTTONNEEDSINTERACTION = 0x200000, +// FOS_DONTADDTORECENT = 0x2000000, +// FOS_FORCESHOWHIDDEN = 0x10000000, +// FOS_DEFAULTNOMINIMODE = 0x20000000, +// FOS_FORCEPREVIEWPANEON = 0x40000000, +// FOS_SUPPORTSTREAMABLEITEMS = 0x80000000 +func (vtbl *iFileDialogVtbl) setOptions(objPtr unsafe.Pointer, options uint32) error { + ret, _, _ := syscall.Syscall(vtbl.SetOptions, + 1, + uintptr(objPtr), + uintptr(options), + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) getOptions(objPtr unsafe.Pointer) (uint32, error) { + var options uint32 + ret, _, _ := syscall.Syscall(vtbl.GetOptions, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(&options)), + 0) + return options, hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) addOption(objPtr unsafe.Pointer, option uint32) error { + if options, err := vtbl.getOptions(objPtr); err == nil { + return vtbl.setOptions(objPtr, options|option) + } else { + return err + } +} + +func (vtbl *iFileDialogVtbl) removeOption(objPtr unsafe.Pointer, option uint32) error { + if options, err := vtbl.getOptions(objPtr); err == nil { + return vtbl.setOptions(objPtr, options&^option) + } else { + return err + } +} + +func (vtbl *iFileDialogVtbl) setDefaultFolder(objPtr unsafe.Pointer, path string) error { + shellItem, err := newIShellItem(path) + if err != nil { + return err + } + defer shellItem.vtbl.release(unsafe.Pointer(shellItem)) + ret, _, _ := syscall.Syscall(vtbl.SetDefaultFolder, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(shellItem)), + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) setFolder(objPtr unsafe.Pointer, path string) error { + shellItem, err := newIShellItem(path) + if err != nil { + return err + } + defer shellItem.vtbl.release(unsafe.Pointer(shellItem)) + ret, _, _ := syscall.Syscall(vtbl.SetFolder, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(shellItem)), + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) setTitle(objPtr unsafe.Pointer, title string) error { + titlePtr := ole.SysAllocString(title) + ret, _, _ := syscall.Syscall(vtbl.SetTitle, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(titlePtr)), + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) close(objPtr unsafe.Pointer) error { + ret, _, _ := syscall.Syscall(vtbl.Close, + 1, + uintptr(objPtr), + 0, + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) getResult(objPtr unsafe.Pointer) (*iShellItem, error) { + var shellItem *iShellItem + ret, _, _ := syscall.Syscall(vtbl.GetResult, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(&shellItem)), + 0) + return shellItem, hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) getResultString(objPtr unsafe.Pointer) (string, error) { + shellItem, err := vtbl.getResult(objPtr) + if err != nil { + return "", err + } + if shellItem == nil { + return "", ErrorCancelled + } + defer shellItem.vtbl.release(unsafe.Pointer(shellItem)) + return shellItem.vtbl.getDisplayName(unsafe.Pointer(shellItem)) +} + +func (vtbl *iFileDialogVtbl) setClientGuid(objPtr unsafe.Pointer, guid *ole.GUID) error { + ret, _, _ := syscall.Syscall(vtbl.SetClientGuid, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(guid)), + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) setDefaultExtension(objPtr unsafe.Pointer, defaultExtension string) error { + if defaultExtension[0] == '.' { + defaultExtension = strings.TrimPrefix(defaultExtension, ".") + } + defaultExtensionPtr := ole.SysAllocString(defaultExtension) + ret, _, _ := syscall.Syscall(vtbl.SetDefaultExtension, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(defaultExtensionPtr)), + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) setFileName(objPtr unsafe.Pointer, fileName string) error { + fileNamePtr := ole.SysAllocString(fileName) + ret, _, _ := syscall.Syscall(vtbl.SetFileName, + 1, + uintptr(objPtr), + uintptr(unsafe.Pointer(fileNamePtr)), + 0) + return hresultToError(ret) +} + +func (vtbl *iFileDialogVtbl) setSelectedFileFilterIndex(objPtr unsafe.Pointer, index uint) error { + ret, _, _ := syscall.Syscall(vtbl.SetFileTypeIndex, + 1, + uintptr(objPtr), + uintptr(index+1), // SetFileTypeIndex counts from 1 + 0) + return hresultToError(ret) +} diff --git a/v3/internal/go-common-file-dialog/cfdutil/CFDUtil.go b/v3/internal/go-common-file-dialog/cfdutil/CFDUtil.go new file mode 100644 index 000000000..aa3a783b2 --- /dev/null +++ b/v3/internal/go-common-file-dialog/cfdutil/CFDUtil.go @@ -0,0 +1,45 @@ +package cfdutil + +import ( + "github.com/wailsapp/wails/v3/internal/go-common-file-dialog/cfd" +) + +// TODO doc +func ShowOpenFileDialog(config cfd.DialogConfig) (string, error) { + dialog, err := cfd.NewOpenFileDialog(config) + if err != nil { + return "", err + } + defer dialog.Release() + return dialog.ShowAndGetResult() +} + +// TODO doc +func ShowOpenMultipleFilesDialog(config cfd.DialogConfig) ([]string, error) { + dialog, err := cfd.NewOpenMultipleFilesDialog(config) + if err != nil { + return nil, err + } + defer dialog.Release() + return dialog.ShowAndGetResults() +} + +// TODO doc +func ShowPickFolderDialog(config cfd.DialogConfig) (string, error) { + dialog, err := cfd.NewSelectFolderDialog(config) + if err != nil { + return "", err + } + defer dialog.Release() + return dialog.ShowAndGetResult() +} + +// TODO doc +func ShowSaveFileDialog(config cfd.DialogConfig) (string, error) { + dialog, err := cfd.NewSaveFileDialog(config) + if err != nil { + return "", err + } + defer dialog.Release() + return dialog.ShowAndGetResult() +} diff --git a/v3/internal/go-common-file-dialog/util/util.go b/v3/internal/go-common-file-dialog/util/util.go new file mode 100644 index 000000000..723fbedc0 --- /dev/null +++ b/v3/internal/go-common-file-dialog/util/util.go @@ -0,0 +1,10 @@ +package util + +import ( + "github.com/go-ole/go-ole" + "github.com/google/uuid" +) + +func StringToUUID(str string) *ole.GUID { + return ole.NewGUID(uuid.NewSHA1(uuid.Nil, []byte(str)).String()) +} diff --git a/v3/internal/go-common-file-dialog/util/util_test.go b/v3/internal/go-common-file-dialog/util/util_test.go new file mode 100644 index 000000000..2e8ffeb05 --- /dev/null +++ b/v3/internal/go-common-file-dialog/util/util_test.go @@ -0,0 +1,14 @@ +package util + +import ( + "github.com/go-ole/go-ole" + "testing" +) + +func TestStringToUUID(t *testing.T) { + generated := *StringToUUID("TestTestTest") + expected := *ole.NewGUID("7933985F-2C87-5A5B-A26E-5D0326829AC2") + if generated != expected { + t.Errorf("not equal. expected %s, found %s", expected.String(), generated.String()) + } +} diff --git a/v3/pkg/application/dialogs.go b/v3/pkg/application/dialogs.go index dba5b3bac..1b7d75e24 100644 --- a/v3/pkg/application/dialogs.go +++ b/v3/pkg/application/dialogs.go @@ -48,11 +48,22 @@ type Button struct { Label string IsCancel bool IsDefault bool - callback func() + Callback func() } -func (b *Button) OnClick(callback func()) { - b.callback = callback +func (b *Button) OnClick(callback func()) *Button { + b.Callback = callback + return b +} + +func (b *Button) SetAsDefault() *Button { + b.IsDefault = true + return b +} + +func (b *Button) SetAsCancel() *Button { + b.IsCancel = true + return b } type messageDialogImpl interface { @@ -336,10 +347,12 @@ type SaveFileDialogOptions struct { AllowOtherFileTypes bool HideExtension bool TreatsFilePackagesAsDirectories bool + Title string Message string Directory string Filename string ButtonText string + Filters []FileFilter } type SaveFileDialog struct { @@ -354,10 +367,12 @@ type SaveFileDialog struct { directory string filename string buttonText string + filters []FileFilter window *WebviewWindow - impl saveFileDialogImpl + impl saveFileDialogImpl + title string } type saveFileDialogImpl interface { @@ -365,6 +380,7 @@ type saveFileDialogImpl interface { } func (d *SaveFileDialog) SetOptions(options *SaveFileDialogOptions) { + d.title = options.Title d.canCreateDirectories = options.CanCreateDirectories d.showHiddenFiles = options.ShowHiddenFiles d.canSelectHiddenExtension = options.CanSelectHiddenExtension @@ -377,6 +393,16 @@ func (d *SaveFileDialog) SetOptions(options *SaveFileDialogOptions) { d.buttonText = options.ButtonText } +// AddFilter adds a filter to the dialog. The filter is a display name and a semicolon separated list of extensions. +// EG: AddFilter("Image Files", "*.jpg;*.png") +func (d *SaveFileDialog) AddFilter(displayName, pattern string) *SaveFileDialog { + d.filters = append(d.filters, FileFilter{ + DisplayName: strings.TrimSpace(displayName), + Pattern: strings.TrimSpace(pattern), + }) + return d +} + func (d *SaveFileDialog) CanCreateDirectories(canCreateDirectories bool) *SaveFileDialog { d.canCreateDirectories = canCreateDirectories return d diff --git a/v3/pkg/application/dialogs_darwin.go b/v3/pkg/application/dialogs_darwin.go index 03ae25c2d..860697c52 100644 --- a/v3/pkg/application/dialogs_darwin.go +++ b/v3/pkg/application/dialogs_darwin.go @@ -364,8 +364,8 @@ func (m *macosDialog) show() { buttonPressed := int(C.dialogRunModal(m.nsDialog)) if len(m.dialog.Buttons) > buttonPressed { button := reversedButtons[buttonPressed] - if button.callback != nil { - button.callback() + if button.Callback != nil { + button.Callback() } } }) diff --git a/v3/pkg/application/dialogs_windows.go b/v3/pkg/application/dialogs_windows.go index d774bc78f..9a05d87ef 100644 --- a/v3/pkg/application/dialogs_windows.go +++ b/v3/pkg/application/dialogs_windows.go @@ -2,6 +2,14 @@ package application +import ( + "github.com/wailsapp/wails/v3/internal/go-common-file-dialog/cfd" + "github.com/wailsapp/wails/v3/pkg/w32" + "golang.org/x/sys/windows" + "path/filepath" + "strings" +) + func (m *windowsApp) showAboutDialog(title string, message string, icon []byte) { panic("implement me") } @@ -13,62 +21,26 @@ type windowsDialog struct { } func (m *windowsDialog) show() { - // - //// Mac can only have 4 Buttons on a dialog - //if len(m.dialog.Buttons) > 4 { - // m.dialog.Buttons = m.dialog.Buttons[:4] - //} - // - //if m.nsDialog != nil { - // C.releaseDialog(m.nsDialog) - //} - //var title *C.char - //if m.dialog.Title != "" { - // title = C.CString(m.dialog.Title) - //} - //var message *C.char - //if m.dialog.Message != "" { - // message = C.CString(m.dialog.Message) - //} - //var iconData unsafe.Pointer - //var iconLength C.int - //if m.dialog.Icon != nil { - // iconData = unsafe.Pointer(&m.dialog.Icon[0]) - // iconLength = C.int(len(m.dialog.Icon)) - //} else { - // // if it's an error, use the application Icon - // if m.dialog.DialogType == ErrorDialog { - // iconData = unsafe.Pointer(&globalApplication.options.Icon[0]) - // iconLength = C.int(len(globalApplication.options.Icon)) - // } - //} - // - //alertType, ok := alertTypeMap[m.dialog.DialogType] - //if !ok { - // alertType = C.NSAlertStyleInformational - //} - // - //m.nsDialog = C.createAlert(alertType, title, message, iconData, iconLength) - // - //// Reverse the Buttons so that the default is on the right - //reversedButtons := make([]*Button, len(m.dialog.Buttons)) - //var count = 0 - //for i := len(m.dialog.Buttons) - 1; i >= 0; i-- { - // button := m.dialog.Buttons[i] - // C.alertAddButton(m.nsDialog, C.CString(button.Label), C.bool(button.IsDefault), C.bool(button.IsCancel)) - // reversedButtons[count] = m.dialog.Buttons[i] - // count++ - //} - // - //buttonPressed := int(C.dialogRunModal(m.nsDialog)) - //if len(m.dialog.Buttons) > buttonPressed { - // button := reversedButtons[buttonPressed] - // if button.callback != nil { - // button.callback() - // } - //} - panic("implement me") + title := w32.MustStringToUTF16Ptr(m.dialog.Title) + message := w32.MustStringToUTF16Ptr(m.dialog.Message) + flags := calculateMessageDialogFlags(m.dialog.MessageDialogOptions) + + button, _ := windows.MessageBox(windows.HWND(0), message, title, flags|windows.MB_SYSTEMMODAL) + // 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 _, button := range m.dialog.Buttons { + if button.Label == result { + if button.Callback != nil { + button.Callback() + } + } + } } func newDialogImpl(d *MessageDialog) *windowsDialog { @@ -87,54 +59,49 @@ func newOpenFileDialogImpl(d *OpenFileDialog) *windowOpenFileDialog { } } +func getDefaultFolder(folder string) (string, error) { + if folder == "" { + return "", nil + } + return filepath.Abs(folder) +} + func (m *windowOpenFileDialog) show() ([]string, error) { - //openFileResponses[m.dialog.id] = make(chan string) - //nsWindow := unsafe.Pointer(nil) - //if m.dialog.window != nil { - // // get NSWindow from window - // nsWindow = m.dialog.window.impl.(*windowsWebviewWindow).nsWindow - //} - // - //// Massage filter patterns into macOS format - //// We iterate all filter patterns, tidy them up and then join them with a semicolon - //// This should produce a single string of extensions like "png;jpg;gif" - //var filterPatterns string - //if len(m.dialog.filters) > 0 { - // var allPatterns []string - // for _, filter := range m.dialog.filters { - // patternComponents := strings.Split(filter.Pattern, ";") - // for i, component := range patternComponents { - // filterPattern := strings.TrimSpace(component) - // filterPattern = strings.TrimPrefix(filterPattern, "*.") - // patternComponents[i] = filterPattern - // } - // allPatterns = append(allPatterns, strings.Join(patternComponents, ";")) - // } - // filterPatterns = strings.Join(allPatterns, ";") - //} - // - //C.showOpenFileDialog(C.uint(m.dialog.id), - // C.bool(m.dialog.canChooseFiles), - // C.bool(m.dialog.canChooseDirectories), - // C.bool(m.dialog.canCreateDirectories), - // C.bool(m.dialog.showHiddenFiles), - // C.bool(m.dialog.allowsMultipleSelection), - // C.bool(m.dialog.resolvesAliases), - // C.bool(m.dialog.hideExtension), - // C.bool(m.dialog.treatsFilePackagesAsDirectories), - // C.bool(m.dialog.allowsOtherFileTypes), - // toCString(filterPatterns), - // C.uint(len(filterPatterns)), - // toCString(m.dialog.message), - // toCString(m.dialog.directory), - // toCString(m.dialog.buttonText), - // nsWindow) - //var result []string - //for filename := range openFileResponses[m.dialog.id] { - // result = append(result, filename) - //} - //return result, nil - panic("implement me") + + defaultFolder, err := getDefaultFolder(m.dialog.directory) + if err != nil { + return nil, err + } + + config := cfd.DialogConfig{ + Title: m.dialog.title, + Role: "PickFolder", + FileFilters: convertFilters(m.dialog.filters), + Folder: defaultFolder, + } + + var result []string + if m.dialog.allowsMultipleSelection { + temp, err := showCfdDialog( + func() (cfd.Dialog, error) { + return cfd.NewOpenMultipleFilesDialog(config) + }, true) + if err != nil { + return nil, err + } + result = temp.([]string) + } else { + temp, err := showCfdDialog( + func() (cfd.Dialog, error) { + return cfd.NewOpenFileDialog(config) + }, false) + if err != nil { + return nil, err + } + result = []string{temp.(string)} + } + + return result, nil } type windowSaveFileDialog struct { @@ -148,24 +115,71 @@ func newSaveFileDialogImpl(d *SaveFileDialog) *windowSaveFileDialog { } func (m *windowSaveFileDialog) show() (string, error) { - //saveFileResponses[m.dialog.id] = make(chan string) - //nsWindow := unsafe.Pointer(nil) - //if m.dialog.window != nil { - // // get NSWindow from window - // nsWindow = m.dialog.window.impl.(*macosWebviewWindow).nsWindow - //} - //C.showSaveFileDialog(C.uint(m.dialog.id), - // C.bool(m.dialog.canCreateDirectories), - // C.bool(m.dialog.showHiddenFiles), - // C.bool(m.dialog.canSelectHiddenExtension), - // C.bool(m.dialog.hideExtension), - // C.bool(m.dialog.treatsFilePackagesAsDirectories), - // C.bool(m.dialog.allowOtherFileTypes), - // toCString(m.dialog.message), - // toCString(m.dialog.directory), - // toCString(m.dialog.buttonText), - // toCString(m.dialog.filename), - // nsWindow) - //return <-saveFileResponses[m.dialog.id], nil - panic("implement me") + defaultFolder, err := getDefaultFolder(m.dialog.directory) + if err != nil { + return "", err + } + + config := cfd.DialogConfig{ + Title: m.dialog.title, + Role: "SaveFile", + FileFilters: convertFilters(m.dialog.filters), + FileName: m.dialog.filename, + Folder: defaultFolder, + } + + result, err := showCfdDialog( + func() (cfd.Dialog, error) { + return cfd.NewSaveFileDialog(config) + }, false) + return result.(string), nil +} + +func calculateMessageDialogFlags(options MessageDialogOptions) uint32 { + var flags uint32 + + switch options.DialogType { + case InfoDialog: + flags = windows.MB_OK | windows.MB_ICONINFORMATION + case ErrorDialog: + flags = windows.MB_ICONERROR | windows.MB_OK + case QuestionDialog: + flags = windows.MB_YESNO + for _, button := range options.Buttons { + if strings.TrimSpace(strings.ToLower(button.Label)) == "no" && button.IsDefault { + flags |= windows.MB_DEFBUTTON2 + } + } + case WarningDialog: + flags = windows.MB_OK | windows.MB_ICONWARNING + } + + return flags +} + +func convertFilters(filters []FileFilter) []cfd.FileFilter { + var result []cfd.FileFilter + for _, filter := range filters { + result = append(result, cfd.FileFilter(filter)) + } + return result +} + +func showCfdDialog(newDlg func() (cfd.Dialog, error), isMultiSelect bool) (any, error) { + dlg, err := newDlg() + if err != nil { + return nil, err + } + defer func() { + err := dlg.Release() + if err != nil { + println("ERROR: Unable to release dialog:", err.Error()) + } + }() + + dlg.SetParentWindowHandle(0) + if multi, _ := dlg.(cfd.OpenMultipleFilesDialog); multi != nil && isMultiSelect { + return multi.ShowAndGetResults() + } + return dlg.ShowAndGetResult() } diff --git a/v3/pkg/application/menuitem_windows.go b/v3/pkg/application/menuitem_windows.go index caf07b850..9cec67a8e 100644 --- a/v3/pkg/application/menuitem_windows.go +++ b/v3/pkg/application/menuitem_windows.go @@ -3,32 +3,99 @@ package application import ( + "github.com/wailsapp/wails/v3/pkg/w32" "unsafe" ) type windowsMenuItem struct { menuItem *MenuItem - menuItemImpl unsafe.Pointer + hMenu w32.HMENU + id int + label string + disabled bool + checked bool + itemType menuItemType + hidden bool + submenu w32.HMENU } -func (m windowsMenuItem) setTooltip(tooltip string) { - //C.setMenuItemTooltip(m.nsMenuItem, C.CString(tooltip)) +func (m *windowsMenuItem) setHidden(hidden bool) { + m.hidden = hidden } -func (m windowsMenuItem) setLabel(s string) { - //C.setMenuItemLabel(m.nsMenuItem, C.CString(s)) +func (m *windowsMenuItem) Checked() bool { + return m.checked } -func (m windowsMenuItem) setDisabled(disabled bool) { - //C.setMenuItemDisabled(m.nsMenuItem, C.bool(disabled)) +func (m *windowsMenuItem) IsSeparator() bool { + return m.itemType == separator } -func (m windowsMenuItem) setChecked(checked bool) { - //C.setMenuItemChecked(m.nsMenuItem, C.bool(checked)) +func (m *windowsMenuItem) IsCheckbox() bool { + return m.itemType == checkbox } -func (m windowsMenuItem) setAccelerator(accelerator *accelerator) { +func (m *windowsMenuItem) Enabled() bool { + return !m.disabled +} + +func (m *windowsMenuItem) update() { + var mii w32.MENUITEMINFO + mii.CbSize = uint32(unsafe.Sizeof(mii)) + mii.FMask = w32.MIIM_FTYPE | w32.MIIM_ID | w32.MIIM_STATE | w32.MIIM_STRING + if m.IsSeparator() { + mii.FType = w32.MFT_SEPARATOR + } else { + mii.FType = w32.MFT_STRING + //var text string + //if s := a.shortcut; s.Key != 0 { + // text = fmt.Sprintf("%s\t%s", a.text, s.String()) + // shortcut2Action[a.shortcut] = a + //} else { + // text = a.text + //} + mii.DwTypeData = w32.MustStringToUTF16Ptr(m.label) + mii.Cch = uint32(len([]rune(m.label))) + } + mii.WID = uint32(m.id) + if m.Enabled() { + mii.FState &^= w32.MFS_DISABLED + } else { + mii.FState |= w32.MFS_DISABLED + } + + if m.IsCheckbox() { + mii.FMask |= w32.MIIM_CHECKMARKS + } + if m.Checked() { + mii.FState |= w32.MFS_CHECKED + } + + if m.menuItem.submenu != nil { + mii.FMask |= w32.MIIM_SUBMENU + mii.HSubMenu = m.submenu + } + + w32.SetMenuItemInfo(m.hMenu, uint32(m.id), false, &mii) +} + +func (m *windowsMenuItem) setLabel(label string) { + m.label = label + m.update() +} + +func (m *windowsMenuItem) setDisabled(disabled bool) { + m.disabled = disabled + m.update() +} + +func (m *windowsMenuItem) setChecked(checked bool) { + m.checked = checked + m.update() +} + +func (m *windowsMenuItem) setAccelerator(accelerator *accelerator) { //// Set the keyboard shortcut of the menu item //var modifier C.int //var key *C.char @@ -41,23 +108,18 @@ func (m windowsMenuItem) setAccelerator(accelerator *accelerator) { //C.setMenuItemKeyEquivalent(m.nsMenuItem, key, modifier) } -func newMenuItemImpl(item *MenuItem) *windowsMenuItem { +func newMenuItemImpl(item *MenuItem, parentMenu w32.HMENU, ID int) *windowsMenuItem { result := &windowsMenuItem{ menuItem: item, + hMenu: parentMenu, + id: ID, + disabled: item.disabled, + checked: item.checked, + itemType: item.itemType, + label: item.label, + hidden: item.hidden, } - // - //switch item.itemType { - //case text, checkbox, submenu, radio: - // result.nsMenuItem = unsafe.Pointer(C.newMenuItem(C.uint(item.id), C.CString(item.label), C.bool(item.disabled), C.CString(item.tooltip))) - // if item.itemType == checkbox || item.itemType == radio { - // C.setMenuItemChecked(result.nsMenuItem, C.bool(item.checked)) - // } - // if item.accelerator != nil { - // result.setAccelerator(item.accelerator) - // } - //default: - // panic("WTF") - //} + return result } @@ -181,3 +243,9 @@ func newZoomMenuItem() *MenuItem { func newFullScreenMenuItem() *MenuItem { panic("implement me") } + +// ---------- unsupported on windows ---------- + +func (m *windowsMenuItem) setTooltip(_ string) { + // Unsupported +} diff --git a/v3/pkg/application/popupmenu_windows.go b/v3/pkg/application/popupmenu_windows.go new file mode 100644 index 000000000..dd77042a4 --- /dev/null +++ b/v3/pkg/application/popupmenu_windows.go @@ -0,0 +1,213 @@ +package application + +import ( + "fmt" + "github.com/wailsapp/wails/v3/pkg/w32" +) + +const ( + MenuItemMsgID = w32.WM_APP + 1024 +) + +type RadioGroupMember struct { + ID int + MenuItem *MenuItem +} + +type RadioGroup []*RadioGroupMember + +func (r *RadioGroup) Add(id int, item *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 *MenuItem) int { + for _, member := range *r { + if member.MenuItem == item { + return member.ID + } + } + panic("RadioGroup.MenuID: item not found:") +} + +type PopupMenu struct { + menu w32.PopupMenu + parent w32.HWND + menuMapping map[int]*MenuItem + checkboxItems map[*MenuItem][]int + radioGroups map[*MenuItem][]*RadioGroup + menuData *Menu + currentMenuID int + onMenuClose func() + onMenuOpen func() +} + +func (p *PopupMenu) buildMenu(parentMenu w32.PopupMenu, inputMenu *Menu) { + var currentRadioGroup RadioGroup + for _, item := range inputMenu.items { + if item.Hidden() { + continue + } + p.currentMenuID++ + itemID := p.currentMenuID + p.menuMapping[itemID] = item + + menuItemImpl := newMenuItemImpl(item, w32.HWND(parentMenu), itemID) + + flags := uint32(w32.MF_STRING) + if item.disabled { + flags = flags | w32.MF_GRAYED + } + if item.checked && item.IsCheckbox() { + flags = flags | w32.MF_CHECKED + } + if item.IsSeparator() { + flags = flags | w32.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 | w32.MF_POPUP + newSubmenu := w32.CreatePopupMenu() + p.buildMenu(newSubmenu, item.submenu) + itemID = int(newSubmenu) + menuItemImpl.submenu = w32.HWND(newSubmenu) + } + + var menuText = item.Label() + + ok := parentMenu.Append(flags, uintptr(itemID), menuText) + if !ok { + w32.Fatal(fmt.Sprintf("Error adding menu item: %s", menuText)) + } + + item.impl = menuItemImpl + } + if len(currentRadioGroup) > 0 { + for _, radioMember := range currentRadioGroup { + currentRadioGroup := currentRadioGroup + p.radioGroups[radioMember.MenuItem] = append(p.radioGroups[radioMember.MenuItem], ¤tRadioGroup) + } + currentRadioGroup = RadioGroup{} + } +} + +func (p *PopupMenu) Update() { + p.menu = w32.CreatePopupMenu() + p.menuMapping = make(map[int]*MenuItem) + p.currentMenuID = MenuItemMsgID + p.buildMenu(p.menu, p.menuData) + p.updateRadioGroups() +} + +func NewPopupMenu(parent w32.HWND, inputMenu *Menu) *PopupMenu { + result := &PopupMenu{ + parent: parent, + menuData: inputMenu, + checkboxItems: make(map[*MenuItem][]int), + radioGroups: make(map[*MenuItem][]*RadioGroup), + } + result.Update() + return result +} + +func (p *PopupMenu) ShowAtCursor() { + x, y, ok := w32.GetCursorPos() + if ok == false { + w32.Fatal("GetCursorPos failed") + } + + w32.SetForegroundWindow(p.parent) + + if p.onMenuOpen != nil { + p.onMenuOpen() + } + + if p.menu.Track(p.parent, w32.TPM_LEFTALIGN, int32(x), int32(y-5)) == false { + w32.Fatal("TrackPopupMenu failed") + } + + if p.onMenuClose != nil { + p.onMenuClose() + } + + if !w32.PostMessage(p.parent, w32.WM_NULL, 0, 0) { + w32.Fatal("PostMessage failed") + } + +} + +func (p *PopupMenu) ProcessCommand(cmdMsgID int) { + item := p.menuMapping[cmdMsgID] + if item == nil { + return + } + if item.IsRadio() { + item.checked = true + p.updateRadioGroup(item) + } + if item.callback != nil { + item.handleClick() + } +} + +func (p *PopupMenu) Destroy() { + p.menu.Destroy() +} + +func (p *PopupMenu) UpdateMenuItem(item *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 *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 +} diff --git a/v3/pkg/application/systemtray.go b/v3/pkg/application/systemtray.go index dbac6372a..4cb95e14d 100644 --- a/v3/pkg/application/systemtray.go +++ b/v3/pkg/application/systemtray.go @@ -38,6 +38,8 @@ type SystemTray struct { rightDoubleClickHandler func() mouseEnterHandler func() mouseLeaveHandler func() + onMenuOpen func() + onMenuClose func() // Platform specific implementation impl systemTrayImpl diff --git a/v3/pkg/application/systemtray_windows.go b/v3/pkg/application/systemtray_windows.go index 8e053e5f7..513a67639 100644 --- a/v3/pkg/application/systemtray_windows.go +++ b/v3/pkg/application/systemtray_windows.go @@ -19,7 +19,7 @@ const ( type windowsSystemTray struct { parent *SystemTray - menu *windowsMenu + menu *PopupMenu // Platform specific implementation uid uint32 @@ -30,8 +30,7 @@ type windowsSystemTray struct { } func (s *windowsSystemTray) setMenu(menu *Menu) { - //s.menu = menu - panic("implement me") + s.updateMenu(menu) } func (s *windowsSystemTray) run() { @@ -84,7 +83,7 @@ func (s *windowsSystemTray) run() { s.uid = nid.UID if s.parent.menu != nil { - s.updateMenu() + s.updateMenu(s.parent.menu) } // Set Default Callbacks @@ -226,10 +225,11 @@ func (s *windowsSystemTray) wndProc(msg uint32, wParam, lParam uintptr) uintptr return w32.DefWindowProc(s.hwnd, msg, wParam, lParam) } -func (s *windowsSystemTray) updateMenu() { - s.menu = newMenuImpl(s.parent.menu) - s.menu.hWnd = s.hwnd - s.menu.update() +func (s *windowsSystemTray) updateMenu(menu *Menu) { + s.menu = NewPopupMenu(s.hwnd, menu) + s.menu.onMenuOpen = s.parent.onMenuOpen + s.menu.onMenuClose = s.parent.onMenuClose + s.menu.Update() } // ---- Unsupported ---- diff --git a/v3/pkg/w32/utils.go b/v3/pkg/w32/utils.go index 1a3ad7cdb..fb6612e21 100644 --- a/v3/pkg/w32/utils.go +++ b/v3/pkg/w32/utils.go @@ -9,6 +9,9 @@ package w32 import ( "fmt" + "os" + "runtime/debug" + "strconv" "syscall" "unicode/utf16" "unsafe" @@ -635,3 +638,11 @@ func WMMessageToString(msg uintptr) string { return fmt.Sprintf("0x%08x", msg) } } + +func Fatal(message string) { + println("***************** FATAL ERROR ******************") + fmt.Println("Message: " + message) + fmt.Println("Last Error: " + strconv.Itoa(int(GetLastError()))) + fmt.Println("Stack: " + string(debug.Stack())) + os.Exit(1) +} diff --git a/v3/pkg/w32/window.go b/v3/pkg/w32/window.go index 7a60d650c..e4aaafdc1 100644 --- a/v3/pkg/w32/window.go +++ b/v3/pkg/w32/window.go @@ -117,7 +117,11 @@ func showWindow(hwnd uintptr, cmdshow int) bool { } func MustStringToUTF16Ptr(input string) *uint16 { - return lo.Must(syscall.UTF16PtrFromString(input)) + result, err := syscall.UTF16PtrFromString(input) + if err != nil { + Fatal(err.Error()) + } + return result } func MustStringToUTF16uintptr(input string) uintptr {