From 1af16b6d1f247f817f5104299e5da1aef45e6c12 Mon Sep 17 00:00:00 2001 From: yory8 Date: Sun, 3 May 2020 11:00:35 +0200 Subject: [PATCH] feat: full support for custom selectors --- CHANGELOG.md | 6 +++- README.md | 3 +- docs/clipman.1 | 13 +++++++-- main.go | 12 ++++---- selector.go | 75 +++++++++++++++++++++++++++++++++----------------- 5 files changed, 74 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f522faa..47dc760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ -# Next +# 1.5.0 + +**New features** + +- support custom selectors **Notable bug fixes** diff --git a/README.md b/README.md index 00c026a..3aa8ff6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Requirements: - a windows manager that uses `wlr-data-control`, like Sway and other wlroots-based WMs. - wl-clipboard >= 2.0 -- either: wofi, bemenu, dmenu or rofi +- a selector: wofi, bemenu, dmenu or rofi are specially supported, but you can use what you want - notify-send (optional, for desktop notifications) [Install go](https://golang.org/doc/install), add `$GOPATH/bin` to your path, then run `go get github.com/yory8/clipman` OR run `go install` inside this folder. @@ -28,6 +28,7 @@ For primary clipboard support, also add `exec wl-paste -p -t text --watch clipma To query the history and select items, run the binary as `clipman pick -t wofi`. You can assign it to a keybinding: `bindsym $mod+h exec clipman pick -t wofi`. For primary clipboard support, `clipman pick -t wofi --histpath="~/.local/share/clipman-primary.json`. You can pass additional arguments to the selector like this: `clipman pick --tool wofi -T'--prompt=my-prompt -i'` (both `--prompt` and `-i` are flags of wofi). +You can use a custom selector like this: `clipman pick --print0 --tool=CUSTOM --tool-args="fzf --prompt 'pick > ' --bind 'tab:up' --cycle --read0"`. To remove items from history, `clipman clear -t wofi` and `clipman clear --all`. diff --git a/docs/clipman.1 b/docs/clipman.1 index bdd8ef8..37be35f 100644 --- a/docs/clipman.1 +++ b/docs/clipman.1 @@ -1,4 +1,4 @@ -.TH clipman 1 1.4.0 "" +.TH clipman 1 1.5.0 "" .SH "NAME" clipman .SH "SYNOPSIS" @@ -44,10 +44,13 @@ Pick an item from clipboard history scrollview length .TP \fB-t, --tool=TOOL\fR -Which selector to use: wofi/bemenu/dmenu/rofi/STDOUT +Which selector to use: wofi/bemenu/CUSTOM/dmenu/rofi/STDOUT .TP \fB-T, --tool-args=""\fR Extra arguments to pass to the --tool +.TP +\fB--print0\fR +Separate items using NULL; recommended if your tool supports --read0 or similar .SS \fBclear []\fR .PP @@ -57,13 +60,16 @@ Remove item/s from history scrollview length .TP \fB-t, --tool=TOOL\fR -Which selector to use: wofi/bemenu/dmenu/rofi/STDOUT +Which selector to use: wofi/bemenu/CUSTOM/dmenu/rofi/STDOUT .TP \fB-T, --tool-args=""\fR Extra arguments to pass to the --tool .TP \fB-a, --all\fR Remove all items +.TP +\fB--print0\fR +Separate items using NULL; recommended if your tool supports --read0 or similar .SS \fBrestore\fR .PP @@ -75,6 +81,7 @@ For primary clipboard support, also add `exec wl-paste -p -t text --watch clipma To query the history and select items, run the binary as `clipman pick -t wofi`. You can assign it to a keybinding: `bindsym $mod+h exec clipman pick -t wofi`. For primary clipboard support, `clipman pick -t wofi --histpath="~/.local/share/clipman-primary.json`. You can pass additional arguments to the selector like this: `clipman pick --tool wofi -T'--prompt=my-prompt -i'` (both `--prompt` and `-i` are flags of wofi). +You can use a custom selector like this: `clipman pick --print0 --tool=CUSTOM --tool-args="fzf --prompt 'pick > ' --bind 'tab:up' --cycle --read0"`. .PP To remove items from history, `clipman clear -t wofi` and `clipman clear --all`. .PP diff --git a/main.go b/main.go index cd3ce07..a9e7468 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,7 @@ import ( "gopkg.in/alecthomas/kingpin.v2" ) -const version = "1.4.0" +const version = "1.5.0" var ( app = kingpin.New("clipman", "A clipboard manager for Wayland") @@ -27,14 +27,16 @@ var ( picker = app.Command("pick", "Pick an item from clipboard history") maxPicker = picker.Flag("max-items", "scrollview length").Default("15").Int() - pickTool = picker.Flag("tool", "Which selector to use: wofi/bemenu/dmenu/rofi/STDOUT").Short('t').Required().String() + pickTool = picker.Flag("tool", "Which selector to use: wofi/bemenu/CUSTOM/dmenu/rofi/STDOUT").Short('t').Required().String() pickToolArgs = picker.Flag("tool-args", "Extra arguments to pass to the --tool").Short('T').Default("").String() + pickEsc = picker.Flag("print0", "Separate items using NULL; recommended if your tool supports --read0 or similar").Default("false").Bool() clearer = app.Command("clear", "Remove item/s from history") maxClearer = clearer.Flag("max-items", "scrollview length").Default("15").Int() - clearTool = clearer.Flag("tool", "Which selector to use: wofi/bemenu/dmenu/rofi/STDOUT").Short('t').String() + clearTool = clearer.Flag("tool", "Which selector to use: wofi/bemenu/CUSTOM/dmenu/rofi/STDOUT").Short('t').String() clearToolArgs = clearer.Flag("tool-args", "Extra arguments to pass to the --tool").Short('T').Default("").String() clearAll = clearer.Flag("all", "Remove all items").Short('a').Default("false").Bool() + clearEsc = clearer.Flag("print0", "Separate items using NULL; recommended if your tool supports --read0 or similar").Default("false").Bool() _ = app.Command("restore", "Serve the last recorded item from history") ) @@ -68,7 +70,7 @@ func main() { smartLog(err.Error(), "critical", *alert) } case "pick": - selection, err := selector(history, *maxPicker, *pickTool, "pick", *pickToolArgs) + selection, err := selector(history, *maxPicker, *pickTool, "pick", *pickToolArgs, *pickEsc) if err != nil { smartLog(err.Error(), "normal", *alert) } @@ -98,7 +100,7 @@ func main() { os.Exit(1) } - selection, err := selector(history, *maxClearer, *clearTool, "clear", *clearToolArgs) + selection, err := selector(history, *maxClearer, *clearTool, "clear", *clearToolArgs, *clearEsc) if err != nil { smartLog(err.Error(), "normal", *alert) } diff --git a/selector.go b/selector.go index 1064343..e985d1b 100644 --- a/selector.go +++ b/selector.go @@ -11,24 +11,27 @@ import ( "github.com/kballard/go-shellquote" ) -func selector(data []string, max int, tool string, prompt string, toolArgs string) (string, error) { +func selector(data []string, max int, tool, prompt, toolArgs string, null bool) (string, error) { if len(data) == 0 { return "", errors.New("nothing to show: no data available") } // output to stdout and return if tool == "STDOUT" { - escaped, _ := preprocessData(data, false, true) - os.Stdout.WriteString(strings.Join(escaped, "\n")) + escaped, _ := preprocessData(data, 0, !null) + sep := "\n" + if null { + sep = "\000" + } + os.Stdout.WriteString(strings.Join(escaped, sep)) return "", nil } - bin, err := exec.LookPath(tool) - if err != nil { - return "", fmt.Errorf("%s is not installed", tool) - } + var ( + args []string + err error + ) - var args []string switch tool { case "dmenu": args = []string{"dmenu", "-b", @@ -44,11 +47,21 @@ func selector(data []string, max int, tool string, prompt string, toolArgs strin strconv.Itoa(max)} case "wofi": args = []string{"wofi", "-p", prompt, "--cache-file", "/dev/null", "--dmenu"} + case "CUSTOM": + if len(toolArgs) == 0 { + return "", fmt.Errorf("missing tool args for CUSTOM tool") + } + args, err = shellquote.Split(toolArgs) + if err != nil { + return "", fmt.Errorf("selector: %w", err) + } default: return "", fmt.Errorf("unsupported tool: %s", tool) } - if len(toolArgs) > 0 { + if tool == "CUSTOM" { + tool = args[0] + } else if len(toolArgs) > 0 { targs, err := shellquote.Split(toolArgs) if err != nil { return "", fmt.Errorf("selector: %w", err) @@ -56,25 +69,39 @@ func selector(data []string, max int, tool string, prompt string, toolArgs strin args = append(args, targs...) } - processed, guide := preprocessData(data, true, false) + bin, err := exec.LookPath(tool) + if err != nil { + return "", fmt.Errorf("%s is not installed", tool) + } - cmd := exec.Cmd{Path: bin, Args: args, Stdin: strings.NewReader(strings.Join(processed, "\n") + "\n")} + processed, guide := preprocessData(data, 1000, !null) + sep := "\n" + if null { + sep = "\000" + } + + cmd := exec.Cmd{Path: bin, Args: args, Stdin: strings.NewReader(strings.Join(processed, sep) + "\n")} cmd.Stderr = os.Stderr // let stderr pass to console b, err := cmd.Output() if err != nil { - if err.Error() == "exit status 1" { - // dmenu/rofi exits with this error when no selection done + if err.Error() == "exit status 1" || err.Error() == "exit status 130" { + // dmenu/rofi exits with 1 when no selection done + // fzf exits with 1 when no match, 130 when no selection done return "", nil } return "", err } - // Wofi however does not error when no selection is done + // we received no selection; wofi doesn't error in this case if len(b) == 0 { return "", nil } - sel, ok := guide[string(b[:len(b)-1])] // drop newline added by dmenu/roi/wofi + // drop newline added by proper unix tools + if b[len(b)-1] == '\n' { + b = b[:len(b)-1] + } + sel, ok := guide[string(b)] if !ok { return "", errors.New("couldn't recover original string") } @@ -84,28 +111,26 @@ func selector(data []string, max int, tool string, prompt string, toolArgs strin // preprocessData: // - reverses the data -// - escapes \n (it would break external selectors) -// - optionally it cuts items longer than 400 bytes (dmenu doesn't allow more than ~1200) +// - optionally escapes \n and \t (it would break some external selectors) +// - optionally it cuts items longer than maxChars bytes (dmenu doesn't allow more than ~1200) // A guide is created to allow restoring the selected item. -func preprocessData(data []string, cutting bool, allowTabs bool) ([]string, map[string]string) { +func preprocessData(data []string, maxChars int, escape bool) ([]string, map[string]string) { var escaped []string guide := make(map[string]string) for i := len(data) - 1; i >= 0; i-- { // reverse slice original := data[i] + repr := original // escape newlines - repr := strings.ReplaceAll(original, "\\n", "\\\\n") // preserve literal \n - repr = strings.ReplaceAll(repr, "\n", "\\n") - - if !allowTabs { + if escape { + repr = strings.ReplaceAll(repr, "\\n", "\\\\n") // preserve literal \n + repr = strings.ReplaceAll(repr, "\n", "\\n") repr = strings.ReplaceAll(repr, "\\t", "\\\\t") repr = strings.ReplaceAll(repr, "\t", "\\t") } - // optionally cut to maxChars - const maxChars = 400 - if cutting && len(repr) > maxChars { + if maxChars > 0 && len(repr) > maxChars { repr = repr[:maxChars] }