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**
|
||||
|
||||
|
|
|
@ -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`.
|
||||
|
||||
|
|
|
@ -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
12
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)
|
||||
}
|
||||
|
|
73
selector.go
73
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
|
||||
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]
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue