feat: full support for custom selectors
This commit is contained in:
parent
6a818c80c4
commit
1af16b6d1f
5 changed files with 74 additions and 35 deletions
|
@ -1,4 +1,8 @@
|
||||||
# Next
|
# 1.5.0
|
||||||
|
|
||||||
|
**New features**
|
||||||
|
|
||||||
|
- support custom selectors
|
||||||
|
|
||||||
**Notable bug fixes**
|
**Notable bug fixes**
|
||||||
|
|
||||||
|
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
@ -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
12
main.go
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
73
selector.go
73
selector.go
|
@ -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") // preserve literal \n
|
||||||
repr = strings.ReplaceAll(repr, "\n", "\\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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue