├── .gitignore ├── LICENSE ├── PKGBUILD ├── README.md ├── blocks.go ├── clickevents.go ├── main.go └── wercker.yml /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Vincent Petithory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Vincent Petithory 2 | 3 | pkgname=i3cat-git 4 | pkgver=r3.417f5ba 5 | pkgrel=2 6 | pkgdesc='A simple program to combine multiple i3bar JSON inputs into one to forward to i3bar.' 7 | arch=('x86_64' 'i686' 'ARM') 8 | url='http://vincent-petithory.github.io/i3cat/' 9 | license=('MIT') 10 | makedepends=('go' 'git') 11 | depends=() 12 | optdepends=('i3-wm: for i3bar') 13 | options=('!strip' '!emptydirs') 14 | source='git+https://github.com/vincent-petithory/i3cat.git' 15 | md5sums=('SKIP') 16 | _gourl='github.com/vincent-petithory/i3cat' 17 | 18 | pkgver() { 19 | cd "$srcdir/i3cat" 20 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" 21 | } 22 | 23 | build() { 24 | p="$(dirname "${srcdir}/src/${_gourl}")" 25 | mkdir -p "$p" 26 | mv "$srcdir/i3cat" "$p" 27 | export GOPATH="$srcdir" 28 | go get -v "${_gourl}/..." 29 | go install "${_gourl}/..." 30 | } 31 | 32 | package() { 33 | install -D -m755 "${srcdir}/bin/i3cat" "${pkgdir}/usr/bin/i3cat" 34 | install -D -m644 "${srcdir}/src/${_gourl}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # i3cat [![wercker status](https://app.wercker.com/status/f9749c41b63024450dc703f139e922ce/m/ "wercker status")](https://app.wercker.com/project/bykey/f9749c41b63024450dc703f139e922ce) [![Gobuild Download](http://gobuild.io/badge/github.com/vincent-petithory/i3cat/download.png)](http://gobuild.io/github.com/vincent-petithory/i3cat) 2 | 3 | A simple program to combine multiple i3bar JSON inputs into one to forward to i3bar. 4 | 5 | [Project page](http://vincent-petithory.github.io/i3cat/) 6 | 7 | ## Motivation 8 | 9 | * enjoy the simplicity of i3status, do not replace it with a fully featured wrapper 10 | * use simple shell scripts to add new i3bar blocks 11 | 12 | ## Walkthrough 13 | 14 | ### Install 15 | 16 | Several options: 17 | 18 | * Download a binary for your platform [here](http://gobuild.io/github.com/vincent-petithory/i3cat) 19 | * [Install Go](http://golang.org/doc/install) and run `go get github.com/vincent-petithory/i3cat` 20 | * If you're on Arch Linux, you can install from [AUR](https://aur.archlinux.org/packages/i3cat-git/). 21 | 22 | ### Get what you had with i3status: 23 | 24 | `status_command i3status --config ~/.i3/status` becomes `status_command echo "i3status --config ~/.i3/status" | i3cat` 25 | 26 | But since you will want to add other blocks, it's more handy to add the commands in a conf file: 27 | 28 | $ cat ~/.i3/i3cat.conf 29 | # i3 status 30 | i3status -c ~/.i3/status 31 | 32 | and the status command is now `status_command i3cat` (`~/.i3/i3cat.conf` is the default location for its conf file). 33 | 34 | Note that your i3status'conf must have his output in i3bar format. If you didn't have it yet, modify it as follows: 35 | 36 | general { 37 | ... 38 | output_format = i3bar 39 | ... 40 | } 41 | 42 | ### Add a block 43 | 44 | Say we want to display the current song played by MPD and its state. The script could be: 45 | 46 | $ cat ~/.i3/mpd-nowplaying.sh 47 | #!/bin/sh 48 | display_song() { 49 | status= 50 | color= 51 | case $(mpc status | sed 1d | head -n1 | awk '{ print $1 }') in 52 | '[playing]') 53 | status= 54 | color='#36a8d5' 55 | ;; 56 | '[paused]') 57 | status= 58 | color= 59 | ;; 60 | esac 61 | echo '[{"name": "mpd", "instance": "now playing", "full_text": " '${status}' '$1'", "color": "'${color}'"}]' 62 | } 63 | 64 | (while :; do 65 | display_song "$(mpc current --wait)" 66 | done) & 67 | 68 | while :; do 69 | display_song "$(mpc current)" 70 | mpc idle player > /dev/null 71 | done 72 | 73 | Edit `~/.i3/i3cat.conf`: 74 | 75 | $ cat i3cat.conf 76 | # mpc status 77 | ~/.i3/mpd-nowplaying.sh 78 | # i3 status 79 | i3status -c ~/.i3/status 80 | 81 | The order matters: the output of the commands are sent to i3bar in that order. 82 | Lines starting with `#` are comments and ignored. 83 | 84 | Note the JSON output of the script is an array. `i3cat` also supports variants like the output from `i3status`: a i3bar header (or not) followed by an infinite array. 85 | 86 | #### Replace echo by i3cat encode 87 | 88 | Outputting JSON by hand works only for simple cases. 89 | `i3cat` provides a helper command to send blocks: 90 | 91 | echo '[{"name": "mpd", "instance": "now playing", "full_text": " '${status}' '$1'", "color": "'${color}'"}]' 92 | 93 | becomes 94 | 95 | i3cat encode --name mpd --instance "now playing" --color "${color}" " ${status} $1" 96 | 97 | ### Listen for click events on a block 98 | 99 | i3cat listens for click events generated by the user and writes their JSON representation to the STDIN of the command which created the clicked block. 100 | 101 | See [the i3bar protocol](http://i3wm.org/docs/i3bar-protocol.html) for details on its structure. 102 | 103 | Using our MPD script from above, we want that when we click on its block, we want i3 to focus a container marked as _music_ (e.g ncmpcpp). 104 | All that is needed is to read the process' `STDIN`. Each i3bar click event is output on one line, so a generic recipe boils down to: 105 | 106 | cat | while read line; do on_click_event "$line"; done 107 | 108 | `on_click_event` will parse the JSON output and perform the action. 109 | 110 | To read properties of an incoming click event, you could use: 111 | 112 | #!/bin/sh 113 | click_event_prop() { 114 | python -c "import json,sys; obj=json.load(sys.stdin); print(obj['$1'])" 115 | } 116 | ... 117 | # read the 'button' property 118 | button=$(echo $@ | click_event_prop button) 119 | 120 | But `i3cat` provides a decode command for that: 121 | 122 | button=$(echo $@ | i3cat decode button) 123 | 124 | Full example below: 125 | 126 | #!/bin/sh 127 | display_song() { 128 | status= 129 | color= 130 | case $(mpc status | sed 1d | head -n1 | awk '{ print $1 }') in 131 | '[playing]') 132 | status= 133 | color='#36a8d5' 134 | ;; 135 | '[paused]') 136 | status= 137 | color= 138 | ;; 139 | esac 140 | i3cat encode --name mpd --instance "now playing" --color "${color}" " ${status} $1" 141 | } 142 | 143 | on_click_event() { 144 | button=$(echo $@ | i3cat decode button) 145 | case $button in 146 | 1) 147 | i3-msg '[con_mark="music"]' focus > /dev/null 148 | ;; 149 | esac 150 | } 151 | 152 | (while :; do 153 | display_song "$(mpc current --wait)" 154 | done) & 155 | 156 | (while :; do 157 | display_song "$(mpc current)" 158 | mpc idle player > /dev/null 159 | done) & 160 | 161 | cat | while read line; do on_click_event "$line"; done 162 | 163 | 164 | #### Case of programs which you can't read stdin from 165 | 166 | You simply need to wrap them in a script of your choice. 167 | Example with i3status and a Shell script: 168 | 169 | #!/bin/sh 170 | on_click_event() { 171 | button=$(echo "$@" | i3cat decode button) 172 | if [ $button != '1' ]; then 173 | return 174 | fi 175 | name=$(echo "$@" | i3cat decode name) 176 | instance=$(echo "$@" | i3cat decode instance) 177 | # Do something with block $name::$instance ... 178 | } 179 | 180 | # Output i3status blocks 181 | i3status -c $HOME/.i3/status & 182 | # Read stdin for JSON click events 183 | cat | while read line; do on_click_event "$line"; done 184 | 185 | ### More 186 | 187 | Run `i3cat -h` for a list of options: 188 | 189 | Usage: i3cat [COMMAND] [ARGS] 190 | 191 | If COMMAND is not specified, i3cat will print i3bar blocks to stdout. 192 | 193 | -cmd-file="$HOME/.i3/i3cat.conf": File listing of the commands to run. It will read from STDIN if - is provided 194 | -debug-file="": Outputs JSON to this file as well; for debugging what is sent to i3bar. 195 | -header-clickevents=false: The i3bar header click_events 196 | -header-contsignal=0: The i3bar header cont_signal. i3cat will send this signal to the processes it manages. 197 | -header-stopsignal=0: The i3bar header stop_signal. i3cat will send this signal to the processes it manages. 198 | -header-version=1: The i3bar header version 199 | -log-file="": Logs i3cat events in this file. Defaults to STDERR 200 | 201 | decode: FIELD 202 | 203 | Reads STDIN and decodes a JSON payload representing a click event; typically sent by i3bar. 204 | It will print the FIELD from the JSON structure to stdout. 205 | 206 | Possible fields are name, instance, button, x, y. 207 | 208 | 209 | encode: [OPTS] [FULL_TEXT...] 210 | 211 | Concats FULL_TEXT arguments, separated with spaces, and encodes it as an i3bar block JSON payload. 212 | If FULL_TEXT is -, it will read from STDIN instead. 213 | 214 | The other fields of an i3bar block are optional and specified with the following options: 215 | 216 | -align="": the block.align field to encode. 217 | -color="": the block.color field to encode. 218 | -instance="": the block.instance field to encode. 219 | -min-width=0: the block.min_width field to encode. 220 | -name="": the block.name field to encode. 221 | -separator=false: the block.separator field to encode. 222 | -separator-block-width=0: the block.separator_block_width field to encode. 223 | -short-text="": the block.short_text field to encode. 224 | -single=false: If true, the block will not be in a JSON array. This allows to combine other blocks before sending to i3bar. 225 | -urgent=false: the block.urgent field to encode. 226 | 227 | ## Design 228 | 229 | `i3cat` sends data to i3bar only when necessary: when a command sends an updated output of its blocks, `i3cat` caches it and sends to i3bar the updated output of all blocks, using the latest cached blocks of the other commands. This means commands don't need to have the same update frequency. 230 | 231 | It is not advised to send SIGSTOP and SIGCONT signals to`i3cat`, as its subprocesses will continue to output data anyway. 232 | For pausing and resuming processing (usually asked by i3bar), `i3cat` will listen for SIGUSR1 and SIGUSR2 for pausing and resuming, respectively. It will then forward the signals specified with `-header-stopsignal` and `-header-contsignal` flags (defaults to SIGSTOP and SIGCONT) to all its managed processes. 233 | -------------------------------------------------------------------------------- /blocks.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "syscall" 12 | "unicode" 13 | 14 | "github.com/vincent-petithory/structfield" 15 | ) 16 | 17 | // Header defines the struct of the header in the i3bar protocol. 18 | type Header struct { 19 | Version int `json:"version"` 20 | StopSignal int `json:"stop_signal,omitempty"` 21 | ContSignal int `json:"cont_signal,omitempty"` 22 | ClickEvents bool `json:"click_events,omitempty"` 23 | } 24 | 25 | var trueBoolTransformer = structfield.TransformerFunc(func(field string, value interface{}) (string, interface{}) { 26 | switch x := value.(type) { 27 | case bool: 28 | if !x { 29 | return field, false 30 | } 31 | default: 32 | panic("trueBoolTransformer: expected bool") 33 | } 34 | return "", nil 35 | }) 36 | 37 | // Block defines the struct of blocks in the i3bar protocol. 38 | type Block struct { 39 | FullText string `json:"full_text"` 40 | ShortText string `json:"short_text,omitempty"` 41 | Color string `json:"color,omitempty"` 42 | MinWidth int `json:"min_width,omitempty"` 43 | Align string `json:"align,omitempty"` 44 | Name string `json:"name,omitempty"` 45 | Instance string `json:"instance,omitempty"` 46 | Urgent bool `json:"urgent,omitempty"` 47 | Separator bool `json:"separator"` 48 | SeparatorBlockWidth int `json:"separator_block_width,omitempty"` 49 | Background string `json:"background,omitempty"` 50 | Border string `json:"border,omitempty"` 51 | Markup string `json:"markup,omitempty"` 52 | } 53 | 54 | func (b Block) MarshalJSON() ([]byte, error) { 55 | m := structfield.Transform(b, map[string]structfield.Transformer{ 56 | "separator": trueBoolTransformer, 57 | }) 58 | return json.Marshal(m) 59 | } 60 | 61 | func (b *Block) UnmarshalJSON(data []byte) error { 62 | type blockAlias Block 63 | ba := blockAlias{} 64 | if err := json.Unmarshal(data, &ba); err != nil { 65 | return err 66 | } 67 | *b = Block(ba) 68 | 69 | sep := struct { 70 | Value *bool `json:"separator"` 71 | }{} 72 | if err := json.Unmarshal(data, &sep); err != nil { 73 | return err 74 | } 75 | if sep.Value != nil { 76 | b.Separator = *sep.Value 77 | } else { 78 | // defaults to true 79 | b.Separator = true 80 | } 81 | return nil 82 | } 83 | 84 | // String implements Stringer interface. 85 | func (b Block) String() string { 86 | return b.FullText 87 | } 88 | 89 | // BlockAggregate relates a CmdIO to the Blocks it produced during one update. 90 | type BlockAggregate struct { 91 | CmdIO *CmdIO 92 | Blocks []*Block 93 | } 94 | 95 | // A CmdIO defines a cmd that will feed the i3bar. 96 | type CmdIO struct { 97 | // Cmd is the command being run 98 | Cmd *exec.Cmd 99 | // reader is the underlying stream where Cmd outputs data. 100 | reader io.ReadCloser 101 | // writer is the underlying stream where Cmd outputs data. 102 | writer io.WriteCloser 103 | } 104 | 105 | // NewCmdIO creates a new CmdIO from command c. 106 | // c must be properly quoted for a shell as it's passed to sh -c. 107 | func NewCmdIO(c string) (*CmdIO, error) { 108 | cmd := exec.Command(os.Getenv("SHELL"), "-c", c) 109 | reader, err := cmd.StdoutPipe() 110 | if err != nil { 111 | return nil, err 112 | } 113 | writer, err := cmd.StdinPipe() 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | cmdio := CmdIO{ 119 | Cmd: cmd, 120 | reader: reader, 121 | writer: writer, 122 | } 123 | return &cmdio, nil 124 | } 125 | 126 | // Start runs the command of CmdIO and feeds the BlockAggregatesCh channel 127 | // with the Blocks it produces. 128 | func (c *CmdIO) Start(blockAggregatesCh chan<- *BlockAggregate) error { 129 | if err := c.Cmd.Start(); err != nil { 130 | return err 131 | } 132 | go func() { 133 | // We'll handle a few cases here. 134 | // If JSON is output from i3status, then we need 135 | // to ignore the i3bar header and opening [, 136 | // then ignore leading comma on each line. 137 | // If JSON is output from a script, it assumes the 138 | // author will not have the header and [, but maybe the comma 139 | r := bufio.NewReader(c.reader) 140 | // try Read a header first 141 | ruune, _, err := r.ReadRune() 142 | if err != nil { 143 | log.Println(err) 144 | return 145 | } 146 | if ruune == '{' { 147 | // Consume the header line 148 | if _, err := r.ReadString('\n'); err != nil { 149 | log.Println(err) 150 | return 151 | } 152 | // Consume the next line (opening bracket) 153 | if _, err := r.ReadString('\n'); err != nil { 154 | log.Println(err) 155 | return 156 | } 157 | } else { 158 | _ = r.UnreadRune() 159 | } 160 | dec := json.NewDecoder(r) 161 | for { 162 | var b []*Block 163 | // Ignore unwanted chars first 164 | IgnoreChars: 165 | for { 166 | ruune, _, err := r.ReadRune() 167 | if err != nil { 168 | log.Println(err) 169 | break IgnoreChars 170 | } 171 | switch { 172 | case unicode.IsSpace(ruune): 173 | // Loop again 174 | case ruune == ',': 175 | break IgnoreChars 176 | default: 177 | _ = r.UnreadRune() 178 | break IgnoreChars 179 | } 180 | } 181 | if err := dec.Decode(&b); err != nil { 182 | if err == io.EOF { 183 | log.Println("reached EOF") 184 | return 185 | } 186 | log.Printf("Invalid JSON input: all decoding methods failed (%v)\n", err) 187 | // consume all remaining data to prevent looping forever on a decoding err 188 | for r.Buffered() > 0 { 189 | _, err := r.ReadByte() 190 | if err != nil { 191 | log.Println(err) 192 | } 193 | } 194 | // send an error block 195 | b = []*Block{ 196 | { 197 | FullText: fmt.Sprintf("Error parsing input: %v", err), 198 | Color: "#FF0000", 199 | }, 200 | } 201 | } 202 | blockAggregatesCh <- &BlockAggregate{c, b} 203 | } 204 | }() 205 | return nil 206 | } 207 | 208 | // Close closes reader and writers of this CmdIO. 209 | func (c *CmdIO) Close() error { 210 | if err := c.Cmd.Process.Signal(syscall.SIGTERM); err != nil { 211 | log.Println(err) 212 | if err := c.Cmd.Process.Kill(); err != nil { 213 | return err 214 | } 215 | } 216 | if err := c.Cmd.Process.Release(); err != nil { 217 | return err 218 | } 219 | if err := c.reader.Close(); err != nil { 220 | return err 221 | } 222 | if err := c.writer.Close(); err != nil { 223 | return err 224 | } 225 | return nil 226 | } 227 | 228 | // BlockAggregator fans-in all Blocks produced by a list of CmdIO and sends it to the writer W. 229 | type BlockAggregator struct { 230 | // Blocks keeps track of which CmdIO produced which Block list. 231 | Blocks map[*CmdIO][]*Block 232 | // CmdIOs keeps an ordered list of the CmdIOs being aggregated. 233 | CmdIOs []*CmdIO 234 | // W is where multiplexed input blocks are written to. 235 | W io.Writer 236 | } 237 | 238 | // NewBlockAggregator returns a BlockAggregator which will write to w. 239 | func NewBlockAggregator(w io.Writer) *BlockAggregator { 240 | return &BlockAggregator{ 241 | Blocks: make(map[*CmdIO][]*Block), 242 | CmdIOs: make([]*CmdIO, 0), 243 | W: w, 244 | } 245 | } 246 | 247 | // Aggregate starts aggregating data coming from the BlockAggregates channel. 248 | func (ba *BlockAggregator) Aggregate(blockAggregates <-chan *BlockAggregate) { 249 | jw := json.NewEncoder(ba.W) 250 | for blockAggregate := range blockAggregates { 251 | ba.Blocks[blockAggregate.CmdIO] = blockAggregate.Blocks 252 | var blocksUpdate []*Block 253 | for _, cmdio := range ba.CmdIOs { 254 | blocksUpdate = append(blocksUpdate, ba.Blocks[cmdio]...) 255 | } 256 | if blocksUpdate == nil { 257 | blocksUpdate = []*Block{} 258 | } 259 | if err := jw.Encode(blocksUpdate); err != nil { 260 | log.Println(err) 261 | } 262 | if _, err := ba.W.Write([]byte(",")); err != nil { 263 | log.Println(err) 264 | } 265 | } 266 | } 267 | 268 | // ForwardClickEvents relays click events emitted on ceCh to interested parties. 269 | // An interested party is a cmdio whose 270 | func (ba *BlockAggregator) ForwardClickEvents(ceCh <-chan ClickEvent) { 271 | FWCE: 272 | for ce := range ceCh { 273 | for _, cmdio := range ba.CmdIOs { 274 | blocks, ok := ba.Blocks[cmdio] 275 | if !ok { 276 | continue 277 | } 278 | for _, block := range blocks { 279 | if block.Name == ce.Name && block.Instance == ce.Instance { 280 | if err := json.NewEncoder(cmdio.writer).Encode(ce); err != nil { 281 | log.Println(err) 282 | } 283 | log.Printf("Sending click event %+v to %s\n", ce, cmdio.Cmd.Args) 284 | // One of the blocks of this cmdio matched. 285 | // We don't want more since a name/instance is supposed to be unique. 286 | continue FWCE 287 | } 288 | } 289 | } 290 | log.Printf("No block source found for click event %+v\n", ce) 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /clickevents.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "io" 7 | "log" 8 | "unicode" 9 | ) 10 | 11 | // ClickEvent holds data sent by i3bar when the user clicks a block. 12 | type ClickEvent struct { 13 | Name string `json:"name"` 14 | Instance string `json:"instance"` 15 | Button int `json:"button"` 16 | X int `json:"x"` 17 | Y int `json:"y"` 18 | } 19 | 20 | // ClickEventsListener parses the click event stream and notifies its subscribers. 21 | type ClickEventsListener struct { 22 | r io.Reader 23 | clickEventChans []chan ClickEvent 24 | } 25 | 26 | // NewClickEventsListener returns a ClickEventsListener which reads from r. 27 | func NewClickEventsListener(r io.Reader) *ClickEventsListener { 28 | return &ClickEventsListener{r: r, clickEventChans: make([]chan ClickEvent, 0)} 29 | } 30 | 31 | // Listen reads and decodes the click event stream and forwards them to the channels subscribers. 32 | func (cel *ClickEventsListener) Listen() { 33 | r := bufio.NewReader(cel.r) 34 | dec := json.NewDecoder(r) 35 | for { 36 | var ce ClickEvent 37 | // Ignore unwanted chars first 38 | IgnoreChars: 39 | for { 40 | ruune, _, err := r.ReadRune() 41 | if err != nil { 42 | log.Println(err) 43 | break IgnoreChars 44 | } 45 | switch { 46 | case unicode.IsSpace(ruune): 47 | // Loop again 48 | case ruune == '[': 49 | // Loop again 50 | case ruune == ',': 51 | break IgnoreChars 52 | default: 53 | _ = r.UnreadRune() 54 | break IgnoreChars 55 | } 56 | } 57 | err := dec.Decode(&ce) 58 | switch { 59 | case err == io.EOF: 60 | log.Println("ClickEventsListener: reached EOF") 61 | return 62 | case err != nil: 63 | log.Printf("ClickEventsListener: invalid JSON input: %v\n", err) 64 | return 65 | default: 66 | log.Printf("Received click event %+v\n", ce) 67 | for _, ch := range cel.clickEventChans { 68 | sch := ch 69 | go func() { 70 | sch <- ce 71 | }() 72 | } 73 | } 74 | } 75 | } 76 | 77 | // Notify returns a channel which will be fed by incoming ClickEvents. 78 | func (cel *ClickEventsListener) Notify() chan ClickEvent { 79 | ch := make(chan ClickEvent) 80 | cel.clickEventChans = append(cel.clickEventChans, ch) 81 | return ch 82 | } 83 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "os/signal" 13 | "strings" 14 | "syscall" 15 | ) 16 | 17 | func main() { 18 | var debugFile string 19 | var logFile string 20 | var cmdsFile string 21 | var header Header 22 | 23 | stdFlagSet := flag.NewFlagSet(os.Args[0], flag.ExitOnError) 24 | stdFlagSet.StringVar(&debugFile, "debug-file", "", "Outputs JSON to this file as well; for debugging what is sent to i3bar.") 25 | stdFlagSet.StringVar(&logFile, "log-file", "", "Logs i3cat events in this file. Defaults to STDERR") 26 | stdFlagSet.StringVar(&cmdsFile, "cmd-file", "$HOME/.i3/i3cat.conf", "File listing of the commands to run. It will read from STDIN if - is provided") 27 | stdFlagSet.IntVar(&header.Version, "header-version", 1, "The i3bar header version") 28 | stdFlagSet.BoolVar(&header.ClickEvents, "header-clickevents", false, "The i3bar header click_events") 29 | 30 | decFlagSet := flag.NewFlagSet("decode", flag.ExitOnError) 31 | var decField string 32 | 33 | encFlagSet := flag.NewFlagSet("encode", flag.ExitOnError) 34 | var block Block 35 | var singleBlock bool 36 | encFlagSet.BoolVar(&singleBlock, "single", false, "If true, the block will not be in a JSON array. This allows to combine other blocks before sending to i3bar.") 37 | encFlagSet.StringVar(&block.ShortText, "short-text", "", "the block.short_text field to encode.") 38 | encFlagSet.StringVar(&block.Color, "color", "", "the block.color field to encode.") 39 | encFlagSet.IntVar(&block.MinWidth, "min-width", 0, "the block.min_width field to encode.") 40 | encFlagSet.StringVar(&block.Align, "align", "", "the block.align field to encode.") 41 | encFlagSet.StringVar(&block.Name, "name", "", "the block.name field to encode.") 42 | encFlagSet.StringVar(&block.Instance, "instance", "", "the block.instance field to encode.") 43 | encFlagSet.BoolVar(&block.Urgent, "urgent", false, "the block.urgent field to encode.") 44 | encFlagSet.BoolVar(&block.Separator, "separator", true, "the block.separator field to encode.") 45 | encFlagSet.IntVar(&block.SeparatorBlockWidth, "separator-block-width", 0, "the block.separator_block_width field to encode.") 46 | encFlagSet.StringVar(&block.Background, "background", "", "the block.background field to encode.") 47 | encFlagSet.StringVar(&block.Border, "border", "", "the block.border field to encode.") 48 | encFlagSet.StringVar(&block.Markup, "markup", "", "the block.markup field to encode.") 49 | 50 | usage := func() { 51 | fmt.Fprintf(os.Stderr, `Usage: i3cat [COMMAND] [ARGS] 52 | 53 | If COMMAND is not specified, i3cat will print i3bar blocks to stdout. 54 | 55 | `) 56 | stdFlagSet.PrintDefaults() 57 | fmt.Fprintf(os.Stderr, ` 58 | decode: FIELD 59 | 60 | Reads STDIN and decodes a JSON payload representing a click event; typically sent by i3bar. 61 | It will print the FIELD from the JSON structure to stdout. 62 | 63 | Possible fields are name, instance, button, x, y. 64 | 65 | `) 66 | decFlagSet.PrintDefaults() 67 | fmt.Fprintf(os.Stderr, ` 68 | encode: [OPTS] [FULL_TEXT...] 69 | 70 | Concats FULL_TEXT arguments, separated with spaces, and encodes it as an i3bar block JSON payload. 71 | If FULL_TEXT is -, it will read from STDIN instead. 72 | 73 | The other fields of an i3bar block are optional and specified with the following options: 74 | 75 | `) 76 | encFlagSet.PrintDefaults() 77 | } 78 | 79 | switch { 80 | case len(os.Args) > 1 && os.Args[1] == "decode": 81 | if err := decFlagSet.Parse(os.Args[2:]); err != nil { 82 | log.Print(err) 83 | usage() 84 | os.Exit(2) 85 | } 86 | if decFlagSet.NArg() == 0 { 87 | usage() 88 | os.Exit(2) 89 | } 90 | decField = decFlagSet.Arg(0) 91 | if err := DecodeClickEvent(os.Stdout, os.Stdin, decField); err != nil { 92 | log.Fatal(err) 93 | } 94 | case len(os.Args) > 1 && os.Args[1] == "encode": 95 | if err := encFlagSet.Parse(os.Args[2:]); err != nil { 96 | log.Print(err) 97 | usage() 98 | os.Exit(2) 99 | } 100 | switch { 101 | case encFlagSet.NArg() == 0: 102 | fallthrough 103 | case encFlagSet.NArg() == 1 && encFlagSet.Arg(0) == "-": 104 | fullText, err := ioutil.ReadAll(os.Stdin) 105 | if err != nil { 106 | log.Fatal(err) 107 | } 108 | block.FullText = string(fullText) 109 | case encFlagSet.NArg() > 0: 110 | block.FullText = strings.Join(encFlagSet.Args(), " ") 111 | } 112 | if err := EncodeBlock(os.Stdout, block, singleBlock); err != nil { 113 | log.Fatal(err) 114 | } 115 | default: 116 | if err := stdFlagSet.Parse(os.Args[1:]); err != nil { 117 | log.Print(err) 118 | usage() 119 | os.Exit(2) 120 | } 121 | if stdFlagSet.NArg() > 0 { 122 | usage() 123 | os.Exit(2) 124 | } 125 | CatBlocksToI3Bar(cmdsFile, header, logFile, debugFile) 126 | } 127 | } 128 | 129 | func EncodeBlock(w io.Writer, block Block, single bool) error { 130 | var v interface{} 131 | if single { 132 | v = block 133 | } else { 134 | v = []Block{block} 135 | } 136 | return json.NewEncoder(w).Encode(v) 137 | } 138 | 139 | func DecodeClickEvent(w io.Writer, r io.Reader, field string) error { 140 | var ce ClickEvent 141 | if err := json.NewDecoder(r).Decode(&ce); err != nil { 142 | return err 143 | } 144 | var v interface{} 145 | switch field { 146 | case "name": 147 | v = ce.Name 148 | case "instance": 149 | v = ce.Instance 150 | case "button": 151 | v = ce.Button 152 | case "x": 153 | v = ce.X 154 | case "y": 155 | v = ce.Y 156 | default: 157 | return fmt.Errorf("unknown property %s", field) 158 | } 159 | fmt.Fprintln(w, v) 160 | return nil 161 | } 162 | 163 | func CatBlocksToI3Bar(cmdsFile string, header Header, logFile string, debugFile string) { 164 | // Read and parse commands to run. 165 | var cmdsReader io.ReadCloser 166 | if cmdsFile == "-" { 167 | cmdsReader = ioutil.NopCloser(os.Stdin) 168 | } else { 169 | f, err := os.Open(os.ExpandEnv(cmdsFile)) 170 | if err != nil { 171 | log.Fatal(err) 172 | } 173 | cmdsReader = f 174 | } 175 | var commands []string 176 | scanner := bufio.NewScanner(cmdsReader) 177 | for scanner.Scan() { 178 | cmd := strings.TrimSpace(scanner.Text()) 179 | if cmd != "" && !strings.HasPrefix(cmd, "#") { 180 | commands = append(commands, cmd) 181 | } 182 | } 183 | if err := scanner.Err(); err != nil { 184 | log.Fatal(err) 185 | } 186 | if err := cmdsReader.Close(); err != nil { 187 | log.Fatal(err) 188 | } 189 | 190 | // Init log output. 191 | if logFile != "" { 192 | f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0660) 193 | if err != nil { 194 | log.Fatal(err) 195 | } 196 | defer func() { 197 | _ = f.Close() 198 | }() 199 | log.SetOutput(f) 200 | } 201 | 202 | // Init where i3cat will print its output. 203 | var out io.Writer 204 | if debugFile != "" { 205 | f, err := os.OpenFile(debugFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0660) 206 | if err != nil { 207 | log.Fatal(err) 208 | } 209 | defer func() { 210 | _ = f.Close() 211 | }() 212 | out = io.MultiWriter(os.Stdout, f) 213 | } else { 214 | out = os.Stdout 215 | } 216 | 217 | // We print the header of i3bar 218 | hb, err := json.Marshal(header) 219 | if err != nil { 220 | log.Fatal(err) 221 | } 222 | fmt.Fprintf(out, "%s\n[\n", hb) 223 | 224 | // Listen for click events sent from i3bar 225 | cel := NewClickEventsListener(os.Stdin) 226 | go cel.Listen() 227 | 228 | // Create the block aggregator and start the commands 229 | blocksCh := make(chan *BlockAggregate) 230 | var cmdios []*CmdIO 231 | ba := NewBlockAggregator(out) 232 | for _, c := range commands { 233 | cmdio, err := NewCmdIO(c) 234 | if err != nil { 235 | log.Fatal(err) 236 | } 237 | cmdios = append(cmdios, cmdio) 238 | if err := cmdio.Start(blocksCh); err != nil { 239 | log.Fatal(err) 240 | } else { 241 | log.Printf("Starting command: %s", c) 242 | } 243 | } 244 | ba.CmdIOs = cmdios 245 | go ba.Aggregate(blocksCh) 246 | 247 | ceCh := cel.Notify() 248 | go ba.ForwardClickEvents(ceCh) 249 | 250 | // Listen for worthy signals 251 | c := make(chan os.Signal, 1) 252 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 253 | 254 | for { 255 | s := <-c 256 | switch s { 257 | case syscall.SIGTERM: 258 | fallthrough 259 | case os.Interrupt: 260 | // Kill all processes on interrupt 261 | log.Println("SIGINT or SIGTERM received: terminating all processes...") 262 | for _, cmdio := range ba.CmdIOs { 263 | if err := cmdio.Close(); err != nil { 264 | log.Println(err) 265 | } 266 | } 267 | os.Exit(0) 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /wercker.yml: -------------------------------------------------------------------------------- 1 | box: wercker/golang 2 | # Build definition 3 | build: 4 | # The steps that will be executed on build 5 | steps: 6 | # Sets the go workspace and places you package 7 | # at the right place in the workspace tree 8 | - setup-go-workspace 9 | 10 | # Gets the dependencies 11 | - script: 12 | name: go get 13 | code: | 14 | cd $WERCKER_SOURCE_DIR 15 | go version 16 | go get -t ./... 17 | 18 | # Build the project 19 | - script: 20 | name: go build 21 | code: | 22 | go build ./... 23 | 24 | # Test the project 25 | - script: 26 | name: go test 27 | code: | 28 | go test ./... 29 | --------------------------------------------------------------------------------