diff --git a/README.md b/README.md index 6f7fd93..145e14a 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@ Implemented dialogs: * [text entry](https://github.com/ncruces/zenity/wiki/Text-Entry-dialog) * [list](https://github.com/ncruces/zenity/wiki/List-dialog) (simple) * [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) +* [file selection](https://github.com/ncruces/zenity/wiki/File-selection-dialog) +* [color selection](https://github.com/ncruces/zenity/wiki/Color-selection-dialog) +* [progress](https://github.com/ncruces/zenity/wiki/Progress-dialog) * [notification](https://github.com/ncruces/zenity/wiki/Notification) Behavior on Windows, macOS and other Unixes might differ slightly. Some of that is intended (reflecting platform differences), -other bits are unfortunate limitations, -others still are open to be fixed. +other bits are unfortunate limitations. ## Why? @@ -37,7 +37,8 @@ Why reinvent this particular wheel? * Explorer shell not required * works in Server Core * Unicode support - * High DPI support (no manifest required) + * High DPI (no manifest required) + * Visual Styles (no manifest required) * WSL/Cygwin/MSYS2 [support](https://github.com/ncruces/zenity/wiki/Zenity-for-WSL,-Cygwin,-MSYS2) * on macOS: * only dependency is `osascript` diff --git a/cmd/zenity/main.go b/cmd/zenity/main.go index 4d4e6bc..44b6610 100644 --- a/cmd/zenity/main.go +++ b/cmd/zenity/main.go @@ -34,6 +34,7 @@ var ( passwordDlg bool fileSelectionDlg bool colorSelectionDlg bool + progressDlg bool notification bool // General options @@ -73,6 +74,13 @@ var ( defaultColor string showPalette bool + // Progress options + percentage float64 + pulsate bool + autoClose bool + autoKill bool + noCancel bool + // Windows specific options cygpath bool wslpath bool @@ -95,32 +103,32 @@ func main() { if zenutil.Timeout > 0 { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(zenutil.Timeout)*time.Second) opts = append(opts, zenity.Context(ctx)) - _ = cancel + defer cancel() } switch { case errorDlg: - okResult(zenity.Error(text, opts...)) + errResult(zenity.Error(text, opts...)) case infoDlg: - okResult(zenity.Info(text, opts...)) + errResult(zenity.Info(text, opts...)) case warningDlg: - okResult(zenity.Warning(text, opts...)) + errResult(zenity.Warning(text, opts...)) case questionDlg: - okResult(zenity.Question(text, opts...)) + errResult(zenity.Question(text, opts...)) case entryDlg: - strOKResult(zenity.Entry(text, opts...)) + strResult(zenity.Entry(text, opts...)) case listDlg: if multiple { - listResult(zenity.ListMultiple(text, flag.Args(), opts...)) + lstResult(zenity.ListMultiple(text, flag.Args(), opts...)) } else { - strOKResult(zenity.List(text, flag.Args(), opts...)) + strResult(zenity.List(text, flag.Args(), opts...)) } case passwordDlg: - _, pw, ok, err := zenity.Password(opts...) - strOKResult(pw, ok, err) + _, pw, err := zenity.Password(opts...) + strResult(pw, err) case fileSelectionDlg: switch { @@ -129,17 +137,21 @@ func main() { case save: strResult(egestPath(zenity.SelectFileSave(opts...))) case multiple: - listResult(egestPaths(zenity.SelectFileMutiple(opts...))) + lstResult(egestPaths(zenity.SelectFileMutiple(opts...))) } case colorSelectionDlg: - colorResult(zenity.SelectColor(opts...)) + colResult(zenity.SelectColor(opts...)) + + case progressDlg: + errResult(progress(opts...)) case notification: errResult(zenity.Notify(text, opts...)) - } - flag.Usage() + default: + flag.Usage() + } } func setupFlags() { @@ -153,6 +165,7 @@ func setupFlags() { 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(&progressDlg, "progress", false, "Display progress indication dialog") flag.BoolVar(¬ification, "notification", false, "Display notification") // General options @@ -165,12 +178,12 @@ func setupFlags() { flag.StringVar(&text, "text", "", "Set the dialog `text`") flag.StringVar(&icon, "window-icon", "", "Set the window `icon` (error, info, question, warning)") flag.BoolVar(&multiple, "multiple", false, "Allow multiple items to be selected") + flag.BoolVar(&defaultCancel, "default-cancel", false, "Give Cancel button focus by default") // Message options flag.StringVar(&icon, "icon-name", "", "Set the dialog `icon` (dialog-error, dialog-information, dialog-question, dialog-warning)") flag.BoolVar(&noWrap, "no-wrap", false, "Do not enable text wrapping") 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`") @@ -194,6 +207,15 @@ func setupFlags() { flag.StringVar(&defaultColor, "color", "", "Set the `color`") flag.BoolVar(&showPalette, "show-palette", false, "Show the palette") + // Progress options + flag.Float64Var(&percentage, "percentage", 0, "Set initial `percentage`") + flag.BoolVar(&pulsate, "pulsate", false, "Pulsate progress bar") + flag.BoolVar(&noCancel, "no-cancel", false, "Hide Cancel button (Windows and Unix only)") + flag.BoolVar(&autoClose, "auto-close", false, "Dismiss the dialog when 100% has been reached") + if runtime.GOOS != "windows" { + flag.BoolVar(&autoKill, "auto-kill", false, "Kill parent process if Cancel button is pressed (macOS and Unix only)") + } + // Windows specific options if runtime.GOOS == "windows" { flag.BoolVar(&cygpath, "cygpath", false, "Use cygpath for path translation (Windows only)") @@ -242,6 +264,9 @@ func validateFlags() { if colorSelectionDlg { n++ } + if progressDlg { + n++ + } if notification { n++ } @@ -297,6 +322,11 @@ func loadFlags() []zenity.Option { setDefault(&icon, "dialog-password") setDefault(&okLabel, "OK") setDefault(&cancelLabel, "Cancel") + case progressDlg: + setDefault(&title, "Progress") + setDefault(&text, "Running...") + setDefault(&okLabel, "OK") + setDefault(&cancelLabel, "Cancel") default: setDefault(&text, "") } @@ -388,6 +418,15 @@ func loadFlags() []zenity.Option { opts = append(opts, zenity.ShowPalette()) } + // Progress options + + if pulsate { + opts = append(opts, zenity.Pulsate()) + } + if noCancel { + opts = append(opts, zenity.NoCancel()) + } + return opts } @@ -395,6 +434,9 @@ func errResult(err error) { if os.IsTimeout(err) { os.Exit(5) } + if err == zenity.ErrCanceled { + os.Exit(1) + } if err == zenity.ErrExtraButton { os.Stdout.WriteString(extraButton) os.Stdout.WriteString(zenutil.LineBreak) @@ -408,64 +450,33 @@ func errResult(err error) { os.Exit(0) } -func okResult(ok bool, err error) { - if err != nil { - errResult(err) - } - if ok { - os.Exit(0) - } - os.Exit(1) -} - func strResult(s string, err error) { if err != nil { errResult(err) } - if s == "" { - os.Exit(1) - } os.Stdout.WriteString(s) os.Stdout.WriteString(zenutil.LineBreak) os.Exit(0) } -func listResult(l []string, err error) { +func lstResult(l []string, err error) { if err != nil { errResult(err) } - if l == nil { - os.Exit(1) - } os.Stdout.WriteString(strings.Join(l, zenutil.Separator)) os.Stdout.WriteString(zenutil.LineBreak) os.Exit(0) } -func colorResult(c color.Color, err error) { +func colResult(c color.Color, err error) { if err != nil { errResult(err) } - if c == nil { - os.Exit(1) - } os.Stdout.WriteString(zenutil.UnparseColor(c)) os.Stdout.WriteString(zenutil.LineBreak) os.Exit(0) } -func strOKResult(s string, ok bool, err error) { - if err != nil { - errResult(err) - } - if !ok { - os.Exit(1) - } - os.Stdout.WriteString(s) - os.Stdout.WriteString(zenutil.LineBreak) - os.Exit(0) -} - func ingestPath(path string) string { if runtime.GOOS == "windows" && path != "" { var args []string diff --git a/cmd/zenity/progress.go b/cmd/zenity/progress.go new file mode 100644 index 0000000..4fff0f3 --- /dev/null +++ b/cmd/zenity/progress.go @@ -0,0 +1,72 @@ +package main + +import ( + "bufio" + "math" + "os" + "strconv" + "strings" + + "github.com/ncruces/zenity" +) + +func progress(opts ...zenity.Option) (err error) { + dlg, err := zenity.Progress(opts...) + if err != nil { + return err + } + + if autoKill { + defer func() { + if err == zenity.ErrCanceled { + killParent() + } + }() + } + + if err := dlg.Text(text); err != nil { + return err + } + if err := dlg.Value(int(math.Round(percentage))); err != nil { + return err + } + + lines := make(chan string) + + go func() { + defer close(lines) + for scanner := bufio.NewScanner(os.Stdin); scanner.Scan(); { + lines <- scanner.Text() + } + }() + + for { + select { + case line, ok := <-lines: + if !ok { + break + } + if len(line) > 1 && line[0] == '#' { + if err := dlg.Text(strings.TrimSpace(line[1:])); err != nil { + return err + } + } else if v, err := strconv.ParseFloat(line, 64); err == nil { + if err := dlg.Value(int(math.Round(v))); err != nil { + return err + } + if v >= 100 && autoClose { + return dlg.Close() + } + } + continue + case <-dlg.Done(): + } + break + } + + if err := dlg.Complete(); err != nil { + return err + } + <-dlg.Done() + return dlg.Close() +} diff --git a/cmd/zenity/progress_unix.go b/cmd/zenity/progress_unix.go new file mode 100644 index 0000000..df4cdb7 --- /dev/null +++ b/cmd/zenity/progress_unix.go @@ -0,0 +1,12 @@ +// +build !windows,!js + +package main + +import ( + "os" + "syscall" +) + +func killParent() { + syscall.Kill(os.Getppid(), syscall.SIGHUP) +} diff --git a/cmd/zenity/progress_windows.go b/cmd/zenity/progress_windows.go new file mode 100644 index 0000000..6563f08 --- /dev/null +++ b/cmd/zenity/progress_windows.go @@ -0,0 +1,3 @@ +package main + +func killParent() {} diff --git a/color.go b/color.go index f9978de..781e6a5 100644 --- a/color.go +++ b/color.go @@ -4,8 +4,6 @@ import "image/color" // SelectColor displays the color selection dialog. // -// Returns nil on cancel. -// // Valid options: Title, Color, ShowPalette. func SelectColor(options ...Option) (color.Color, error) { return selectColor(applyOptions(options)) diff --git a/color_darwin.go b/color_darwin.go index 4b745a2..1abc599 100644 --- a/color_darwin.go +++ b/color_darwin.go @@ -20,9 +20,9 @@ func selectColor(opts options) (color.Color, error) { float32(g) / 0xffff, float32(b) / 0xffff, }) - str, ok, err := strResult(opts, out, err) - if ok { - return zenutil.ParseColor(str), nil + str, err := strResult(opts, out, err) + if err != nil { + return nil, err } - return nil, err + return zenutil.ParseColor(str), nil } diff --git a/color_test.go b/color_test.go index 1a19091..acf1aa4 100644 --- a/color_test.go +++ b/color_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/ncruces/zenity" + "go.uber.org/goleak" ) func ExampleSelectColor() { @@ -24,18 +25,19 @@ func ExampleSelectColor_palette() { // Output: } -func TestSelectColorTimeout(t *testing.T) { +func TestSelectColor_timeout(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) + defer cancel() _, err := zenity.SelectColor(zenity.Context(ctx)) if !os.IsTimeout(err) { t.Error("did not timeout:", err) } - - cancel() } -func TestSelectColorCancel(t *testing.T) { +func TestSelectColor_cancel(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/color_unix.go b/color_unix.go index b8efc1f..8a2fc1e 100644 --- a/color_unix.go +++ b/color_unix.go @@ -20,9 +20,9 @@ func selectColor(opts options) (color.Color, error) { } out, err := zenutil.Run(opts.ctx, args) - str, ok, err := strResult(opts, out, err) - if ok { - return zenutil.ParseColor(str), nil + str, err := strResult(opts, out, err) + if err != nil { + return nil, err } - return nil, err + return zenutil.ParseColor(str), nil } diff --git a/color_windows.go b/color_windows.go index b422929..a678f4a 100644 --- a/color_windows.go +++ b/color_windows.go @@ -9,7 +9,7 @@ import ( var ( chooseColor = comdlg32.NewProc("ChooseColorW") - savedColors = [16]uint32{} + savedColors [16]uint32 colorsMutex sync.Mutex ) @@ -32,7 +32,7 @@ func selectColor(opts options) (color.Color, error) { if opts.color != nil { args.Flags |= 0x1 // CC_RGBINIT n := color.NRGBAModel.Convert(opts.color).(color.NRGBA) - args.RgbResult = uint32(n.R) | (uint32(n.G) << 8) | (uint32(n.B) << 16) + args.RgbResult = uint32(n.R) | uint32(n.G)<<8 | uint32(n.B)<<16 } if opts.showPalette { args.Flags |= 0x4 // CC_PREVENTFULLOPEN diff --git a/entry.go b/entry.go index 4a96d80..ca571d8 100644 --- a/entry.go +++ b/entry.go @@ -2,11 +2,9 @@ package zenity // Entry displays the text entry dialog. // -// Returns false on cancel, or ErrExtraButton. -// // Valid options: Title, Width, Height, OKLabel, CancelLabel, ExtraButton, // Icon, EntryText, HideText. -func Entry(text string, options ...Option) (string, bool, error) { +func Entry(text string, options ...Option) (string, error) { return entry(text, applyOptions(options)) } diff --git a/entry_darwin.go b/entry_darwin.go index 6de4e61..039a296 100644 --- a/entry_darwin.go +++ b/entry_darwin.go @@ -4,7 +4,7 @@ import ( "github.com/ncruces/zenity/internal/zenutil" ) -func entry(text string, opts options) (string, bool, error) { +func entry(text string, opts options) (string, error) { var data zenutil.Dialog data.Text = text data.Operation = "displayDialog" diff --git a/entry_test.go b/entry_test.go index ee3cbc2..1c72a09 100644 --- a/entry_test.go +++ b/entry_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ncruces/zenity" + "go.uber.org/goleak" ) func ExampleEntry() { @@ -16,22 +17,23 @@ func ExampleEntry() { // Output: } -func TestEntryTimeout(t *testing.T) { +func TestEntry_timeout(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) + defer cancel() - _, _, err := zenity.Entry("", zenity.Context(ctx)) + _, err := zenity.Entry("", zenity.Context(ctx)) if !os.IsTimeout(err) { t.Error("did not timeout:", err) } - - cancel() } -func TestEntryCancel(t *testing.T) { +func TestEntry_cancel(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := zenity.Entry("", zenity.Context(ctx)) + _, err := zenity.Entry("", 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 05b2a70..9f51e88 100644 --- a/entry_unix.go +++ b/entry_unix.go @@ -6,7 +6,7 @@ import ( "github.com/ncruces/zenity/internal/zenutil" ) -func entry(text string, opts options) (string, bool, error) { +func entry(text string, opts options) (string, error) { args := []string{"--entry", "--text", text} args = appendTitle(args, opts) args = appendButtons(args, opts) diff --git a/entry_windows.go b/entry_windows.go index c128420..4e0916d 100644 --- a/entry_windows.go +++ b/entry_windows.go @@ -4,10 +4,9 @@ import ( "syscall" ) -func entry(text string, opts options) (out string, ok bool, err error) { - var title string - if opts.title != nil { - title = *opts.title +func entry(text string, opts options) (out string, err error) { + if opts.title == nil { + opts.title = stringPtr("") } if opts.okLabel == nil { opts.okLabel = stringPtr("OK") @@ -15,10 +14,7 @@ func entry(text string, opts options) (out string, ok bool, err error) { if opts.cancelLabel == nil { opts.cancelLabel = stringPtr("Cancel") } - return entryDlg(title, text, opts) -} -func entryDlg(title, text string, opts options) (out string, ok bool, err error) { defer setup()() font := getFont() defer font.Delete() @@ -33,6 +29,7 @@ func entryDlg(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) + sendMessage.Call(extraBtn, 0x0030 /* WM_SETFONT */, hfont, 1) 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 @@ -40,7 +37,6 @@ func entryDlg(title, text string, opts options) (out string, ok bool, err error) 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(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 @@ -53,6 +49,7 @@ func entryDlg(title, text string, opts options) (out string, ok bool, err error) postQuitMessage.Call(0) case 0x0010: // WM_CLOSE + err = ErrCanceled destroyWindow.Call(wnd) case 0x0111: // WM_COMMAND @@ -61,8 +58,8 @@ func entryDlg(title, text string, opts options) (out string, ok bool, err error) return 1 case 1, 6: // IDOK, IDYES out = getWindowString(editCtl) - ok = true case 2: // IDCANCEL + err = ErrCanceled case 7: // IDNO err = ErrExtraButton } @@ -72,39 +69,39 @@ func entryDlg(title, text string, opts options) (out string, ok bool, err error) layout(dpi(uint32(wparam) >> 16)) default: - ret, _, _ := syscall.Syscall6(defWindowProc, 4, wnd, uintptr(msg), wparam, lparam, 0, 0) - return ret + res, _, _ := syscall.Syscall6(defWindowProc, 4, wnd, uintptr(msg), wparam, lparam, 0, 0) + return res } return 0 } if opts.ctx != nil && opts.ctx.Err() != nil { - return "", false, opts.ctx.Err() + return "", opts.ctx.Err() } instance, _, err := getModuleHandle.Call(0) if instance == 0 { - return "", false, err + return "", err } cls, err := registerClass(instance, syscall.NewCallback(proc)) if cls == 0 { - return "", false, err + return "", err } defer unregisterClass.Call(cls, instance) wnd, _, _ = createWindowEx.Call(0x10101, // WS_EX_CONTROLPARENT|WS_EX_WINDOWEDGE|WS_EX_DLGMODALFRAME - cls, strptr(title), + cls, strptr(*opts.title), 0x84c80000, // WS_POPUPWINDOW|WS_CLIPSIBLINGS|WS_DLGFRAME 0x80000000, // CW_USEDEFAULT 0x80000000, // CW_USEDEFAULT - 281, 141, 0, 0, instance) + 281, 141, 0, 0, instance, 0) 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) + 12, 10, 241, 16, wnd, 0, instance, 0) var flags uintptr = 0x50030080 // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP|ES_AUTOHSCROLL if opts.hideText { @@ -113,21 +110,21 @@ func entryDlg(title, text string, opts options) (out string, ok bool, err error) editCtl, _, _ = createWindowEx.Call(0x200, // WS_EX_CLIENTEDGE strptr("EDIT"), strptr(opts.entryText), flags, - 12, 30, 241, 24, wnd, 0, instance) + 12, 30, 241, 24, wnd, 0, instance, 0) okBtn, _, _ = createWindowEx.Call(0, strptr("BUTTON"), strptr(*opts.okLabel), 0x50030001, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP|BS_DEFPUSHBUTTON - 12, 66, 75, 24, wnd, 1 /* IDOK */, instance) + 12, 66, 75, 24, wnd, 1 /* IDOK */, instance, 0) cancelBtn, _, _ = createWindowEx.Call(0, strptr("BUTTON"), strptr(*opts.cancelLabel), 0x50010000, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP - 12, 66, 75, 24, wnd, 2 /* IDCANCEL */, instance) + 12, 66, 75, 24, wnd, 2 /* IDCANCEL */, instance, 0) if opts.extraButton != nil { extraBtn, _, _ = createWindowEx.Call(0, strptr("BUTTON"), strptr(*opts.extraButton), 0x50010000, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP - 12, 66, 75, 24, wnd, 7 /* IDNO */, instance) + 12, 66, 75, 24, wnd, 7 /* IDNO */, instance, 0) } layout(getDPI(wnd)) @@ -149,13 +146,13 @@ func entryDlg(title, text string, opts options) (out string, ok bool, err error) } // set default values - out, ok, err = "", false, nil + out, err = "", nil if err := messageLoop(wnd); err != nil { - return "", false, err + return "", err } if opts.ctx != nil && opts.ctx.Err() != nil { - return "", false, opts.ctx.Err() + return "", opts.ctx.Err() } - return out, ok, err + return out, err } diff --git a/file.go b/file.go index ae98daf..fb2ca03 100644 --- a/file.go +++ b/file.go @@ -8,8 +8,6 @@ import ( // SelectFile displays the file selection dialog. // -// Returns an empty string on cancel. -// // Valid options: Title, Directory, Filename, ShowHidden, FileFilter(s). func SelectFile(options ...Option) (string, error) { return selectFile(applyOptions(options)) @@ -17,8 +15,6 @@ func SelectFile(options ...Option) (string, error) { // SelectFileMutiple displays the multiple file selection dialog. // -// Returns a nil slice on cancel. -// // Valid options: Title, Directory, Filename, ShowHidden, FileFilter(s). func SelectFileMutiple(options ...Option) ([]string, error) { return selectFileMutiple(applyOptions(options)) @@ -26,8 +22,6 @@ func SelectFileMutiple(options ...Option) ([]string, error) { // SelectFileSave displays the save file selection dialog. // -// Returns an empty string on cancel. -// // Valid options: Title, Filename, ConfirmOverwrite, ConfirmCreate, ShowHidden, // FileFilter(s). func SelectFileSave(options ...Option) (string, error) { @@ -69,6 +63,9 @@ func Filename(filename string) Option { // // macOS hides filename filters from the user, // and only supports filtering by extension (or "type"). +// +// Patterns may use the GTK syntax on all platforms: +// https://developer.gnome.org/pygtk/stable/class-gtkfilefilter.html#method-gtkfilefilter--add-pattern type FileFilter struct { Name string // display string that describes the filter (optional) Patterns []string // filter patterns for the display string diff --git a/file_darwin.go b/file_darwin.go index dc73507..668424d 100644 --- a/file_darwin.go +++ b/file_darwin.go @@ -18,8 +18,7 @@ func selectFile(opts options) (string, error) { } out, err := zenutil.Run(opts.ctx, "file", data) - str, _, err := strResult(opts, out, err) - return str, err + return strResult(opts, out, err) } func selectFileMutiple(opts options) ([]string, error) { @@ -54,6 +53,5 @@ func selectFileSave(opts options) (string, error) { } out, err := zenutil.Run(opts.ctx, "file", data) - str, _, err := strResult(opts, out, err) - return str, err + return strResult(opts, out, err) } diff --git a/file_test.go b/file_test.go index 34a9c03..65d6a8d 100644 --- a/file_test.go +++ b/file_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ncruces/zenity" + "go.uber.org/goleak" ) const defaultPath = `` @@ -77,7 +78,7 @@ var fileFuncs = []func(...zenity.Option) (string, error){ }, } -func TestFileTimeout(t *testing.T) { +func TestFile_timeout(t *testing.T) { for _, f := range fileFuncs { ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) @@ -87,10 +88,12 @@ func TestFileTimeout(t *testing.T) { } cancel() + goleak.VerifyNone(t) } } -func TestFileCancel(t *testing.T) { +func TestFile_cancel(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/file_unix.go b/file_unix.go index a200562..c1a5074 100644 --- a/file_unix.go +++ b/file_unix.go @@ -14,8 +14,7 @@ func selectFile(opts options) (string, error) { args = appendFileArgs(args, opts) out, err := zenutil.Run(opts.ctx, args) - str, _, err := strResult(opts, out, err) - return str, err + return strResult(opts, out, err) } func selectFileMutiple(opts options) ([]string, error) { @@ -33,8 +32,7 @@ func selectFileSave(opts options) (string, error) { args = appendFileArgs(args, opts) out, err := zenutil.Run(opts.ctx, args) - str, _, err := strResult(opts, out, err) - return str, err + return strResult(opts, out, err) } func initFilters(filters []FileFilter) []string { diff --git a/file_windows.go b/file_windows.go index 8727206..305fd10 100644 --- a/file_windows.go +++ b/file_windows.go @@ -37,7 +37,7 @@ func selectFile(opts options) (string, error) { args.Filter = &initFilters(opts.fileFilters)[0] } - res := [32768]uint16{} + var res [32768]uint16 args.File = &res[0] args.MaxFile = uint32(len(res)) args.InitialDir, args.DefExt = initDirNameExt(opts.filename, res[:]) @@ -82,7 +82,7 @@ func selectFileMutiple(opts options) ([]string, error) { args.Filter = &initFilters(opts.fileFilters)[0] } - res := [32768 + 1024*256]uint16{} + var res [32768 + 1024*256]uint16 args.File = &res[0] args.MaxFile = uint32(len(res)) args.InitialDir, args.DefExt = initDirNameExt(opts.filename, res[:]) @@ -158,7 +158,7 @@ func selectFileSave(opts options) (string, error) { args.Filter = &initFilters(opts.fileFilters)[0] } - res := [32768]uint16{} + var res [32768]uint16 args.File = &res[0] args.MaxFile = uint32(len(res)) args.InitialDir, args.DefExt = initDirNameExt(opts.filename, res[:]) @@ -251,7 +251,7 @@ func pickFolders(opts options, multi bool) (str string, lst []string, err error) return "", nil, opts.ctx.Err() } if hr == 0x800704c7 { // ERROR_CANCELLED - return "", nil, nil + return "", nil, ErrCanceled } if int32(hr) < 0 { return "", nil, syscall.Errno(hr) @@ -335,11 +335,11 @@ func browseForFolder(opts options) (string, []string, error) { return "", nil, opts.ctx.Err() } if ptr == 0 { - return "", nil, nil + return "", nil, ErrCanceled } defer coTaskMemFree.Call(ptr) - res := [32768]uint16{} + var res [32768]uint16 shGetPathFromIDListEx.Call(ptr, uintptr(unsafe.Pointer(&res[0])), uintptr(len(res)), 0) str := syscall.UTF16ToString(res[:]) diff --git a/internal/zenutil/env.go b/internal/zenutil/env.go new file mode 100644 index 0000000..12e4d5f --- /dev/null +++ b/internal/zenutil/env.go @@ -0,0 +1,12 @@ +package zenutil + +// These are internal. +const ( + ErrCanceled = stringErr("dialog canceled") + ErrExtraButton = stringErr("extra button pressed") + ErrUnsupported = stringErr("unsupported option") +) + +type stringErr string + +func (e stringErr) Error() string { return string(e) } diff --git a/internal/zenutil/osa_generated.go b/internal/zenutil/osa_generated.go index 4396954..ed15e73 100644 --- a/internal/zenutil/osa_generated.go +++ b/internal/zenutil/osa_generated.go @@ -48,4 +48,25 @@ res.join({{json .Separator}}) var app=Application.currentApplication() app.includeStandardAdditions=true void app.displayNotification({{json .Text}},{{json .Options}}) +{{- end}} +{{define "progress" -}} +var app=Application.currentApplication() +app.includeStandardAdditions=true +app.activate() +ObjC.import('stdlib') +ObjC.import('readline') +{{- if .Total}} +Progress.totalUnitCount={{.Total}} +{{- end}} +{{- if .Description}} +Progress.description={{json .Description}} +{{- end}} +while(true){var s +try{s=$.readline('')}catch(e){if(e.errorNumber===-128)$.exit(1) +break} +if(s.indexOf('#')===0){Progress.additionalDescription=s.slice(1) +continue} +var i=parseInt(s) +if(i>=0&&Progress.totalUnitCount>0){Progress.completedUnitCount=i +continue}} {{- end}}`)) diff --git a/internal/zenutil/osa_generator.go b/internal/zenutil/osa_generator.go index 0270174..f69dfb6 100644 --- a/internal/zenutil/osa_generator.go +++ b/internal/zenutil/osa_generator.go @@ -4,7 +4,6 @@ package main import ( "bytes" - "io/ioutil" "log" "os" "path/filepath" @@ -17,7 +16,7 @@ import ( func main() { dir := os.Args[1] - files, err := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil { log.Fatal(err) } @@ -26,12 +25,7 @@ func main() { for _, file := range files { name := file.Name() - - str.WriteString("\n" + `{{define "`) - str.WriteString(strings.TrimSuffix(name, filepath.Ext(name))) - str.WriteString(`" -}}` + "\n") - - data, err := ioutil.ReadFile(filepath.Join(dir, name)) + data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { log.Fatal(err) } @@ -40,6 +34,9 @@ func main() { log.Fatal(err) } + str.WriteString("\n" + `{{define "`) + str.WriteString(strings.TrimSuffix(name, filepath.Ext(name))) + str.WriteString(`" -}}` + "\n") str.Write(data) str.WriteString("\n{{- end}}") } @@ -108,5 +105,4 @@ import ( var scripts = template.Must(template.New("").Funcs(template.FuncMap{"json": func(v interface{}) (string, error) { b, err := json.Marshal(v) return string(b), err -}}).Parse(` + "`{{.}}`" + `)) -`)) +}}).Parse(` + "`{{.}}`))\n")) diff --git a/internal/zenutil/osascripts/progress.gojs b/internal/zenutil/osascripts/progress.gojs new file mode 100644 index 0000000..45c8102 --- /dev/null +++ b/internal/zenutil/osascripts/progress.gojs @@ -0,0 +1,34 @@ +var app = Application.currentApplication() +app.includeStandardAdditions = true +app.activate() + +ObjC.import('stdlib') +ObjC.import('readline') + +{{- if .Total}} + Progress.totalUnitCount = {{.Total}} +{{- end}} +{{- if .Description}} + Progress.description = {{json .Description}} +{{- end}} + +while (true) { + var s + try { + s = $.readline('') + } catch (e) { + if (e.errorNumber === -128) $.exit(1) + break + } + + if (s.indexOf('#') === 0) { + Progress.additionalDescription = s.slice(1) + continue + } + + var i = parseInt(s) + if (i >= 0 && Progress.totalUnitCount > 0) { + Progress.completedUnitCount = i + continue + } +} \ No newline at end of file diff --git a/internal/zenutil/progress_unix.go b/internal/zenutil/progress_unix.go new file mode 100644 index 0000000..5706b23 --- /dev/null +++ b/internal/zenutil/progress_unix.go @@ -0,0 +1,116 @@ +// +build !windows,!js + +package zenutil + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "runtime" + "strconv" + "sync/atomic" + "time" +) + +type progressDialog struct { + ctx context.Context + cmd *exec.Cmd + max int + percent bool + closed int32 + lines chan string + done chan struct{} + err error +} + +func (d *progressDialog) send(line string) error { + select { + case d.lines <- line: + return nil + case <-d.done: + return d.err + } +} + +func (d *progressDialog) Text(text string) error { + return d.send("#" + text) +} + +func (d *progressDialog) Value(value int) error { + if d.percent { + return d.send(strconv.FormatFloat(100*float64(value)/float64(d.max), 'f', -1, 64)) + } else { + return d.send(strconv.Itoa(value)) + } +} + +func (d *progressDialog) MaxValue() int { + return d.max +} + +func (d *progressDialog) Done() <-chan struct{} { + return d.done +} + +func (d *progressDialog) Complete() error { + err := d.Value(d.max) + close(d.lines) + return err +} + +func (d *progressDialog) Close() error { + atomic.StoreInt32(&d.closed, 1) + d.cmd.Process.Signal(os.Interrupt) + <-d.done + return d.err +} + +func (d *progressDialog) wait(extra *string, out *bytes.Buffer) { + err := d.cmd.Wait() + if cerr := d.ctx.Err(); cerr != nil { + err = cerr + } + if eerr, ok := err.(*exec.ExitError); ok { + switch { + case eerr.ExitCode() == -1 && atomic.LoadInt32(&d.closed) != 0: + err = nil + case eerr.ExitCode() == 1: + if extra != nil && *extra+"\n" == string(out.Bytes()) { + err = ErrExtraButton + } else { + err = ErrCanceled + } + } + } + d.err = err + close(d.done) +} + +func (d *progressDialog) pipe(w io.WriteCloser) { + defer w.Close() + var timeout = time.Second + if runtime.GOOS == "darwin" { + timeout = 40 * time.Millisecond + } + for { + var line string + select { + case s, ok := <-d.lines: + if !ok { + return + } + line = s + case <-d.ctx.Done(): + return + case <-d.done: + return + case <-time.After(timeout): + // line = "" + } + if _, err := w.Write([]byte(line + "\n")); err != nil { + return + } + } +} diff --git a/internal/zenutil/run_darwin.go b/internal/zenutil/run_darwin.go index 80c862e..d7e9b87 100644 --- a/internal/zenutil/run_darwin.go +++ b/internal/zenutil/run_darwin.go @@ -1,30 +1,29 @@ package zenutil import ( + "bytes" "context" "io/ioutil" "os" "os/exec" - "strings" + "path/filepath" "syscall" ) // Run is internal. func Run(ctx context.Context, script string, data interface{}) ([]byte, error) { - var buf strings.Builder - + var buf bytes.Buffer err := scripts.ExecuteTemplate(&buf, script, data) if err != nil { return nil, err } - script = buf.String() if Command { // 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.Write(buf.Bytes()); err != nil { } else if _, err := t.Seek(0, 0); err != nil { } else if err := syscall.Dup2(int(t.Fd()), syscall.Stdin); err != nil { } else if err := os.Stderr.Close(); err != nil { @@ -35,7 +34,7 @@ func Run(ctx context.Context, script string, data interface{}) ([]byte, error) { if ctx != nil { cmd := exec.CommandContext(ctx, "osascript", "-l", "JavaScript") - cmd.Stdin = strings.NewReader(script) + cmd.Stdin = &buf out, err := cmd.Output() if ctx.Err() != nil { err = ctx.Err() @@ -43,25 +42,86 @@ func Run(ctx context.Context, script string, data interface{}) ([]byte, error) { return out, err } cmd := exec.Command("osascript", "-l", "JavaScript") - cmd.Stdin = strings.NewReader(script) + cmd.Stdin = &buf return cmd.Output() } -// File is internal. -type File struct { - Operation string - Separator string - Options FileOptions -} +// RunProgress is internal. +func RunProgress(ctx context.Context, max int, data Progress) (dlg *progressDialog, err error) { + var buf bytes.Buffer + err = scripts.ExecuteTemplate(&buf, "progress", data) + if err != nil { + return nil, err + } -// FileOptions is internal. -type FileOptions struct { - Prompt *string `json:"withPrompt,omitempty"` - Type []string `json:"ofType,omitempty"` - Name string `json:"defaultName,omitempty"` - Location string `json:"defaultLocation,omitempty"` - Multiple bool `json:"multipleSelectionsAllowed,omitempty"` - Invisibles bool `json:"invisibles,omitempty"` + t, err := ioutil.TempDir("", "") + if err != nil { + return nil, err + } + defer func() { + if err != nil { + if ctx != nil && ctx.Err() != nil { + err = ctx.Err() + } + os.RemoveAll(t) + } + }() + if ctx == nil { + ctx = context.Background() + } + + var cmd *exec.Cmd + name := filepath.Join(t, "progress.app") + + cmd = exec.CommandContext(ctx, "osacompile", "-l", "JavaScript", "-o", name) + cmd.Stdin = &buf + if err := cmd.Run(); err != nil { + return nil, err + } + + plist := filepath.Join(name, "Contents/Info.plist") + + cmd = exec.CommandContext(ctx, "defaults", "write", plist, "LSUIElement", "true") + if err := cmd.Run(); err != nil { + return nil, err + } + + cmd = exec.CommandContext(ctx, "defaults", "write", plist, "CFBundleName", "") + if err := cmd.Run(); err != nil { + return nil, err + } + + var executable string + cmd = exec.CommandContext(ctx, "defaults", "read", plist, "CFBundleExecutable") + if out, err := cmd.Output(); err != nil { + return nil, err + } else { + out = bytes.TrimSuffix(out, []byte{'\n'}) + executable = filepath.Join(name, "Contents/MacOS", string(out)) + } + + cmd = exec.CommandContext(ctx, executable) + pipe, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + + dlg = &progressDialog{ + ctx: ctx, + cmd: cmd, + max: max, + lines: make(chan string), + done: make(chan struct{}), + } + go dlg.pipe(pipe) + go func() { + defer os.RemoveAll(t) + dlg.wait(nil, nil) + }() + return dlg, nil } // Dialog is internal. @@ -86,6 +146,25 @@ type DialogOptions struct { Timeout int `json:"givingUpAfter,omitempty"` } +// DialogButtons is internal. +type DialogButtons struct { + Buttons []string + Default int + Cancel int + Extra int +} + +// SetButtons is internal. +func (d *Dialog) SetButtons(btns DialogButtons) { + d.Options.Buttons = btns.Buttons + d.Options.Default = btns.Default + d.Options.Cancel = btns.Cancel + if btns.Extra > 0 { + name := btns.Buttons[btns.Extra-1] + d.Extra = &name + } +} + // List is internal. type List struct { Items []string @@ -104,6 +183,22 @@ type ListOptions struct { Empty bool `json:"emptySelectionAllowed,omitempty"` } +// File is internal. +type File struct { + Operation string + Separator string + Options FileOptions +} + +type FileOptions struct { + Prompt *string `json:"withPrompt,omitempty"` + Type []string `json:"ofType,omitempty"` + Name string `json:"defaultName,omitempty"` + Location string `json:"defaultLocation,omitempty"` + Multiple bool `json:"multipleSelectionsAllowed,omitempty"` + Invisibles bool `json:"invisibles,omitempty"` +} + // Notify is internal. type Notify struct { Text string @@ -116,19 +211,8 @@ type NotifyOptions struct { Subtitle string `json:"subtitle,omitempty"` } -type Buttons struct { - Buttons []string - Default int - Cancel int - Extra int -} - -func (d *Dialog) SetButtons(btns Buttons) { - d.Options.Buttons = btns.Buttons - d.Options.Default = btns.Default - d.Options.Cancel = btns.Cancel - if btns.Extra > 0 { - name := btns.Buttons[btns.Extra-1] - d.Extra = &name - } +// Progress is internal. +type Progress struct { + Description *string + Total *int } diff --git a/internal/zenutil/run_unix.go b/internal/zenutil/run_unix.go index a648e94..b270340 100644 --- a/internal/zenutil/run_unix.go +++ b/internal/zenutil/run_unix.go @@ -3,6 +3,7 @@ package zenutil import ( + "bytes" "context" "os" "os/exec" @@ -40,3 +41,42 @@ func Run(ctx context.Context, args []string) ([]byte, error) { } return exec.Command(tool, args...).Output() } + +// RunProgress is internal. +func RunProgress(ctx context.Context, max int, extra *string, args []string) (*progressDialog, error) { + if Command && path != "" { + if Timeout > 0 { + args = append(args, "--timeout", strconv.Itoa(Timeout)) + } + syscall.Exec(path, append([]string{tool}, args...), os.Environ()) + } + if ctx == nil { + ctx = context.Background() + } + + cmd := exec.CommandContext(ctx, tool, args...) + pipe, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + var out *bytes.Buffer + if extra != nil { + out = &bytes.Buffer{} + cmd.Stdout = out + } + if err := cmd.Start(); err != nil { + return nil, err + } + + dlg := &progressDialog{ + ctx: ctx, + cmd: cmd, + max: max, + percent: true, + lines: make(chan string), + done: make(chan struct{}), + } + go dlg.pipe(pipe) + go dlg.wait(extra, out) + return dlg, nil +} diff --git a/list.go b/list.go index d3acebd..2c78313 100644 --- a/list.go +++ b/list.go @@ -2,25 +2,19 @@ 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) { +func List(text string, items []string, options ...Option) (string, 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) { +func ListItems(text string, items ...string) (string, 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) { @@ -28,8 +22,6 @@ func ListMultiple(text string, items []string, options ...Option) ([]string, err } // ListMultipleItems 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) } diff --git a/list_darwin.go b/list_darwin.go index a6b2be0..1ccb7ea 100644 --- a/list_darwin.go +++ b/list_darwin.go @@ -4,7 +4,11 @@ import ( "github.com/ncruces/zenity/internal/zenutil" ) -func list(text string, items []string, opts options) (string, bool, error) { +func list(text string, items []string, opts options) (string, error) { + if opts.extraButton != nil { + return "", ErrUnsupported + } + var data zenutil.List data.Items = items data.Options.Prompt = &text @@ -19,6 +23,10 @@ func list(text string, items []string, opts options) (string, bool, error) { } func listMultiple(text string, items []string, opts options) ([]string, error) { + if opts.extraButton != nil { + return nil, ErrUnsupported + } + var data zenutil.List data.Items = items data.Options.Prompt = &text diff --git a/list_test.go b/list_test.go index 4967020..9f23f29 100644 --- a/list_test.go +++ b/list_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ncruces/zenity" + "go.uber.org/goleak" ) func ExampleList() { @@ -44,22 +45,23 @@ func ExampleListMultipleItems() { // Output: } -func TestListTimeout(t *testing.T) { +func TestList_timeout(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) + defer cancel() - _, _, err := zenity.List("", nil, zenity.Context(ctx)) + _, err := zenity.List("", nil, zenity.Context(ctx)) if !os.IsTimeout(err) { t.Error("did not timeout:", err) } - - cancel() } -func TestListCancel(t *testing.T) { +func TestList_cancel(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := zenity.List("", nil, zenity.Context(ctx)) + _, 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 index ce905a4..c7de987 100644 --- a/list_unix.go +++ b/list_unix.go @@ -6,7 +6,7 @@ import ( "github.com/ncruces/zenity/internal/zenutil" ) -func list(text string, items []string, opts options) (string, bool, error) { +func list(text string, items []string, opts options) (string, error) { args := []string{"--list", "--column=", "--hide-header", "--text", text} args = appendTitle(args, opts) args = appendButtons(args, opts) diff --git a/list_windows.go b/list_windows.go index 16a7201..f8d4576 100644 --- a/list_windows.go +++ b/list_windows.go @@ -5,28 +5,21 @@ import ( "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) +func list(text string, items []string, opts options) (string, error) { + items, err := listDlg(text, items, false, opts) if len(items) == 1 { - return items[0], true, err + return items[0], err } - return "", false, err + return "", err } func listMultiple(text string, items []string, opts options) ([]string, error) { - var title string - if opts.title != nil { - title = *opts.title + return listDlg(text, items, true, opts) +} + +func listDlg(text string, items []string, multiple bool, opts options) (out []string, err error) { + if opts.title == nil { + opts.title = stringPtr("") } if opts.okLabel == nil { opts.okLabel = stringPtr("OK") @@ -34,10 +27,7 @@ func listMultiple(text string, items []string, opts options) ([]string, error) { 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() @@ -52,6 +42,7 @@ func listDlg(title, text string, items []string, multiple bool, opts options) (o sendMessage.Call(listCtl, 0x0030 /* WM_SETFONT */, hfont, 1) sendMessage.Call(okBtn, 0x0030 /* WM_SETFONT */, hfont, 1) sendMessage.Call(cancelBtn, 0x0030 /* WM_SETFONT */, hfont, 1) + sendMessage.Call(extraBtn, 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 @@ -59,7 +50,6 @@ func listDlg(title, text string, items []string, multiple bool, opts options) (o 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 @@ -72,6 +62,7 @@ func listDlg(title, text string, items []string, multiple bool, opts options) (o postQuitMessage.Call(0) case 0x0010: // WM_CLOSE + err = ErrCanceled destroyWindow.Call(wnd) case 0x0111: // WM_COMMAND @@ -98,6 +89,7 @@ func listDlg(title, text string, items []string, multiple bool, opts options) (o } } case 2: // IDCANCEL + err = ErrCanceled case 7: // IDNO err = ErrExtraButton } @@ -107,8 +99,8 @@ func listDlg(title, text string, items []string, multiple bool, opts options) (o layout(dpi(uint32(wparam) >> 16)) default: - ret, _, _ := syscall.Syscall6(defWindowProc, 4, wnd, uintptr(msg), wparam, lparam, 0, 0) - return ret + res, _, _ := syscall.Syscall6(defWindowProc, 4, wnd, uintptr(msg), wparam, lparam, 0, 0) + return res } return 0 @@ -130,16 +122,16 @@ func listDlg(title, text string, items []string, multiple bool, opts options) (o defer unregisterClass.Call(cls, instance) wnd, _, _ = createWindowEx.Call(0x10101, // WS_EX_CONTROLPARENT|WS_EX_WINDOWEDGE|WS_EX_DLGMODALFRAME - cls, strptr(title), + cls, strptr(*opts.title), 0x84c80000, // WS_POPUPWINDOW|WS_CLIPSIBLINGS|WS_DLGFRAME 0x80000000, // CW_USEDEFAULT 0x80000000, // CW_USEDEFAULT - 281, 281, 0, 0, instance) + 281, 281, 0, 0, instance, 0) 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) + 12, 10, 241, 16, wnd, 0, instance, 0) var flags uintptr = 0x50320000 // WS_CHILD|WS_VISIBLE|WS_VSCROLL|WS_GROUP|WS_TABSTOP if multiple { @@ -148,21 +140,21 @@ func listDlg(title, text string, items []string, multiple bool, opts options) (o listCtl, _, _ = createWindowEx.Call(0x200, // WS_EX_CLIENTEDGE strptr("LISTBOX"), strptr(opts.entryText), flags, - 12, 30, 241, 164, wnd, 0, instance) + 12, 30, 241, 164, wnd, 0, instance, 0) 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) + 12, 206, 75, 24, wnd, 1 /* IDOK */, instance, 0) 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) + 12, 206, 75, 24, wnd, 2 /* IDCANCEL */, instance, 0) 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) + 12, 206, 75, 24, wnd, 7 /* IDNO */, instance, 0) } for _, item := range items { diff --git a/msg.go b/msg.go index d054c9f..48e20b1 100644 --- a/msg.go +++ b/msg.go @@ -1,46 +1,34 @@ package zenity -// ErrExtraButton is returned by dialog functions when the extra button is -// pressed. -const ErrExtraButton = stringErr("extra button pressed") - // Question displays the question dialog. // -// Returns true on OK, false on Cancel, or ErrExtraButton. -// // Valid options: Title, Width, Height, OKLabel, CancelLabel, ExtraButton, // Icon, NoWrap, Ellipsize, DefaultCancel. -func Question(text string, options ...Option) (bool, error) { +func Question(text string, options ...Option) error { return message(questionKind, text, applyOptions(options)) } // Info displays the info dialog. // -// Returns true on OK, false on dismiss, or ErrExtraButton. -// // Valid options: Title, Width, Height, OKLabel, ExtraButton, Icon, // NoWrap, Ellipsize. -func Info(text string, options ...Option) (bool, error) { +func Info(text string, options ...Option) error { return message(infoKind, text, applyOptions(options)) } // Warning displays the warning dialog. // -// Returns true on OK, false on dismiss, or ErrExtraButton. -// // Valid options: Title, Width, Height, OKLabel, ExtraButton, Icon, // NoWrap, Ellipsize. -func Warning(text string, options ...Option) (bool, error) { +func Warning(text string, options ...Option) error { return message(warningKind, text, applyOptions(options)) } // Error displays the error dialog. // -// Returns true on OK, false on dismiss, or ErrExtraButton. -// // Valid options: Title, Width, Height, OKLabel, ExtraButton, Icon, // NoWrap, Ellipsize. -func Error(text string, options ...Option) (bool, error) { +func Error(text string, options ...Option) error { return message(errorKind, text, applyOptions(options)) } diff --git a/msg_darwin.go b/msg_darwin.go index 03a1827..27836cf 100644 --- a/msg_darwin.go +++ b/msg_darwin.go @@ -4,7 +4,7 @@ import ( "github.com/ncruces/zenity/internal/zenutil" ) -func message(kind messageKind, text string, opts options) (bool, error) { +func message(kind messageKind, text string, opts options) error { var data zenutil.Dialog data.Text = text data.Options.Timeout = zenutil.Timeout @@ -33,6 +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) - _, ok, err := strResult(opts, out, err) - return ok, err + _, err = strResult(opts, out, err) + return err } diff --git a/msg_test.go b/msg_test.go index 984669f..ea649ce 100644 --- a/msg_test.go +++ b/msg_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ncruces/zenity" + "go.uber.org/goleak" ) func ExampleError() { @@ -38,32 +39,34 @@ func ExampleQuestion() { // Output: } -var msgFuncs = []func(string, ...zenity.Option) (bool, error){ +var msgFuncs = []func(string, ...zenity.Option) error{ zenity.Error, zenity.Info, zenity.Warning, zenity.Question, } -func TestMessageTimeout(t *testing.T) { +func TestMessage_timeout(t *testing.T) { for _, f := range msgFuncs { ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) - _, err := f("text", zenity.Context(ctx)) + err := f("text", zenity.Context(ctx)) if !os.IsTimeout(err) { t.Error("did not timeout:", err) } cancel() + goleak.VerifyNone(t) } } -func TestMessageCancel(t *testing.T) { +func TestMessage_cancel(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithCancel(context.Background()) cancel() for _, f := range msgFuncs { - _, err := f("text", zenity.Context(ctx)) + err := f("text", zenity.Context(ctx)) if !errors.Is(err, context.Canceled) { t.Error("was not canceled:", err) } diff --git a/msg_unix.go b/msg_unix.go index 9ba81e1..12f647d 100644 --- a/msg_unix.go +++ b/msg_unix.go @@ -6,7 +6,7 @@ import ( "github.com/ncruces/zenity/internal/zenutil" ) -func message(kind messageKind, text string, opts options) (bool, error) { +func message(kind messageKind, text string, opts options) error { args := []string{"--text", text, "--no-markup"} switch kind { case questionKind: @@ -47,6 +47,6 @@ func message(kind messageKind, text string, opts options) (bool, error) { } out, err := zenutil.Run(opts.ctx, args) - _, ok, err := strResult(opts, out, err) - return ok, err + _, err = strResult(opts, out, err) + return err } diff --git a/msg_windows.go b/msg_windows.go index 0f4214e..26a740c 100644 --- a/msg_windows.go +++ b/msg_windows.go @@ -12,7 +12,7 @@ var ( getDlgCtrlID = user32.NewProc("GetDlgCtrlID") ) -func message(kind messageKind, text string, opts options) (bool, error) { +func message(kind messageKind, text string, opts options) error { var flags uintptr switch { @@ -48,7 +48,7 @@ func message(kind messageKind, text string, opts options) (bool, error) { if opts.ctx != nil || opts.okLabel != nil || opts.cancelLabel != nil || opts.extraButton != nil { unhook, err := hookMessageLabels(kind, opts) if err != nil { - return false, err + return err } defer unhook() } @@ -62,17 +62,17 @@ func message(kind messageKind, text string, opts options) (bool, error) { s, _, err := messageBox.Call(0, strptr(text), title, flags) if opts.ctx != nil && opts.ctx.Err() != nil { - return false, opts.ctx.Err() + return opts.ctx.Err() } switch s { case 1, 6: // IDOK, IDYES - return true, nil + return nil case 2: // IDCANCEL - return false, nil + return ErrCanceled case 7: // IDNO - return false, ErrExtraButton + return ErrExtraButton default: - return false, err + return err } } @@ -80,7 +80,7 @@ func hookMessageLabels(kind messageKind, opts options) (unhook context.CancelFun return hookDialog(opts.ctx, func(wnd uintptr) { enumChildWindows.Call(wnd, syscall.NewCallback(func(wnd, lparam uintptr) uintptr { - name := [8]uint16{} + var name [8]uint16 getClassName.Call(wnd, uintptr(unsafe.Pointer(&name)), uintptr(len(name))) if syscall.UTF16ToString(name[:]) == "Button" { ctl, _, _ := getDlgCtrlID.Call(wnd) @@ -89,11 +89,7 @@ func hookMessageLabels(kind messageKind, opts options) (unhook context.CancelFun case 1, 6: // IDOK, IDYES text = opts.okLabel case 2: // IDCANCEL - if kind == questionKind { - text = opts.cancelLabel - } else { - text = opts.okLabel - } + text = opts.cancelLabel case 7: // IDNO text = opts.extraButton } diff --git a/notify_test.go b/notify_test.go index c002d12..015ae61 100644 --- a/notify_test.go +++ b/notify_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/ncruces/zenity" + "go.uber.org/goleak" ) func ExampleNotify() { @@ -15,7 +16,8 @@ func ExampleNotify() { // Output: } -func TestNotifyCancel(t *testing.T) { +func TestNotify_cancel(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/progress.go b/progress.go new file mode 100644 index 0000000..281f56b --- /dev/null +++ b/progress.go @@ -0,0 +1,51 @@ +package zenity + +// Progress displays the progress indication dialog. +// +// Valid options: Title, Width, Height, OKLabel, CancelLabel, ExtraButton, +// Icon, MaxValue, Pulsate, NoCancel, TimeRemaining. +func Progress(options ...Option) (ProgressDialog, error) { + return progress(applyOptions(options)) +} + +// ProgressDialog allows you to interact with the progress indication dialog. +type ProgressDialog interface { + // Text sets the dialog text. + Text(string) error + + // Value sets how much of the task has been completed. + Value(int) error + + // MaxValue gets how much work the task requires in total. + MaxValue() int + + // Complete marks the task completed. + Complete() error + + // Close closes the dialog. + Close() error + + // Done returns a channel that's closed when the dialog is closed. + Done() <-chan struct{} +} + +// MaxValue returns an Option to set the maximum value (Windows and macOS only). +// The default maximum value is 100. +func MaxValue(value int) Option { + return funcOption(func(o *options) { o.maxValue = value }) +} + +// Pulsate returns an Option to pulsate the progress bar. +func Pulsate() Option { + return funcOption(func(o *options) { o.maxValue = -1 }) +} + +// NoCancel returns an Option to hide the Cancel button (Windows and Unix only). +func NoCancel() Option { + return funcOption(func(o *options) { o.noCancel = true }) +} + +// TimeRemaining returns an Option to estimate when progress will reach 100% (Unix only). +func TimeRemaining() Option { + return funcOption(func(o *options) { o.timeRemaining = true }) +} diff --git a/progress_darwin.go b/progress_darwin.go new file mode 100644 index 0000000..5df70f3 --- /dev/null +++ b/progress_darwin.go @@ -0,0 +1,22 @@ +package zenity + +import ( + "github.com/ncruces/zenity/internal/zenutil" +) + +func progress(opts options) (ProgressDialog, error) { + if opts.extraButton != nil { + return nil, ErrUnsupported + } + + var data zenutil.Progress + data.Description = opts.title + if opts.maxValue == 0 { + opts.maxValue = 100 + } + if opts.maxValue >= 0 { + data.Total = &opts.maxValue + } + + return zenutil.RunProgress(opts.ctx, opts.maxValue, data) +} diff --git a/progress_test.go b/progress_test.go new file mode 100644 index 0000000..b1657dd --- /dev/null +++ b/progress_test.go @@ -0,0 +1,100 @@ +package zenity_test + +import ( + "context" + "errors" + "log" + "testing" + "time" + + "github.com/ncruces/zenity" + "go.uber.org/goleak" +) + +func ExampleProgress() { + dlg, err := zenity.Progress( + zenity.Title("Update System Logs")) + if err != nil { + log.Fatal(err) + } + defer dlg.Close() + + dlg.Text("Scanning mail logs...") + dlg.Value(0) + time.Sleep(time.Second) + + dlg.Value(25) + time.Sleep(time.Second) + + dlg.Text("Updating mail logs...") + dlg.Value(50) + time.Sleep(time.Second) + + dlg.Text("Resetting cron jobs...") + dlg.Value(75) + time.Sleep(time.Second) + + dlg.Text("Rebooting system...") + dlg.Value(100) + time.Sleep(time.Second) + + dlg.Complete() + time.Sleep(time.Second) + + // Output: +} + +func ExampleProgress_pulsate() { + dlg, err := zenity.Progress( + zenity.Title("Update System Logs"), + zenity.Pulsate()) + if err != nil { + log.Fatal(err) + } + defer dlg.Close() + + dlg.Text("Scanning mail logs...") + time.Sleep(time.Second) + + dlg.Text("Updating mail logs...") + time.Sleep(time.Second) + + dlg.Text("Resetting cron jobs...") + time.Sleep(time.Second) + + dlg.Text("Rebooting system...") + time.Sleep(time.Second) + + dlg.Complete() + time.Sleep(time.Second) + + // Output: +} + +func TestProgress_cancel(t *testing.T) { + defer goleak.VerifyNone(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := zenity.Progress(zenity.Context(ctx)) + if !errors.Is(err, context.Canceled) { + t.Error("was not canceled:", err) + } +} + +func TestProgress_cancelAfter(t *testing.T) { + defer goleak.VerifyNone(t) + ctx, cancel := context.WithCancel(context.Background()) + + dlg, err := zenity.Progress(zenity.Context(ctx)) + if err != nil { + t.Fatal(err) + } + + go cancel() + <-dlg.Done() + err = dlg.Close() + if !errors.Is(err, context.Canceled) { + t.Error("was not canceled:", err) + } +} diff --git a/progress_unix.go b/progress_unix.go new file mode 100644 index 0000000..5be2ed2 --- /dev/null +++ b/progress_unix.go @@ -0,0 +1,28 @@ +// +build !windows,!darwin,!js + +package zenity + +import ( + "github.com/ncruces/zenity/internal/zenutil" +) + +func progress(opts options) (ProgressDialog, error) { + args := []string{"--progress"} + args = appendTitle(args, opts) + args = appendButtons(args, opts) + args = appendWidthHeight(args, opts) + args = appendIcon(args, opts) + if opts.maxValue == 0 { + opts.maxValue = 100 + } + if opts.maxValue < 0 { + args = append(args, "--pulsate") + } + if opts.noCancel { + args = append(args, "--no-cancel") + } + if opts.timeRemaining { + args = append(args, "--time-remaining") + } + return zenutil.RunProgress(opts.ctx, opts.maxValue, opts.extraButton, args) +} diff --git a/progress_windows.go b/progress_windows.go new file mode 100644 index 0000000..8047bb8 --- /dev/null +++ b/progress_windows.go @@ -0,0 +1,261 @@ +package zenity + +import ( + "context" + "sync" + "syscall" +) + +func progress(opts options) (ProgressDialog, error) { + if opts.title == nil { + opts.title = stringPtr("") + } + if opts.okLabel == nil { + opts.okLabel = stringPtr("OK") + } + if opts.cancelLabel == nil { + opts.cancelLabel = stringPtr("Cancel") + } + if opts.maxValue == 0 { + opts.maxValue = 100 + } + if opts.ctx == nil { + opts.ctx = context.Background() + } + + dlg := &progressDialog{ + done: make(chan struct{}), + max: opts.maxValue, + } + dlg.init.Add(1) + + go func() { + err := progressDlg(opts, dlg) + if cerr := opts.ctx.Err(); cerr != nil { + err = cerr + } + dlg.err = err + close(dlg.done) + }() + + dlg.init.Wait() + return dlg, nil +} + +func progressDlg(opts options, dlg *progressDialog) (err error) { + defer setup()() + font := getFont() + defer font.Delete() + defWindowProc := defWindowProc.Addr() + + layout := func(dpi dpi) { + hfont := font.ForDPI(dpi) + sendMessage.Call(dlg.textCtl, 0x0030 /* WM_SETFONT */, hfont, 1) + sendMessage.Call(dlg.okBtn, 0x0030 /* WM_SETFONT */, hfont, 1) + sendMessage.Call(dlg.cancelBtn, 0x0030 /* WM_SETFONT */, hfont, 1) + sendMessage.Call(dlg.extraBtn, 0x0030 /* WM_SETFONT */, hfont, 1) + setWindowPos.Call(dlg.wnd, 0, 0, 0, dpi.Scale(281), dpi.Scale(141), 0x6) // SWP_NOZORDER|SWP_NOMOVE + setWindowPos.Call(dlg.textCtl, 0, dpi.Scale(12), dpi.Scale(10), dpi.Scale(241), dpi.Scale(16), 0x4) // SWP_NOZORDER + setWindowPos.Call(dlg.progCtl, 0, dpi.Scale(12), dpi.Scale(30), dpi.Scale(241), dpi.Scale(24), 0x4) // SWP_NOZORDER + if dlg.extraBtn == 0 { + if dlg.cancelBtn == 0 { + setWindowPos.Call(dlg.okBtn, 0, dpi.Scale(178), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + } else { + setWindowPos.Call(dlg.okBtn, 0, dpi.Scale(95), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(dlg.cancelBtn, 0, dpi.Scale(178), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + } + } else { + if dlg.cancelBtn == 0 { + setWindowPos.Call(dlg.okBtn, 0, dpi.Scale(95), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(dlg.extraBtn, 0, dpi.Scale(178), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + } else { + setWindowPos.Call(dlg.okBtn, 0, dpi.Scale(12), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(dlg.extraBtn, 0, dpi.Scale(95), dpi.Scale(66), dpi.Scale(75), dpi.Scale(24), 0x4) // SWP_NOZORDER + setWindowPos.Call(dlg.cancelBtn, 0, dpi.Scale(178), dpi.Scale(66), 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 + err = ErrCanceled + destroyWindow.Call(wnd) + + case 0x0111: // WM_COMMAND + switch wparam { + default: + return 1 + case 1, 6: // IDOK, IDYES + // + case 2: // IDCANCEL + err = ErrCanceled + case 7: // IDNO + err = ErrExtraButton + } + destroyWindow.Call(wnd) + + case 0x02e0: // WM_DPICHANGED + layout(dpi(uint32(wparam) >> 16)) + + default: + res, _, _ := syscall.Syscall6(defWindowProc, 4, wnd, uintptr(msg), wparam, lparam, 0, 0) + return res + } + + return 0 + } + + if opts.ctx != nil && opts.ctx.Err() != nil { + return opts.ctx.Err() + } + + instance, _, err := getModuleHandle.Call(0) + if instance == 0 { + return err + } + + cls, err := registerClass(instance, syscall.NewCallback(proc)) + if cls == 0 { + return err + } + defer unregisterClass.Call(cls, instance) + + dlg.wnd, _, _ = createWindowEx.Call(0x10101, // WS_EX_CONTROLPARENT|WS_EX_WINDOWEDGE|WS_EX_DLGMODALFRAME + cls, strptr(*opts.title), + 0x84c80000, // WS_POPUPWINDOW|WS_CLIPSIBLINGS|WS_DLGFRAME + 0x80000000, // CW_USEDEFAULT + 0x80000000, // CW_USEDEFAULT + 281, 141, 0, 0, instance, 0) + + dlg.textCtl, _, _ = createWindowEx.Call(0, + strptr("STATIC"), 0, + 0x5002e080, // WS_CHILD|WS_VISIBLE|WS_GROUP|SS_WORDELLIPSIS|SS_EDITCONTROL|SS_NOPREFIX + 12, 10, 241, 16, dlg.wnd, 0, instance, 0) + + var flags uintptr = 0x50000001 // WS_CHILD|WS_VISIBLE|PBS_SMOOTH + if opts.maxValue < 0 { + flags |= 0x8 // PBS_MARQUEE + } + dlg.progCtl, _, _ = createWindowEx.Call(0, + strptr("msctls_progress32"), // PROGRESS_CLASS + 0, flags, + 12, 30, 241, 24, dlg.wnd, 0, instance, 0) + + dlg.okBtn, _, _ = createWindowEx.Call(0, + strptr("BUTTON"), strptr(*opts.okLabel), + 0x58030001, // WS_CHILD|WS_VISIBLE|WS_DISABLED|WS_GROUP|WS_TABSTOP|BS_DEFPUSHBUTTON + 12, 66, 75, 24, dlg.wnd, 1 /* IDOK */, instance, 0) + if !opts.noCancel { + dlg.cancelBtn, _, _ = createWindowEx.Call(0, + strptr("BUTTON"), strptr(*opts.cancelLabel), + 0x50010000, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP + 12, 66, 75, 24, dlg.wnd, 2 /* IDCANCEL */, instance, 0) + } + if opts.extraButton != nil { + dlg.extraBtn, _, _ = createWindowEx.Call(0, + strptr("BUTTON"), strptr(*opts.extraButton), + 0x50010000, // WS_CHILD|WS_VISIBLE|WS_GROUP|WS_TABSTOP + 12, 66, 75, 24, dlg.wnd, 7 /* IDNO */, instance, 0) + } + + layout(getDPI(dlg.wnd)) + centerWindow(dlg.wnd) + showWindow.Call(dlg.wnd, 1 /* SW_SHOWNORMAL */, 0) + if opts.maxValue < 0 { + sendMessage.Call(dlg.progCtl, 0x40a /* PBM_SETMARQUEE */, 1, 0) + } else { + sendMessage.Call(dlg.progCtl, 0x406 /* PBM_SETRANGE32 */, 0, uintptr(opts.maxValue)) + } + dlg.init.Done() + + if opts.ctx != nil { + wait := make(chan struct{}) + defer close(wait) + go func() { + select { + case <-opts.ctx.Done(): + sendMessage.Call(dlg.wnd, 0x0112 /* WM_SYSCOMMAND */, 0xf060 /* SC_CLOSE */, 0) + case <-wait: + } + }() + } + + // set default values + err = nil + + if err := messageLoop(dlg.wnd); err != nil { + return err + } + if opts.ctx != nil && opts.ctx.Err() != nil { + return opts.ctx.Err() + } + return err +} + +type progressDialog struct { + max int + done chan struct{} + init sync.WaitGroup + wnd uintptr + textCtl uintptr + progCtl uintptr + okBtn uintptr + cancelBtn uintptr + extraBtn uintptr + err error +} + +func (d *progressDialog) Text(text string) error { + select { + default: + setWindowText.Call(d.textCtl, strptr(text)) + return nil + case <-d.done: + return d.err + } +} + +func (d *progressDialog) Value(value int) error { + select { + default: + sendMessage.Call(d.progCtl, 0x402 /* PBM_SETPOS */, uintptr(value), 0) + if value >= d.max { + enableWindow.Call(d.okBtn, 1) + } + return nil + case <-d.done: + return d.err + } +} + +func (d *progressDialog) MaxValue() int { + return d.max +} + +func (d *progressDialog) Done() <-chan struct{} { + return d.done +} + +func (d *progressDialog) Complete() error { + select { + default: + setWindowLong.Call(d.progCtl, intptr(-16) /* GWL_STYLE */, 0x50000001 /* WS_CHILD|WS_VISIBLE|PBS_SMOOTH */) + sendMessage.Call(d.progCtl, 0x406 /* PBM_SETRANGE32 */, 0, 1) + sendMessage.Call(d.progCtl, 0x402 /* PBM_SETPOS */, 1, 0) + enableWindow.Call(d.okBtn, 1) + enableWindow.Call(d.cancelBtn, 0) + return nil + case <-d.done: + return d.err + } +} + +func (d *progressDialog) Close() error { + sendMessage.Call(d.wnd, 0x0112 /* WM_SYSCOMMAND */, 0xf060 /* SC_CLOSE */, 0) + <-d.done + return d.err +} diff --git a/pwd.go b/pwd.go index 685c129..4c3b7aa 100644 --- a/pwd.go +++ b/pwd.go @@ -2,10 +2,8 @@ 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) { +func Password(options ...Option) (usr string, pw string, err error) { return password(applyOptions(options)) } diff --git a/pwd_stub.go b/pwd_stub.go index 63bc24a..d204940 100644 --- a/pwd_stub.go +++ b/pwd_stub.go @@ -2,8 +2,11 @@ package zenity -func password(opts options) (string, string, bool, error) { +func password(opts options) (string, string, error) { + if opts.username { + return "", "", ErrUnsupported + } opts.hideText = true - str, ok, err := entry("Password:", opts) - return "", str, ok, err + str, err := entry("Password:", opts) + return "", str, err } diff --git a/pwd_test.go b/pwd_test.go index 7b6df89..7ed751b 100644 --- a/pwd_test.go +++ b/pwd_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ncruces/zenity" + "go.uber.org/goleak" ) func ExamplePassword() { @@ -15,22 +16,23 @@ func ExamplePassword() { // Output: } -func TestPasswordTimeout(t *testing.T) { +func TestPassword_timeout(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithTimeout(context.Background(), time.Second/10) + defer cancel() - _, _, _, err := zenity.Password(zenity.Context(ctx)) + _, _, err := zenity.Password(zenity.Context(ctx)) if !os.IsTimeout(err) { t.Error("did not timeout:", err) } - - cancel() } -func TestPasswordCancel(t *testing.T) { +func TestPassword_cancel(t *testing.T) { + defer goleak.VerifyNone(t) ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, _, err := zenity.Password(zenity.Context(ctx)) + _, _, 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 index e97f3ed..135ffdc 100644 --- a/pwd_unix.go +++ b/pwd_unix.go @@ -8,7 +8,7 @@ import ( "github.com/ncruces/zenity/internal/zenutil" ) -func password(opts options) (string, string, bool, error) { +func password(opts options) (string, string, error) { args := []string{"--password"} args = appendTitle(args, opts) args = appendButtons(args, opts) @@ -17,11 +17,11 @@ func password(opts options) (string, string, bool, error) { } out, err := zenutil.Run(opts.ctx, args) - str, ok, err := strResult(opts, out, err) - if ok && opts.username { + str, err := strResult(opts, out, err) + if err == nil && opts.username { if split := strings.SplitN(string(out), "|", 2); len(split) == 2 { - return split[0], split[1], true, nil + return split[0], split[1], nil } } - return "", str, ok, err + return "", str, err } diff --git a/util_darwin.go b/util_darwin.go index 8a79755..b05c1c6 100644 --- a/util_darwin.go +++ b/util_darwin.go @@ -2,13 +2,13 @@ package zenity import "github.com/ncruces/zenity/internal/zenutil" -func getButtons(dialog, okcancel bool, opts options) (btns zenutil.Buttons) { +func getButtons(dialog, okcancel bool, opts options) (btns zenutil.DialogButtons) { if !okcancel { opts.cancelLabel = nil opts.defaultCancel = false } - if opts.okLabel != nil || opts.cancelLabel != nil || opts.extraButton != nil || (dialog != okcancel) { + if opts.okLabel != nil || opts.cancelLabel != nil || opts.extraButton != nil || dialog != okcancel { if opts.okLabel == nil { opts.okLabel = stringPtr("OK") } diff --git a/util_unix.go b/util_unix.go index 2348a28..1bc15e0 100644 --- a/util_unix.go +++ b/util_unix.go @@ -3,7 +3,6 @@ package zenity import ( - "bytes" "os/exec" "strconv" "strings" @@ -55,23 +54,22 @@ func appendIcon(args []string, opts options) []string { return args } -func strResult(opts options, out []byte, err error) (string, bool, error) { - out = bytes.TrimSuffix(out, []byte{'\n'}) +func strResult(opts options, out []byte, err error) (string, error) { if err, ok := err.(*exec.ExitError); ok && err.ExitCode() == 1 { - if opts.extraButton != nil && *opts.extraButton == string(out) { - return "", false, ErrExtraButton + if opts.extraButton != nil && *opts.extraButton+"\n" == string(out) { + return "", ErrExtraButton } - return "", false, nil + return "", ErrCanceled } if err != nil { - return "", false, err + return "", err } - return string(out), true, nil + return string(out), nil } func lstResult(opts options, out []byte, err error) ([]string, error) { - str, ok, err := strResult(opts, out, err) - if ok { + str, err := strResult(opts, out, err) + if err == nil { return strings.Split(str, zenutil.Separator), nil } return nil, err diff --git a/util_windows.go b/util_windows.go index c199bd7..a1cc59a 100644 --- a/util_windows.go +++ b/util_windows.go @@ -13,6 +13,7 @@ import ( ) var ( + comctl32 = syscall.NewLazyDLL("comctl32.dll") comdlg32 = syscall.NewLazyDLL("comdlg32.dll") gdi32 = syscall.NewLazyDLL("gdi32.dll") kernel32 = syscall.NewLazyDLL("kernel32.dll") @@ -21,6 +22,7 @@ var ( user32 = syscall.NewLazyDLL("user32.dll") wtsapi32 = syscall.NewLazyDLL("wtsapi32.dll") + initCommonControlsEx = comctl32.NewProc("InitCommonControlsEx") commDlgExtendedError = comdlg32.NewProc("CommDlgExtendedError") deleteObject = gdi32.NewProc("DeleteObject") @@ -30,6 +32,10 @@ var ( getModuleHandle = kernel32.NewProc("GetModuleHandleW") getCurrentThreadId = kernel32.NewProc("GetCurrentThreadId") getConsoleWindow = kernel32.NewProc("GetConsoleWindow") + getSystemDirectory = kernel32.NewProc("GetSystemDirectoryW") + createActCtx = kernel32.NewProc("CreateActCtxW") + activateActCtx = kernel32.NewProc("ActivateActCtx") + deactivateActCtx = kernel32.NewProc("DeactivateActCtx") coInitializeEx = ole32.NewProc("CoInitializeEx") coUninitialize = ole32.NewProc("CoUninitialize") @@ -60,12 +66,14 @@ var ( systemParametersInfo = user32.NewProc("SystemParametersInfoW") setWindowPos = user32.NewProc("SetWindowPos") getWindowRect = user32.NewProc("GetWindowRect") + setWindowLong = user32.NewProc("SetWindowLongPtrW") getSystemMetrics = user32.NewProc("GetSystemMetrics") unregisterClass = user32.NewProc("UnregisterClassW") registerClassEx = user32.NewProc("RegisterClassExW") destroyWindow = user32.NewProc("DestroyWindow") createWindowEx = user32.NewProc("CreateWindowExW") showWindow = user32.NewProc("ShowWindow") + enableWindow = user32.NewProc("EnableWindow") setFocus = user32.NewProc("SetFocus") defWindowProc = user32.NewProc("DefWindowProcW") ) @@ -96,24 +104,33 @@ func setup() context.CancelFunc { setForegroundWindow.Call(hwnd) } - var old uintptr runtime.LockOSThread() + + var restore uintptr + cookie := enableVisualStyles() if setThreadDpiAwarenessContext.Find() == nil { // try: // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE // DPI_AWARENESS_CONTEXT_SYSTEM_AWARE for i := -4; i <= -2; i++ { - restore, _, _ := setThreadDpiAwarenessContext.Call(uintptr(i)) + restore, _, _ = setThreadDpiAwarenessContext.Call(uintptr(i)) if restore != 0 { break } } } + var icc _INITCOMMONCONTROLSEX + icc.Size = uint32(unsafe.Sizeof(icc)) + icc.ICC = 0x00004020 // ICC_STANDARD_CLASSES|ICC_PROGRESS_CLASS + return func() { - if old != 0 { - setThreadDpiAwarenessContext.Call(old) + if restore != 0 { + setThreadDpiAwarenessContext.Call(restore) + } + if cookie != 0 { + deactivateActCtx.Call(cookie) } runtime.UnlockOSThread() } @@ -122,7 +139,7 @@ func setup() context.CancelFunc { func commDlgError() error { s, _, _ := commDlgExtendedError.Call() if s == 0 { - return nil + return ErrCanceled } else { return fmt.Errorf("Common Dialog error: %x", s) } @@ -139,7 +156,7 @@ func hookDialog(ctx context.Context, initDialog func(wnd uintptr)) (unhook conte hook, _, err = setWindowsHookEx.Call(12, // WH_CALLWNDPROCRET syscall.NewCallback(func(code int32, wparam uintptr, lparam *_CWPRETSTRUCT) uintptr { if lparam.Message == 0x0110 { // WM_INITDIALOG - name := [8]uint16{} + var name [8]uint16 getClassName.Call(lparam.Wnd, uintptr(unsafe.Pointer(&name)), uintptr(len(name))) if syscall.UTF16ToString(name[:]) == "#32770" { // The class for a dialog box var close bool @@ -252,8 +269,8 @@ func (f *font) Delete() { func centerWindow(wnd uintptr) { getMetric := func(i uintptr) int32 { - ret, _, _ := getSystemMetrics.Call(i) - return int32(ret) + n, _, _ := getSystemMetrics.Call(i) + return int32(n) } var rect _RECT @@ -280,8 +297,8 @@ func registerClass(instance, proc uintptr) (uintptr, error) { wcx.Background = 5 // COLOR_WINDOW wcx.ClassName = syscall.StringToUTF16Ptr(name) - ret, _, err := registerClassEx.Call(uintptr(unsafe.Pointer(&wcx))) - return ret, err + atom, _, err := registerClassEx.Call(uintptr(unsafe.Pointer(&wcx))) + return atom, err } // https://docs.microsoft.com/en-us/windows/win32/winmsg/using-messages-and-message-queues @@ -293,22 +310,62 @@ func messageLoop(wnd uintptr) error { for { var msg _MSG - ret, _, err := syscall.Syscall6(getMessage, 4, uintptr(unsafe.Pointer(&msg)), 0, 0, 0, 0, 0) - if int32(ret) == -1 { + s, _, err := syscall.Syscall6(getMessage, 4, uintptr(unsafe.Pointer(&msg)), 0, 0, 0, 0, 0) + if int32(s) == -1 { return err } - if ret == 0 { + if s == 0 { return nil } - ret, _, _ = syscall.Syscall(isDialogMessage, 2, wnd, uintptr(unsafe.Pointer(&msg)), 0) - if ret == 0 { + s, _, _ = syscall.Syscall(isDialogMessage, 2, wnd, uintptr(unsafe.Pointer(&msg)), 0) + if s == 0 { syscall.Syscall(translateMessage, 1, uintptr(unsafe.Pointer(&msg)), 0, 0) syscall.Syscall(dispatchMessage, 1, uintptr(unsafe.Pointer(&msg)), 0, 0) } } } +// https://stackoverflow.com/questions/4308503/how-to-enable-visual-styles-without-a-manifest +func enableVisualStyles() (cookie uintptr) { + var dir [260]uint16 + n, _, _ := getSystemDirectory.Call(uintptr(unsafe.Pointer(&dir[0])), uintptr(len(dir))) + if n == 0 || int(n) >= len(dir) { + return + } + + var ctx _ACTCTX + ctx.Size = uint32(unsafe.Sizeof(ctx)) + ctx.Flags = 0x01c // ACTCTX_FLAG_RESOURCE_NAME_VALID|ACTCTX_FLAG_SET_PROCESS_DEFAULT|ACTCTX_FLAG_ASSEMBLY_DIRECTORY_VALID + ctx.Source = syscall.StringToUTF16Ptr("shell32.dll") + ctx.AssemblyDirectory = &dir[0] + ctx.ResourceName = 124 + + if h, _, _ := createActCtx.Call(uintptr(unsafe.Pointer(&ctx))); h != 0 { + activateActCtx.Call(h, uintptr(unsafe.Pointer(&cookie))) + } + return +} + +// https://docs.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-actctxw +type _ACTCTX struct { + Size uint32 + Flags uint32 + Source *uint16 + ProcessorArchitecture uint16 + LangId uint16 + AssemblyDirectory *uint16 + ResourceName uintptr + ApplicationName *uint16 + Module uintptr +} + +// https://docs.microsoft.com/en-us/windows/win32/api/commctrl/ns-commctrl-initcommoncontrolsex +type _INITCOMMONCONTROLSEX struct { + Size uint32 + ICC uint32 +} + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-cwpretstruct type _CWPRETSTRUCT struct { Result uintptr diff --git a/zenity.go b/zenity.go index 0b872e9..f4f6350 100644 --- a/zenity.go +++ b/zenity.go @@ -13,14 +13,22 @@ package zenity import ( "context" "image/color" + + "github.com/ncruces/zenity/internal/zenutil" ) -type stringErr string - -func (e stringErr) Error() string { return string(e) } - func stringPtr(s string) *string { return &s } +// ErrCanceled is returned when the cancel button is pressed, +// or window functions are used to close the dialog. +const ErrCanceled = zenutil.ErrCanceled + +// ErrExtraButton is returned when the extra button is pressed. +const ErrExtraButton = zenutil.ErrExtraButton + +// ErrUnsupported is returned when a combination of options is not supported. +const ErrUnsupported = zenutil.ErrUnsupported + type options struct { // General options title *string @@ -57,6 +65,11 @@ type options struct { color color.Color showPalette bool + // Progress indication options + maxValue int + noCancel bool + timeRemaining bool + // Context for timeout ctx context.Context }