feat: full support for custom selectors

This commit is contained in:
yory8 2020-05-03 11:00:35 +02:00
parent 6a818c80c4
commit 1af16b6d1f
5 changed files with 74 additions and 35 deletions

View file

@ -1,4 +1,8 @@
# Next # 1.5.0
**New features**
- support custom selectors
**Notable bug fixes** **Notable bug fixes**

View file

@ -10,7 +10,7 @@ Requirements:
- a windows manager that uses `wlr-data-control`, like Sway and other wlroots-based WMs. - a windows manager that uses `wlr-data-control`, like Sway and other wlroots-based WMs.
- wl-clipboard >= 2.0 - 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) - 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. [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`. 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`. 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 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`. To remove items from history, `clipman clear -t wofi` and `clipman clear --all`.

View file

@ -1,4 +1,4 @@
.TH clipman 1 1.4.0 "" .TH clipman 1 1.5.0 ""
.SH "NAME" .SH "NAME"
clipman clipman
.SH "SYNOPSIS" .SH "SYNOPSIS"
@ -44,10 +44,13 @@ Pick an item from clipboard history
scrollview length scrollview length
.TP .TP
\fB-t, --tool=TOOL\fR \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 .TP
\fB-T, --tool-args=""\fR \fB-T, --tool-args=""\fR
Extra arguments to pass to the --tool Extra arguments to pass to the --tool
.TP
\fB--print0\fR
Separate items using NULL; recommended if your tool supports --read0 or similar
.SS .SS
\fBclear [<flags>]\fR \fBclear [<flags>]\fR
.PP .PP
@ -57,13 +60,16 @@ Remove item/s from history
scrollview length scrollview length
.TP .TP
\fB-t, --tool=TOOL\fR \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 .TP
\fB-T, --tool-args=""\fR \fB-T, --tool-args=""\fR
Extra arguments to pass to the --tool Extra arguments to pass to the --tool
.TP .TP
\fB-a, --all\fR \fB-a, --all\fR
Remove all items Remove all items
.TP
\fB--print0\fR
Separate items using NULL; recommended if your tool supports --read0 or similar
.SS .SS
\fBrestore\fR \fBrestore\fR
.PP .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`. 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`. 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 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 .PP
To remove items from history, `clipman clear -t wofi` and `clipman clear --all`. To remove items from history, `clipman clear -t wofi` and `clipman clear --all`.
.PP .PP

12
main.go
View file

@ -14,7 +14,7 @@ import (
"gopkg.in/alecthomas/kingpin.v2" "gopkg.in/alecthomas/kingpin.v2"
) )
const version = "1.4.0" const version = "1.5.0"
var ( var (
app = kingpin.New("clipman", "A clipboard manager for Wayland") app = kingpin.New("clipman", "A clipboard manager for Wayland")
@ -27,14 +27,16 @@ var (
picker = app.Command("pick", "Pick an item from clipboard history") picker = app.Command("pick", "Pick an item from clipboard history")
maxPicker = picker.Flag("max-items", "scrollview length").Default("15").Int() 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() 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") clearer = app.Command("clear", "Remove item/s from history")
maxClearer = clearer.Flag("max-items", "scrollview length").Default("15").Int() 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() 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() 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") _ = app.Command("restore", "Serve the last recorded item from history")
) )
@ -68,7 +70,7 @@ func main() {
smartLog(err.Error(), "critical", *alert) smartLog(err.Error(), "critical", *alert)
} }
case "pick": case "pick":
selection, err := selector(history, *maxPicker, *pickTool, "pick", *pickToolArgs) selection, err := selector(history, *maxPicker, *pickTool, "pick", *pickToolArgs, *pickEsc)
if err != nil { if err != nil {
smartLog(err.Error(), "normal", *alert) smartLog(err.Error(), "normal", *alert)
} }
@ -98,7 +100,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
selection, err := selector(history, *maxClearer, *clearTool, "clear", *clearToolArgs) selection, err := selector(history, *maxClearer, *clearTool, "clear", *clearToolArgs, *clearEsc)
if err != nil { if err != nil {
smartLog(err.Error(), "normal", *alert) smartLog(err.Error(), "normal", *alert)
} }

View file

@ -11,24 +11,27 @@ import (
"github.com/kballard/go-shellquote" "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 { if len(data) == 0 {
return "", errors.New("nothing to show: no data available") return "", errors.New("nothing to show: no data available")
} }
// output to stdout and return // output to stdout and return
if tool == "STDOUT" { if tool == "STDOUT" {
escaped, _ := preprocessData(data, false, true) escaped, _ := preprocessData(data, 0, !null)
os.Stdout.WriteString(strings.Join(escaped, "\n")) sep := "\n"
if null {
sep = "\000"
}
os.Stdout.WriteString(strings.Join(escaped, sep))
return "", nil return "", nil
} }
bin, err := exec.LookPath(tool) var (
if err != nil { args []string
return "", fmt.Errorf("%s is not installed", tool) err error
} )
var args []string
switch tool { switch tool {
case "dmenu": case "dmenu":
args = []string{"dmenu", "-b", args = []string{"dmenu", "-b",
@ -44,11 +47,21 @@ func selector(data []string, max int, tool string, prompt string, toolArgs strin
strconv.Itoa(max)} strconv.Itoa(max)}
case "wofi": case "wofi":
args = []string{"wofi", "-p", prompt, "--cache-file", "/dev/null", "--dmenu"} 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: default:
return "", fmt.Errorf("unsupported tool: %s", tool) 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) targs, err := shellquote.Split(toolArgs)
if err != nil { if err != nil {
return "", fmt.Errorf("selector: %w", err) 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...) 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 cmd.Stderr = os.Stderr // let stderr pass to console
b, err := cmd.Output() b, err := cmd.Output()
if err != nil { if err != nil {
if err.Error() == "exit status 1" { if err.Error() == "exit status 1" || err.Error() == "exit status 130" {
// dmenu/rofi exits with this error when no selection done // dmenu/rofi exits with 1 when no selection done
// fzf exits with 1 when no match, 130 when no selection done
return "", nil return "", nil
} }
return "", err 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 { if len(b) == 0 {
return "", nil 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 { if !ok {
return "", errors.New("couldn't recover original string") 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: // preprocessData:
// - reverses the data // - reverses the data
// - escapes \n (it would break external selectors) // - optionally escapes \n and \t (it would break some external selectors)
// - optionally it cuts items longer than 400 bytes (dmenu doesn't allow more than ~1200) // - 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. // 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 var escaped []string
guide := make(map[string]string) guide := make(map[string]string)
for i := len(data) - 1; i >= 0; i-- { // reverse slice for i := len(data) - 1; i >= 0; i-- { // reverse slice
original := data[i] original := data[i]
repr := original
// escape newlines // escape newlines
repr := strings.ReplaceAll(original, "\\n", "\\\\n") // preserve literal \n if escape {
repr = strings.ReplaceAll(repr, "\n", "\\n") repr = strings.ReplaceAll(repr, "\\n", "\\\\n") // preserve literal \n
repr = strings.ReplaceAll(repr, "\n", "\\n")
if !allowTabs {
repr = strings.ReplaceAll(repr, "\\t", "\\\\t") repr = strings.ReplaceAll(repr, "\\t", "\\\\t")
repr = strings.ReplaceAll(repr, "\t", "\\t") repr = strings.ReplaceAll(repr, "\t", "\\t")
} }
// optionally cut to maxChars // optionally cut to maxChars
const maxChars = 400 if maxChars > 0 && len(repr) > maxChars {
if cutting && len(repr) > maxChars {
repr = repr[:maxChars] repr = repr[:maxChars]
} }