diff --git a/README.md b/README.md index 357fef6..c186481 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ A basic clipboard manager for Wayland. ## Usage -The demon that collects history is written in Go. Install it in your path, then run it in your Sway session by adding `exec clipman` at the beginning of your config. +Install the binary in your path, then run it in your Sway session by adding `exec clipman -d` at the beginning of your config. -You can configure how many history items to preserve (default: 15) by editing directly the source. +You can configure how many unique history items to preserve (default: 15) by editing directly the source. -To query the history and select items, run the provided python script (`clipman.py`). You can assign it to a keybinding. +To query the history and select items, run the binary as `clipman -s`. You can assign it to a keybinding: `bindsym $mod+h exec clipman -s`. diff --git a/clipman.py b/clipman.py deleted file mode 100755 index a30ff64..0000000 --- a/clipman.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/python -import json -import os -import subprocess -import sys - -MAX = 15 - - -def selector(input_, max_): - """Send list to dmenu for selection. Display max items.""" - cmd = [ - "dmenu", - "-b", - "-fn", - "-misc-dejavu sans mono-medium-r-normal--17-120-100-100-m-0-iso8859-16", - "-l", - str(max_), - ] - chosen = ( - subprocess.run( - cmd, input="\n".join(input_).encode("utf-8"), capture_output=True - ) - .stdout.decode() - .strip() - ) - return chosen - - -def main(): - try: - with open(os.path.expanduser("~/.local/share/clipman.json")) as f: - history = json.load(f) - except FileNotFoundError: - sys.exit("No history available") - - history = [repr(x) for x in reversed(history)] # don't expand newlines - - selected = selector(history, MAX) - - subprocess.run(["wl-copy", selected]) - - -if __name__ == "__main__": - main() diff --git a/demon.go b/demon.go new file mode 100644 index 0000000..fe43ca2 --- /dev/null +++ b/demon.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "os/exec" + "time" +) + +const sleep = 1 * time.Second + +func write(history []string, histfile string) error { + histlog, err := json.Marshal(history) + if err != nil { + return err + } + err = ioutil.WriteFile(histfile, histlog, 0644) + if err != nil { + return err + } + return nil +} + +func filter(history []string, text string) []string { + var ( + found bool + idx int + ) + + for i, el := range history { + if el == text { + found = true + idx = i + break + } + } + + if found { + // we know that idx can't be the last element, because + // we never get to call this function if that's the case + history = append(history[:idx], history[idx+1:]...) + } + + return history +} + +func listen(history []string, histfile string) error { + + for { + + t, err := exec.Command("wl-paste", []string{"-n", "-t", "text"}...).Output() + text := string(t) + if err != nil || text == "" { + // there's nothing to select, so we sleep. + time.Sleep(sleep) + continue + } + + l := len(history) + + if l > 0 { + + // wl-paste will always give back the last copied text + // (as long as the place we copied from is still running) + if history[l-1] == text { + time.Sleep(sleep) + continue + } + + if l == max { + // we know that at any given time len(history) cannot be bigger than max, + // so it's enough to drop the first element + history = history[1:] + } + + // remove duplicates + // consider doing this in the frontend, for sparing resources + history = filter(history, text) + + } + + history = append(history, text) + + // dump history to file so that other apps can query it + err = write(history, histfile) + if err != nil { + return err + } + + time.Sleep(sleep) + } + +} diff --git a/go.mod b/go.mod index d170492..70367e2 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,9 @@ module clipman go 1.12 + +require ( + github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect + github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect + gopkg.in/alecthomas/kingpin.v2 v2.2.6 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a6d69f --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= diff --git a/main.go b/main.go index a9310de..4c4f534 100644 --- a/main.go +++ b/main.go @@ -5,101 +5,51 @@ import ( "io/ioutil" "log" "os" - "os/exec" "path" - "time" + + "gopkg.in/alecthomas/kingpin.v2" ) const max = 15 -const sleep = 1 * time.Second -func write(history []string, histf string) error { - histlog, err := json.Marshal(history) - if err != nil { - return err - } - err = ioutil.WriteFile(histf, histlog, 0644) - if err != nil { - return err - } - return nil -} +var ( + app = kingpin.New("clipman", "A clipboard manager for Wayland") + asDemon = app.Flag("demon", "Run as a demon to record clipboard events").Short('d').Default("false").Bool() + asSelector = app.Flag("select", "Select an item from clipboard history").Short('s').Default("false").Bool() +) -func filter(history []string, text string) []string { - var ( - found bool - idx int - ) - - for i, el := range history { - if el == text { - found = true - idx = i - break - } - } - - if found { - // we know that idx can't be the last element, because - // we never get to call this function if that's the case - history = append(history[:idx], history[idx+1:]...) - } - - return history -} +var ( + histfile string + history []string +) func main() { + app.HelpFlag.Short('h') + kingpin.MustParse(app.Parse(os.Args[1:])) + if (*asDemon && *asSelector) || (!*asDemon && !*asSelector) { + log.Fatal("Missing or incompatible options. See -h/--help for info") + } + h, err := os.UserHomeDir() if err != nil { log.Fatal(err) } - histf := path.Join(h, ".local/share/clipman.json") + histfile = path.Join(h, ".local/share/clipman.json") - var history []string - b, err := ioutil.ReadFile(histf) + b, err := ioutil.ReadFile(histfile) if err == nil { if err := json.Unmarshal(b, &history); err != nil { log.Fatal(err) } } - for { - t, err := exec.Command("wl-paste", []string{"-n", "-t", "text"}...).Output() - text := string(t) - if err != nil || text == "" { - // there's nothing to select, so we sleep. - time.Sleep(sleep) - continue - } - - l := len(history) - - if l > 0 { - // wl-paste will always give back the last copied text - // (as long as the place we copied from is still running) - if history[l-1] == text { - time.Sleep(sleep) - continue - } - - if l == max { - // we know that at any given time len(history) cannot be bigger than max, - // so it's enough to drop the first element - history = history[1:] - } - - // remove duplicates - history = filter(history, text) - } - - history = append(history, text) - - // dump history to file so that other apps can query it - err = write(history, histf) - if err != nil { + if *asDemon { + if err := listen(history, histfile); err != nil { + log.Fatal(err) + } + } else if *asSelector { + if err := selector(history); err != nil { log.Fatal(err) } - - time.Sleep(sleep) } } diff --git a/selector.go b/selector.go new file mode 100644 index 0000000..f35ddb7 --- /dev/null +++ b/selector.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "os/exec" + "strconv" + "strings" +) + +func selector(history []string) error { + + // reverse the history + for i, j := 0, len(history)-1; i < j; i, j = i+1, j-1 { + history[i], history[j] = history[j], history[i] + } + + selected, err := dmenu(history, max) + if err != nil { + // dmenu exits with error when no selection done + return nil + } + + if err := exec.Command("wl-copy", selected).Run(); err != nil { + return err + } + + return nil +} + +func dmenu(list []string, max int) (string, error) { + args := []string{"dmenu", "-b", + "-fn", + "-misc-dejavu sans mono-medium-r-normal--17-120-100-100-m-0-iso8859-16", + "-l", + strconv.Itoa(max)} + + // dmenu will break if items contain newlines, so we must pass them as literals. + // however, when it sends them back, we need a way to restore them to non literals + guide := make(map[string]int) + reprList := []string{} + for i, l := range list { + repr := fmt.Sprintf("%#v", l) + guide[repr] = i + reprList = append(reprList, repr) + } + + input := strings.NewReader(strings.Join(reprList, "\n")) + + cmd := exec.Cmd{Path: "/usr/bin/dmenu", Args: args, Stdin: input} + selected, err := cmd.Output() + if err != nil { + return "", err + } + + unescaped := list[guide[string(selected)]] + + return unescaped, nil +}