diff --git a/README.md b/README.md index a00e70c..514d516 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,13 @@ This repo includes both a cross-platform Go package providing (simple dialogs that interact graphically with the user), as well as a *“port”* of the `zenity` command to both Windows and macOS based on that library. -**This is a work in progress.** - -Lots of things are missing. -For now, these are the only implemented dialogs: +Implemented dialogs: * [message](https://github.com/ncruces/zenity/wiki/Message-dialog) (error, info, question, warning) +* [text entry](https://github.com/ncruces/zenity/wiki/Text-Entry-dialog) +* [list](https://github.com/ncruces/zenity/wiki/List-dialog) +* [password](https://github.com/ncruces/zenity/wiki/Password-dialog) * [file selection](https://github.com/ncruces/zenity/wiki/File-Selection-dialog) * [color selection](https://github.com/ncruces/zenity/wiki/Color-Selection-dialog) -* [text entry](https://github.com/ncruces/zenity/wiki/Text-Entry-dialog) -* [password](https://github.com/ncruces/zenity/wiki/Password-dialog) * [notification](https://github.com/ncruces/zenity/wiki/Notification) Behavior on Windows, macOS and other Unixes might differ slightly. diff --git a/cmd/zenity/main.go b/cmd/zenity/main.go index 9f9c99a..2e5cda3 100644 --- a/cmd/zenity/main.go +++ b/cmd/zenity/main.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "errors" "flag" "image/color" "os" @@ -24,15 +25,16 @@ const ( var ( // Application Options - notification bool - entryDlg bool errorDlg bool infoDlg bool warningDlg bool questionDlg bool + entryDlg bool + listDlg bool passwordDlg bool fileSelectionDlg bool colorSelectionDlg bool + notification bool // General options title string @@ -43,25 +45,29 @@ var ( extraButton string text string icon string - - // Entry options - entryText string - hideText bool + multiple bool // Message options noWrap bool ellipsize bool defaultCancel bool + // Entry options + entryText string + hideText bool + + // List options + columns int + allowEmpty bool + // File selection options save bool - multiple bool directory bool confirmOverwrite bool confirmCreate bool showHidden bool filename string - fileFilters FileFilters + fileFilters zenity.FileFilters // Color selection options defaultColor string @@ -93,12 +99,6 @@ func main() { } switch { - case notification: - errResult(zenity.Notify(text, opts...)) - - case entryDlg: - strOKResult(zenity.Entry(text, opts...)) - case errorDlg: okResult(zenity.Error(text, opts...)) case infoDlg: @@ -108,6 +108,16 @@ func main() { case questionDlg: okResult(zenity.Question(text, opts...)) + case entryDlg: + strOKResult(zenity.Entry(text, opts...)) + + case listDlg: + if multiple { + listResult(zenity.ListMultiple(text, flag.Args(), opts...)) + } else { + strOKResult(zenity.List(text, flag.Args(), opts...)) + } + case passwordDlg: _, pw, ok, err := zenity.Password(opts...) strOKResult(pw, ok, err) @@ -124,6 +134,9 @@ func main() { case colorSelectionDlg: colorResult(zenity.SelectColor(opts...)) + + case notification: + errResult(zenity.Notify(text, opts...)) } flag.Usage() @@ -131,15 +144,16 @@ func main() { func setupFlags() { // Application Options - flag.BoolVar(¬ification, "notification", false, "Display notification") - flag.BoolVar(&entryDlg, "entry", false, "Display text entry dialog") flag.BoolVar(&errorDlg, "error", false, "Display error dialog") flag.BoolVar(&infoDlg, "info", false, "Display info dialog") flag.BoolVar(&warningDlg, "warning", false, "Display warning dialog") flag.BoolVar(&questionDlg, "question", false, "Display question dialog") + flag.BoolVar(&entryDlg, "entry", false, "Display text entry dialog") + flag.BoolVar(&listDlg, "list", false, "Display list dialog") flag.BoolVar(&passwordDlg, "password", false, "Display password dialog") flag.BoolVar(&fileSelectionDlg, "file-selection", false, "Display file selection dialog") flag.BoolVar(&colorSelectionDlg, "color-selection", false, "Display color selection dialog") + flag.BoolVar(¬ification, "notification", false, "Display notification") // General options flag.StringVar(&title, "title", "", "Set the dialog `title`") @@ -150,10 +164,7 @@ func setupFlags() { flag.StringVar(&extraButton, "extra-button", "", "Add an extra button") flag.StringVar(&text, "text", "", "Set the dialog `text`") flag.StringVar(&icon, "window-icon", "", "Set the window `icon` (error, info, question, warning)") - - // Entry options - flag.StringVar(&entryText, "entry-text", "", "Set the entry `text`") - flag.BoolVar(&hideText, "hide-text", false, "Hide the entry text") + flag.BoolVar(&multiple, "multiple", false, "Allow multiple items to be selected") // Message options flag.StringVar(&icon, "icon-name", "", "Set the dialog `icon` (dialog-error, dialog-information, dialog-question, dialog-warning)") @@ -161,15 +172,23 @@ func setupFlags() { flag.BoolVar(&ellipsize, "ellipsize", false, "Enable ellipsizing in the dialog text") flag.BoolVar(&defaultCancel, "default-cancel", false, "Give Cancel button focus by default") + // Entry options + flag.StringVar(&entryText, "entry-text", "", "Set the entry `text`") + flag.BoolVar(&hideText, "hide-text", false, "Hide the entry text") + + // List options + flag.Var(funcValue(addColumn), "column", "Set the column header") + flag.Bool("hide-header", true, "Hide the column headers") + flag.BoolVar(&allowEmpty, "allow-empty", true, "Allow empty selection (macOS only)") + // File selection options flag.BoolVar(&save, "save", false, "Activate save mode") - flag.BoolVar(&multiple, "multiple", false, "Allow multiple files to be selected") flag.BoolVar(&directory, "directory", false, "Activate directory-only selection") flag.BoolVar(&confirmOverwrite, "confirm-overwrite", false, "Confirm file selection if filename already exists") flag.BoolVar(&confirmCreate, "confirm-create", false, "Confirm file selection if filename does not yet exist (Windows only)") flag.BoolVar(&showHidden, "show-hidden", false, "Show hidden files (Windows and macOS only)") flag.StringVar(&filename, "filename", "", "Set the `filename`") - flag.Var(&fileFilters, "file-filter", "Set a filename filter (NAME | PATTERN1 PATTERN2 ...)") + flag.Var(funcValue(addFileFilter), "file-filter", "Set a filename filter (NAME | PATTERN1 PATTERN2 ...)") // Color selection options flag.StringVar(&defaultColor, "color", "", "Set the `color`") @@ -196,12 +215,6 @@ func setupFlags() { func validateFlags() { var n int - if notification { - n++ - } - if entryDlg { - n++ - } if errorDlg { n++ } @@ -214,6 +227,12 @@ func validateFlags() { if questionDlg { n++ } + if entryDlg { + n++ + } + if listDlg { + n++ + } if passwordDlg { n++ } @@ -223,6 +242,9 @@ func validateFlags() { if colorSelectionDlg { n++ } + if notification { + n++ + } if n != 1 { flag.Usage() } @@ -239,11 +261,6 @@ func loadFlags() []zenity.Option { } } switch { - case entryDlg: - setDefault(&title, "Add a new entry") - setDefault(&text, "Enter new text:") - setDefault(&okLabel, "OK") - setDefault(&cancelLabel, "Cancel") case errorDlg: setDefault(&title, "Error") setDefault(&icon, "dialog-error") @@ -265,6 +282,16 @@ func loadFlags() []zenity.Option { setDefault(&text, "Are you sure you want to proceed?") setDefault(&okLabel, "Yes") setDefault(&cancelLabel, "No") + case entryDlg: + setDefault(&title, "Add a new entry") + setDefault(&text, "Enter new text:") + setDefault(&okLabel, "OK") + setDefault(&cancelLabel, "Cancel") + case listDlg: + setDefault(&title, "Select items from the list") + setDefault(&text, "Select items from the list below:") + setDefault(&okLabel, "OK") + setDefault(&cancelLabel, "Cancel") case passwordDlg: setDefault(&title, "Type your password") setDefault(&icon, "dialog-password") @@ -308,13 +335,6 @@ func loadFlags() []zenity.Option { } opts = append(opts, zenity.Icon(ico)) - // Entry options - - opts = append(opts, zenity.EntryText(entryText)) - if hideText { - opts = append(opts, zenity.HideText()) - } - // Message options if noWrap { @@ -327,6 +347,19 @@ func loadFlags() []zenity.Option { opts = append(opts, zenity.DefaultCancel()) } + // Entry options + + opts = append(opts, zenity.EntryText(entryText)) + if hideText { + opts = append(opts, zenity.HideText()) + } + + // List options + + if !allowEmpty { + opts = append(opts, zenity.DisallowEmpty()) + } + // File selection options if directory { @@ -487,18 +520,20 @@ func egestPaths(paths []string, err error) ([]string, error) { return paths, err } -// FileFilters is internal. -type FileFilters struct { - zenity.FileFilters +type funcValue func(string) error + +func (f funcValue) String() string { return "" } +func (f funcValue) Set(s string) error { return f(s) } + +func addColumn(s string) error { + columns++ + if columns <= 1 { + return nil + } + return errors.New("multiple columns not supported") } -// String is internal. -func (f *FileFilters) String() string { - return "zenity.FileFilters" -} - -// Set is internal. -func (f *FileFilters) Set(s string) error { +func addFileFilter(s string) error { var filter zenity.FileFilter if split := strings.SplitN(s, "|", 2); len(split) > 1 { @@ -507,7 +542,7 @@ func (f *FileFilters) Set(s string) error { } filter.Patterns = strings.Split(strings.TrimSpace(s), " ") - f.FileFilters = append(f.FileFilters, filter) + fileFilters = append(fileFilters, filter) return nil } diff --git a/color_darwin.go b/color_darwin.go index 138c305..4b745a2 100644 --- a/color_darwin.go +++ b/color_darwin.go @@ -2,7 +2,6 @@ package zenity import ( "image/color" - "os/exec" "github.com/ncruces/zenity/internal/zenutil" ) @@ -21,11 +20,9 @@ func selectColor(opts options) (color.Color, error) { float32(g) / 0xffff, float32(b) / 0xffff, }) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - return nil, nil + str, ok, err := strResult(opts, out, err) + if ok { + return zenutil.ParseColor(str), nil } - if err != nil { - return nil, err - } - return zenutil.ParseColor(string(out)), nil + return nil, err } diff --git a/color_unix.go b/color_unix.go index 83614d9..464e0d1 100644 --- a/color_unix.go +++ b/color_unix.go @@ -4,7 +4,6 @@ package zenity import ( "image/color" - "os/exec" "github.com/ncruces/zenity/internal/zenutil" ) @@ -12,9 +11,7 @@ import ( func selectColor(opts options) (color.Color, error) { args := []string{"--color-selection"} - if opts.title != nil { - args = append(args, "--title", *opts.title) - } + args = appendTitle(args, opts) if opts.color != nil { args = append(args, "--color", zenutil.UnparseColor(opts.color)) } @@ -23,11 +20,9 @@ func selectColor(opts options) (color.Color, error) { } out, err := zenutil.Run(opts.ctx, args) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - return nil, nil + str, ok, err := strResult(opts, out, err) + if ok { + return zenutil.ParseColor(str), nil } - if err != nil { - return nil, err - } - return zenutil.ParseColor(string(out)), nil + return nil, err } diff --git a/entry.go b/entry.go index 259fb82..4a96d80 100644 --- a/entry.go +++ b/entry.go @@ -10,15 +10,6 @@ func Entry(text string, options ...Option) (string, bool, error) { return entry(text, applyOptions(options)) } -// Password displays the password dialog. -// -// Returns false on cancel, or ErrExtraButton. -// -// Valid options: Title, OKLabel, CancelLabel, ExtraButton, Icon, Username. -func Password(options ...Option) (usr string, pw string, ok bool, err error) { - return password(applyOptions(options)) -} - // EntryText returns an Option to set the entry text. func EntryText(text string) Option { return funcOption(func(o *options) { o.entryText = text }) @@ -28,8 +19,3 @@ func EntryText(text string) Option { func HideText() Option { return funcOption(func(o *options) { o.hideText = true }) } - -// Username returns an Option to display the username (Unix only). -func Username() Option { - return funcOption(func(o *options) { o.username = true }) -} diff --git a/entry_darwin.go b/entry_darwin.go index 4867ca6..6de4e61 100644 --- a/entry_darwin.go +++ b/entry_darwin.go @@ -1,9 +1,6 @@ package zenity import ( - "bytes" - "os/exec" - "github.com/ncruces/zenity/internal/zenutil" ) @@ -19,22 +16,5 @@ func entry(text string, opts options) (string, bool, error) { data.SetButtons(getButtons(true, true, opts)) out, err := zenutil.Run(opts.ctx, "dialog", data) - out = bytes.TrimSuffix(out, []byte{'\n'}) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - if opts.extraButton != nil && - *opts.extraButton == string(out) { - return "", false, ErrExtraButton - } - return "", false, nil - } - if err != nil { - return "", false, err - } - return string(out), true, nil -} - -func password(opts options) (string, string, bool, error) { - opts.hideText = true - pass, ok, err := entry("Password:", opts) - return "", pass, ok, err + return strResult(opts, out, err) } diff --git a/entry_test.go b/entry_test.go index 698d312..ee3cbc2 100644 --- a/entry_test.go +++ b/entry_test.go @@ -16,11 +16,6 @@ func ExampleEntry() { // Output: } -func ExamplePassword() { - zenity.Password(zenity.Title("Type your password")) - // Output: -} - func TestEntryTimeout(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) @@ -41,24 +36,3 @@ func TestEntryCancel(t *testing.T) { t.Error("was not canceled:", err) } } - -func TestPasswordTimeout(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) - - _, _, _, err := zenity.Password(zenity.Context(ctx)) - if !os.IsTimeout(err) { - t.Error("did not timeout:", err) - } - - cancel() -} - -func TestPasswordCancel(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - _, _, _, err := zenity.Password(zenity.Context(ctx)) - if !errors.Is(err, context.Canceled) { - t.Error("was not canceled:", err) - } -} diff --git a/entry_unix.go b/entry_unix.go index d6361ed..a688033 100644 --- a/entry_unix.go +++ b/entry_unix.go @@ -3,97 +3,22 @@ package zenity import ( - "bytes" - "os/exec" - "strconv" - "strings" - "github.com/ncruces/zenity/internal/zenutil" ) func entry(text string, opts options) (string, bool, error) { - args := []string{"--entry", "--text", text, "--entry-text", opts.entryText} - if opts.title != nil { - args = append(args, "--title", *opts.title) - } - if opts.width > 0 { - args = append(args, "--width", strconv.FormatUint(uint64(opts.width), 10)) - } - if opts.height > 0 { - args = append(args, "--height", strconv.FormatUint(uint64(opts.height), 10)) - } - if opts.okLabel != nil { - args = append(args, "--ok-label", *opts.okLabel) - } - if opts.cancelLabel != nil { - args = append(args, "--cancel-label", *opts.cancelLabel) - } - if opts.extraButton != nil { - args = append(args, "--extra-button", *opts.extraButton) + args := []string{"--entry", "--text", text} + args = appendTitle(args, opts) + args = appendButtons(args, opts) + args = appendWidthHeight(args, opts) + args = appendIcon(args, opts) + if opts.entryText != "" { + args = append(args, "--entry-text", opts.entryText) } if opts.hideText { args = append(args, "--hide-text") } - switch opts.icon { - case ErrorIcon: - args = append(args, "--window-icon=error") - case WarningIcon: - args = append(args, "--window-icon=warning") - case InfoIcon: - args = append(args, "--window-icon=info") - case QuestionIcon: - args = append(args, "--window-icon=question") - } out, err := zenutil.Run(opts.ctx, args) - out = bytes.TrimSuffix(out, []byte{'\n'}) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - if opts.extraButton != nil && - *opts.extraButton == string(out) { - return "", false, ErrExtraButton - } - return "", false, nil - } - if err != nil { - return "", false, err - } - return string(out), true, nil -} - -func password(opts options) (string, string, bool, error) { - args := []string{"--password"} - if opts.title != nil { - args = append(args, "--title", *opts.title) - } - if opts.okLabel != nil { - args = append(args, "--ok-label", *opts.okLabel) - } - if opts.cancelLabel != nil { - args = append(args, "--cancel-label", *opts.cancelLabel) - } - if opts.extraButton != nil { - args = append(args, "--extra-button", *opts.extraButton) - } - if opts.username { - args = append(args, "--username") - } - - out, err := zenutil.Run(opts.ctx, args) - out = bytes.TrimSuffix(out, []byte{'\n'}) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - if opts.extraButton != nil && - *opts.extraButton == string(out) { - return "", "", false, ErrExtraButton - } - return "", "", false, nil - } - if err != nil { - return "", "", false, err - } - if opts.username { - if split := strings.SplitN(string(out), "|", 2); len(split) == 2 { - return split[0], split[1], true, nil - } - } - return "", string(out), true, nil + return strResult(opts, out, err) } diff --git a/entry_windows.go b/entry_windows.go index 8a1c21f..c128420 100644 --- a/entry_windows.go +++ b/entry_windows.go @@ -1,12 +1,10 @@ package zenity import ( - "strconv" "syscall" - "unsafe" ) -func entry(text string, opts options) (string, bool, error) { +func entry(text string, opts options) (out string, ok bool, err error) { var title string if opts.title != nil { title = *opts.title @@ -17,238 +15,17 @@ func entry(text string, opts options) (string, bool, error) { if opts.cancelLabel == nil { opts.cancelLabel = stringPtr("Cancel") } - return editBox(title, text, opts) + return entryDlg(title, text, opts) } -func password(opts options) (string, string, bool, error) { - opts.hideText = true - pass, ok, err := entry("Password:", opts) - return "", pass, ok, err -} - -var ( - registerClassEx = user32.NewProc("RegisterClassExW") - unregisterClass = user32.NewProc("UnregisterClassW") - createWindowEx = user32.NewProc("CreateWindowExW") - destroyWindow = user32.NewProc("DestroyWindow") - isDialogMessage = user32.NewProc("IsDialogMessageW") - translateMessage = user32.NewProc("TranslateMessage") - dispatchMessage = user32.NewProc("DispatchMessageW") - postQuitMessage = user32.NewProc("PostQuitMessage") - defWindowProc = user32.NewProc("DefWindowProcW") - getWindowRect = user32.NewProc("GetWindowRect") - setWindowPos = user32.NewProc("SetWindowPos") - setFocus = user32.NewProc("SetFocus") - showWindow = user32.NewProc("ShowWindow") - systemParametersInfo = user32.NewProc("SystemParametersInfoW") - getSystemMetrics = user32.NewProc("GetSystemMetrics") - getWindowDC = user32.NewProc("GetWindowDC") - releaseDC = user32.NewProc("ReleaseDC") - getDpiForWindow = user32.NewProc("GetDpiForWindow") - - deleteObject = gdi32.NewProc("DeleteObject") - getDeviceCaps = gdi32.NewProc("GetDeviceCaps") - createFontIndirect = gdi32.NewProc("CreateFontIndirectW") -) - -// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-wndclassexw -type _WNDCLASSEX struct { - Size uint32 - Style uint32 - WndProc uintptr - ClsExtra int32 - WndExtra int32 - Instance uintptr - Icon uintptr - Cursor uintptr - Background uintptr - MenuName *uint16 - ClassName *uint16 - IconSm uintptr -} - -// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-msg -type _MSG struct { - Owner syscall.Handle - Message uint32 - WParam uintptr - LParam uintptr - Time uint32 - Pt _POINT -} - -// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-nonclientmetricsw -type _NONCLIENTMETRICS struct { - Size uint32 - BorderWidth int32 - ScrollWidth int32 - ScrollHeight int32 - CaptionWidth int32 - CaptionHeight int32 - CaptionFont _LOGFONT - SmCaptionWidth int32 - SmCaptionHeight int32 - SmCaptionFont _LOGFONT - MenuWidth int32 - MenuHeight int32 - MenuFont _LOGFONT - StatusFont _LOGFONT - MessageFont _LOGFONT -} - -// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-logfontw -type _LOGFONT struct { - Height int32 - Width int32 - Escapement int32 - Orientation int32 - Weight int32 - Italic byte - Underline byte - StrikeOut byte - CharSet byte - OutPrecision byte - ClipPrecision byte - Quality byte - PitchAndFamily byte - FaceName [32]uint16 -} - -// https://docs.microsoft.com/en-us/windows/win32/api/windef/ns-windef-point -type _POINT struct { - x, y int32 -} - -// https://docs.microsoft.com/en-us/windows/win32/api/windef/ns-windef-rect -type _RECT struct { - left int32 - top int32 - right int32 - bottom int32 -} - -type dpi uintptr - -func getDPI(wnd uintptr) dpi { - var res uintptr - - if wnd != 0 && getDpiForWindow.Find() == nil { - res, _, _ = getDpiForWindow.Call(wnd) - } else if dc, _, _ := getWindowDC.Call(wnd); dc != 0 { - res, _, _ = getDeviceCaps.Call(dc, 90) // LOGPIXELSY - releaseDC.Call(0, dc) - } - - if res == 0 { - return 96 // USER_DEFAULT_SCREEN_DPI - } - return dpi(res) -} - -func (d dpi) Scale(dim uintptr) uintptr { - if d == 0 { - return dim - } - return dim * uintptr(d) / 96 // USER_DEFAULT_SCREEN_DPI -} - -type font struct { - handle uintptr - face _LOGFONT -} - -func getFont() font { - var metrics _NONCLIENTMETRICS - metrics.Size = uint32(unsafe.Sizeof(metrics)) - systemParametersInfo.Call(0x29, // SPI_GETNONCLIENTMETRICS - unsafe.Sizeof(metrics), uintptr(unsafe.Pointer(&metrics)), 0) - return font{face: metrics.MessageFont} -} - -func (f *font) ForDPI(dpi dpi) uintptr { - if h := -int32(dpi.Scale(12)); f.handle == 0 || f.face.Height != h { - f.Delete() - f.face.Height = h - f.handle, _, _ = createFontIndirect.Call(uintptr(unsafe.Pointer(&f.face))) - } - return f.handle -} - -func (f *font) Delete() { - if f.handle != 0 { - deleteObject.Call(f.handle) - f.handle = 0 - } -} - -func getWindowTextString(wnd uintptr) string { - len, _, _ := getWindowTextLength.Call(wnd) - buf := make([]uint16, len+1) - getWindowText.Call(wnd, uintptr(unsafe.Pointer(&buf[0])), len+1) - return syscall.UTF16ToString(buf) -} - -func centerWindow(wnd uintptr) { - getMetric := func(i uintptr) int32 { - ret, _, _ := getSystemMetrics.Call(i) - return int32(ret) - } - - var rect _RECT - getWindowRect.Call(wnd, uintptr(unsafe.Pointer(&rect))) - x := (getMetric(0 /* SM_CXSCREEN */) - (rect.right - rect.left)) / 2 - y := (getMetric(1 /* SM_CYSCREEN */) - (rect.bottom - rect.top)) / 2 - setWindowPos.Call(wnd, 0, uintptr(x), uintptr(y), 0, 0, 0x5) // SWP_NOZORDER|SWP_NOSIZE -} - -func registerClass(instance, proc uintptr) (uintptr, error) { - name := "WC_" + strconv.FormatUint(uint64(proc), 16) - - var wcx _WNDCLASSEX - wcx.Size = uint32(unsafe.Sizeof(wcx)) - wcx.WndProc = proc - wcx.Instance = instance - wcx.Background = 5 // COLOR_WINDOW - wcx.ClassName = syscall.StringToUTF16Ptr(name) - - ret, _, err := registerClassEx.Call(uintptr(unsafe.Pointer(&wcx))) - return ret, err -} - -// https://docs.microsoft.com/en-us/windows/win32/winmsg/using-messages-and-message-queues -func messageLoop(wnd uintptr) error { - getMessage := getMessage.Addr() - isDialogMessage := isDialogMessage.Addr() - translateMessage := translateMessage.Addr() - dispatchMessage := dispatchMessage.Addr() - - for { - var msg _MSG - ret, _, err := syscall.Syscall6(getMessage, 4, uintptr(unsafe.Pointer(&msg)), 0, 0, 0, 0, 0) - if int32(ret) == -1 { - return err - } - if ret == 0 { - return nil - } - - ret, _, _ = syscall.Syscall(isDialogMessage, 2, wnd, uintptr(unsafe.Pointer(&msg)), 0) - if ret == 0 { - syscall.Syscall(translateMessage, 1, uintptr(unsafe.Pointer(&msg)), 0, 0) - syscall.Syscall(dispatchMessage, 1, uintptr(unsafe.Pointer(&msg)), 0, 0) - } - } -} - -func editBox(title, text string, opts options) (out string, ok bool, err error) { - var wnd, textCtl, editCtl uintptr - var okBtn, cancelBtn, extraBtn uintptr - defWindowProc := defWindowProc.Addr() - +func entryDlg(title, text string, opts options) (out string, ok bool, err error) { defer setup()() - font := getFont() defer font.Delete() + defWindowProc := defWindowProc.Addr() + + var wnd, textCtl, editCtl uintptr + var okBtn, cancelBtn, extraBtn uintptr layout := func(dpi dpi) { hfont := font.ForDPI(dpi) @@ -256,17 +33,17 @@ func editBox(title, text string, opts options) (out string, ok bool, err error) sendMessage.Call(editCtl, 0x0030 /* WM_SETFONT */, hfont, 1) sendMessage.Call(okBtn, 0x0030 /* WM_SETFONT */, hfont, 1) sendMessage.Call(cancelBtn, 0x0030 /* WM_SETFONT */, hfont, 1) - setWindowPos.Call(wnd, 0, 0, 0, dpi.Scale(281), dpi.Scale(140), 0x6) // SWP_NOZORDER|SWP_NOMOVE + setWindowPos.Call(wnd, 0, 0, 0, dpi.Scale(281), dpi.Scale(141), 0x6) // SWP_NOZORDER|SWP_NOMOVE setWindowPos.Call(textCtl, 0, dpi.Scale(12), dpi.Scale(10), dpi.Scale(241), dpi.Scale(16), 0x4) // SWP_NOZORDER setWindowPos.Call(editCtl, 0, dpi.Scale(12), dpi.Scale(30), dpi.Scale(241), dpi.Scale(24), 0x4) // SWP_NOZORDER if extraBtn == 0 { - setWindowPos.Call(okBtn, 0, dpi.Scale(95), dpi.Scale(65), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER - setWindowPos.Call(cancelBtn, 0, dpi.Scale(178), dpi.Scale(65), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(okBtn, 0, dpi.Scale(95), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(cancelBtn, 0, dpi.Scale(178), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER } else { sendMessage.Call(extraBtn, 0x0030 /* WM_SETFONT */, hfont, 1) - setWindowPos.Call(okBtn, 0, dpi.Scale(12), dpi.Scale(65), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER - setWindowPos.Call(extraBtn, 0, dpi.Scale(95), dpi.Scale(65), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER - setWindowPos.Call(cancelBtn, 0, dpi.Scale(178), dpi.Scale(65), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(okBtn, 0, dpi.Scale(12), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(extraBtn, 0, dpi.Scale(95), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(cancelBtn, 0, dpi.Scale(178), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER } } @@ -283,7 +60,7 @@ func editBox(title, text string, opts options) (out string, ok bool, err error) default: return 1 case 1, 6: // IDOK, IDYES - out = getWindowTextString(editCtl) + out = getWindowString(editCtl) ok = true case 2: // IDCANCEL case 7: // IDNO @@ -322,7 +99,7 @@ func editBox(title, text string, opts options) (out string, ok bool, err error) 0x84c80000, // WS_POPUPWINDOW|WS_CLIPSIBLINGS|WS_DLGFRAME 0x80000000, // CW_USEDEFAULT 0x80000000, // CW_USEDEFAULT - 281, 140, 0, 0, instance) + 281, 141, 0, 0, instance) textCtl, _, _ = createWindowEx.Call(0, strptr("STATIC"), strptr(text), @@ -341,16 +118,16 @@ func editBox(title, text string, opts options) (out string, ok bool, err error) okBtn, _, _ = createWindowEx.Call(0, strptr("BUTTON"), strptr(*opts.okLabel), 0x50030001, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP|BS_DEFPUSHBUTTON - 12, 65, 75, 24, wnd, 1 /* IDOK */, instance) + 12, 66, 75, 24, wnd, 1 /* IDOK */, instance) cancelBtn, _, _ = createWindowEx.Call(0, strptr("BUTTON"), strptr(*opts.cancelLabel), 0x50010000, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP - 12, 65, 75, 24, wnd, 2 /* IDCANCEL */, instance) + 12, 66, 75, 24, wnd, 2 /* IDCANCEL */, instance) if opts.extraButton != nil { extraBtn, _, _ = createWindowEx.Call(0, strptr("BUTTON"), strptr(*opts.extraButton), 0x50010000, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP - 12, 65, 75, 24, wnd, 7 /* IDNO */, instance) + 12, 66, 75, 24, wnd, 7 /* IDNO */, instance) } layout(getDPI(wnd)) diff --git a/file_darwin.go b/file_darwin.go index 275d234..438b83a 100644 --- a/file_darwin.go +++ b/file_darwin.go @@ -1,8 +1,6 @@ package zenity import ( - "bytes" - "os/exec" "strings" "github.com/ncruces/zenity/internal/zenutil" @@ -22,13 +20,8 @@ func selectFile(opts options) (string, error) { } out, err := zenutil.Run(opts.ctx, "file", data) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - return "", nil - } - if err != nil { - return "", err - } - return string(bytes.TrimSuffix(out, []byte{'\n'})), nil + str, _, err := strResult(opts, out, err) + return str, err } func selectFileMutiple(opts options) ([]string, error) { @@ -47,17 +40,7 @@ func selectFileMutiple(opts options) ([]string, error) { } out, err := zenutil.Run(opts.ctx, "file", data) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - return nil, nil - } - if err != nil { - return nil, err - } - out = bytes.TrimSuffix(out, []byte{'\n'}) - if len(out) == 0 { - return nil, nil - } - return strings.Split(string(out), zenutil.Separator), nil + return lstResult(opts, out, err) } func selectFileSave(opts options) (string, error) { @@ -73,13 +56,8 @@ func selectFileSave(opts options) (string, error) { } out, err := zenutil.Run(opts.ctx, "file", data) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - return "", nil - } - if err != nil { - return "", err - } - return string(bytes.TrimSuffix(out, []byte{'\n'})), nil + str, _, err := strResult(opts, out, err) + return str, err } func initFilters(filters []FileFilter) []string { diff --git a/file_unix.go b/file_unix.go index a9be018..347e205 100644 --- a/file_unix.go +++ b/file_unix.go @@ -3,8 +3,6 @@ package zenity import ( - "bytes" - "os/exec" "strings" "github.com/ncruces/zenity/internal/zenutil" @@ -12,78 +10,31 @@ import ( func selectFile(opts options) (string, error) { args := []string{"--file-selection"} - if opts.title != nil { - args = append(args, "--title", *opts.title) - } - if opts.directory { - args = append(args, "--directory") - } - if opts.filename != "" { - args = append(args, "--filename", opts.filename) - } - args = append(args, initFilters(opts.fileFilters)...) + args = appendTitle(args, opts) + args = appendFileArgs(args, opts) out, err := zenutil.Run(opts.ctx, args) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - return "", nil - } - if err != nil { - return "", err - } - return string(bytes.TrimSuffix(out, []byte{'\n'})), nil + str, _, err := strResult(opts, out, err) + return str, err } func selectFileMutiple(opts options) ([]string, error) { args := []string{"--file-selection", "--multiple", "--separator", zenutil.Separator} - if opts.title != nil { - args = append(args, "--title", *opts.title) - } - if opts.directory { - args = append(args, "--directory") - } - if opts.filename != "" { - args = append(args, "--filename", opts.filename) - } - args = append(args, initFilters(opts.fileFilters)...) + args = appendTitle(args, opts) + args = appendFileArgs(args, opts) out, err := zenutil.Run(opts.ctx, args) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - return nil, nil - } - if err != nil { - return nil, err - } - out = bytes.TrimSuffix(out, []byte{'\n'}) - if len(out) == 0 { - return nil, nil - } - return strings.Split(string(out), zenutil.Separator), nil + return lstResult(opts, out, err) } func selectFileSave(opts options) (string, error) { args := []string{"--file-selection", "--save"} - if opts.title != nil { - args = append(args, "--title", *opts.title) - } - if opts.directory { - args = append(args, "--directory") - } - if opts.confirmOverwrite { - args = append(args, "--confirm-overwrite") - } - if opts.filename != "" { - args = append(args, "--filename", opts.filename) - } - args = append(args, initFilters(opts.fileFilters)...) + args = appendTitle(args, opts) + args = appendFileArgs(args, opts) out, err := zenutil.Run(opts.ctx, args) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - return "", nil - } - if err != nil { - return "", err - } - return string(bytes.TrimSuffix(out, []byte{'\n'})), nil + str, _, err := strResult(opts, out, err) + return str, err } func initFilters(filters []FileFilter) []string { @@ -103,3 +54,18 @@ func initFilters(filters []FileFilter) []string { } return res } + +func appendFileArgs(args []string, opts options) []string { + if opts.directory { + args = append(args, "--directory") + } + if opts.filename != "" { + args = append(args, "--filename", opts.filename) + } + if opts.confirmOverwrite { + args = append(args, "--confirm-overwrite") + } + args = append(args, initFilters(opts.fileFilters)...) + + return args +} diff --git a/internal/zenutil/color.go b/internal/zenutil/color.go index 8388270..0cd82a7 100644 --- a/internal/zenutil/color.go +++ b/internal/zenutil/color.go @@ -47,11 +47,8 @@ func ParseColor(s string) color.Color { } } - c, ok := colornames.Map[strings.ToLower(s)] - if ok { - return c - } - return nil + c, _ := colornames.Map[strings.ToLower(s)] + return c } // UnparseColor is internal. diff --git a/internal/zenutil/osa_generated.go b/internal/zenutil/osa_generated.go index 7258dd8..4396954 100644 --- a/internal/zenutil/osa_generated.go +++ b/internal/zenutil/osa_generated.go @@ -3,8 +3,10 @@ package zenutil -import "encoding/json" -import "text/template" +import ( + "encoding/json" + "text/template" +) var scripts = template.Must(template.New("").Funcs(template.FuncMap{"json": func(v interface{}) (string, error) { b, err := json.Marshal(v) @@ -36,6 +38,12 @@ app.activate() var res=app.{{.Operation}}({{json .Options}}) if(Array.isArray(res)){res.join({{json .Separator}})}else{res.toString()} {{- end}} +{{define "list" -}} +var app=Application.currentApplication() +app.includeStandardAdditions=true +var res=app.chooseFromList({{json .Items}},{{json .Options}}) +res.join({{json .Separator}}) +{{- end}} {{define "notify" -}} var app=Application.currentApplication() app.includeStandardAdditions=true diff --git a/internal/zenutil/osa_generator.go b/internal/zenutil/osa_generator.go index 6e3f2e4..0270174 100644 --- a/internal/zenutil/osa_generator.go +++ b/internal/zenutil/osa_generator.go @@ -100,8 +100,10 @@ var generator = template.Must(template.New("").Parse(`// Code generated by zenit package zenutil -import "encoding/json" -import "text/template" +import ( + "encoding/json" + "text/template" +) var scripts = template.Must(template.New("").Funcs(template.FuncMap{"json": func(v interface{}) (string, error) { b, err := json.Marshal(v) diff --git a/internal/zenutil/osascripts/list.gojs b/internal/zenutil/osascripts/list.gojs new file mode 100644 index 0000000..060c48f --- /dev/null +++ b/internal/zenutil/osascripts/list.gojs @@ -0,0 +1,5 @@ +var app = Application.currentApplication() +app.includeStandardAdditions = true + +var res = app.chooseFromList({{json .Items}}, {{json .Options}}) +res.join({{json .Separator}}) \ No newline at end of file diff --git a/internal/zenutil/run_darwin.go b/internal/zenutil/run_darwin.go index bd7f392..80c862e 100644 --- a/internal/zenutil/run_darwin.go +++ b/internal/zenutil/run_darwin.go @@ -23,9 +23,9 @@ func Run(ctx context.Context, script string, data interface{}) ([]byte, error) { // Try to use syscall.Exec, fallback to exec.Command. if path, err := exec.LookPath("osascript"); err != nil { } else if t, err := ioutil.TempFile("", ""); err != nil { + } else if err := os.Remove(t.Name()); err != nil { } else if _, err := t.WriteString(script); err != nil { } else if _, err := t.Seek(0, 0); err != nil { - } else if err := os.Remove(t.Name()); err != nil { } else if err := syscall.Dup2(int(t.Fd()), syscall.Stdin); err != nil { } else if err := os.Stderr.Close(); err != nil { } else { @@ -86,6 +86,24 @@ type DialogOptions struct { Timeout int `json:"givingUpAfter,omitempty"` } +// List is internal. +type List struct { + Items []string + Separator string + Options ListOptions +} + +// ListOptions is internal. +type ListOptions struct { + Title *string `json:"withTitle,omitempty"` + Prompt *string `json:"withPrompt,omitempty"` + OK *string `json:"okButtonName,omitempty"` + Cancel *string `json:"cancelButtonName,omitempty"` + Default []string `json:"defaultItems,omitempty"` + Multiple bool `json:"multipleSelectionsAllowed,omitempty"` + Empty bool `json:"emptySelectionAllowed,omitempty"` +} + // Notify is internal. type Notify struct { Text string diff --git a/list.go b/list.go new file mode 100644 index 0000000..4363e49 --- /dev/null +++ b/list.go @@ -0,0 +1,45 @@ +package zenity + +// List displays the list dialog. +// +// Returns false on cancel, or ErrExtraButton. +// +// Valid options: Title, Width, Height, OKLabel, CancelLabel, ExtraButton, +// Icon, DefaultItems, DisallowEmpty. +func List(text string, items []string, options ...Option) (string, bool, error) { + return list(text, items, applyOptions(options)) +} + +// ListItems displays the list dialog. +// +// Returns false on cancel, or ErrExtraButton. +func ListItems(text string, items ...string) (string, bool, error) { + return List(text, items) +} + +// ListMultiple displays the list dialog, allowing multiple items to be selected. +// +// Returns a nil slice on cancel, or ErrExtraButton. +// +// Valid options: Title, Width, Height, OKLabel, CancelLabel, ExtraButton, +// Icon, DefaultItems, DisallowEmpty. +func ListMultiple(text string, items []string, options ...Option) ([]string, error) { + return listMultiple(text, items, applyOptions(options)) +} + +// ListMultiple displays the list dialog, allowing multiple items to be selected. +// +// Returns a nil slice on cancel, or ErrExtraButton. +func ListMultipleItems(text string, items ...string) ([]string, error) { + return ListMultiple(text, items) +} + +// DefaultItems returns an Option to set the items to initally select (macOS only). +func DefaultItems(items ...string) Option { + return funcOption(func(o *options) { o.defaultItems = items }) +} + +// DisallowEmpty returns an Option to not allow zero items to be selected (macOS only). +func DisallowEmpty() Option { + return funcOption(func(o *options) { o.disallowEmpty = true }) +} diff --git a/list_darwin.go b/list_darwin.go new file mode 100644 index 0000000..a6b2be0 --- /dev/null +++ b/list_darwin.go @@ -0,0 +1,35 @@ +package zenity + +import ( + "github.com/ncruces/zenity/internal/zenutil" +) + +func list(text string, items []string, opts options) (string, bool, error) { + var data zenutil.List + data.Items = items + data.Options.Prompt = &text + data.Options.Title = opts.title + data.Options.OK = opts.okLabel + data.Options.Cancel = opts.cancelLabel + data.Options.Default = opts.defaultItems + data.Options.Empty = !opts.disallowEmpty + + out, err := zenutil.Run(opts.ctx, "list", data) + return strResult(opts, out, err) +} + +func listMultiple(text string, items []string, opts options) ([]string, error) { + var data zenutil.List + data.Items = items + data.Options.Prompt = &text + data.Options.Title = opts.title + data.Options.OK = opts.okLabel + data.Options.Cancel = opts.cancelLabel + data.Options.Default = opts.defaultItems + data.Options.Empty = !opts.disallowEmpty + data.Options.Multiple = true + data.Separator = zenutil.Separator + + out, err := zenutil.Run(opts.ctx, "list", data) + return lstResult(opts, out, err) +} diff --git a/list_test.go b/list_test.go new file mode 100644 index 0000000..4967020 --- /dev/null +++ b/list_test.go @@ -0,0 +1,66 @@ +package zenity_test + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/ncruces/zenity" +) + +func ExampleList() { + zenity.List( + "Select items from the list below:", + []string{"apples", "oranges", "bananas", "strawberries"}, + zenity.Title("Select items from the list"), + zenity.DisallowEmpty(), + ) + // Output: +} + +func ExampleListItems() { + zenity.ListItems( + "Select items from the list below:", + "apples", "oranges", "bananas", "strawberries") + // Output: +} + +func ExampleListMultiple() { + zenity.ListMultiple( + "Select items from the list below:", + []string{"apples", "oranges", "bananas", "strawberries"}, + zenity.Title("Select items from the list"), + zenity.DefaultItems("apples", "bananas"), + ) + // Output: +} + +func ExampleListMultipleItems() { + zenity.ListMultipleItems( + "Select items from the list below:", + "apples", "oranges", "bananas", "strawberries") + // Output: +} + +func TestListTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) + + _, _, err := zenity.List("", nil, zenity.Context(ctx)) + if !os.IsTimeout(err) { + t.Error("did not timeout:", err) + } + + cancel() +} + +func TestListCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := zenity.List("", nil, zenity.Context(ctx)) + if !errors.Is(err, context.Canceled) { + t.Error("was not canceled:", err) + } +} diff --git a/list_unix.go b/list_unix.go new file mode 100644 index 0000000..db6aad0 --- /dev/null +++ b/list_unix.go @@ -0,0 +1,31 @@ +// +build !windows,!darwin + +package zenity + +import ( + "github.com/ncruces/zenity/internal/zenutil" +) + +func list(text string, items []string, opts options) (string, bool, error) { + args := []string{"--list", "--column=", "--hide-header", "--text", text} + args = appendTitle(args, opts) + args = appendButtons(args, opts) + args = appendWidthHeight(args, opts) + args = appendIcon(args, opts) + args = append(args, items...) + + out, err := zenutil.Run(opts.ctx, args) + return strResult(opts, out, err) +} + +func listMultiple(text string, items []string, opts options) ([]string, error) { + args := []string{"--list", "--column=", "--hide-header", "--text", text, "--multiple", "--separator", zenutil.Separator} + args = appendTitle(args, opts) + args = appendButtons(args, opts) + args = appendWidthHeight(args, opts) + args = appendIcon(args, opts) + args = append(args, items...) + + out, err := zenutil.Run(opts.ctx, args) + return lstResult(opts, out, err) +} diff --git a/list_windows.go b/list_windows.go new file mode 100644 index 0000000..16a7201 --- /dev/null +++ b/list_windows.go @@ -0,0 +1,199 @@ +package zenity + +import ( + "syscall" + "unsafe" +) + +func list(text string, items []string, opts options) (string, bool, error) { + var title string + if opts.title != nil { + title = *opts.title + } + if opts.okLabel == nil { + opts.okLabel = stringPtr("OK") + } + if opts.cancelLabel == nil { + opts.cancelLabel = stringPtr("Cancel") + } + items, err := listDlg(title, text, items, false, opts) + if len(items) == 1 { + return items[0], true, err + } + return "", false, err +} + +func listMultiple(text string, items []string, opts options) ([]string, error) { + var title string + if opts.title != nil { + title = *opts.title + } + if opts.okLabel == nil { + opts.okLabel = stringPtr("OK") + } + if opts.cancelLabel == nil { + opts.cancelLabel = stringPtr("Cancel") + } + return listDlg(title, text, items, true, opts) +} + +func listDlg(title, text string, items []string, multiple bool, opts options) (out []string, err error) { + defer setup()() + font := getFont() + defer font.Delete() + defWindowProc := defWindowProc.Addr() + + var wnd, textCtl, listCtl uintptr + var okBtn, cancelBtn, extraBtn uintptr + + layout := func(dpi dpi) { + hfont := font.ForDPI(dpi) + sendMessage.Call(textCtl, 0x0030 /* WM_SETFONT */, hfont, 1) + sendMessage.Call(listCtl, 0x0030 /* WM_SETFONT */, hfont, 1) + sendMessage.Call(okBtn, 0x0030 /* WM_SETFONT */, hfont, 1) + sendMessage.Call(cancelBtn, 0x0030 /* WM_SETFONT */, hfont, 1) + setWindowPos.Call(wnd, 0, 0, 0, dpi.Scale(281), dpi.Scale(281), 0x6) // SWP_NOZORDER|SWP_NOMOVE + setWindowPos.Call(textCtl, 0, dpi.Scale(12), dpi.Scale(10), dpi.Scale(241), dpi.Scale(16), 0x4) // SWP_NOZORDER + setWindowPos.Call(listCtl, 0, dpi.Scale(12), dpi.Scale(30), dpi.Scale(241), dpi.Scale(164), 0x4) // SWP_NOZORDER + if extraBtn == 0 { + setWindowPos.Call(okBtn, 0, dpi.Scale(95), dpi.Scale(206), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(cancelBtn, 0, dpi.Scale(178), dpi.Scale(206), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + } else { + sendMessage.Call(extraBtn, 0x0030 /* WM_SETFONT */, hfont, 1) + setWindowPos.Call(okBtn, 0, dpi.Scale(12), dpi.Scale(206), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(extraBtn, 0, dpi.Scale(95), dpi.Scale(206), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(cancelBtn, 0, dpi.Scale(178), dpi.Scale(206), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + } + } + + proc := func(wnd uintptr, msg uint32, wparam, lparam uintptr) uintptr { + switch msg { + case 0x0002: // WM_DESTROY + postQuitMessage.Call(0) + + case 0x0010: // WM_CLOSE + destroyWindow.Call(wnd) + + case 0x0111: // WM_COMMAND + switch wparam { + default: + return 1 + case 1, 6: // IDOK, IDYES + if multiple { + if len, _, _ := sendMessage.Call(listCtl, 0x190 /* LB_GETSELCOUNT */, 0, 0); int32(len) >= 0 { + out = make([]string, len) + if len > 0 { + indices := make([]int32, len) + sendMessage.Call(listCtl, 0x191 /* LB_GETSELITEMS */, len, uintptr(unsafe.Pointer(&indices[0]))) + for i, idx := range indices { + out[i] = items[idx] + } + } + } + } else { + if idx, _, _ := sendMessage.Call(listCtl, 0x188 /* LB_GETCURSEL */, 0, 0); int32(idx) >= 0 { + out = []string{items[idx]} + } else { + out = []string{} + } + } + case 2: // IDCANCEL + case 7: // IDNO + err = ErrExtraButton + } + destroyWindow.Call(wnd) + + case 0x02e0: // WM_DPICHANGED + layout(dpi(uint32(wparam) >> 16)) + + default: + ret, _, _ := syscall.Syscall6(defWindowProc, 4, wnd, uintptr(msg), wparam, lparam, 0, 0) + return ret + } + + return 0 + } + + if opts.ctx != nil && opts.ctx.Err() != nil { + return nil, opts.ctx.Err() + } + + instance, _, err := getModuleHandle.Call(0) + if instance == 0 { + return nil, err + } + + cls, err := registerClass(instance, syscall.NewCallback(proc)) + if cls == 0 { + return nil, err + } + defer unregisterClass.Call(cls, instance) + + wnd, _, _ = createWindowEx.Call(0x10101, // WS_EX_CONTROLPARENT|WS_EX_WINDOWEDGE|WS_EX_DLGMODALFRAME + cls, strptr(title), + 0x84c80000, // WS_POPUPWINDOW|WS_CLIPSIBLINGS|WS_DLGFRAME + 0x80000000, // CW_USEDEFAULT + 0x80000000, // CW_USEDEFAULT + 281, 281, 0, 0, instance) + + textCtl, _, _ = createWindowEx.Call(0, + strptr("STATIC"), strptr(text), + 0x5002e080, // WS_CHILD|WS_VISIBLE|WS_GROUP|SS_WORDELLIPSIS|SS_EDITCONTROL|SS_NOPREFIX + 12, 10, 241, 16, wnd, 0, instance) + + var flags uintptr = 0x50320000 // WS_CHILD|WS_VISIBLE|WS_VSCROLL|WS_GROUP|WS_TABSTOP + if multiple { + flags |= 0x0800 // LBS_EXTENDEDSEL + } + listCtl, _, _ = createWindowEx.Call(0x200, // WS_EX_CLIENTEDGE + strptr("LISTBOX"), strptr(opts.entryText), + flags, + 12, 30, 241, 164, wnd, 0, instance) + + okBtn, _, _ = createWindowEx.Call(0, + strptr("BUTTON"), strptr(*opts.okLabel), + 0x50030001, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP|BS_DEFPUSHBUTTON + 12, 206, 75, 24, wnd, 1 /* IDOK */, instance) + cancelBtn, _, _ = createWindowEx.Call(0, + strptr("BUTTON"), strptr(*opts.cancelLabel), + 0x50010000, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP + 12, 206, 75, 24, wnd, 2 /* IDCANCEL */, instance) + if opts.extraButton != nil { + extraBtn, _, _ = createWindowEx.Call(0, + strptr("BUTTON"), strptr(*opts.extraButton), + 0x50010000, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP + 12, 206, 75, 24, wnd, 7 /* IDNO */, instance) + } + + for _, item := range items { + sendMessage.Call(listCtl, 0x180 /* LB_ADDSTRING */, 0, strptr(item)) + } + + layout(getDPI(wnd)) + centerWindow(wnd) + setFocus.Call(listCtl) + showWindow.Call(wnd, 1 /* SW_SHOWNORMAL */, 0) + + if opts.ctx != nil { + wait := make(chan struct{}) + defer close(wait) + go func() { + select { + case <-opts.ctx.Done(): + sendMessage.Call(wnd, 0x0112 /* WM_SYSCOMMAND */, 0xf060 /* SC_CLOSE */, 0) + case <-wait: + } + }() + } + + // set default values + out, err = nil, nil + + if err := messageLoop(wnd); err != nil { + return nil, err + } + if opts.ctx != nil && opts.ctx.Err() != nil { + return nil, opts.ctx.Err() + } + return out, err +} diff --git a/msg_darwin.go b/msg_darwin.go index 3cc4a34..03a1827 100644 --- a/msg_darwin.go +++ b/msg_darwin.go @@ -1,9 +1,6 @@ package zenity import ( - "bytes" - "os/exec" - "github.com/ncruces/zenity/internal/zenutil" ) @@ -36,15 +33,6 @@ func message(kind messageKind, text string, opts options) (bool, error) { data.SetButtons(getButtons(dialog, kind == questionKind, opts)) out, err := zenutil.Run(opts.ctx, "dialog", data) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - if opts.extraButton != nil && - *opts.extraButton == string(bytes.TrimSuffix(out, []byte{'\n'})) { - return false, ErrExtraButton - } - return false, nil - } - if err != nil { - return false, err - } - return true, err + _, ok, err := strResult(opts, out, err) + return ok, err } diff --git a/msg_unix.go b/msg_unix.go index 85249d0..bcdf794 100644 --- a/msg_unix.go +++ b/msg_unix.go @@ -3,10 +3,6 @@ package zenity import ( - "bytes" - "os/exec" - "strconv" - "github.com/ncruces/zenity/internal/zenutil" ) @@ -22,24 +18,10 @@ func message(kind messageKind, text string, opts options) (bool, error) { case errorKind: args = append(args, "--error") } - if opts.title != nil { - args = append(args, "--title", *opts.title) - } - if opts.width > 0 { - args = append(args, "--width", strconv.FormatUint(uint64(opts.width), 10)) - } - if opts.height > 0 { - args = append(args, "--height", strconv.FormatUint(uint64(opts.height), 10)) - } - if opts.okLabel != nil { - args = append(args, "--ok-label", *opts.okLabel) - } - if opts.cancelLabel != nil { - args = append(args, "--cancel-label", *opts.cancelLabel) - } - if opts.extraButton != nil { - args = append(args, "--extra-button", *opts.extraButton) - } + args = appendTitle(args, opts) + args = appendButtons(args, opts) + args = appendWidthHeight(args, opts) + args = appendIcon(args, opts) if opts.noWrap { args = append(args, "--no-wrap") } @@ -51,13 +33,13 @@ func message(kind messageKind, text string, opts options) (bool, error) { } switch opts.icon { case ErrorIcon: - args = append(args, "--window-icon=error", "--icon-name=dialog-error") + args = append(args, "--icon-name=dialog-error") case WarningIcon: - args = append(args, "--window-icon=warning", "--icon-name=dialog-warning") + args = append(args, "--icon-name=dialog-warning") case InfoIcon: - args = append(args, "--window-icon=info", "--icon-name=dialog-information") + args = append(args, "--icon-name=dialog-information") case QuestionIcon: - args = append(args, "--window-icon=question", "--icon-name=dialog-question") + args = append(args, "--icon-name=dialog-question") case PasswordIcon: args = append(args, "--icon-name=dialog-password") case NoIcon: @@ -65,15 +47,6 @@ func message(kind messageKind, text string, opts options) (bool, error) { } out, err := zenutil.Run(opts.ctx, args) - if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - if opts.extraButton != nil && - *opts.extraButton == string(bytes.TrimSuffix(out, []byte{'\n'})) { - return false, ErrExtraButton - } - return false, nil - } - if err != nil { - return false, err - } - return true, err + _, ok, err := strResult(opts, out, err) + return ok, err } diff --git a/notify_unix.go b/notify_unix.go index f8204f4..5c040d6 100644 --- a/notify_unix.go +++ b/notify_unix.go @@ -8,9 +8,7 @@ import ( func notify(text string, opts options) error { args := []string{"--notification", "--text", text} - if opts.title != nil { - args = append(args, "--title", *opts.title) - } + args = appendTitle(args, opts) switch opts.icon { case ErrorIcon: args = append(args, "--window-icon=dialog-error") diff --git a/pwd.go b/pwd.go new file mode 100644 index 0000000..685c129 --- /dev/null +++ b/pwd.go @@ -0,0 +1,15 @@ +package zenity + +// Password displays the password dialog. +// +// Returns false on cancel, or ErrExtraButton. +// +// Valid options: Title, OKLabel, CancelLabel, ExtraButton, Icon, Username. +func Password(options ...Option) (usr string, pw string, ok bool, err error) { + return password(applyOptions(options)) +} + +// Username returns an Option to display the username (Unix only). +func Username() Option { + return funcOption(func(o *options) { o.username = true }) +} diff --git a/pwd_stub.go b/pwd_stub.go new file mode 100644 index 0000000..63bc24a --- /dev/null +++ b/pwd_stub.go @@ -0,0 +1,9 @@ +// +build windows darwin + +package zenity + +func password(opts options) (string, string, bool, error) { + opts.hideText = true + str, ok, err := entry("Password:", opts) + return "", str, ok, err +} diff --git a/pwd_test.go b/pwd_test.go new file mode 100644 index 0000000..7b6df89 --- /dev/null +++ b/pwd_test.go @@ -0,0 +1,37 @@ +package zenity_test + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/ncruces/zenity" +) + +func ExamplePassword() { + zenity.Password(zenity.Title("Type your password")) + // Output: +} + +func TestPasswordTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) + + _, _, _, err := zenity.Password(zenity.Context(ctx)) + if !os.IsTimeout(err) { + t.Error("did not timeout:", err) + } + + cancel() +} + +func TestPasswordCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, _, err := zenity.Password(zenity.Context(ctx)) + if !errors.Is(err, context.Canceled) { + t.Error("was not canceled:", err) + } +} diff --git a/pwd_unix.go b/pwd_unix.go new file mode 100644 index 0000000..dc19245 --- /dev/null +++ b/pwd_unix.go @@ -0,0 +1,27 @@ +// +build !windows,!darwin + +package zenity + +import ( + "strings" + + "github.com/ncruces/zenity/internal/zenutil" +) + +func password(opts options) (string, string, bool, error) { + args := []string{"--password"} + args = appendTitle(args, opts) + args = appendButtons(args, opts) + if opts.username { + args = append(args, "--username") + } + + out, err := zenutil.Run(opts.ctx, args) + str, ok, err := strResult(opts, out, err) + if ok && opts.username { + if split := strings.SplitN(string(out), "|", 2); len(split) == 2 { + return split[0], split[1], true, nil + } + } + return "", str, ok, err +} diff --git a/util_unix.go b/util_unix.go new file mode 100644 index 0000000..792fc6c --- /dev/null +++ b/util_unix.go @@ -0,0 +1,78 @@ +// +build !windows + +package zenity + +import ( + "bytes" + "os/exec" + "strconv" + "strings" + + "github.com/ncruces/zenity/internal/zenutil" +) + +func appendTitle(args []string, opts options) []string { + if opts.title != nil { + args = append(args, "--title", *opts.title) + } + return args +} + +func appendButtons(args []string, opts options) []string { + if opts.okLabel != nil { + args = append(args, "--ok-label", *opts.okLabel) + } + if opts.cancelLabel != nil { + args = append(args, "--cancel-label", *opts.cancelLabel) + } + if opts.extraButton != nil { + args = append(args, "--extra-button", *opts.extraButton) + } + return args +} + +func appendWidthHeight(args []string, opts options) []string { + if opts.width > 0 { + args = append(args, "--width", strconv.FormatUint(uint64(opts.width), 10)) + } + if opts.height > 0 { + args = append(args, "--height", strconv.FormatUint(uint64(opts.height), 10)) + } + return args +} + +func appendIcon(args []string, opts options) []string { + switch opts.icon { + case ErrorIcon: + args = append(args, "--window-icon=error") + case WarningIcon: + args = append(args, "--window-icon=warning") + case InfoIcon: + args = append(args, "--window-icon=info") + case QuestionIcon: + args = append(args, "--window-icon=question") + } + return args +} + +func strResult(opts options, out []byte, err error) (string, bool, error) { + out = bytes.TrimSuffix(out, []byte{'\n'}) + if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { + if opts.extraButton != nil && *opts.extraButton == string(out) { + return "", false, ErrExtraButton + } + return "", false, nil + } + if err != nil { + return "", false, err + } + return string(out), true, nil +} + +func lstResult(opts options, out []byte, err error) ([]string, error) { + str, ok, err := strResult(opts, out, err) + if ok { + return strings.Split(str, zenutil.Separator), nil + } + return nil, err +} diff --git a/util_windows.go b/util_windows.go index 92e1e01..0f32ae5 100644 --- a/util_windows.go +++ b/util_windows.go @@ -6,6 +6,7 @@ import ( "os" "reflect" "runtime" + "strconv" "sync/atomic" "syscall" "unsafe" @@ -22,6 +23,10 @@ var ( commDlgExtendedError = comdlg32.NewProc("CommDlgExtendedError") + deleteObject = gdi32.NewProc("DeleteObject") + getDeviceCaps = gdi32.NewProc("GetDeviceCaps") + createFontIndirect = gdi32.NewProc("CreateFontIndirectW") + getModuleHandle = kernel32.NewProc("GetModuleHandleW") getCurrentThreadId = kernel32.NewProc("GetCurrentThreadId") getConsoleWindow = kernel32.NewProc("GetConsoleWindow") @@ -33,9 +38,13 @@ var ( getMessage = user32.NewProc("GetMessageW") sendMessage = user32.NewProc("SendMessageW") + postQuitMessage = user32.NewProc("PostQuitMessage") + isDialogMessage = user32.NewProc("IsDialogMessageW") + dispatchMessage = user32.NewProc("DispatchMessageW") + translateMessage = user32.NewProc("TranslateMessage") getClassName = user32.NewProc("GetClassNameW") - setWindowsHookEx = user32.NewProc("SetWindowsHookExW") unhookWindowsHookEx = user32.NewProc("UnhookWindowsHookEx") + setWindowsHookEx = user32.NewProc("SetWindowsHookExW") callNextHookEx = user32.NewProc("CallNextHookEx") enumWindows = user32.NewProc("EnumWindows") enumChildWindows = user32.NewProc("EnumChildWindows") @@ -45,8 +54,30 @@ var ( setForegroundWindow = user32.NewProc("SetForegroundWindow") getWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId") setThreadDpiAwarenessContext = user32.NewProc("SetThreadDpiAwarenessContext") + getDpiForWindow = user32.NewProc("GetDpiForWindow") + releaseDC = user32.NewProc("ReleaseDC") + getWindowDC = user32.NewProc("GetWindowDC") + systemParametersInfo = user32.NewProc("SystemParametersInfoW") + setWindowPos = user32.NewProc("SetWindowPos") + getWindowRect = user32.NewProc("GetWindowRect") + getSystemMetrics = user32.NewProc("GetSystemMetrics") + unregisterClass = user32.NewProc("UnregisterClassW") + registerClassEx = user32.NewProc("RegisterClassExW") + destroyWindow = user32.NewProc("DestroyWindow") + createWindowEx = user32.NewProc("CreateWindowExW") + showWindow = user32.NewProc("ShowWindow") + setFocus = user32.NewProc("SetFocus") + defWindowProc = user32.NewProc("DefWindowProcW") ) +func intptr(i int64) uintptr { + return uintptr(i) +} + +func strptr(s string) uintptr { + return uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(s))) +} + func setup() context.CancelFunc { var hwnd uintptr enumWindows.Call(syscall.NewCallback(func(wnd, lparam uintptr) uintptr { @@ -83,8 +114,8 @@ func setup() context.CancelFunc { return func() { if old != 0 { setThreadDpiAwarenessContext.Call(old) - runtime.UnlockOSThread() } + runtime.UnlockOSThread() } } @@ -97,15 +128,6 @@ func commDlgError() error { } } -// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-cwpretstruct -type _CWPRETSTRUCT struct { - Result uintptr - LParam uintptr - WParam uintptr - Message uint32 - Wnd uintptr -} - func hookDialog(ctx context.Context, initDialog func(wnd uintptr)) (unhook context.CancelFunc, err error) { if ctx != nil && ctx.Err() != nil { return nil, ctx.Err() @@ -174,12 +196,202 @@ func hookDialogTitle(ctx context.Context, title *string) (unhook context.CancelF return hookDialog(ctx, init) } -func strptr(s string) uintptr { - return uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(s))) +type dpi uintptr + +func getDPI(wnd uintptr) dpi { + var res uintptr + + if wnd != 0 && getDpiForWindow.Find() == nil { + res, _, _ = getDpiForWindow.Call(wnd) + } else if dc, _, _ := getWindowDC.Call(wnd); dc != 0 { + res, _, _ = getDeviceCaps.Call(dc, 90) // LOGPIXELSY + releaseDC.Call(0, dc) + } + + if res == 0 { + return 96 // USER_DEFAULT_SCREEN_DPI + } + return dpi(res) } -func intptr(i int64) uintptr { - return uintptr(i) +func (d dpi) Scale(dim uintptr) uintptr { + if d == 0 { + return dim + } + return dim * uintptr(d) / 96 // USER_DEFAULT_SCREEN_DPI +} + +type font struct { + handle uintptr + logical _LOGFONT +} + +func getFont() font { + var metrics _NONCLIENTMETRICS + metrics.Size = uint32(unsafe.Sizeof(metrics)) + systemParametersInfo.Call(0x29, // SPI_GETNONCLIENTMETRICS + unsafe.Sizeof(metrics), uintptr(unsafe.Pointer(&metrics)), 0) + return font{logical: metrics.MessageFont} +} + +func (f *font) ForDPI(dpi dpi) uintptr { + if h := -int32(dpi.Scale(12)); f.handle == 0 || f.logical.Height != h { + f.Delete() + f.logical.Height = h + f.handle, _, _ = createFontIndirect.Call(uintptr(unsafe.Pointer(&f.logical))) + } + return f.handle +} + +func (f *font) Delete() { + if f.handle != 0 { + deleteObject.Call(f.handle) + f.handle = 0 + } +} + +func centerWindow(wnd uintptr) { + getMetric := func(i uintptr) int32 { + ret, _, _ := getSystemMetrics.Call(i) + return int32(ret) + } + + var rect _RECT + getWindowRect.Call(wnd, uintptr(unsafe.Pointer(&rect))) + x := (getMetric(0 /* SM_CXSCREEN */) - (rect.right - rect.left)) / 2 + y := (getMetric(1 /* SM_CYSCREEN */) - (rect.bottom - rect.top)) / 2 + setWindowPos.Call(wnd, 0, uintptr(x), uintptr(y), 0, 0, 0x5) // SWP_NOZORDER|SWP_NOSIZE +} + +func getWindowString(wnd uintptr) string { + len, _, _ := getWindowTextLength.Call(wnd) + buf := make([]uint16, len+1) + getWindowText.Call(wnd, uintptr(unsafe.Pointer(&buf[0])), len+1) + return syscall.UTF16ToString(buf) +} + +func registerClass(instance, proc uintptr) (uintptr, error) { + name := "WC_" + strconv.FormatUint(uint64(proc), 16) + + var wcx _WNDCLASSEX + wcx.Size = uint32(unsafe.Sizeof(wcx)) + wcx.WndProc = proc + wcx.Instance = instance + wcx.Background = 5 // COLOR_WINDOW + wcx.ClassName = syscall.StringToUTF16Ptr(name) + + ret, _, err := registerClassEx.Call(uintptr(unsafe.Pointer(&wcx))) + return ret, err +} + +// https://docs.microsoft.com/en-us/windows/win32/winmsg/using-messages-and-message-queues +func messageLoop(wnd uintptr) error { + getMessage := getMessage.Addr() + isDialogMessage := isDialogMessage.Addr() + translateMessage := translateMessage.Addr() + dispatchMessage := dispatchMessage.Addr() + + for { + var msg _MSG + ret, _, err := syscall.Syscall6(getMessage, 4, uintptr(unsafe.Pointer(&msg)), 0, 0, 0, 0, 0) + if int32(ret) == -1 { + return err + } + if ret == 0 { + return nil + } + + ret, _, _ = syscall.Syscall(isDialogMessage, 2, wnd, uintptr(unsafe.Pointer(&msg)), 0) + if ret == 0 { + syscall.Syscall(translateMessage, 1, uintptr(unsafe.Pointer(&msg)), 0, 0) + syscall.Syscall(dispatchMessage, 1, uintptr(unsafe.Pointer(&msg)), 0, 0) + } + } +} + +// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-cwpretstruct +type _CWPRETSTRUCT struct { + Result uintptr + LParam uintptr + WParam uintptr + Message uint32 + Wnd uintptr +} + +// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-logfontw +type _LOGFONT struct { + Height int32 + Width int32 + Escapement int32 + Orientation int32 + Weight int32 + Italic byte + Underline byte + StrikeOut byte + CharSet byte + OutPrecision byte + ClipPrecision byte + Quality byte + PitchAndFamily byte + FaceName [32]uint16 +} + +// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-nonclientmetricsw +type _NONCLIENTMETRICS struct { + Size uint32 + BorderWidth int32 + ScrollWidth int32 + ScrollHeight int32 + CaptionWidth int32 + CaptionHeight int32 + CaptionFont _LOGFONT + SmCaptionWidth int32 + SmCaptionHeight int32 + SmCaptionFont _LOGFONT + MenuWidth int32 + MenuHeight int32 + MenuFont _LOGFONT + StatusFont _LOGFONT + MessageFont _LOGFONT +} + +// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-msg +type _MSG struct { + Owner syscall.Handle + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt _POINT +} + +// https://docs.microsoft.com/en-us/windows/win32/api/windef/ns-windef-point +type _POINT struct { + x, y int32 +} + +// https://docs.microsoft.com/en-us/windows/win32/api/windef/ns-windef-rect +type _RECT struct { + left int32 + top int32 + right int32 + bottom int32 +} + +// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-wndclassexw +type _WNDCLASSEX struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance uintptr + Icon uintptr + Cursor uintptr + Background uintptr + MenuName *uint16 + ClassName *uint16 + IconSm uintptr } // https://github.com/wine-mirror/wine/blob/master/include/unknwn.idl diff --git a/util_windows_test.go b/util_windows_test.go deleted file mode 100644 index f1a4489..0000000 --- a/util_windows_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package zenity - -func init() { - user32.NewProc("SetProcessDPIAware").Call() -} diff --git a/zenity.go b/zenity.go index 0162829..872c57a 100644 --- a/zenity.go +++ b/zenity.go @@ -41,6 +41,10 @@ type options struct { ellipsize bool defaultCancel bool + // List options + disallowEmpty bool + defaultItems []string + // File selection options directory bool confirmOverwrite bool