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**

View file

@ -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`.

View file

@ -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 [<flags>]\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

12
main.go
View file

@ -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)
}

View file

@ -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
if escape {
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")
}
// optionally cut to maxChars
const maxChars = 400
if cutting && len(repr) > maxChars {
if maxChars > 0 && len(repr) > maxChars {
repr = repr[:maxChars]
}