├── .gitignore ├── LICENSE ├── README.md ├── civ.go ├── cmd └── civ │ └── civ.go ├── command.go ├── go.mod ├── go.sum ├── queryline.go ├── table.go └── terminal.go /.gitignore: -------------------------------------------------------------------------------- 1 | /civ/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 masahiko 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # civ 2 | 3 | A simple CSV interactive viewer. 4 | 5 | # Demo 6 | 7 | ![civ demo](https://github.com/MasahikoSawada/masahikosawada.github.io/raw/master/images/civ.gif) 8 | 9 | # Build 10 | 11 | ``` 12 | go get -u github.com/MasahikoSawada/civ 13 | ``` 14 | 15 | # Usage 16 | 17 | ``` 18 | $ civ [options] [FILE] 19 | ``` 20 | 21 | | Option | Description | 22 | |:------------|:--------------------------------------------------| 23 | | `-d string` | Use `string` as a delimiter instead of comma(`,`) | 24 | |`-H` | Set dummy header (col_1, col_2 ...)| 25 | 26 | * civ reads data from stdin if no file is specified. 27 | * civ processes the first line as a header line by default. If the first line of the file is not header line please use `-H` option to set dummy headers. 28 | * `-d` option allows a speciial argument `\t` to parse TSV. 29 | 30 | # Query Buffer 31 | 32 | civ has a buffer for user-input query at top of the window. The first character indicates the current mode as described below. 33 | 34 | # Modes 35 | 36 | civ has 4 modes: view mode, command mode, search mode and filter mode. 37 | 38 | You can swtich modes by special character when the query buffer is empty. 39 | 40 | * '`:`' : View Mode 41 | * '`@`' : Command Mode 42 | * '`/`' : Search Mode 43 | * '`^`' : Filter Mode 44 | 45 | Press `Ctrl-g` always clear all query buffer and switch to view mode. 46 | 47 | Press `Ctrl-c` exits but executing `@exit` also exits while output the table data to `stdout`. 48 | 49 | Press `Enter` saves the result of the current command (at most one result for each searching and filtering). 50 | 51 | ## View Mode(`:`) 52 | 53 | Viewing the table data with the following ''less-like'' key binds: 54 | 55 | |Key|Description| 56 | |:---|:-----------| 57 | |e|Forward one line| 58 | |y|Backward one line| 59 | |f, SPACE|Forward one window| 60 | |b|Forward one window| 61 | |d|Forward one half-window| 62 | |u|Backward one half-window| 63 | |g|Go to first line in file| 64 | |G|Go to last line in file| 65 | 66 | ## Command Mode(`@`) 67 | 68 | Executing the following commands modify the table: 69 | 70 | |Command|Description| 71 | |:------|:----------| 72 | |`@hide column-name [...]`|Hide the specified column(s)| 73 | |`@show column-name [...]`|Show the specified hidden column(s)| 74 | |`@show_only column-name [...]`|Show only the specified column(s)| 75 | |`@reset`|Reset all configurations(row filtering, column visibility etc)| 76 | |`@exit`|Output the table data to stdout and exit normally| 77 | 78 | Pressing `Enter` key executes the input command. 79 | 80 | Note that specifying column name is case-insensive. 81 | 82 | ## Search Mode(`/`) 83 | 84 | civ supports the incremental search hight-lighting the matched words. 85 | 86 | ## Filter Mode(`^`) 87 | 88 | civ supports the incremental filtering rows. 89 | 90 | ## Limitation 91 | 92 | Since civ is continually being improved it has some limitations. These limitation might be resolved in the future. 93 | 94 | * Not supports multi-byte characters. 95 | -------------------------------------------------------------------------------- /civ.go: -------------------------------------------------------------------------------- 1 | package civ 2 | 3 | import ( 4 | "fmt" 5 | termbox "github.com/nsf/termbox-go" 6 | "os" 7 | ) 8 | 9 | const ( 10 | SCROLL_SIZE = 1 11 | ) 12 | 13 | type Civ struct { 14 | ql *QueryLine 15 | terminal *Terminal 16 | table *Table 17 | mode int 18 | terminate bool 19 | } 20 | 21 | const promptDefault string = "Search> " 22 | 23 | func NewCiv(data [][]string, dummyHeader bool) *Civ { 24 | c := &Civ{ 25 | ql: NewQueryLine(), 26 | terminal: NewTerminal(), 27 | table: NewTable(data, dummyHeader), 28 | } 29 | 30 | return c 31 | } 32 | 33 | // Key event used by view mode 34 | func (c *Civ) keyEventView(event termbox.Event) { 35 | 36 | switch event.Key { 37 | case 0: 38 | if !c.maybeChangeMode(event.Ch) { 39 | _, maxY := GetMaxXY() 40 | windowMoveSize := maxY / 2 41 | 42 | switch event.Ch { 43 | case 'b': 44 | c.table.MoveUp(windowMoveSize) 45 | case 'F': 46 | c.table.MoveDown(windowMoveSize) 47 | case 'f': 48 | c.table.MoveDown(windowMoveSize) 49 | case 'e': 50 | c.table.MoveDown(SCROLL_SIZE) 51 | case 'y': 52 | c.table.MoveUp(SCROLL_SIZE) 53 | case 'd': 54 | c.table.MoveDown(windowMoveSize / 2) 55 | case 'u': 56 | c.table.MoveUp(windowMoveSize / 2) 57 | case 'g': 58 | c.table.SetOffsetRow(0) 59 | case 'G': 60 | h := c.table.computeHeight() 61 | moveTo := 0 62 | if h > maxY { 63 | moveTo = h - maxY 64 | } 65 | c.table.SetOffsetRow(moveTo) 66 | } 67 | } 68 | case termbox.KeyArrowRight: 69 | c.table.MoveRight(SCROLL_SIZE) 70 | case termbox.KeyArrowLeft: 71 | c.table.MoveLeft(SCROLL_SIZE) 72 | case termbox.KeyArrowDown: 73 | c.table.MoveDown(SCROLL_SIZE) 74 | case termbox.KeyArrowUp: 75 | c.table.MoveUp(SCROLL_SIZE) 76 | case termbox.KeySpace: 77 | _, maxY := GetMaxXY() 78 | windowMoveSize := maxY / 2 79 | c.table.MoveDown(windowMoveSize) 80 | case termbox.KeyEnter: 81 | c.table.MoveDown(SCROLL_SIZE) 82 | } 83 | } 84 | 85 | // Key event used by command mode and search mode 86 | func (c *Civ) keyEventInput(event termbox.Event) { 87 | 88 | // When press enter key, execute the command and clear query 89 | // line string while keeping mode and state of each cell (matches, 90 | // visibility). Also we return immediately after cleared query line 91 | // so that we can leave the current state. 92 | if event.Key == termbox.KeyEnter { 93 | c.ExecuteCommand() 94 | c.ql.ClearQuery() 95 | return 96 | } 97 | 98 | switch event.Key { 99 | case 0: 100 | if !c.maybeChangeMode(event.Ch) { 101 | // mode is not changed, input char 102 | c.ql.InputChar(event.Ch) 103 | 104 | } 105 | case termbox.KeySpace: 106 | c.ql.InputChar(' ') 107 | case termbox.KeyBackspace, termbox.KeyBackspace2: 108 | c.ql.BackwardChar() 109 | case termbox.KeyCtrlD: 110 | c.ql.DeleteChar() 111 | case termbox.KeyCtrlK: 112 | c.ql.TruncateChars() 113 | case termbox.KeyArrowRight, termbox.KeyCtrlF: 114 | c.ql.MoveForward() 115 | case termbox.KeyArrowLeft, termbox.KeyCtrlB: 116 | c.ql.MoveBackward() 117 | case termbox.KeyHome, termbox.KeyCtrlA: 118 | c.ql.MoveToTop() 119 | case termbox.KeyEnd, termbox.KeyCtrlE: 120 | c.ql.MoveToEnd() 121 | default: 122 | // Don't execute increment search/filter when get invalid 123 | // key. 124 | return 125 | } 126 | 127 | // Do incremental search and filter 128 | if c.ql.mode == MODE_SEARCH { 129 | c.executeSearchCommand() 130 | } else if c.ql.mode == MODE_FILTER { 131 | c.executeFilterCommand() 132 | } 133 | } 134 | 135 | func (c *Civ) handleKeyEvent(event termbox.Event) { 136 | if c.ql.mode == MODE_VIEW { 137 | c.keyEventView(event) 138 | } else { 139 | // MODE_COMMAND, MODE_SEARCH 140 | c.keyEventInput(event) 141 | } 142 | } 143 | 144 | func (c *Civ) maybeChangeMode(key rune) bool { 145 | // If this is not first character, it's not mode change 146 | if ql := c.ql.QueryLen(); ql != 0 { 147 | return false 148 | } 149 | 150 | if key == '/' && c.ql.mode != MODE_SEARCH { 151 | c.ql.mode = MODE_SEARCH 152 | return true 153 | } else if key == '@' && c.ql.mode != MODE_COMMAND { 154 | c.ql.mode = MODE_COMMAND 155 | return true 156 | } else if key == '^' && c.ql.mode != MODE_FILTER { 157 | c.ql.mode = MODE_FILTER 158 | return true 159 | } else if key == ':' && c.ql.mode != MODE_VIEW { 160 | c.ql.mode = MODE_VIEW 161 | return true 162 | } 163 | 164 | return false 165 | } 166 | 167 | func (c *Civ) Draw() { 168 | c.terminal.Draw(c.ql, c.table) 169 | } 170 | 171 | func (c *Civ) Run() bool { 172 | for { 173 | // Terminate if commanded 174 | if c.terminate { 175 | return true 176 | } 177 | 178 | c.terminal.Draw(c.ql, c.table) 179 | 180 | switch ev := termbox.PollEvent(); ev.Type { 181 | case termbox.EventKey: 182 | 183 | if ev.Key == termbox.KeyCtrlC { 184 | // Ctrl-c is always used to terminate 185 | return false 186 | } else if ev.Key == termbox.KeyCtrlG { 187 | c.ql.ClearAll() 188 | } 189 | 190 | // Dispatch key input even to the current mode 191 | c.handleKeyEvent(ev) 192 | 193 | case termbox.EventError: 194 | fmt.Printf("detected an error event from termbox\n") 195 | os.Exit(1) 196 | break 197 | default: 198 | } 199 | } 200 | 201 | return false 202 | } 203 | -------------------------------------------------------------------------------- /cmd/civ/civ.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/csv" 5 | "flag" 6 | "fmt" 7 | "github.com/MasahikoSawada/civ" 8 | termbox "github.com/nsf/termbox-go" 9 | "os" 10 | ) 11 | 12 | func main() { 13 | var d rune 14 | 15 | dd := flag.String("d", ",", "use the delimiter instead of comma") 16 | withoutHeader := flag.Bool("H", false, "set dummy header") 17 | in := os.Stdin 18 | flag.Parse() 19 | 20 | if len(flag.Args()) > 1 { 21 | panic("civ can accept only one file") 22 | } 23 | 24 | // File is specified 25 | if len(flag.Args()) != 0 { 26 | _in, err := os.Open(flag.Args()[0]) 27 | if err != nil { 28 | fmt.Printf("could not open file %s: %s\n", flag.Args()[0], err) 29 | os.Exit(1) 30 | } 31 | defer in.Close() 32 | in = _in 33 | } 34 | 35 | // Determine the delimiter. We allow special character 36 | // '\t' which represents a tab for ease of use. 37 | if *dd == "\\t" { 38 | d = '\t' 39 | } else { 40 | d = rune((*dd)[0]) 41 | } 42 | 43 | // read file 44 | reader := csv.NewReader(in) 45 | reader.Comma = d 46 | csv, err := reader.ReadAll() 47 | if err != nil { 48 | fmt.Printf("could not read data: %s\n", err) 49 | os.Exit(1) 50 | } 51 | 52 | // initialize termbox 53 | if err := termbox.Init(); err != nil { 54 | fmt.Printf("could not initialize termbox: %s\n", err) 55 | os.Exit(1) 56 | } 57 | 58 | c := civ.NewCiv(csv, *withoutHeader) 59 | exited := c.Run() 60 | 61 | termbox.Close() 62 | 63 | if exited { 64 | c.Draw() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package civ 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func (c *Civ) ExecuteCommand() { 8 | // Quick exit if there is no actual query 9 | if len(c.ql.query) <= 0 { 10 | return 11 | } 12 | 13 | a := strings.Fields(string(c.ql.query)) 14 | 15 | command := a[0] 16 | vargs := a[1:] 17 | 18 | if strings.HasPrefix("show", command) { 19 | c.executeShowCommand(vargs) 20 | } else if strings.HasPrefix("show_only", command) { 21 | c.executeShowOnlyCommand(vargs) 22 | } else if strings.HasPrefix("hide", command) { 23 | c.executeHideCommand(vargs) 24 | } else if strings.HasPrefix("reset", command) { 25 | c.executeResetCommand(vargs) 26 | } else if strings.HasPrefix("exit", command) { 27 | c.executeExitCommand(vargs) 28 | } 29 | } 30 | 31 | func (c *Civ) executeExitCommand(vargs []string) { 32 | c.table.outputStdout = true 33 | c.terminate = true 34 | } 35 | 36 | // Hide the given columns 37 | func (c *Civ) executeHideCommand(vargs []string) { 38 | for _, a := range vargs { 39 | if i, ok := c.table.FindColName(a); ok { 40 | c.table.AddDisabledCol(i) 41 | } 42 | } 43 | } 44 | 45 | // Show only the given colums by hidding other columns 46 | func (c *Civ) executeShowOnlyCommand(vargs []string) { 47 | var hideCols []string 48 | 49 | for _, a := range c.table.header.cols { 50 | show := false 51 | for _, s := range vargs { 52 | if a.data == s { 53 | show = true 54 | } 55 | } 56 | 57 | if !show { 58 | hideCols = append(hideCols, a.data) 59 | } 60 | } 61 | 62 | c.executeHideCommand(hideCols) 63 | } 64 | 65 | func (c *Civ) executeResetCommand(vargs []string) { 66 | // Make all columsn visible 67 | c.table.ResetDisabledCol() 68 | 69 | // Make all rows visible 70 | c.table.ResetVisibility() 71 | } 72 | 73 | func (c *Civ) executeShowCommand(vargs []string) { 74 | for _, a := range vargs { 75 | if i, ok := c.table.FindColName(a); ok { 76 | c.table.RemoveDisabledCol(i) 77 | } 78 | } 79 | } 80 | 81 | func (c *Civ) executeSearchCommand() { 82 | searchWord := string(c.ql.query) 83 | 84 | for _r, row := range c.table.contents { 85 | matched := false 86 | for _c, cell := range row.cols { 87 | if idx := strings.Index(cell.data, searchWord); idx != -1 { 88 | c.table.SetMatched(_r, _c, idx, idx+len(searchWord)) 89 | matched = true 90 | } else { 91 | c.table.SetMatched(_r, _c, -1, -1) 92 | } 93 | } 94 | row.hasMatched = matched 95 | } 96 | } 97 | 98 | func (c *Civ) executeFilterCommand() { 99 | searchWord := string(c.ql.query) 100 | 101 | for _r, row := range c.table.contents { 102 | found := false 103 | 104 | for _, cell := range row.cols { 105 | if idx := strings.Index(cell.data, searchWord); idx != -1 { 106 | found = true 107 | break 108 | } 109 | } 110 | 111 | c.table.SetRowVisibility(_r, found) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MasahikoSawada/civ 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/mattn/go-runewidth v0.0.7 7 | github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= 2 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 3 | github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317 h1:hhGN4SFXgXo61Q4Sjj/X9sBjyeSa2kdpaOzCO+8EVQw= 4 | github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 5 | -------------------------------------------------------------------------------- /queryline.go: -------------------------------------------------------------------------------- 1 | package civ 2 | 3 | type QueryLine struct { 4 | query []rune 5 | curCursor int // starts from 0 6 | mode int 7 | } 8 | 9 | // Modes 10 | const ( 11 | MODE_VIEW = 1 12 | MODE_COMMAND = 2 13 | MODE_SEARCH = 3 14 | MODE_FILTER = 4 15 | ) 16 | 17 | func NewQueryLine() *QueryLine { 18 | q := &QueryLine{ 19 | query: nil, 20 | curCursor: 0, 21 | mode: MODE_VIEW, 22 | } 23 | return q 24 | } 25 | 26 | func (q *QueryLine) ClearQuery() { 27 | q.query = nil 28 | q.curCursor = 0 29 | } 30 | 31 | func (q *QueryLine) ClearAll() { 32 | q.query = nil 33 | q.curCursor = 0 34 | q.mode = MODE_VIEW 35 | } 36 | 37 | func (q *QueryLine) QueryLen() int { 38 | return len(q.query) 39 | } 40 | 41 | func (q *QueryLine) InputChar(r rune) { 42 | if len(q.query) <= q.curCursor { 43 | q.query = append(q.query, r) 44 | } else { 45 | _q := make([]rune, q.curCursor) 46 | copy(_q, q.query[:q.curCursor]) 47 | q.query = append(append(_q, r), q.query[q.curCursor:]...) 48 | } 49 | 50 | q.curCursor++ 51 | } 52 | 53 | func (q *QueryLine) BackwardChar() { 54 | if q.curCursor == 0 { 55 | return 56 | } 57 | 58 | _q := make([]rune, q.curCursor-1) 59 | copy(_q, q.query[:q.curCursor]) 60 | q.query = append(_q, q.query[q.curCursor:]...) 61 | 62 | q.curCursor-- 63 | } 64 | 65 | func (q *QueryLine) DeleteChar() { 66 | if q.curCursor >= len(q.query) { 67 | return 68 | } 69 | 70 | _q := make([]rune, q.curCursor) 71 | copy(_q, q.query[:q.curCursor]) 72 | q.query = append(_q, q.query[(q.curCursor+1):]...) 73 | } 74 | 75 | func (q *QueryLine) TruncateChars() { 76 | if q.curCursor >= len(q.query) { 77 | return 78 | } 79 | 80 | _q := make([]rune, q.curCursor) 81 | copy(_q, q.query[:q.curCursor]) 82 | q.query = _q 83 | } 84 | 85 | func (q *QueryLine) MoveForward() { 86 | if len(q.query) > q.curCursor { 87 | q.curCursor++ 88 | } 89 | } 90 | 91 | func (q *QueryLine) MoveBackward() { 92 | if len(q.query) >= q.curCursor && q.curCursor > 0 { 93 | q.curCursor-- 94 | } 95 | } 96 | 97 | func (q *QueryLine) MoveToTop() { 98 | q.curCursor = 0 99 | } 100 | 101 | func (q *QueryLine) MoveToEnd() { 102 | q.curCursor = len(q.query) 103 | } 104 | -------------------------------------------------------------------------------- /table.go: -------------------------------------------------------------------------------- 1 | package civ 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | type Table struct { 9 | header Row 10 | 11 | // Maximum length of each columns data including both header 12 | // and contents 13 | maxLen []int 14 | 15 | // A list of column number that is disabled to display 16 | disabledCols []int 17 | 18 | // Table contents excluding header 19 | contents []Row 20 | 21 | // View setting, starting from 0 22 | offsetCol int 23 | offsetRow int 24 | 25 | outputStdout bool 26 | } 27 | 28 | type Row struct { 29 | cols []Cell 30 | hasMatched bool 31 | isVisible bool 32 | } 33 | 34 | type Cell struct { 35 | data string 36 | matchBegin int 37 | matchEnd int 38 | width int // formatted size, available on only header 39 | } 40 | 41 | func NewTable(indata [][]string, dummyHeader bool) *Table { 42 | t := &Table{ 43 | offsetCol: 0, 44 | offsetRow: 0, 45 | } 46 | nCols := len(indata[0]) 47 | 48 | // Load header data 49 | t.maxLen = make([]int, nCols) 50 | for i, cell := range indata[0] { 51 | c := Cell{ 52 | matchBegin: -1, 53 | matchEnd: -1, 54 | } 55 | 56 | if dummyHeader { 57 | // make dummy header 58 | c.data = "col_" + strconv.Itoa(i) 59 | t.header.cols = append(t.header.cols, c) 60 | } else { 61 | c.data = cell 62 | t.header.cols = append(t.header.cols, c) 63 | } 64 | 65 | // initialize the maximum lenght of column by 66 | // the header data 67 | t.maxLen[i] = len(cell) 68 | } 69 | 70 | // Load table data 71 | csvdata := indata[1:] 72 | if dummyHeader { 73 | // If using dummy header, csv data starts from indata[0] 74 | csvdata = indata 75 | } 76 | for _, row := range csvdata { 77 | var r Row 78 | r.isVisible = true 79 | 80 | for i, cell := range row { 81 | c := &Cell{ 82 | data: cell, 83 | matchBegin: -1, 84 | matchEnd: -1, 85 | } 86 | r.cols = append(r.cols, *c) 87 | 88 | if t.maxLen[i] < len(cell) { 89 | t.maxLen[i] = len(cell) 90 | } 91 | } 92 | t.contents = append(t.contents, r) 93 | } 94 | 95 | return t 96 | } 97 | 98 | // Return true and position if the given name is in col names 99 | func (t *Table) FindColName(name string) (int, bool) { 100 | for i, c := range t.header.cols { 101 | if c.data == name { 102 | return i, true 103 | } 104 | } 105 | 106 | return -1, false 107 | } 108 | 109 | func (t *Table) NEnabledCols() int { 110 | return len(t.header.cols) - len(t.disabledCols) 111 | } 112 | 113 | func (t *Table) IsColEnabled(colNum int) bool { 114 | for _, n := range t.disabledCols { 115 | if n == colNum { 116 | return false 117 | } 118 | } 119 | 120 | return true 121 | } 122 | 123 | // Add the idx'th column to disabled column list. 124 | // That is, make it invisible 125 | func (t *Table) AddDisabledCol(idx int) { 126 | t.disabledCols = append(t.disabledCols, idx) 127 | } 128 | 129 | // Remove the idx'th column from disabled column list. 130 | // That is, make it visible 131 | func (t *Table) RemoveDisabledCol(idx int) { 132 | res := []int{} 133 | 134 | for _, c := range t.disabledCols { 135 | if c != idx { 136 | res = append(res, c) 137 | } 138 | } 139 | 140 | t.disabledCols = res 141 | } 142 | 143 | func (t *Table) SetRowVisibility(rowIdx int, visible bool) { 144 | t.contents[rowIdx].isVisible = visible 145 | } 146 | 147 | func (t *Table) SetMatched(r int, c int, b int, e int) { 148 | t.contents[r].cols[c].matchBegin = b 149 | t.contents[r].cols[c].matchEnd = e 150 | } 151 | 152 | func (t *Table) ResetDisabledCol() { 153 | t.disabledCols = []int{} 154 | } 155 | 156 | func (t *Table) ResetVisibility() { 157 | for i := 0; i < len(t.contents); i++ { 158 | t.contents[i].isVisible = true 159 | } 160 | } 161 | 162 | // Return the total size of visible cols. Note that c.width is the size 163 | // of formatted cell, i.e. includes padding. 164 | func (t *Table) computeWidth() (width int) { 165 | width = 0 166 | for i, c := range t.header.cols { 167 | // Skip until offset 168 | if i < t.offsetCol { 169 | continue 170 | } 171 | 172 | // Skip if this col is disabled 173 | if !t.IsColEnabled(i) { 174 | continue 175 | } 176 | 177 | width += c.width 178 | } 179 | 180 | return width 181 | } 182 | 183 | // Return the total size of visible rows. 184 | func (t *Table) computeHeight() (height int) { 185 | height = 0 186 | for i, r := range t.contents { 187 | // Skip until offset 188 | if i < t.offsetRow { 189 | continue 190 | } 191 | 192 | if !r.isVisible { 193 | continue 194 | } 195 | 196 | height++ 197 | } 198 | 199 | // always include both header and line 200 | return height + 2 201 | } 202 | 203 | // 0: up, 1: right, 2:down, 3: left 204 | func (t *Table) isMovable(direction int) bool { 205 | maxX, maxY := GetMaxXY() 206 | x := t.computeWidth() 207 | y := t.computeHeight() 208 | 209 | if x >= maxX && direction == 1 { 210 | // Movable to right and want to move right 211 | return true 212 | } else if t.offsetCol > 0 && direction == 3 { 213 | // Viewing right part and want to move left 214 | return true 215 | } else if y > maxY && direction == 2 { 216 | // Movable to down ana want ot move down 217 | return true 218 | } else if t.offsetRow > 0 && direction == 0 { 219 | // Viewing bottom part and want to move up 220 | return true 221 | } 222 | return false 223 | } 224 | 225 | func (t *Table) SetOffsetRow(r int) { 226 | t.offsetRow = r 227 | } 228 | 229 | func (t *Table) MoveRight(move int) { 230 | if !t.isMovable(1) { 231 | return 232 | } 233 | 234 | t.offsetCol += move 235 | 236 | if t.offsetCol > len(t.header.cols) { 237 | t.offsetRow = len(t.header.cols) 238 | } 239 | } 240 | 241 | func (t *Table) MoveLeft(move int) { 242 | if !t.isMovable(3) { 243 | return 244 | } 245 | 246 | t.offsetCol -= move 247 | 248 | if t.offsetCol < 0 { 249 | t.offsetCol = 0 250 | } 251 | } 252 | 253 | func (t *Table) MoveUp(move int) { 254 | if !t.isMovable(0) { 255 | return 256 | } 257 | 258 | // Skip invisible rows 259 | for i := t.offsetRow; !t.contents[i].isVisible && i > 0; i-- { 260 | if !t.contents[i].isVisible { 261 | move++ 262 | } 263 | } 264 | t.offsetRow -= move 265 | 266 | if t.offsetRow < 0 { 267 | t.offsetRow = 0 268 | } 269 | } 270 | 271 | func (t *Table) MoveDown(move int) { 272 | if !t.isMovable(2) { 273 | return 274 | } 275 | 276 | // Skip invisible rows 277 | for i := t.offsetRow; !t.contents[i].isVisible && i < len(t.contents); i++ { 278 | if !t.contents[i].isVisible { 279 | move++ 280 | } 281 | } 282 | t.offsetRow += move 283 | 284 | if t.offsetRow > len(t.contents) { 285 | t.offsetRow = len(t.contents) 286 | } 287 | } 288 | 289 | func (t *Table) Debugdump() { 290 | fmt.Println("---- Header ----") 291 | for _, cell := range t.header.cols { 292 | fmt.Print(cell.data, " ") 293 | } 294 | fmt.Println("") 295 | fmt.Println("---- Contents -----------") 296 | for _, row := range t.contents { 297 | for _, cell := range row.cols { 298 | fmt.Print(cell.data, " ") 299 | } 300 | fmt.Println("") 301 | } 302 | 303 | fmt.Println("maxLen: ", t.maxLen) 304 | } 305 | -------------------------------------------------------------------------------- /terminal.go: -------------------------------------------------------------------------------- 1 | package civ 2 | 3 | import ( 4 | "fmt" 5 | runewidth "github.com/mattn/go-runewidth" 6 | termbox "github.com/nsf/termbox-go" 7 | ) 8 | 9 | type Terminal struct { 10 | prompt string 11 | } 12 | 13 | func NewTerminal() *Terminal { 14 | t := &Terminal{ 15 | prompt: ":", 16 | } 17 | 18 | return t 19 | } 20 | 21 | func (t *Terminal) Draw(ql *QueryLine, tb *Table) error { 22 | termbox.Clear(termbox.ColorDefault, termbox.ColorDefault) 23 | 24 | drawQueryLine(ql) 25 | 26 | drawTable(tb) 27 | 28 | termbox.Flush() 29 | 30 | return nil 31 | } 32 | 33 | // This function creates a list of termbox.Cell from rowIdx'th row of 34 | // the table. 35 | func formatRow(tb *Table, rowIdx int, isHeader bool) []termbox.Cell { 36 | var cells []termbox.Cell 37 | var row Row 38 | 39 | if isHeader { 40 | row = tb.header 41 | } else { 42 | row = tb.contents[rowIdx] 43 | } 44 | 45 | nWritten := 0 46 | for i, cell := range row.cols { 47 | field := formatCell(cell, tb.maxLen[i], isHeader) 48 | 49 | if isHeader { 50 | // Set length of formatted column including a delimiter 51 | tb.header.cols[i].width = len(field) + 1 52 | } 53 | 54 | // Skip if this col is disabled 55 | if !tb.IsColEnabled(i) { 56 | continue 57 | } 58 | 59 | // Skip until offset 60 | if i < tb.offsetCol { 61 | continue 62 | } 63 | 64 | cells = append(cells, field...) 65 | nWritten++ 66 | 67 | // Don't need delimiter after the last column 68 | if nWritten != tb.NEnabledCols() { 69 | cells = append(cells, termbox.Cell{ 70 | Ch: '|', 71 | Fg: termbox.ColorDefault, 72 | Bg: termbox.ColorDefault, 73 | }) 74 | } 75 | } 76 | 77 | return cells 78 | } 79 | 80 | func formatCell(cell Cell, maxSize int, isHeader bool) []termbox.Cell { 81 | var tcells []termbox.Cell 82 | var beforeSpaces int 83 | var afterSpaces int 84 | 85 | if isHeader { 86 | beforeSpaces = ((maxSize + 2) - len(cell.data) + 1) / 2 87 | afterSpaces = maxSize + 2 - len(cell.data) - beforeSpaces 88 | } else { 89 | beforeSpaces = 1 90 | afterSpaces = ((maxSize + 1) - len(cell.data)) 91 | } 92 | 93 | // pad before data by space 94 | tcells = padSpaces(tcells, beforeSpaces) 95 | 96 | // Prepare termbox cells for table's cells 97 | for i, r := range cell.data { 98 | // Determine cell color 99 | fg := termbox.ColorDefault 100 | bg := termbox.ColorDefault 101 | 102 | if cell.matchBegin != -1 && 103 | cell.matchBegin <= i && 104 | cell.matchEnd > i { 105 | fg = termbox.ColorBlack | termbox.AttrBold 106 | bg = termbox.ColorCyan | termbox.AttrBold 107 | } 108 | 109 | tcells = append(tcells, termbox.Cell{ 110 | Ch: r, 111 | Fg: fg, 112 | Bg: bg, 113 | }) 114 | } 115 | 116 | // pad after data by space 117 | tcells = padSpaces(tcells, afterSpaces) 118 | 119 | return tcells 120 | } 121 | 122 | func drawTable(tb *Table) { 123 | var lineCells []termbox.Cell 124 | 125 | // draw header cells 126 | headerCells := formatRow(tb, 0, true) 127 | 128 | if tb.outputStdout { 129 | outputCells(headerCells) 130 | } else { 131 | drawCells(0, 1, headerCells) 132 | } 133 | 134 | // draw line cells 135 | for _, c := range headerCells { 136 | r := '-' 137 | if c.Ch == '|' { 138 | r = '+' 139 | } 140 | lineCells = append(lineCells, termbox.Cell{ 141 | Ch: r, 142 | Fg: termbox.ColorDefault, 143 | Bg: termbox.ColorDefault, 144 | }) 145 | } 146 | if tb.outputStdout { 147 | outputCells(lineCells) 148 | } else { 149 | drawCells(0, 2, lineCells) 150 | } 151 | 152 | // draw table 153 | nRows := 0 154 | for i, r := range tb.contents { 155 | // Skip invisible row 156 | if !r.isVisible { 157 | continue 158 | } 159 | // Skip until offset 160 | if i < tb.offsetRow { 161 | continue 162 | } 163 | 164 | cells := formatRow(tb, i, false) 165 | 166 | // Remember the length of row 167 | if tb.outputStdout { 168 | outputCells(cells) 169 | } else { 170 | drawCells(0, 3+nRows, cells) 171 | } 172 | nRows++ 173 | } 174 | } 175 | 176 | // Return the padded cell 177 | func padSpaces(cells []termbox.Cell, nSpaces int) []termbox.Cell { 178 | // Add spaces before data 179 | for _i := 0; _i < nSpaces; _i++ { 180 | cells = append(cells, termbox.Cell{ 181 | Ch: ' ', 182 | Fg: termbox.ColorDefault, 183 | Bg: termbox.ColorDefault, 184 | }) 185 | } 186 | 187 | return cells 188 | } 189 | 190 | // Function to draw both prompt and query line 191 | func drawQueryLine(ql *QueryLine) { 192 | var cells []termbox.Cell 193 | 194 | if ql == nil { 195 | return 196 | } 197 | 198 | // Print prefix 199 | modeRune := ':' 200 | if ql.mode == MODE_SEARCH { 201 | modeRune = '/' 202 | } else if ql.mode == MODE_COMMAND { 203 | modeRune = '@' 204 | } else if ql.mode == MODE_FILTER { 205 | modeRune = '^' 206 | } else if ql.mode == MODE_VIEW { 207 | modeRune = ':' 208 | } 209 | cells = append(cells, termbox.Cell{ 210 | Ch: modeRune, 211 | Fg: termbox.ColorDefault, 212 | Bg: termbox.ColorDefault, 213 | }) 214 | 215 | // Print query line 216 | for _, r := range ql.query { 217 | cells = append(cells, termbox.Cell{ 218 | Ch: r, 219 | Fg: termbox.ColorDefault, 220 | Bg: termbox.ColorDefault, 221 | }) 222 | } 223 | 224 | drawCells(0, 0, cells) 225 | termbox.SetCursor(1+ql.curCursor, 0) 226 | } 227 | 228 | // Actual drawing the given cells to the terminal 229 | func drawCells(x int, y int, cells []termbox.Cell) { 230 | i := 0 231 | maxX, _ := termbox.Size() 232 | 233 | for _, c := range cells { 234 | if i >= maxX-2 { 235 | // Before reaching the right edge we show '.' 236 | // instead of data to indicate that the data 237 | // is continuing, like 'hel..'. 238 | termbox.SetCell(x+i, y, rune('.'), c.Fg, c.Bg) 239 | } else { 240 | termbox.SetCell(x+i, y, c.Ch, c.Fg, c.Bg) 241 | } 242 | 243 | w := runewidth.RuneWidth(c.Ch) 244 | if w == 0 || w == 2 && runewidth.IsAmbiguousWidth(c.Ch) { 245 | w = 1 246 | } 247 | 248 | i += w 249 | } 250 | } 251 | 252 | func GetMaxXY() (maxX int, maxY int) { 253 | return termbox.Size() 254 | 255 | } 256 | 257 | func outputCells(cells []termbox.Cell) { 258 | for _, c := range cells { 259 | fmt.Printf("%s", string(c.Ch)) 260 | } 261 | fmt.Println("") 262 | } 263 | --------------------------------------------------------------------------------