├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── CHANGELOG.txt ├── LICENSE ├── README.md ├── database ├── query.go └── sqlite.go ├── go.mod ├── go.sum ├── list ├── defaultitem.go ├── keys.go ├── list.go └── style.go ├── main.go ├── tuiutil ├── csv2sql.go ├── textinput.go ├── theme.go └── wordwrap.go └── viewer ├── defs.go ├── events.go ├── global.go ├── help.go ├── lineedit.go ├── mode.go ├── modelutil.go ├── serialize.go ├── snippets.go ├── table.go ├── tableutil.go ├── ui.go ├── util.go └── viewer.go /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '38 18 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sh 2 | .idea/* 3 | .termdbms 4 | build/ -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | - MYSQL Support 3 | - Database creation tools 4 | 5 | ##[1.0-alpha] 6 | ### Added 7 | - Added ability to remove snippets 8 | - Line wrapping + horizontal scroll! 9 | 10 | ### Changed 11 | - Unified help text display logic with selection display logic 12 | 13 | ##[0.7-alpha] - 10-4-2021 14 | ### Added 15 | - SQL querying 16 | - Clipboard for SQL queries 17 | 18 | ### Changed 19 | - Refactored codebase to be easier to reason with and manage 20 | - Many bug fixes I forgot about 21 | 22 | This changelog was started with 0.7-alpha, so the details before this are a little hazy. But here's to better transparency going forward! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Matt Farstad 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # termdbms 2 | 3 | ## A TUI for viewing and editing databases, written in pure Go 4 | 5 | #### Installation Instructions 6 | 7 | ###### Go Install 8 | 9 | ```go 10 | go install github.com/mathaou/termdbms@latest 11 | ``` 12 | 13 | ###### Arch Linux 14 | 15 | ```bash 16 | // pacman 17 | sudo pacman -S termdbms-git 18 | // yay 19 | yay -S termdbms-git 20 | ``` 21 | 22 | --- 23 | 24 | ###### Database Support 25 | SQLite 26 | CSV* (see note below) 27 | ### made with modernc.org/sqlite, charmbracelet/bubbletea, and charmbracelet/lipgloss 28 | 29 | #### Works with keyboard and mouse! 30 | 31 | ![Keyboard Control](https://i.imgur.com/vmK0DVn.gif) 32 | 33 | #### Navigate tables with any number of columns! 34 | 35 | ![Columns and Tables](https://i.imgur.com/EqZRPqO.gif) 36 | 37 | #### Navigate tables with any number of rows! 38 | 39 | ![Lot of Rows](https://i.imgur.com/yo7DMaa.gif) 40 | 41 | #### Serialize your changes as a copy or over the database original! (SQLite only) 42 | 43 | ![Serialization](https://i.imgur.com/GhMcnid.gif) 44 | 45 | #### Query your database! 46 | 47 | ![querying](https://i.imgur.com/9FB3ETs.gif) 48 | 49 | #### Other Features 50 | 51 | - Run SQL queries and display the results! 52 | - Save SQL queries to a clipboard! 53 | - Update, delete, or insert with SQL, with undo/redo supported for SQLite 54 | - Automatic JSON formatting in selection/format mode 55 | - Edit multi-line text with vim-like controls 56 | - Undo/Redo of changes (SQLite only) 57 | - Themes (press T in table mode) 58 | - Output query results as a csv 59 | - Convert .csv to SQLite database! Export as a SQLite database or .csv file again! 60 | 61 | #### Roadmap 62 | 63 | - MySQL/ PostgreSQL support 64 | - Line wrapping / horizontal scroll for format/SQL mode + revamped (faster format mode) 65 | 66 | #### 67 |
68 | How To Build 69 | 70 | ##### Linux 71 | 72 | GOOS=linux GOARCH=amd64/386 go build 73 | 74 | ##### ARM (runs kind of slow depending on the specs of the system) 75 | 76 | GOOS=linux GOARCH=arm GOARM=7 go build 77 | 78 | ##### Windows 79 | 80 | GOOS=windows GOARCH=amd64/386 go build 81 | 82 | ##### OSX 83 | 84 | GOOS=darwin GOARCH=amd64 go build 85 | 86 |
87 | 88 | #### Terminal settings 89 | Whatever terminal emulator used should support ANSI escape sequences. If there is an option for 256 color mode, enable it. If not available, try running program in ascii mode (-a). 90 | 91 | #### Known Issues 92 | - Using termdbms over a serial connection works very poorly. This is due to ANSI sequences not being supported natively. Maybe putty/mobaxterm have settings to allow this? 93 | - The headers wig out sometimes in selection mode 94 | - Line wrapping is not yet implemented, so text in format mode should be less than the maximum number of columns available per line for best use. It's in the works! 95 | - Weird combinations of newlines + tabs can break stuff. Tabs at beginning of line and mid-line works in a stable manner. 96 | 97 | ##### Help: 98 | -p / database/.csv path 99 | -d / specifies which database driver to use (sqlite/mysql) 100 | -a / enable ascii mode 101 | -h / prints this message 102 | -t / starts app with specific theme (default, nord, solarized) 103 | ##### Controls: 104 | ###### MOUSE 105 | Scroll up + down to navigate table/text 106 | Move cursor to select cells for full screen viewing 107 | ###### KEYBOARD 108 | [WASD] to move around cells, and also move columns if close to edge 109 | [ENTER] to select selected cell for full screen view 110 | [UP/K and DOWN/J] to navigate schemas 111 | [LEFT/H and RIGHT/L] to navigate columns if there are more than the screen allows. 112 | Also to control the cursor of the text editor in edit mode 113 | [BACKSPACE] to delete text before cursor in edit mode 114 | [M(scroll up) and N(scroll down)] to scroll manually 115 | [Q or CTRL+C] to quit program 116 | [B] to toggle borders! 117 | [C] to expand column 118 | [T] to cycle through themes! 119 | [P] in selection mode to write cell to file, or to print query results as CSV. 120 | [R] to redo actions, if applicable 121 | [U] to undo actions, if applicable 122 | [ESC] to exit full screen view, or to enter edit mode 123 | [PGDOWN] to scroll down one views worth of rows 124 | [PGUP] to scroll up one views worth of rows 125 | ###### EDIT MODE (for quick, single line changes and commands) 126 | [ESC] to enter edit mode with no pre-loaded text input from selection 127 | When a cell is selected, press [:] to enter edit mode with selection pre-loaded 128 | The text field in the header will be populated with the selected cells text. Modifications can be made freely 129 | [ESC] to clear text field in edit mode 130 | [ENTER] to save text. Anything besides one of the reserved strings below will overwrite the current cell 131 | [:q] to exit edit mode/ format mode/ SQL mode 132 | [:s] to save database to a new file (SQLite only) 133 | [:s!] to overwrite original database file (SQLite only). A confirmation dialog will be added soon 134 | [:h] to display help text 135 | [:new] opens current cell with a blank buffer 136 | [:edit] opens current cell in format mode 137 | [:sql] opens blank buffer for creating an SQL statement 138 | [:clip] to open clipboard of SQL queries. [/] to filter, [ENTER] to select. 139 | [HOME] to set cursor to end of the text 140 | [END] to set cursor to the end of the text 141 | ###### FORMAT MODE (for editing lines of text) 142 | [ESC] to move between top control bar and format buffer 143 | [HOME] to set cursor to end of the text 144 | [END] to set cursor to the end of the text 145 | [:wq] to save changes and quit to main table view 146 | [:w] to save changes and remain in format view 147 | [:s] to serialize changes, non-destructive (SQLite only) 148 | [:s!] to serialize changes, overwriting original file (SQLite only) 149 | ###### SQL MODE (for querying database) 150 | [ESC] to move between top control bar and text buffer 151 | [:q] to quit out of statement 152 | [:exec] to execute statement. Errors will be displayed in full screen view. 153 | [:stow ] to create a snippet for the clipboard with an optional name. A random number will be used if no name is specified. 154 | ###### QUERY MODE (specifically when viewing query results) 155 | [:d] to reset table data back to original view 156 | [:sql] to query original database again 157 | -------------------------------------------------------------------------------- /database/query.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "sync" 6 | ) 7 | 8 | var ( 9 | DBMutex sync.Mutex 10 | Databases map[string]*sql.DB 11 | DriverString string 12 | IsCSV bool 13 | ) 14 | 15 | func init() { 16 | // We keep one connection pool per database. 17 | DBMutex = sync.Mutex{} 18 | Databases = make(map[string]*sql.DB) 19 | } 20 | 21 | type Query interface { 22 | GetValues() map[string]interface{} 23 | SetValues(map[string]interface{}) 24 | } 25 | 26 | type Database interface { 27 | Update(q *Update) 28 | GenerateQuery(u *Update) (string, []string) 29 | GetPlaceholderForDatabaseType() string 30 | GetFileName() string 31 | GetTableNamesQuery() string 32 | GetDatabaseReference() *sql.DB 33 | CloseDatabaseReference() 34 | SetDatabaseReference(dbPath string) 35 | } 36 | 37 | type Update struct { 38 | v map[string]interface{} // these are anchors to ensure the right row/col gets updated 39 | Column string // this is the header 40 | Update interface{} // this is the new cell value 41 | TableName string 42 | } 43 | 44 | func (u *Update) GetValues() map[string]interface{} { 45 | return u.v 46 | } 47 | 48 | func (u *Update) SetValues(v map[string]interface{}) { 49 | u.v = v 50 | } 51 | 52 | // GetDatabaseForFile does what you think it does 53 | func GetDatabaseForFile(database string) *sql.DB { 54 | DBMutex.Lock() 55 | defer DBMutex.Unlock() 56 | if db, ok := Databases[database]; ok { 57 | return db 58 | } 59 | db, err := sql.Open(DriverString, database) 60 | if err != nil { 61 | panic(err) 62 | } 63 | Databases[database] = db 64 | return db 65 | } 66 | 67 | func ProcessSqlQueryForDatabaseType(q Query, rowData map[string]interface{}, schemaName, columnName string, db *Database) { 68 | switch conv := q.(type) { 69 | case *Update: 70 | conv.SetValues(rowData) 71 | conv.TableName = schemaName 72 | conv.Column = columnName 73 | (*db).Update(conv) 74 | break 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /database/sqlite.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "strings" 8 | ) 9 | 10 | type SQLite struct { 11 | FileName string 12 | Database *sql.DB 13 | } 14 | 15 | func (db *SQLite) Update(q *Update) { 16 | protoQuery, columnOrder := db.GenerateQuery(q) 17 | values := make([]interface{}, len(columnOrder)) 18 | updateValues := q.GetValues() 19 | for i, v := range columnOrder { 20 | var u interface{} 21 | if i == 0 { 22 | u = q.Update 23 | } else { 24 | u = updateValues[v] 25 | } 26 | 27 | if u == nil { 28 | u = "NULL" 29 | } 30 | 31 | values[i] = u 32 | } 33 | tx, err := db.GetDatabaseReference().Begin() 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | stmt, err := tx.Prepare(protoQuery) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | defer stmt.Close() 42 | stmt.Exec(values...) 43 | err = tx.Commit() 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | } 48 | 49 | func (db *SQLite) GetFileName() string { 50 | return db.FileName 51 | } 52 | 53 | func (db *SQLite) GetDatabaseReference() *sql.DB { 54 | return db.Database 55 | } 56 | 57 | func (db *SQLite) CloseDatabaseReference() { 58 | db.GetDatabaseReference().Close() 59 | db.Database = nil 60 | } 61 | 62 | func (db *SQLite) SetDatabaseReference(dbPath string) { 63 | database := GetDatabaseForFile(dbPath) 64 | db.FileName = dbPath 65 | db.Database = database 66 | } 67 | 68 | func (db SQLite) GetPlaceholderForDatabaseType() string { 69 | return "?" 70 | } 71 | 72 | func (db SQLite) GetTableNamesQuery() string { 73 | val := "SELECT name FROM " 74 | val += "sqlite_master" 75 | val += " WHERE type='table'" 76 | 77 | return val 78 | } 79 | 80 | func (db *SQLite) GenerateQuery(u *Update) (string, []string) { 81 | var ( 82 | query string 83 | querySkeleton string 84 | valueOrder []string 85 | ) 86 | 87 | placeholder := db.GetPlaceholderForDatabaseType() 88 | 89 | querySkeleton = fmt.Sprintf("UPDATE %s"+ 90 | " SET %s=%s ", u.TableName, u.Column, placeholder) 91 | valueOrder = append(valueOrder, u.Column) 92 | 93 | whereBuilder := strings.Builder{} 94 | whereBuilder.WriteString(" WHERE ") 95 | uLen := len(u.GetValues()) 96 | i := 0 97 | for k := range u.GetValues() { // keep track of order since maps aren't deterministic 98 | assertion := fmt.Sprintf("%s=%s ", k, placeholder) 99 | valueOrder = append(valueOrder, k) 100 | whereBuilder.WriteString(assertion) 101 | if uLen > 1 && i < uLen-1 { 102 | whereBuilder.WriteString("AND ") 103 | } 104 | i++ 105 | } 106 | query = querySkeleton + strings.TrimSpace(whereBuilder.String()) + ";" 107 | return query, valueOrder 108 | } 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mathaou/termdbms 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/atotto/clipboard v0.1.2 7 | github.com/charmbracelet/bubbles v0.9.0 8 | github.com/charmbracelet/bubbletea v0.18.0 9 | github.com/charmbracelet/lipgloss v0.4.0 10 | github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74 // indirect 11 | github.com/mattn/go-runewidth v0.0.13 12 | github.com/muesli/reflow v0.3.0 13 | github.com/muesli/termenv v0.9.0 14 | github.com/sahilm/fuzzy v0.1.0 15 | modernc.org/sqlite v1.13.0 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= 2 | github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/charmbracelet/bubbles v0.9.0 h1:lqJ8FXwoLceQF2J0A+dWo1Cuu1dNyjbW4Opgdi2vkhw= 4 | github.com/charmbracelet/bubbles v0.9.0/go.mod h1:NWT/c+0rYEnYChz5qCyX4Lj6fDw9gGToh9EFJPajghU= 5 | github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE= 6 | github.com/charmbracelet/bubbletea v0.18.0 h1:v9JrrWADDZ5Tk5DV8Rj3MIiqhrrk33RIGBUnYvZsQbc= 7 | github.com/charmbracelet/bubbletea v0.18.0/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= 8 | github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 9 | github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk= 10 | github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= 11 | github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= 12 | github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= 13 | github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE= 14 | github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= 15 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 16 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 17 | github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo= 18 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 19 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 20 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 21 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 22 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 23 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 24 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 25 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 26 | github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 27 | github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74 h1:3kEhu34+VLPo2YgQ1PXHLQRgMQKtBmq+MmMYEBHGX7U= 28 | github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 29 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 30 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 31 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 32 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 33 | github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU= 34 | github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 35 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 36 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 37 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 38 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 39 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 40 | github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= 41 | github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= 42 | github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= 43 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 44 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 45 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= 46 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 47 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 48 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 49 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 50 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 51 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 52 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 53 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 54 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 55 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 56 | golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= 57 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 58 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 59 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 60 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 61 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 62 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 63 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 64 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 65 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 66 | golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 67 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 68 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 69 | golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 70 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 71 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 72 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/sys v0.0.0-20210902050250-f475640dd07b h1:S7hKs0Flbq0bbc9xgYt4stIEG1zNDFqyrPwAX2Wj/sE= 74 | golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= 76 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 77 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 78 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 79 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 80 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 81 | golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs= 82 | golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 83 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 84 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 85 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 86 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 87 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 88 | lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU= 89 | lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= 90 | modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= 91 | modernc.org/cc/v3 v3.33.9/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= 92 | modernc.org/cc/v3 v3.33.11/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= 93 | modernc.org/cc/v3 v3.34.0 h1:dFhZc/HKR3qp92sYQxKRRaDMz+sr1bwcFD+m7LSCrAs= 94 | modernc.org/cc/v3 v3.34.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= 95 | modernc.org/ccgo/v3 v3.9.5/go.mod h1:umuo2EP2oDSBnD3ckjaVUXMrmeAw8C8OSICVa0iFf60= 96 | modernc.org/ccgo/v3 v3.10.0/go.mod h1:c0yBmkRFi7uW4J7fwx/JiijwOjeAeR2NoSaRVFPmjMw= 97 | modernc.org/ccgo/v3 v3.11.0/go.mod h1:dGNposbDp9TOZ/1KBxghxtUp/bzErD0/0QW4hhSaBMI= 98 | modernc.org/ccgo/v3 v3.11.1/go.mod h1:lWHxfsn13L3f7hgGsGlU28D9eUOf6y3ZYHKoPaKU0ag= 99 | modernc.org/ccgo/v3 v3.11.2 h1:gqa8PQ2v7SjrhHCgxUO5dzoAJWSLAveJqZTNkPCN0kc= 100 | modernc.org/ccgo/v3 v3.11.2/go.mod h1:6kii3AptTDI+nUrM9RFBoIEUEisSWCbdczD9ZwQH2FE= 101 | modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= 102 | modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= 103 | modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w= 104 | modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q= 105 | modernc.org/libc v1.11.0/go.mod h1:2lOfPmj7cz+g1MrPNmX65QCzVxgNq2C5o0jdLY2gAYg= 106 | modernc.org/libc v1.11.2/go.mod h1:ioIyrl3ETkugDO3SGZ+6EOKvlP3zSOycUETe4XM4n8M= 107 | modernc.org/libc v1.11.3 h1:q//spBhqp23lC/if8/o8hlyET57P8mCZqrqftzT2WmY= 108 | modernc.org/libc v1.11.3/go.mod h1:k3HDCP95A6U111Q5TmG3nAyUcp3kR5YFZTeDS9v8vSU= 109 | modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 110 | modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 111 | modernc.org/mathutil v1.4.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 112 | modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8= 113 | modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 114 | modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc= 115 | modernc.org/memory v1.0.5 h1:XRch8trV7GgvTec2i7jc33YlUI0RKVDBvZ5eZ5m8y14= 116 | modernc.org/memory v1.0.5/go.mod h1:B7OYswTRnfGg+4tDH1t1OeUNnsy2viGTdME4tzd+IjM= 117 | modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A= 118 | modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 119 | modernc.org/sqlite v1.13.0 h1:cwhUj0jTBgPjk/demWheV+T6xi6ifTfsGIFKFq0g3Ck= 120 | modernc.org/sqlite v1.13.0/go.mod h1:2qO/6jZJrcQaxFUHxOwa6Q6WfiGSsiVj6GXX0Ker+Jg= 121 | modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs= 122 | modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= 123 | modernc.org/tcl v1.5.9 h1:DZMfR+RDJRhcrmMEMTJgVIX+Wf5qhfVX0llI0rsc20w= 124 | modernc.org/tcl v1.5.9/go.mod h1:bcwjvBJ2u0exY6K35eAmxXBBij5kXb1dHlAWmfhqThE= 125 | modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= 126 | modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 127 | modernc.org/z v1.1.2 h1:IjjzDsIFbl0wuF2KfwvdyUAJVwxD4iwZ6akLNiDoClM= 128 | modernc.org/z v1.1.2/go.mod h1:sj9T1AGBG0dm6SCVzldPOHWrif6XBpooJtbttMn1+Js= 129 | -------------------------------------------------------------------------------- /list/defaultitem.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/muesli/reflow/truncate" 11 | ) 12 | 13 | // DefaultItemStyles defines styling for a default list item. 14 | // See DefaultItemView for when these come into play. 15 | type DefaultItemStyles struct { 16 | // The Normal state. 17 | NormalTitle lipgloss.Style 18 | NormalDesc lipgloss.Style 19 | 20 | // The selected item state. 21 | SelectedTitle lipgloss.Style 22 | SelectedDesc lipgloss.Style 23 | 24 | // The dimmed state, for when the filter input is initially activated. 25 | DimmedTitle lipgloss.Style 26 | DimmedDesc lipgloss.Style 27 | 28 | // Charcters matching the current filter, if any. 29 | FilterMatch lipgloss.Style 30 | } 31 | 32 | // NewDefaultItemStyles returns style definitions for a default item. See 33 | // DefaultItemView for when these come into play. 34 | func NewDefaultItemStyles() (s DefaultItemStyles) { 35 | s.NormalTitle = lipgloss.NewStyle(). 36 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}). 37 | Padding(0, 0, 0, 2) 38 | 39 | s.NormalDesc = s.NormalTitle.Copy(). 40 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}) 41 | 42 | s.SelectedTitle = lipgloss.NewStyle(). 43 | Border(lipgloss.NormalBorder(), false, false, false, true). 44 | BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}). 45 | Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}). 46 | Padding(0, 0, 0, 1) 47 | 48 | s.SelectedDesc = s.SelectedTitle.Copy(). 49 | Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}) 50 | 51 | s.DimmedTitle = lipgloss.NewStyle(). 52 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). 53 | Padding(0, 0, 0, 2) 54 | 55 | s.DimmedDesc = s.DimmedTitle.Copy(). 56 | Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}) 57 | 58 | s.FilterMatch = lipgloss.NewStyle().Underline(true) 59 | 60 | return s 61 | } 62 | 63 | // DefaultItem describes an items designed to work with DefaultDelegate. 64 | type DefaultItem interface { 65 | Item 66 | Title() string 67 | Description() string 68 | } 69 | 70 | // DefaultDelegate is a standard delegate designed to work in lists. It's 71 | // styled by DefaultItemStyles, which can be customized as you like. 72 | // 73 | // The description line can be hidden by setting Description to false, which 74 | // renders the list as single-line-items. The spacing between items can be set 75 | // with the SetSpacing method. 76 | // 77 | // Setting UpdateFunc is optional. If it's set it will be called when the 78 | // ItemDelegate called, which is called when the list's Update function is 79 | // invoked. 80 | // 81 | // Settings ShortHelpFunc and FullHelpFunc is optional. They can can be set to 82 | // include items in the list's default short and full help menus. 83 | type DefaultDelegate struct { 84 | ShowDescription bool 85 | Styles DefaultItemStyles 86 | UpdateFunc func(tea.Msg, *Model) tea.Cmd 87 | ShortHelpFunc func() []key.Binding 88 | FullHelpFunc func() [][]key.Binding 89 | spacing int 90 | } 91 | 92 | // NewDefaultDelegate creates a new delegate with default styles. 93 | func NewDefaultDelegate() DefaultDelegate { 94 | return DefaultDelegate{ 95 | ShowDescription: true, 96 | Styles: NewDefaultItemStyles(), 97 | spacing: 1, 98 | } 99 | } 100 | 101 | // Height returns the delegate's preferred height. 102 | func (d DefaultDelegate) Height() int { 103 | if d.ShowDescription { 104 | return 2 //nolint:gomnd 105 | } 106 | return 1 107 | } 108 | 109 | // SetSpacing set the delegate's spacing. 110 | func (d *DefaultDelegate) SetSpacing(i int) { 111 | d.spacing = i 112 | } 113 | 114 | // Spacing returns the delegate's spacing. 115 | func (d DefaultDelegate) Spacing() int { 116 | return d.spacing 117 | } 118 | 119 | // Update checks whether the delegate's UpdateFunc is set and calls it. 120 | func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd { 121 | if d.UpdateFunc == nil { 122 | return nil 123 | } 124 | return d.UpdateFunc(msg, m) 125 | } 126 | 127 | // Render prints an item. 128 | func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) { 129 | var ( 130 | title, desc string 131 | matchedRunes []int 132 | s = &d.Styles 133 | ) 134 | 135 | if i, ok := item.(DefaultItem); ok { 136 | title = i.Title() 137 | desc = i.Description() 138 | } else { 139 | return 140 | } 141 | 142 | // Prevent text from exceeding list width 143 | if m.width > 0 { 144 | textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight()) 145 | title = truncate.StringWithTail(title, textwidth, ellipsis) 146 | desc = truncate.StringWithTail(desc, textwidth, ellipsis) 147 | } 148 | 149 | // Conditions 150 | var ( 151 | isSelected = index == m.Index() 152 | emptyFilter = m.FilterState() == Filtering && m.FilterValue() == "" 153 | isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied 154 | ) 155 | 156 | if isFiltered && index < len(m.filteredItems) { 157 | // Get indices of matched characters 158 | matchedRunes = m.MatchesForItem(index) 159 | } 160 | 161 | if emptyFilter { 162 | title = s.DimmedTitle.Render(title) 163 | desc = s.DimmedDesc.Render(desc) 164 | } else if isSelected && m.FilterState() != Filtering { 165 | if isFiltered { 166 | // Highlight matches 167 | unmatched := s.SelectedTitle.Inline(true) 168 | matched := unmatched.Copy().Inherit(s.FilterMatch) 169 | title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) 170 | } 171 | title = s.SelectedTitle.Render(title) 172 | desc = s.SelectedDesc.Render(desc) 173 | } else { 174 | if isFiltered { 175 | // Highlight matches 176 | unmatched := s.NormalTitle.Inline(true) 177 | matched := unmatched.Copy().Inherit(s.FilterMatch) 178 | title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) 179 | } 180 | title = s.NormalTitle.Render(title) 181 | desc = s.NormalDesc.Render(desc) 182 | } 183 | 184 | if d.ShowDescription { 185 | fmt.Fprintf(w, "%s\n%s", title, desc) 186 | return 187 | } 188 | fmt.Fprintf(w, "%s", title) 189 | } 190 | 191 | // ShortHelp returns the delegate's short help. 192 | func (d DefaultDelegate) ShortHelp() []key.Binding { 193 | if d.ShortHelpFunc != nil { 194 | return d.ShortHelpFunc() 195 | } 196 | return nil 197 | } 198 | 199 | // FullHelp returns the delegate's full help. 200 | func (d DefaultDelegate) FullHelp() [][]key.Binding { 201 | if d.FullHelpFunc != nil { 202 | return d.FullHelpFunc() 203 | } 204 | return nil 205 | } 206 | -------------------------------------------------------------------------------- /list/keys.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which 6 | // is used to render the menu menu. 7 | type KeyMap struct { 8 | // Keybindings used when browsing the list. 9 | CursorUp key.Binding 10 | CursorDown key.Binding 11 | NextPage key.Binding 12 | PrevPage key.Binding 13 | GoToStart key.Binding 14 | GoToEnd key.Binding 15 | Filter key.Binding 16 | ClearFilter key.Binding 17 | DeleteSelection key.Binding 18 | 19 | // Keybindings used when setting a filter. 20 | CancelWhileFiltering key.Binding 21 | AcceptWhileFiltering key.Binding 22 | 23 | // Help toggle keybindings. 24 | ShowFullHelp key.Binding 25 | CloseFullHelp key.Binding 26 | 27 | // The quit keybinding. This won't be caught when filtering. 28 | Quit key.Binding 29 | 30 | // The quit-no-matter-what keybinding. This will be caught when filtering. 31 | ForceQuit key.Binding 32 | } 33 | 34 | // DefaultKeyMap returns a default set of keybindings. 35 | func DefaultKeyMap() KeyMap { 36 | return KeyMap{ 37 | DeleteSelection: key.NewBinding( 38 | key.WithKeys("r"), 39 | key.WithHelp("r", "remove selection")), 40 | // Browsing. 41 | CursorUp: key.NewBinding( 42 | key.WithKeys("up", "k", "w"), 43 | key.WithHelp("↑/k", "up"), 44 | ), 45 | CursorDown: key.NewBinding( 46 | key.WithKeys("down", "j", "s"), 47 | key.WithHelp("↓/j", "down"), 48 | ), 49 | PrevPage: key.NewBinding( 50 | key.WithKeys("left", "h", "pgup", "b", "u", "a"), 51 | key.WithHelp("←/h/pgup", "prev page"), 52 | ), 53 | NextPage: key.NewBinding( 54 | key.WithKeys("right", "l", "pgdown", "f", "d"), 55 | key.WithHelp("→/l/pgdn", "next page"), 56 | ), 57 | GoToStart: key.NewBinding( 58 | key.WithKeys("home", "g"), 59 | key.WithHelp("g/home", "go to start"), 60 | ), 61 | GoToEnd: key.NewBinding( 62 | key.WithKeys("end", "G"), 63 | key.WithHelp("G/end", "go to end"), 64 | ), 65 | Filter: key.NewBinding( 66 | key.WithKeys("/"), 67 | key.WithHelp("/", "filter"), 68 | ), 69 | ClearFilter: key.NewBinding( 70 | key.WithKeys("esc"), 71 | key.WithHelp("esc", "clear filter"), 72 | ), 73 | 74 | // Filtering. 75 | CancelWhileFiltering: key.NewBinding( 76 | key.WithKeys("esc"), 77 | key.WithHelp("esc", "cancel"), 78 | ), 79 | AcceptWhileFiltering: key.NewBinding( 80 | key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"), 81 | key.WithHelp("enter", "apply filter"), 82 | ), 83 | 84 | // Toggle help. 85 | ShowFullHelp: key.NewBinding( 86 | key.WithKeys("?"), 87 | key.WithHelp("?", "more"), 88 | ), 89 | CloseFullHelp: key.NewBinding( 90 | key.WithKeys("?"), 91 | key.WithHelp("?", "close help"), 92 | ), 93 | 94 | // Quitting. 95 | Quit: key.NewBinding( 96 | key.WithKeys("q", "esc"), 97 | key.WithHelp("q", "back"), 98 | ), 99 | ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /list/list.go: -------------------------------------------------------------------------------- 1 | // Package list provides a feature-rich Bubble Tea component for browsing 2 | // a general purpose list of items. It features optional filtering, pagination, 3 | // help, status messages, and a spinner to indicate activity. 4 | package list 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "github.com/charmbracelet/bubbles/help" 14 | "github.com/charmbracelet/bubbles/key" 15 | "github.com/charmbracelet/bubbles/paginator" 16 | "github.com/charmbracelet/bubbles/spinner" 17 | "github.com/charmbracelet/bubbles/textinput" 18 | tea "github.com/charmbracelet/bubbletea" 19 | "github.com/charmbracelet/lipgloss" 20 | "github.com/muesli/reflow/ansi" 21 | "github.com/muesli/reflow/truncate" 22 | "github.com/sahilm/fuzzy" 23 | ) 24 | 25 | // Item is an item that appears in the list. 26 | type Item interface { 27 | // Filter value is the value we use when filtering against this item when 28 | // we're filtering the list. 29 | FilterValue() string 30 | } 31 | 32 | // ItemDelegate encapsulates the general functionality for all list items. The 33 | // benefit to separating this logic from the item itself is that you can change 34 | // the functionality of items without changing the actual items themselves. 35 | // 36 | // Note that if the delegate also implements help.KeyMap delegate-related 37 | // help items will be added to the help view. 38 | type ItemDelegate interface { 39 | // Render renders the item's view. 40 | Render(w io.Writer, m Model, index int, item Item) 41 | 42 | // Height is the height of the list item. 43 | Height() int 44 | 45 | // Spacing is the size of the horizontal gap between list items in cells. 46 | Spacing() int 47 | 48 | // Update is the update loop for items. All messages in the list's update 49 | // loop will pass through here except when the user is setting a filter. 50 | // Use this method to perform item-level updates appropriate to this 51 | // delegate. 52 | Update(msg tea.Msg, m *Model) tea.Cmd 53 | } 54 | 55 | type filteredItem struct { 56 | item Item // item matched 57 | matches []int // rune indices of matched items 58 | } 59 | 60 | type filteredItems []filteredItem 61 | 62 | func (f filteredItems) items() []Item { 63 | agg := make([]Item, len(f)) 64 | for i, v := range f { 65 | agg[i] = v.item 66 | } 67 | return agg 68 | } 69 | 70 | func (f filteredItems) matches() [][]int { 71 | agg := make([][]int, len(f)) 72 | for i, v := range f { 73 | agg[i] = v.matches 74 | } 75 | return agg 76 | } 77 | 78 | type FilterMatchesMessage []filteredItem 79 | 80 | type statusMessageTimeoutMsg struct{} 81 | 82 | // FilterState describes the current filtering state on the model. 83 | type FilterState int 84 | 85 | // Possible filter states. 86 | const ( 87 | Unfiltered FilterState = iota // no filter set 88 | Filtering // user is actively setting a filter 89 | FilterApplied // a filter is applied and user is not editing filter 90 | ) 91 | 92 | // String returns a human-readable string of the current filter state. 93 | func (f FilterState) String() string { 94 | return [...]string{ 95 | "unfiltered", 96 | "filtering", 97 | "filter applied", 98 | }[f] 99 | } 100 | 101 | // Model contains the state of this component. 102 | type Model struct { 103 | showTitle bool 104 | showFilter bool 105 | showStatusBar bool 106 | showPagination bool 107 | showHelp bool 108 | filteringEnabled bool 109 | 110 | Title string 111 | Styles Styles 112 | 113 | // Key mappings for navigating the list. 114 | KeyMap KeyMap 115 | 116 | // Additional key mappings for the short and full help views. This allows 117 | // you to add additional key mappings to the help menu without 118 | // re-implementing the help component. Of course, you can also disable the 119 | // list's help component and implement a new one if you need more 120 | // flexibility. 121 | AdditionalShortHelpKeys func() []key.Binding 122 | AdditionalFullHelpKeys func() []key.Binding 123 | 124 | spinner spinner.Model 125 | showSpinner bool 126 | width int 127 | height int 128 | Paginator paginator.Model 129 | cursor int 130 | Help help.Model 131 | FilterInput textinput.Model 132 | filterState FilterState 133 | 134 | // How long status messages should stay visible. By default this is 135 | // 1 second. 136 | StatusMessageLifetime time.Duration 137 | 138 | statusMessage string 139 | statusMessageTimer *time.Timer 140 | 141 | // The master set of items we're working with. 142 | items []Item 143 | 144 | // Filtered items we're currently displaying. Filtering, toggles and so on 145 | // will alter this slice so we can show what is relevant. For that reason, 146 | // this field should be considered ephemeral. 147 | filteredItems filteredItems 148 | 149 | delegate ItemDelegate 150 | } 151 | 152 | // NewModel returns a new model with sensible defaults. 153 | func NewModel(items []Item, delegate ItemDelegate, width, height int) Model { 154 | styles := DefaultStyles() 155 | 156 | sp := spinner.NewModel() 157 | sp.Spinner = spinner.Line 158 | sp.Style = styles.Spinner 159 | 160 | filterInput := textinput.NewModel() 161 | filterInput.Prompt = "Filter: " 162 | filterInput.PromptStyle = styles.FilterPrompt 163 | filterInput.CursorStyle = styles.FilterCursor 164 | filterInput.CharLimit = 64 165 | filterInput.Focus() 166 | 167 | p := paginator.NewModel() 168 | p.Type = paginator.Dots 169 | p.ActiveDot = styles.ActivePaginationDot.String() 170 | p.InactiveDot = styles.InactivePaginationDot.String() 171 | 172 | m := Model{ 173 | showTitle: true, 174 | showFilter: true, 175 | showStatusBar: true, 176 | showPagination: true, 177 | showHelp: true, 178 | filteringEnabled: true, 179 | KeyMap: DefaultKeyMap(), 180 | Styles: styles, 181 | Title: "List", 182 | FilterInput: filterInput, 183 | StatusMessageLifetime: time.Second, 184 | 185 | width: width, 186 | height: height, 187 | delegate: delegate, 188 | items: items, 189 | Paginator: p, 190 | spinner: sp, 191 | Help: help.NewModel(), 192 | } 193 | 194 | m.updatePagination() 195 | m.updateKeybindings() 196 | return m 197 | } 198 | 199 | // SetFilteringEnabled enables or disables filtering. Note that this is different 200 | // from ShowFilter, which merely hides or shows the input view. 201 | func (m *Model) SetFilteringEnabled(v bool) { 202 | m.filteringEnabled = v 203 | if !v { 204 | m.resetFiltering() 205 | } 206 | m.updateKeybindings() 207 | } 208 | 209 | // FilteringEnabled returns whether or not filtering is enabled. 210 | func (m Model) FilteringEnabled() bool { 211 | return m.filteringEnabled 212 | } 213 | 214 | // SetShowTitle shows or hides the title bar. 215 | func (m *Model) SetShowTitle(v bool) { 216 | m.showTitle = v 217 | m.updatePagination() 218 | } 219 | 220 | // ShowTitle returns whether or not the title bar is set to be rendered. 221 | func (m Model) ShowTitle() bool { 222 | return m.showTitle 223 | } 224 | 225 | // SetShowFilter shows or hides the filer bar. Note that this does not disable 226 | // filtering, it simply hides the built-in filter view. This allows you to 227 | // use the FilterInput to render the filtering UI differently without having to 228 | // re-implement filtering from scratch. 229 | // 230 | // To disable filtering entirely use EnableFiltering. 231 | func (m *Model) SetShowFilter(v bool) { 232 | m.showFilter = v 233 | m.updatePagination() 234 | } 235 | 236 | // ShowFilter returns whether or not the filter is set to be rendered. Note 237 | // that this is separate from FilteringEnabled, so filtering can be hidden yet 238 | // still invoked. This allows you to render filtering differently without 239 | // having to re-implement it from scratch. 240 | func (m Model) ShowFilter() bool { 241 | return m.showFilter 242 | } 243 | 244 | // SetShowStatusBar shows or hides the view that displays metadata about the 245 | // list, such as item counts. 246 | func (m *Model) SetShowStatusBar(v bool) { 247 | m.showStatusBar = v 248 | m.updatePagination() 249 | } 250 | 251 | // ShowStatusBar returns whether or not the status bar is set to be rendered. 252 | func (m Model) ShowStatusBar() bool { 253 | return m.showStatusBar 254 | } 255 | 256 | // ShowingPagination hides or shoes the paginator. Note that pagination will 257 | // still be active, it simply won't be displayed. 258 | func (m *Model) SetShowPagination(v bool) { 259 | m.showPagination = v 260 | m.updatePagination() 261 | } 262 | 263 | // ShowPagination returns whether the pagination is visible. 264 | func (m *Model) ShowPagination() bool { 265 | return m.showPagination 266 | } 267 | 268 | // SetShowHelp shows or hides the help view. 269 | func (m *Model) SetShowHelp(v bool) { 270 | m.showHelp = v 271 | m.updatePagination() 272 | } 273 | 274 | // ShowHelp returns whether or not the help is set to be rendered. 275 | func (m Model) ShowHelp() bool { 276 | return m.showHelp 277 | } 278 | 279 | // Items returns the items in the list. 280 | func (m Model) Items() []Item { 281 | return m.items 282 | } 283 | 284 | // Set the items available in the list. This returns a command. 285 | func (m *Model) SetItems(i []Item) tea.Cmd { 286 | var cmd tea.Cmd 287 | m.items = i 288 | 289 | if m.filterState != Unfiltered { 290 | m.filteredItems = nil 291 | cmd = filterItems(*m) 292 | } 293 | 294 | m.updatePagination() 295 | return cmd 296 | } 297 | 298 | // Select selects the given index of the list and goes to its respective page. 299 | func (m *Model) Select(index int) { 300 | m.Paginator.Page = index / m.Paginator.PerPage 301 | m.cursor = index % m.Paginator.PerPage 302 | } 303 | 304 | // ResetSelected resets the selected item to the first item in the first page of the list. 305 | func (m *Model) ResetSelected() { 306 | m.Select(0) 307 | } 308 | 309 | // ResetFilter resets the current filtering state. 310 | func (m *Model) ResetFilter() { 311 | m.resetFiltering() 312 | } 313 | 314 | // Replace an item at the given index. This returns a command. 315 | func (m *Model) SetItem(index int, item Item) tea.Cmd { 316 | var cmd tea.Cmd 317 | m.items[index] = item 318 | 319 | if m.filterState != Unfiltered { 320 | cmd = filterItems(*m) 321 | } 322 | 323 | m.updatePagination() 324 | return cmd 325 | } 326 | 327 | // Insert an item at the given index. This returns a command. 328 | func (m *Model) InsertItem(index int, item Item) tea.Cmd { 329 | var cmd tea.Cmd 330 | m.items = insertItemIntoSlice(m.items, item, index) 331 | 332 | if m.filterState != Unfiltered { 333 | cmd = filterItems(*m) 334 | } 335 | 336 | m.updatePagination() 337 | return cmd 338 | } 339 | 340 | // RemoveItem removes an item at the given index. If the index is out of bounds 341 | // this will be a no-op. O(n) complexity, which probably won't matter in the 342 | // case of a TUI. 343 | func (m *Model) RemoveItem(index int) { 344 | m.items = removeItemFromSlice(m.items, index) 345 | if m.filterState != Unfiltered { 346 | m.filteredItems = removeFilterMatchFromSlice(m.filteredItems, index) 347 | if len(m.filteredItems) == 0 { 348 | m.resetFiltering() 349 | } 350 | } 351 | m.updatePagination() 352 | } 353 | 354 | // Set the item delegate. 355 | func (m *Model) SetDelegate(d ItemDelegate) { 356 | m.delegate = d 357 | m.updatePagination() 358 | } 359 | 360 | // VisibleItems returns the total items available to be shown. 361 | func (m Model) VisibleItems() []Item { 362 | if m.filterState != Unfiltered { 363 | return m.filteredItems.items() 364 | } 365 | return m.items 366 | } 367 | 368 | // SelectedItems returns the current selected item in the list. 369 | func (m Model) SelectedItem() Item { 370 | i := m.Index() 371 | 372 | items := m.VisibleItems() 373 | if i < 0 || len(items) == 0 || len(items) <= i { 374 | return nil 375 | } 376 | 377 | return items[i] 378 | } 379 | 380 | // MatchesForItem returns rune positions matched by the current filter, if any. 381 | // Use this to style runes matched by the active filter. 382 | // 383 | // See DefaultItemView for a usage example. 384 | func (m Model) MatchesForItem(index int) []int { 385 | if m.filteredItems == nil || index >= len(m.filteredItems) { 386 | return nil 387 | } 388 | return m.filteredItems[index].matches 389 | } 390 | 391 | // Index returns the index of the currently selected item as it appears in the 392 | // entire slice of items. 393 | func (m Model) Index() int { 394 | return m.Paginator.Page*m.Paginator.PerPage + m.cursor 395 | } 396 | 397 | // Cursor returns the index of the cursor on the current page. 398 | func (m Model) Cursor() int { 399 | return m.cursor 400 | } 401 | 402 | // CursorUp moves the cursor up. This can also move the state to the previous 403 | // page. 404 | func (m *Model) CursorUp() { 405 | m.cursor-- 406 | 407 | // If we're at the start, stop 408 | if m.cursor < 0 && m.Paginator.Page == 0 { 409 | m.cursor = 0 410 | return 411 | } 412 | 413 | // Move the cursor as normal 414 | if m.cursor >= 0 { 415 | return 416 | } 417 | 418 | // Go to the previous page 419 | m.Paginator.PrevPage() 420 | m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1 421 | } 422 | 423 | // CursorDown moves the cursor down. This can also advance the state to the 424 | // next page. 425 | func (m *Model) CursorDown() { 426 | itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) 427 | 428 | m.cursor++ 429 | 430 | // If we're at the end, stop 431 | if m.cursor < itemsOnPage { 432 | return 433 | } 434 | 435 | // Go to the next page 436 | if !m.Paginator.OnLastPage() { 437 | m.Paginator.NextPage() 438 | m.cursor = 0 439 | return 440 | } 441 | 442 | // During filtering the cursor position can exceed the number of 443 | // itemsOnPage. It's more intuitive to start the cursor at the 444 | // topmost position when moving it down in this scenario. 445 | if m.cursor > itemsOnPage { 446 | m.cursor = 0 447 | return 448 | } 449 | 450 | m.cursor = itemsOnPage - 1 451 | } 452 | 453 | // PrevPage moves to the previous page, if available. 454 | func (m Model) PrevPage() { 455 | m.Paginator.PrevPage() 456 | } 457 | 458 | // NextPage moves to the next page, if available. 459 | func (m Model) NextPage() { 460 | m.Paginator.NextPage() 461 | } 462 | 463 | // FilterState returns the current filter state. 464 | func (m Model) FilterState() FilterState { 465 | return m.filterState 466 | } 467 | 468 | // FilterValue returns the current value of the filter. 469 | func (m Model) FilterValue() string { 470 | return m.FilterInput.Value() 471 | } 472 | 473 | // SettingFilter returns whether or not the user is currently editing the 474 | // filter value. It's purely a convenience method for the following: 475 | // 476 | // m.FilterState() == Filtering 477 | // 478 | // It's included here because it's a common thing to check for when 479 | // implementing this component. 480 | func (m Model) SettingFilter() bool { 481 | return m.filterState == Filtering 482 | } 483 | 484 | // Width returns the current width setting. 485 | func (m Model) Width() int { 486 | return m.width 487 | } 488 | 489 | // Height returns the current height setting. 490 | func (m Model) Height() int { 491 | return m.height 492 | } 493 | 494 | // SetSpinner allows to set the spinner style. 495 | func (m *Model) SetSpinner(spinner spinner.Spinner) { 496 | m.spinner.Spinner = spinner 497 | } 498 | 499 | // Toggle the spinner. Note that this also returns a command. 500 | func (m *Model) ToggleSpinner() tea.Cmd { 501 | if !m.showSpinner { 502 | return m.StartSpinner() 503 | } 504 | m.StopSpinner() 505 | return nil 506 | } 507 | 508 | // StartSpinner starts the spinner. Note that this returns a command. 509 | func (m *Model) StartSpinner() tea.Cmd { 510 | m.showSpinner = true 511 | return spinner.Tick 512 | } 513 | 514 | // StopSpinner stops the spinner. 515 | func (m *Model) StopSpinner() { 516 | m.showSpinner = false 517 | } 518 | 519 | // Helper for disabling the keybindings used for quitting, incase you want to 520 | // handle this elsewhere in your application. 521 | func (m *Model) DisableQuitKeybindings() { 522 | m.KeyMap.Quit.SetEnabled(false) 523 | m.KeyMap.ForceQuit.SetEnabled(false) 524 | } 525 | 526 | // NewStatusMessage sets a new status message, which will show for a limited 527 | // amount of time. Note that this also returns a command. 528 | func (m *Model) NewStatusMessage(s string) tea.Cmd { 529 | m.statusMessage = s 530 | if m.statusMessageTimer != nil { 531 | m.statusMessageTimer.Stop() 532 | } 533 | 534 | m.statusMessageTimer = time.NewTimer(m.StatusMessageLifetime) 535 | 536 | // Wait for timeout 537 | return func() tea.Msg { 538 | <-m.statusMessageTimer.C 539 | return statusMessageTimeoutMsg{} 540 | } 541 | } 542 | 543 | // SetSize sets the width and height of this component. 544 | func (m *Model) SetSize(width, height int) { 545 | m.setSize(width, height) 546 | } 547 | 548 | // SetWidth sets the width of this component. 549 | func (m *Model) SetWidth(v int) { 550 | m.setSize(v, m.height) 551 | } 552 | 553 | // SetHeight sets the height of this component. 554 | func (m *Model) SetHeight(v int) { 555 | m.setSize(m.width, v) 556 | } 557 | 558 | func (m *Model) setSize(width, height int) { 559 | promptWidth := lipgloss.Width(m.Styles.Title.Render(m.FilterInput.Prompt)) 560 | 561 | m.width = width 562 | m.height = height 563 | m.Help.Width = width 564 | m.FilterInput.Width = width - promptWidth - lipgloss.Width(m.spinnerView()) 565 | m.updatePagination() 566 | } 567 | 568 | func (m *Model) resetFiltering() { 569 | if m.filterState == Unfiltered { 570 | return 571 | } 572 | 573 | m.filterState = Unfiltered 574 | m.FilterInput.Reset() 575 | m.filteredItems = nil 576 | m.updatePagination() 577 | m.updateKeybindings() 578 | } 579 | 580 | func (m Model) itemsAsFilterItems() filteredItems { 581 | fi := make([]filteredItem, len(m.items)) 582 | for i, item := range m.items { 583 | fi[i] = filteredItem{ 584 | item: item, 585 | } 586 | } 587 | 588 | return fi 589 | } 590 | 591 | // Set keybindings according to the filter state. 592 | func (m *Model) updateKeybindings() { 593 | switch m.filterState { 594 | case Filtering: 595 | m.KeyMap.DeleteSelection.SetEnabled(false) 596 | m.KeyMap.CursorUp.SetEnabled(false) 597 | m.KeyMap.CursorDown.SetEnabled(false) 598 | m.KeyMap.NextPage.SetEnabled(false) 599 | m.KeyMap.PrevPage.SetEnabled(false) 600 | m.KeyMap.GoToStart.SetEnabled(false) 601 | m.KeyMap.GoToEnd.SetEnabled(false) 602 | m.KeyMap.Filter.SetEnabled(false) 603 | m.KeyMap.ClearFilter.SetEnabled(false) 604 | m.KeyMap.CancelWhileFiltering.SetEnabled(true) 605 | m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") 606 | m.KeyMap.Quit.SetEnabled(true) 607 | m.KeyMap.ShowFullHelp.SetEnabled(false) 608 | m.KeyMap.CloseFullHelp.SetEnabled(false) 609 | 610 | default: 611 | m.KeyMap.DeleteSelection.SetEnabled(true) 612 | hasItems := m.items != nil 613 | m.KeyMap.CursorUp.SetEnabled(hasItems) 614 | m.KeyMap.CursorDown.SetEnabled(hasItems) 615 | 616 | hasPages := m.Paginator.TotalPages > 1 617 | m.KeyMap.NextPage.SetEnabled(hasPages) 618 | m.KeyMap.PrevPage.SetEnabled(hasPages) 619 | 620 | m.KeyMap.GoToStart.SetEnabled(hasItems) 621 | m.KeyMap.GoToEnd.SetEnabled(hasItems) 622 | 623 | m.KeyMap.Filter.SetEnabled(m.filteringEnabled && hasItems) 624 | m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied) 625 | m.KeyMap.CancelWhileFiltering.SetEnabled(false) 626 | m.KeyMap.AcceptWhileFiltering.SetEnabled(false) 627 | m.KeyMap.Quit.SetEnabled(true) 628 | 629 | if m.Help.ShowAll { 630 | m.KeyMap.ShowFullHelp.SetEnabled(true) 631 | m.KeyMap.CloseFullHelp.SetEnabled(true) 632 | } else { 633 | minHelp := countEnabledBindings(m.FullHelp()) > 1 634 | m.KeyMap.ShowFullHelp.SetEnabled(minHelp) 635 | m.KeyMap.CloseFullHelp.SetEnabled(minHelp) 636 | } 637 | } 638 | } 639 | 640 | // Update pagination according to the amount of items for the current state. 641 | func (m *Model) updatePagination() { 642 | index := m.Index() 643 | availHeight := m.height 644 | 645 | if m.showTitle || (m.showFilter && m.filteringEnabled) { 646 | availHeight -= lipgloss.Height(m.titleView()) 647 | } 648 | if m.showStatusBar { 649 | availHeight -= lipgloss.Height(m.statusView()) 650 | } 651 | if m.showPagination { 652 | availHeight -= lipgloss.Height(m.paginationView()) 653 | } 654 | if m.showHelp { 655 | availHeight -= lipgloss.Height(m.helpView()) 656 | } 657 | 658 | m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing())) 659 | 660 | if pages := len(m.VisibleItems()); pages < 1 { 661 | m.Paginator.SetTotalPages(1) 662 | } else { 663 | m.Paginator.SetTotalPages(pages) 664 | } 665 | 666 | // Restore index 667 | m.Paginator.Page = index / m.Paginator.PerPage 668 | m.cursor = index % m.Paginator.PerPage 669 | 670 | // Make sure the page stays in bounds 671 | if m.Paginator.Page >= m.Paginator.TotalPages-1 { 672 | m.Paginator.Page = max(0, m.Paginator.TotalPages-1) 673 | } 674 | } 675 | 676 | func (m *Model) hideStatusMessage() { 677 | m.statusMessage = "" 678 | if m.statusMessageTimer != nil { 679 | m.statusMessageTimer.Stop() 680 | } 681 | } 682 | 683 | // Update is the Bubble Tea update loop. 684 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 685 | var cmds []tea.Cmd 686 | 687 | switch msg := msg.(type) { 688 | case tea.KeyMsg: 689 | if key.Matches(msg, m.KeyMap.ForceQuit) { 690 | return m, tea.Quit 691 | } 692 | 693 | case FilterMatchesMessage: 694 | m.filteredItems = filteredItems(msg) 695 | return m, nil 696 | 697 | case spinner.TickMsg: 698 | newSpinnerModel, cmd := m.spinner.Update(msg) 699 | m.spinner = newSpinnerModel 700 | if m.showSpinner { 701 | cmds = append(cmds, cmd) 702 | } 703 | 704 | case statusMessageTimeoutMsg: 705 | m.hideStatusMessage() 706 | } 707 | 708 | if m.filterState == Filtering { 709 | cmds = append(cmds, m.handleFiltering(msg)) 710 | } else { 711 | cmds = append(cmds, m.handleBrowsing(msg)) 712 | } 713 | 714 | return m, tea.Batch(cmds...) 715 | } 716 | 717 | var ( 718 | deleteToggleSwich bool 719 | ) 720 | 721 | // Updates for when a user is browsing the list. 722 | func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd { 723 | var cmds []tea.Cmd 724 | wasDeleteSelection := false 725 | numItems := len(m.VisibleItems()) 726 | 727 | switch msg := msg.(type) { 728 | case tea.KeyMsg: 729 | switch { 730 | case key.Matches(msg, m.KeyMap.DeleteSelection): 731 | if len(m.Items()) == 0 { 732 | break 733 | } 734 | wasDeleteSelection = true 735 | if deleteToggleSwich { 736 | m.RemoveItem(m.Cursor()) 737 | m.KeyMap.DeleteSelection.SetHelp("r", "remove selection") 738 | } else { 739 | m.KeyMap.DeleteSelection.SetHelp("r", "confirm selection removal") 740 | } 741 | deleteToggleSwich = !deleteToggleSwich 742 | // Note: we match clear filter before quit because, by default, they're 743 | // both mapped to escape. 744 | case key.Matches(msg, m.KeyMap.ClearFilter): 745 | m.resetFiltering() 746 | 747 | case key.Matches(msg, m.KeyMap.Quit): 748 | return tea.Quit 749 | 750 | case key.Matches(msg, m.KeyMap.CursorUp): 751 | m.CursorUp() 752 | 753 | case key.Matches(msg, m.KeyMap.CursorDown): 754 | m.CursorDown() 755 | 756 | case key.Matches(msg, m.KeyMap.PrevPage): 757 | m.Paginator.PrevPage() 758 | 759 | case key.Matches(msg, m.KeyMap.NextPage): 760 | m.Paginator.NextPage() 761 | 762 | case key.Matches(msg, m.KeyMap.GoToStart): 763 | m.Paginator.Page = 0 764 | m.cursor = 0 765 | 766 | case key.Matches(msg, m.KeyMap.GoToEnd): 767 | m.Paginator.Page = m.Paginator.TotalPages - 1 768 | m.cursor = m.Paginator.ItemsOnPage(numItems) - 1 769 | 770 | case key.Matches(msg, m.KeyMap.Filter): 771 | m.hideStatusMessage() 772 | if m.FilterInput.Value() == "" { 773 | // Populate filter with all items only if the filter is empty. 774 | m.filteredItems = m.itemsAsFilterItems() 775 | } 776 | m.Paginator.Page = 0 777 | m.cursor = 0 778 | m.filterState = Filtering 779 | m.FilterInput.CursorEnd() 780 | m.FilterInput.Focus() 781 | m.updateKeybindings() 782 | return textinput.Blink 783 | 784 | case key.Matches(msg, m.KeyMap.ShowFullHelp): 785 | fallthrough 786 | case key.Matches(msg, m.KeyMap.CloseFullHelp): 787 | m.Help.ShowAll = !m.Help.ShowAll 788 | m.updatePagination() 789 | } 790 | } 791 | 792 | if !wasDeleteSelection { // if anything else reset switch 793 | deleteToggleSwich = false 794 | } 795 | 796 | cmd := m.delegate.Update(msg, m) 797 | cmds = append(cmds, cmd) 798 | 799 | // Keep the index in bounds when paginating 800 | itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) 801 | if m.cursor > itemsOnPage-1 { 802 | m.cursor = max(0, itemsOnPage-1) 803 | } 804 | 805 | return tea.Batch(cmds...) 806 | } 807 | 808 | // Updates for when a user is in the filter editing interface. 809 | func (m *Model) handleFiltering(msg tea.Msg) tea.Cmd { 810 | var cmds []tea.Cmd 811 | 812 | // Handle keys 813 | if msg, ok := msg.(tea.KeyMsg); ok { 814 | switch { 815 | case key.Matches(msg, m.KeyMap.CancelWhileFiltering): 816 | m.resetFiltering() 817 | m.KeyMap.Filter.SetEnabled(true) 818 | m.KeyMap.ClearFilter.SetEnabled(false) 819 | 820 | case key.Matches(msg, m.KeyMap.AcceptWhileFiltering): 821 | m.hideStatusMessage() 822 | 823 | if len(m.items) == 0 { 824 | break 825 | } 826 | 827 | h := m.VisibleItems() 828 | 829 | // If we've filtered down to nothing, clear the filter 830 | if len(h) == 0 { 831 | m.resetFiltering() 832 | break 833 | } 834 | 835 | m.FilterInput.Blur() 836 | m.filterState = FilterApplied 837 | m.updateKeybindings() 838 | 839 | if m.FilterInput.Value() == "" { 840 | m.resetFiltering() 841 | } 842 | } 843 | } 844 | 845 | // Update the filter text input component 846 | newFilterInputModel, inputCmd := m.FilterInput.Update(msg) 847 | filterChanged := m.FilterInput.Value() != newFilterInputModel.Value() 848 | m.FilterInput = newFilterInputModel 849 | cmds = append(cmds, inputCmd) 850 | 851 | // If the filtering input has changed, request updated filtering 852 | if filterChanged { 853 | cmds = append(cmds, filterItems(*m)) 854 | m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") 855 | } 856 | 857 | // Update pagination 858 | m.updatePagination() 859 | 860 | return tea.Batch(cmds...) 861 | } 862 | 863 | // ShortHelp returns bindings to show in the abbreviated help view. It's part 864 | // of the help.KeyMap interface. 865 | func (m Model) ShortHelp() []key.Binding { 866 | kb := []key.Binding{ 867 | m.KeyMap.CursorUp, 868 | m.KeyMap.CursorDown, 869 | } 870 | 871 | filtering := m.filterState == Filtering 872 | 873 | // If the delegate implements the help.KeyMap interface add the short help 874 | // items to the short help after the cursor movement keys. 875 | if !filtering { 876 | if b, ok := m.delegate.(help.KeyMap); ok { 877 | kb = append(kb, b.ShortHelp()...) 878 | } 879 | } 880 | 881 | kb = append(kb, 882 | m.KeyMap.Filter, 883 | m.KeyMap.ClearFilter, 884 | m.KeyMap.AcceptWhileFiltering, 885 | m.KeyMap.CancelWhileFiltering, 886 | m.KeyMap.DeleteSelection, 887 | ) 888 | 889 | if !filtering && m.AdditionalShortHelpKeys != nil { 890 | kb = append(kb, m.AdditionalShortHelpKeys()...) 891 | } 892 | 893 | return append(kb, 894 | m.KeyMap.Quit, 895 | m.KeyMap.ShowFullHelp, 896 | ) 897 | } 898 | 899 | // FullHelp returns bindings to show the full help view. It's part of the 900 | // help.KeyMap interface. 901 | func (m Model) FullHelp() [][]key.Binding { 902 | kb := [][]key.Binding{{ 903 | m.KeyMap.CursorUp, 904 | m.KeyMap.CursorDown, 905 | m.KeyMap.NextPage, 906 | m.KeyMap.PrevPage, 907 | m.KeyMap.GoToStart, 908 | m.KeyMap.GoToEnd, 909 | }} 910 | 911 | filtering := m.filterState == Filtering 912 | 913 | // If the delegate implements the help.KeyMap interface add full help 914 | // keybindings to a special section of the full help. 915 | if !filtering { 916 | if b, ok := m.delegate.(help.KeyMap); ok { 917 | kb = append(kb, b.FullHelp()...) 918 | } 919 | } 920 | 921 | listLevelBindings := []key.Binding{ 922 | m.KeyMap.Filter, 923 | m.KeyMap.ClearFilter, 924 | m.KeyMap.AcceptWhileFiltering, 925 | m.KeyMap.CancelWhileFiltering, 926 | m.KeyMap.DeleteSelection, 927 | } 928 | 929 | if !filtering && m.AdditionalFullHelpKeys != nil { 930 | listLevelBindings = append(listLevelBindings, m.AdditionalFullHelpKeys()...) 931 | } 932 | 933 | return append(kb, 934 | listLevelBindings, 935 | []key.Binding{ 936 | m.KeyMap.Quit, 937 | m.KeyMap.CloseFullHelp, 938 | }) 939 | } 940 | 941 | // View renders the component. 942 | func (m Model) View() string { 943 | var ( 944 | sections []string 945 | availHeight = m.height 946 | ) 947 | 948 | if m.showTitle || (m.showFilter && m.filteringEnabled) { 949 | v := m.titleView() 950 | sections = append(sections, v) 951 | availHeight -= lipgloss.Height(v) 952 | } 953 | 954 | if m.showStatusBar { 955 | v := m.statusView() 956 | sections = append(sections, v) 957 | availHeight -= lipgloss.Height(v) 958 | } 959 | 960 | var pagination string 961 | if m.showPagination { 962 | pagination = m.paginationView() 963 | availHeight -= lipgloss.Height(pagination) 964 | } 965 | 966 | var help string 967 | if m.showHelp { 968 | help = m.helpView() 969 | availHeight -= lipgloss.Height(help) 970 | } 971 | 972 | content := lipgloss.NewStyle().Height(availHeight).Render(m.populatedView()) 973 | sections = append(sections, content) 974 | 975 | if m.showPagination { 976 | sections = append(sections, pagination) 977 | } 978 | 979 | if m.showHelp { 980 | sections = append(sections, help) 981 | } 982 | 983 | return lipgloss.JoinVertical(lipgloss.Left, sections...) 984 | } 985 | 986 | func (m Model) titleView() string { 987 | var ( 988 | view string 989 | titleBarStyle = m.Styles.TitleBar.Copy() 990 | 991 | // We need to account for the size of the spinner, even if we don't 992 | // render it, to reserve some space for it should we turn it on later. 993 | spinnerView = m.spinnerView() 994 | spinnerWidth = lipgloss.Width(spinnerView) 995 | spinnerLeftGap = " " 996 | spinnerOnLeft = titleBarStyle.GetPaddingLeft() >= spinnerWidth+lipgloss.Width(spinnerLeftGap) && m.showSpinner 997 | ) 998 | 999 | // If the filter's showing, draw that. Otherwise draw the title. 1000 | if m.showFilter && m.filterState == Filtering { 1001 | view += m.FilterInput.View() 1002 | } else if m.showTitle { 1003 | if m.showSpinner && spinnerOnLeft { 1004 | view += spinnerView + spinnerLeftGap 1005 | titleBarGap := titleBarStyle.GetPaddingLeft() 1006 | titleBarStyle = titleBarStyle.PaddingLeft(titleBarGap - spinnerWidth - lipgloss.Width(spinnerLeftGap)) 1007 | } 1008 | 1009 | view += m.Styles.Title.Render(m.Title) 1010 | 1011 | // Status message 1012 | if m.filterState != Filtering { 1013 | view += " " + m.statusMessage 1014 | view = truncate.StringWithTail(view, uint(m.width-spinnerWidth), ellipsis) 1015 | } 1016 | } 1017 | 1018 | // Spinner 1019 | if m.showSpinner && !spinnerOnLeft { 1020 | // Place spinner on the right 1021 | availSpace := m.width - lipgloss.Width(m.Styles.TitleBar.Render(view)) 1022 | if availSpace > spinnerWidth { 1023 | view += strings.Repeat(" ", availSpace-spinnerWidth) 1024 | view += spinnerView 1025 | } 1026 | } 1027 | 1028 | return titleBarStyle.Render(view) 1029 | } 1030 | 1031 | func (m Model) statusView() string { 1032 | var status string 1033 | 1034 | totalItems := len(m.items) 1035 | visibleItems := len(m.VisibleItems()) 1036 | 1037 | plural := "" 1038 | if visibleItems != 1 { 1039 | plural = "s" 1040 | } 1041 | 1042 | if m.filterState == Filtering { 1043 | // Filter results 1044 | if visibleItems == 0 { 1045 | status = m.Styles.StatusEmpty.Render("Nothing matched") 1046 | } else { 1047 | status = fmt.Sprintf("%d item%s", visibleItems, plural) 1048 | } 1049 | } else if len(m.items) == 0 { 1050 | // Not filtering: no items. 1051 | status = m.Styles.StatusEmpty.Render("No items") 1052 | } else { 1053 | // Normal 1054 | filtered := m.FilterState() == FilterApplied 1055 | 1056 | if filtered { 1057 | f := strings.TrimSpace(m.FilterInput.Value()) 1058 | f = truncate.StringWithTail(f, 10, "…") 1059 | status += fmt.Sprintf("“%s” ", f) 1060 | } 1061 | 1062 | status += fmt.Sprintf("%d item%s", visibleItems, plural) 1063 | } 1064 | 1065 | numFiltered := totalItems - visibleItems 1066 | if numFiltered > 0 { 1067 | status += m.Styles.DividerDot.String() 1068 | status += m.Styles.StatusBarFilterCount.Render(fmt.Sprintf("%d filtered", numFiltered)) 1069 | } 1070 | 1071 | return m.Styles.StatusBar.Render(status) 1072 | } 1073 | 1074 | func (m Model) paginationView() string { 1075 | if m.Paginator.TotalPages < 2 { //nolint:gomnd 1076 | return "" 1077 | } 1078 | 1079 | s := m.Paginator.View() 1080 | 1081 | // If the dot pagination is wider than the width of the window 1082 | // use the arabic paginator. 1083 | if ansi.PrintableRuneWidth(s) > m.width { 1084 | m.Paginator.Type = paginator.Arabic 1085 | s = m.Styles.ArabicPagination.Render(m.Paginator.View()) 1086 | } 1087 | 1088 | style := m.Styles.PaginationStyle 1089 | if m.delegate.Spacing() == 0 && style.GetMarginTop() == 0 { 1090 | style = style.Copy().MarginTop(1) 1091 | } 1092 | 1093 | return style.Render(s) 1094 | } 1095 | 1096 | func (m Model) populatedView() string { 1097 | items := m.VisibleItems() 1098 | 1099 | b := strings.Builder{} 1100 | 1101 | // Empty states 1102 | if len(items) == 0 { 1103 | if m.filterState == Filtering { 1104 | return "" 1105 | } 1106 | m.Styles.NoItems.Render("No items found.") 1107 | } 1108 | 1109 | if len(items) > 0 { 1110 | start, end := m.Paginator.GetSliceBounds(len(items)) 1111 | docs := items[start:end] 1112 | 1113 | for i, item := range docs { 1114 | m.delegate.Render(&b, m, i+start, item) 1115 | if i != len(docs)-1 { 1116 | fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1)) 1117 | } 1118 | } 1119 | } 1120 | 1121 | // If there aren't enough items to fill up this page (always the last page) 1122 | // then we need to add some newlines to fill up the space where items would 1123 | // have been. 1124 | //itemsOnPage := m.Paginator.ItemsOnPage(len(items)) 1125 | //if itemsOnPage < m.Paginator.PerPage { 1126 | // n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing()) 1127 | // if len(items) == 0 { 1128 | // n -= m.delegate.Height() - 1 1129 | // } 1130 | // fmt.Fprint(&b, strings.Repeat("\n", n)) 1131 | //} 1132 | ret := b.String() 1133 | 1134 | return ret 1135 | } 1136 | 1137 | func (m Model) helpView() string { 1138 | return m.Styles.HelpStyle.Render(m.Help.View(m)) 1139 | } 1140 | 1141 | func (m Model) spinnerView() string { 1142 | return m.spinner.View() 1143 | } 1144 | 1145 | func filterItems(m Model) tea.Cmd { 1146 | return func() tea.Msg { 1147 | if m.FilterInput.Value() == "" || m.filterState == Unfiltered { 1148 | return FilterMatchesMessage(m.itemsAsFilterItems()) // return nothing 1149 | } 1150 | 1151 | var targets []string 1152 | items := m.items 1153 | 1154 | for _, t := range items { 1155 | targets = append(targets, t.FilterValue()) 1156 | } 1157 | 1158 | var ranks = fuzzy.Find(m.FilterInput.Value(), targets) 1159 | sort.Stable(ranks) 1160 | 1161 | var filterMatches []filteredItem 1162 | for _, r := range ranks { 1163 | filterMatches = append(filterMatches, filteredItem{ 1164 | item: items[r.Index], 1165 | matches: r.MatchedIndexes, 1166 | }) 1167 | } 1168 | 1169 | return FilterMatchesMessage(filterMatches) 1170 | } 1171 | } 1172 | 1173 | func insertItemIntoSlice(items []Item, item Item, index int) []Item { 1174 | if items == nil { 1175 | return []Item{item} 1176 | } 1177 | if index >= len(items) { 1178 | return append(items, item) 1179 | } 1180 | 1181 | index = max(0, index) 1182 | 1183 | items = append(items, nil) 1184 | copy(items[index+1:], items[index:]) 1185 | items[index] = item 1186 | return items 1187 | } 1188 | 1189 | // Remove an item from a slice of items at the given index. This runs in O(n). 1190 | func removeItemFromSlice(i []Item, index int) []Item { 1191 | if index >= len(i) { 1192 | return i // noop 1193 | } 1194 | copy(i[index:], i[index+1:]) 1195 | i[len(i)-1] = nil 1196 | return i[:len(i)-1] 1197 | } 1198 | 1199 | func removeFilterMatchFromSlice(i []filteredItem, index int) []filteredItem { 1200 | if index >= len(i) { 1201 | return i // noop 1202 | } 1203 | copy(i[index:], i[index+1:]) 1204 | i[len(i)-1] = filteredItem{} 1205 | return i[:len(i)-1] 1206 | } 1207 | 1208 | func countEnabledBindings(groups [][]key.Binding) (agg int) { 1209 | for _, group := range groups { 1210 | for _, kb := range group { 1211 | if kb.Enabled() { 1212 | agg++ 1213 | } 1214 | } 1215 | } 1216 | return agg 1217 | } 1218 | 1219 | func max(a, b int) int { 1220 | if a > b { 1221 | return a 1222 | } 1223 | return b 1224 | } 1225 | -------------------------------------------------------------------------------- /list/style.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mathaou/termdbms/tuiutil" 6 | ) 7 | 8 | const ( 9 | bullet = "•" 10 | ellipsis = "…" 11 | ) 12 | 13 | // Styles contains style definitions for this list component. By default, these 14 | // values are generated by DefaultStyles. 15 | type Styles struct { 16 | TitleBar lipgloss.Style 17 | Title lipgloss.Style 18 | Spinner lipgloss.Style 19 | FilterPrompt lipgloss.Style 20 | FilterCursor lipgloss.Style 21 | 22 | // Default styling for matched characters in a filter. This can be 23 | // overridden by delegates. 24 | DefaultFilterCharacterMatch lipgloss.Style 25 | 26 | StatusBar lipgloss.Style 27 | StatusEmpty lipgloss.Style 28 | StatusBarActiveFilter lipgloss.Style 29 | StatusBarFilterCount lipgloss.Style 30 | 31 | NoItems lipgloss.Style 32 | 33 | PaginationStyle lipgloss.Style 34 | HelpStyle lipgloss.Style 35 | 36 | // Styled characters. 37 | ActivePaginationDot lipgloss.Style 38 | InactivePaginationDot lipgloss.Style 39 | ArabicPagination lipgloss.Style 40 | DividerDot lipgloss.Style 41 | } 42 | 43 | // DefaultStyles returns a set of default style definitions for this list 44 | // component. 45 | func DefaultStyles() (s Styles) { 46 | verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} 47 | subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"} 48 | 49 | s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2) 50 | 51 | s.Title = lipgloss.NewStyle(). 52 | Background(lipgloss.Color(tuiutil.HeaderBackground())). 53 | Foreground(lipgloss.Color(tuiutil.HeaderForeground())). 54 | Padding(0, 1) 55 | 56 | s.Spinner = lipgloss.NewStyle(). 57 | Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"}) 58 | 59 | s.FilterPrompt = lipgloss.NewStyle(). 60 | Foreground(lipgloss.Color(tuiutil.FooterForeground())) 61 | 62 | s.FilterCursor = lipgloss.NewStyle(). 63 | Foreground(lipgloss.Color(tuiutil.BorderColor())) 64 | 65 | s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true) 66 | 67 | s.StatusBar = lipgloss.NewStyle(). 68 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). 69 | Padding(0, 0, 1, 2) 70 | 71 | s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor) 72 | 73 | s.StatusBarActiveFilter = lipgloss.NewStyle(). 74 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) 75 | 76 | s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor) 77 | 78 | s.NoItems = lipgloss.NewStyle(). 79 | Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}) 80 | 81 | s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor) 82 | 83 | s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd 84 | 85 | s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) 86 | 87 | s.ActivePaginationDot = lipgloss.NewStyle(). 88 | Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}). 89 | SetString(bullet) 90 | 91 | s.InactivePaginationDot = lipgloss.NewStyle(). 92 | Foreground(verySubduedColor). 93 | SetString(bullet) 94 | 95 | s.DividerDot = lipgloss.NewStyle(). 96 | Foreground(verySubduedColor). 97 | SetString(" " + bullet + " ") 98 | 99 | return s 100 | } 101 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "flag" 6 | "fmt" 7 | "io/fs" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | . "github.com/mathaou/termdbms/tuiutil" 14 | . "github.com/mathaou/termdbms/viewer" 15 | 16 | tea "github.com/charmbracelet/bubbletea" 17 | "github.com/charmbracelet/lipgloss" 18 | "github.com/mathaou/termdbms/database" 19 | "github.com/muesli/termenv" 20 | _ "modernc.org/sqlite" 21 | ) 22 | 23 | type DatabaseType string 24 | 25 | const ( 26 | debugPath = "" // set to whatever hardcoded path for testing 27 | ) 28 | 29 | const ( 30 | DatabaseSQLite DatabaseType = "sqlite" 31 | DatabaseMySQL DatabaseType = "mysql" 32 | ) 33 | 34 | var ( 35 | debug bool 36 | path string 37 | databaseType string 38 | theme string 39 | help bool 40 | ascii bool 41 | ) 42 | 43 | func main() { 44 | debug = debugPath != "" 45 | flag.Usage = func() { 46 | help := GetHelpText() 47 | lines := strings.Split(help, "\n") 48 | for _, v := range lines { 49 | println(v) 50 | } 51 | } 52 | 53 | argLength := len(os.Args[1:]) 54 | if (argLength > 4 || argLength == 0) && !debug { 55 | fmt.Printf("ERROR: Invalid number of arguments supplied: %d\n", argLength) 56 | flag.Usage() 57 | os.Exit(1) 58 | } 59 | 60 | // flags declaration using flag package 61 | flag.StringVar(&databaseType, "d", string(DatabaseSQLite), "Specifies the SQL driver to use. Defaults to SQLite.") 62 | flag.StringVar(&path, "p", "", "Path to the database file.") 63 | flag.StringVar(&theme, "t", "default", "sets the color theme of the app.") 64 | flag.BoolVar(&help, "h", false, "Prints the help message.") 65 | flag.BoolVar(&ascii, "a", false, "Denotes that the app should render with minimal styling to remove ANSI sequences.") 66 | 67 | flag.Parse() 68 | 69 | handleFlags() 70 | 71 | var c *sql.Rows 72 | defer func() { 73 | if c != nil { 74 | c.Close() 75 | } 76 | }() 77 | 78 | if debug { 79 | path = debugPath 80 | } 81 | 82 | for i, v := range ValidThemes { 83 | if theme == v { 84 | SelectedTheme = i 85 | break 86 | } 87 | } 88 | 89 | if theme == "" { 90 | theme = "default" 91 | } 92 | 93 | // gets a sqlite instance for the database file 94 | if exists, _ := FileExists(path); exists { 95 | fmt.Printf("ERROR: Database file could not be found at %s\n", path) 96 | os.Exit(1) 97 | } 98 | 99 | if valid, _ := Exists(HiddenTmpDirectoryName); valid { 100 | filepath.Walk(HiddenTmpDirectoryName, func(path string, info fs.FileInfo, err error) error { 101 | if strings.HasPrefix(path, fmt.Sprintf("%s/.", HiddenTmpDirectoryName)) && !info.IsDir() { 102 | os.Remove(path) // remove all temp databaess 103 | } 104 | return nil 105 | }) 106 | } else { 107 | os.Mkdir(HiddenTmpDirectoryName, 0o777) 108 | } 109 | 110 | database.IsCSV = strings.HasSuffix(path, ".csv") 111 | dst := path 112 | if database.IsCSV { // convert the csv to sql, then run the sql through a database 113 | sqlFile := strings.TrimSuffix(path, ".csv") 114 | sqlFile = filepath.Base(sqlFile) 115 | path = Convert(path, sqlFile, true) 116 | csvDBFile := HiddenTmpDirectoryName + "/" + sqlFile + ".db" 117 | os.Create(csvDBFile) 118 | dst, _ = filepath.Abs(csvDBFile) 119 | d, _ := sql.Open(database.DriverString, dst) 120 | f, _ := os.Open(path) 121 | b, _ := ioutil.ReadAll(f) 122 | query := string(b) 123 | _, err := d.Exec(query) 124 | if err != nil { 125 | fmt.Printf("%v", err) 126 | os.Exit(1) 127 | } 128 | d.Close() 129 | os.Remove(path) // this deletes the converted .sql file 130 | } 131 | 132 | dst, _, _ = CopyFile(dst) 133 | 134 | db := database.GetDatabaseForFile(dst) 135 | defer func() { 136 | if db == nil { 137 | db.Close() 138 | } 139 | }() 140 | 141 | // initializes the model used by bubbletea 142 | m := GetNewModel(dst, db) 143 | InitialModel = &m 144 | InitialModel.InitialFileName = path 145 | err := InitialModel.SetModel(c, db) 146 | if err != nil { 147 | fmt.Printf("%v", err) 148 | os.Exit(1) 149 | } 150 | 151 | // creates the program 152 | Program = tea.NewProgram(InitialModel, 153 | tea.WithAltScreen(), 154 | tea.WithMouseAllMotion()) 155 | 156 | if err := Program.Start(); err != nil { 157 | fmt.Printf("ERROR: Error initializing the sqlite viewer: %v", err) 158 | os.Exit(1) 159 | } 160 | } 161 | 162 | func handleFlags() { 163 | if path == "" && !debug { 164 | fmt.Printf("ERROR: no path for database.\n") 165 | flag.Usage() 166 | os.Exit(1) 167 | } 168 | 169 | if help { 170 | flag.Usage() 171 | os.Exit(0) 172 | } 173 | 174 | if ascii { 175 | Ascii = true 176 | lipgloss.SetColorProfile(termenv.Ascii) 177 | } 178 | 179 | if path != "" && !IsUrl(path) { 180 | fmt.Printf("ERROR: Invalid path %s\n", path) 181 | flag.Usage() 182 | os.Exit(1) 183 | } 184 | 185 | if databaseType != string(DatabaseMySQL) && 186 | databaseType != string(DatabaseSQLite) { 187 | fmt.Printf("Invalid database driver specified: %s", databaseType) 188 | os.Exit(1) 189 | } 190 | 191 | database.DriverString = databaseType 192 | } 193 | -------------------------------------------------------------------------------- /tuiutil/csv2sql.go: -------------------------------------------------------------------------------- 1 | package tuiutil 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/csv" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | /* 15 | 16 | csv2sql - conversion program to convert a csv file to sql format 17 | to allow easy checking / validation, and for import into a SQLite3 18 | database using the SQLite '.read' command 19 | 20 | author: simon rowe 21 | license: open-source released under "New BSD License" 22 | 23 | created: 16 Apr 2014 - initial outline code written 24 | updated: 17 Apr 2014 - add flags and output file handling 25 | updated: 27 Apr 2014 - wrap in double quotes instead of single 26 | updated: 28 Apr 2014 - add flush io file buffer to fix SQL missing EOF 27 | updated: 19 Jul 2014 - add more help text, tidy up comments and code 28 | updated: 06 Aug 2014 - enabled the -k flag to alter the table header characters 29 | updated: 28 Sep 2014 - changed default output when run with no params, add -h 30 | to display the help info and also still call flags.Usage() 31 | updated: 09 Dec 2014 - minor tidy up and first 'release' provided on GitHub 32 | updated: 27 Aug 2016 - table name and csv file help output minior changes. Minor cosmetic stuff. Version 1.1 33 | */ 34 | 35 | func SQLFileName(csvFileName string) string { 36 | // include the name of the csv file from command line (ie csvFileName) 37 | // remove any path etc 38 | var justFileName = filepath.Base(csvFileName) 39 | // get the files extension too 40 | var extension = filepath.Ext(csvFileName) 41 | // remove the file extension from the filename 42 | justFileName = justFileName[0 : len(justFileName)-len(extension)] 43 | 44 | sqlOutFile := "./.termdbms/SQL-" + justFileName + ".sql" 45 | return sqlOutFile 46 | } 47 | 48 | func Convert(csvFileName, tableName string, keepOrigCols bool) string { 49 | // check we have a table name and csv file to work with - otherwise abort 50 | if csvFileName == "" || tableName == "" { 51 | return "" 52 | } 53 | 54 | // open the CSV file - name provided via command line input - handle 'file' 55 | file, err := os.Open(csvFileName) 56 | // error - if we have one exit as CSV file not right 57 | if err != nil { 58 | fmt.Printf("ERROR: %s\n", err) 59 | os.Exit(-3) 60 | } 61 | // now file is open - defer the close of CSV file handle until we return 62 | defer file.Close() 63 | // connect a CSV reader to the file handle - which is the actual opened 64 | // CSV file 65 | // TODO : is there an error from this to check? 66 | reader := csv.NewReader(file) 67 | 68 | sqlOutFile := SQLFileName(csvFileName) 69 | 70 | // open the new file using the name we obtained above - handle 'filesql' 71 | filesql, err := os.Create(sqlOutFile) 72 | // error - if we have one when trying open & create the new file 73 | if err != nil { 74 | return "" 75 | } 76 | // now new file is open - defer the close of the file handle until we return 77 | defer filesql.Close() 78 | // attach the opened new sql file handle to a buffered file writer 79 | // the buffered file writer has the handle 'sqlFileBuffer' 80 | sqlFileBuffer := bufio.NewWriter(filesql) 81 | 82 | //------------------------------------------------------------------------- 83 | // prepare to read the each line of the CSV file - and write out to the SQl 84 | //------------------------------------------------------------------------- 85 | // track the number of lines in the csv file 86 | lineCount := 0 87 | 88 | // create a buffer to hold each line of the SQL file as we build it 89 | // handle to this buffer is called 'strbuffer' 90 | var strbuffer bytes.Buffer 91 | 92 | // START - processing of each line in the CSV input file 93 | //------------------------------------------------------------------------- 94 | // loop through the csv file until EOF - or until we hit an error in parsing it. 95 | // Data is read in for each line of the csv file and held in the variable 96 | // 'record'. Build a string for each line - wrapped with the SQL and 97 | // then output to the SQL file writer in its completed new form 98 | //------------------------------------------------------------------------- 99 | for { 100 | record, err := reader.Read() 101 | 102 | // if we hit end of file (EOF) or another unexpected error 103 | if err == io.EOF { 104 | break 105 | } else if err != nil { 106 | return "" 107 | } 108 | 109 | // if we are processing the first line - use the record field contents 110 | // as the SQL table column names - add to the temp string 'strbuffer' 111 | // use the tablename provided by the user 112 | if lineCount == 0 { 113 | strbuffer.WriteString("CREATE TABLE " + tableName + " (") 114 | } 115 | 116 | // if any line except the first one : 117 | // print the start of the SQL insert statement for the record 118 | // and - add to the temp string 'strbuffer' 119 | // use the tablename provided by the user 120 | if lineCount > 0 { 121 | strbuffer.WriteString("INSERT INTO " + tableName + " VALUES (") 122 | } 123 | // loop through each of the csv lines individual fields held in 'record' 124 | // len(record) tells us how many fields are on this line - so we loop right number of times 125 | for i := 0; i < len(record); i++ { 126 | // if we are processing the first line used for the table column name - update the 127 | // record field contents to remove the characters: space | - + @ # / \ : ( ) ' 128 | // from the SQL table column names. Can be overridden on command line with '-k true' 129 | if (lineCount == 0) && (keepOrigCols == false) { 130 | // call the function cleanHeader to do clean up on this field 131 | record[i] = cleanHeader(record[i]) 132 | } 133 | // if a csv record field is empty or has the text "NULL" - replace it with actual NULL field in SQLite 134 | // otherwise just wrap the existing content with '' 135 | // TODO : make sure we don't try to create a 'NULL' table column name? 136 | if len(record[i]) == 0 || record[i] == "NULL" { 137 | strbuffer.WriteString("NULL") 138 | } else { 139 | strbuffer.WriteString("\"" + record[i] + "\"") 140 | } 141 | // if we have not reached the last record yet - add a coma also to the output 142 | if i < len(record)-1 { 143 | strbuffer.WriteString(",") 144 | } 145 | } 146 | // end of the line - so output SQL format required ');' and newline 147 | strbuffer.WriteString(");\n") 148 | // line of SQL is complete - so push out to the new SQL file 149 | bWritten, err := sqlFileBuffer.WriteString(strbuffer.String()) 150 | // check it wrote data ok - otherwise report the error giving the line number affected 151 | if (err != nil) || (bWritten != len(strbuffer.Bytes())) { 152 | return "" 153 | } 154 | // reset the string buffer - so it is empty ready for the next line to build 155 | strbuffer.Reset() 156 | // for debug - show the line number we are processing from the CSV file 157 | // increment the line count - and loop back around for next line of the CSV file 158 | lineCount += 1 159 | } 160 | // write out final line to the SQL file 161 | bWritten, err := sqlFileBuffer.WriteString(strbuffer.String()) 162 | // check it wrote data ok - otherwise report the error giving the line number affected 163 | if (err != nil) || (bWritten != len(strbuffer.Bytes())) { 164 | return "" 165 | } 166 | strbuffer.WriteString("\nCOMMIT;") 167 | // finished the SQl file data writing - flush any IO buffers 168 | // NB below flush required as the data was being lost otherwise - maybe a bug in go version 1.2 only? 169 | sqlFileBuffer.Flush() 170 | // reset the string buffer - so it is empty as it is no longer needed 171 | strbuffer.Reset() 172 | 173 | return sqlOutFile 174 | } 175 | 176 | func cleanHeader(headField string) string { 177 | // ok - remove any spaces and replace with _ 178 | headField = strings.Replace(headField, " ", "_", -1) 179 | // ok - remove any | and replace with _ 180 | headField = strings.Replace(headField, "|", "_", -1) 181 | // ok - remove any - and replace with _ 182 | headField = strings.Replace(headField, "-", "_", -1) 183 | // ok - remove any + and replace with _ 184 | headField = strings.Replace(headField, "+", "_", -1) 185 | // ok - remove any @ and replace with _ 186 | headField = strings.Replace(headField, "@", "_", -1) 187 | // ok - remove any # and replace with _ 188 | headField = strings.Replace(headField, "#", "_", -1) 189 | // ok - remove any / and replace with _ 190 | headField = strings.Replace(headField, "/", "_", -1) 191 | // ok - remove any \ and replace with _ 192 | headField = strings.Replace(headField, "\\", "_", -1) 193 | // ok - remove any : and replace with _ 194 | headField = strings.Replace(headField, ":", "_", -1) 195 | // ok - remove any ( and replace with _ 196 | headField = strings.Replace(headField, "(", "_", -1) 197 | // ok - remove any ) and replace with _ 198 | headField = strings.Replace(headField, ")", "_", -1) 199 | // ok - remove any ' and replace with _ 200 | headField = strings.Replace(headField, "'", "_", -1) 201 | return headField 202 | } 203 | -------------------------------------------------------------------------------- /tuiutil/textinput.go: -------------------------------------------------------------------------------- 1 | package tuiutil 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "sync" 7 | "time" 8 | "unicode" 9 | 10 | "github.com/atotto/clipboard" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | rw "github.com/mattn/go-runewidth" 14 | ) 15 | 16 | const DefaultBlinkSpeed = time.Millisecond * 530 17 | 18 | // Internal ID management for text inputs. Necessary for blink integrity when 19 | // multiple text inputs are involved. 20 | var ( 21 | Ascii bool 22 | lastID int 23 | idMtx sync.Mutex 24 | ) 25 | 26 | // Return the next ID we should use on the TextInputModel. 27 | func nextID() int { 28 | idMtx.Lock() 29 | defer idMtx.Unlock() 30 | lastID++ 31 | return lastID 32 | } 33 | 34 | // initialBlinkMsg initializes cursor blinking. 35 | type initialBlinkMsg struct{} 36 | 37 | // blinkMsg signals that the cursor should blink. It contains metadata that 38 | // allows us to tell if the blink message is the one we're expecting. 39 | type blinkMsg struct { 40 | id int 41 | tag int 42 | } 43 | 44 | // blinkCanceled is sent when a blink operation is canceled. 45 | type blinkCanceled struct{} 46 | 47 | // Internal messages for clipboard operations. 48 | type pasteMsg string 49 | type pasteErrMsg struct{ error } 50 | 51 | // EchoMode sets the input behavior of the text input field. 52 | type EchoMode int 53 | 54 | const ( 55 | // EchoNormal displays text as is. This is the default behavior. 56 | EchoNormal EchoMode = iota 57 | 58 | // EchoPassword displays the EchoCharacter mask instead of actual 59 | // characters. This is commonly used for password fields. 60 | EchoPassword 61 | 62 | // EchoNone displays nothing as characters are entered. This is commonly 63 | // seen for password fields on the command line. 64 | EchoNone 65 | 66 | // EchoOnEdit 67 | ) 68 | 69 | // blinkCtx manages cursor blinking. 70 | type blinkCtx struct { 71 | ctx context.Context 72 | cancel context.CancelFunc 73 | } 74 | 75 | // CursorMode describes the behavior of the cursor. 76 | type CursorMode int 77 | 78 | // Available cursor modes. 79 | const ( 80 | CursorBlink CursorMode = iota 81 | CursorStatic 82 | CursorHide 83 | ) 84 | 85 | // String returns a the cursor mode in a human-readable format. This method is 86 | // provisional and for informational purposes only. 87 | func (c CursorMode) String() string { 88 | return [...]string{ 89 | "blink", 90 | "static", 91 | "hidden", 92 | }[c] 93 | } 94 | 95 | // TextInputModel is the Bubble Tea model for this text input element. 96 | type TextInputModel struct { 97 | Err error 98 | 99 | // General settings. 100 | Prompt string 101 | Placeholder string 102 | BlinkSpeed time.Duration 103 | EchoMode EchoMode 104 | EchoCharacter rune 105 | 106 | // Styles. These will be applied as inline styles. 107 | // 108 | // For an introduction to styling with Lip Gloss see: 109 | // https://github.com/charmbracelet/lipgloss 110 | PromptStyle lipgloss.Style 111 | TextStyle lipgloss.Style 112 | BackgroundStyle lipgloss.Style 113 | PlaceholderStyle lipgloss.Style 114 | CursorStyle lipgloss.Style 115 | 116 | // CharLimit is the maximum amount of characters this input element will 117 | // accept. If 0 or less, there's no limit. 118 | CharLimit int 119 | 120 | // Width is the maximum number of characters that can be displayed at once. 121 | // It essentially treats the text field like a horizontally scrolling 122 | // Viewport. If 0 or less this setting is ignored. 123 | Width int 124 | 125 | // The ID of this TextInputModel as it relates to other textinput Models. 126 | id int 127 | 128 | // The ID of the blink message we're expecting to receive. 129 | blinkTag int 130 | 131 | // Underlying text value. 132 | value []rune 133 | 134 | // Focus indicates whether user input Focus should be on this input 135 | // component. When false, ignore keyboard input and hide the cursor. 136 | Focus bool 137 | 138 | // Cursor blink state. 139 | blink bool 140 | 141 | // Cursor position. 142 | pos int 143 | 144 | // Used to emulate a Viewport when width is set and the content is 145 | // overflowing. 146 | Offset int 147 | OffsetRight int 148 | 149 | // Used to manage cursor blink 150 | blinkCtx *blinkCtx 151 | 152 | // cursorMode determines the behavior of the cursor 153 | cursorMode CursorMode 154 | } 155 | 156 | // NewModel creates a new model with default settings. 157 | func NewModel() TextInputModel { 158 | m := TextInputModel{ 159 | Prompt: "> ", 160 | BlinkSpeed: DefaultBlinkSpeed, 161 | EchoCharacter: '*', 162 | CharLimit: 0, 163 | PlaceholderStyle: lipgloss.NewStyle(), 164 | 165 | id: nextID(), 166 | value: nil, 167 | Focus: false, 168 | blink: true, 169 | pos: 0, 170 | cursorMode: CursorBlink, 171 | 172 | blinkCtx: &blinkCtx{ 173 | ctx: context.Background(), 174 | }, 175 | } 176 | 177 | if !Ascii { 178 | m.PlaceholderStyle = m.PlaceholderStyle.Foreground(lipgloss.Color("240")) 179 | } 180 | 181 | return m 182 | } 183 | 184 | // SetValue sets the value of the text input. 185 | func (m *TextInputModel) SetValue(s string) { 186 | runes := []rune(s) 187 | if m.CharLimit > 0 && len(runes) > m.CharLimit { 188 | m.value = runes[:m.CharLimit] 189 | } else { 190 | m.value = runes 191 | } 192 | if m.pos == 0 || m.pos > len(m.value) { 193 | m.setCursor(len(m.value)) 194 | } 195 | m.handleOverflow() 196 | } 197 | 198 | // Value returns the value of the text input. 199 | func (m TextInputModel) Value() string { 200 | return string(m.value) 201 | } 202 | 203 | // Cursor returns the cursor position. 204 | func (m TextInputModel) Cursor() int { 205 | return m.pos 206 | } 207 | 208 | // SetCursor moves the cursor to the given position. If the position is 209 | // out of bounds the cursor will be moved to the start or end accordingly. 210 | func (m *TextInputModel) SetCursor(pos int) { 211 | m.setCursor(pos) 212 | } 213 | 214 | // setCursor moves the cursor to the given position and returns whether or not 215 | // the cursor blink should be reset. If the position is out of bounds the 216 | // cursor will be moved to the start or end accordingly. 217 | func (m *TextInputModel) setCursor(pos int) bool { 218 | m.pos = Clamp(pos, 0, len(m.value)) 219 | m.handleOverflow() 220 | 221 | // Show the cursor unless it's been explicitly hidden 222 | m.blink = m.cursorMode == CursorHide 223 | 224 | // Reset cursor blink if necessary 225 | return m.cursorMode == CursorBlink 226 | } 227 | 228 | // CursorStart moves the cursor to the start of the input field. 229 | func (m *TextInputModel) CursorStart() { 230 | m.cursorStart() 231 | } 232 | 233 | // cursorStart moves the cursor to the start of the input field and returns 234 | // whether or not the curosr blink should be reset. 235 | func (m *TextInputModel) cursorStart() bool { 236 | return m.setCursor(0) 237 | } 238 | 239 | // CursorEnd moves the cursor to the end of the input field 240 | func (m *TextInputModel) CursorEnd() { 241 | m.cursorEnd() 242 | } 243 | 244 | // CursorMode returns the model's cursor mode. For available cursor modes, see 245 | // type CursorMode. 246 | func (m TextInputModel) CursorMode() CursorMode { 247 | return m.cursorMode 248 | } 249 | 250 | // SetCursorMode CursorMode sets the model's cursor mode. This method returns a command. 251 | // 252 | // For available cursor modes, see type CursorMode. 253 | func (m *TextInputModel) SetCursorMode(mode CursorMode) tea.Cmd { 254 | m.cursorMode = mode 255 | m.blink = m.cursorMode == CursorHide || !m.Focus 256 | if mode == CursorBlink { 257 | return Blink 258 | } 259 | return nil 260 | } 261 | 262 | // cursorEnd moves the cursor to the end of the input field and returns whether 263 | // the cursor should blink should reset. 264 | func (m *TextInputModel) cursorEnd() bool { 265 | return m.setCursor(len(m.value)) 266 | } 267 | 268 | // Focused returns the Focus state on the model. 269 | func (m TextInputModel) Focused() bool { 270 | return m.Focus 271 | } 272 | 273 | // FocusCommand sets the Focus state on the model. When the model is in Focus it can 274 | // receive keyboard input and the cursor will be hidden. 275 | func (m *TextInputModel) FocusCommand() tea.Cmd { 276 | m.Focus = true 277 | m.blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it 278 | 279 | if m.cursorMode == CursorBlink && m.Focus { 280 | return m.blinkCmd() 281 | } 282 | return nil 283 | } 284 | 285 | // Blur removes the Focus state on the model. When the model is blurred it can 286 | // not receive keyboard input and the cursor will be hidden. 287 | func (m *TextInputModel) Blur() { 288 | m.Focus = false 289 | m.blink = true 290 | } 291 | 292 | // Reset sets the input to its default state with no input. Returns whether 293 | // or not the cursor blink should reset. 294 | func (m *TextInputModel) Reset() bool { 295 | m.value = nil 296 | return m.setCursor(0) 297 | } 298 | 299 | // handle a clipboard paste event, if supported. Returns whether or not the 300 | // cursor blink should reset. 301 | func (m *TextInputModel) handlePaste(v string) bool { 302 | paste := []rune(v) 303 | 304 | var availSpace int 305 | if m.CharLimit > 0 { 306 | availSpace = m.CharLimit - len(m.value) 307 | } 308 | 309 | // If the char limit's been reached cancel 310 | if m.CharLimit > 0 && availSpace <= 0 { 311 | return false 312 | } 313 | 314 | // If there's not enough space to paste the whole thing cut the pasted 315 | // runes down so they'll fit 316 | if m.CharLimit > 0 && availSpace < len(paste) { 317 | paste = paste[:len(paste)-availSpace] 318 | } 319 | 320 | // Stuff before and after the cursor 321 | head := m.value[:m.pos] 322 | tailSrc := m.value[m.pos:] 323 | tail := make([]rune, len(tailSrc)) 324 | copy(tail, tailSrc) 325 | 326 | // Insert pasted runes 327 | for _, r := range paste { 328 | head = append(head, r) 329 | m.pos++ 330 | if m.CharLimit > 0 { 331 | availSpace-- 332 | if availSpace <= 0 { 333 | break 334 | } 335 | } 336 | } 337 | 338 | // Put it all back together 339 | m.value = append(head, tail...) 340 | 341 | // Reset blink state if necessary and run overflow checks 342 | return m.setCursor(m.pos) 343 | } 344 | 345 | // If a max width is defined, perform some logic to treat the visible area 346 | // as a horizontally scrolling Viewport. 347 | func (m *TextInputModel) handleOverflow() { 348 | if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width { 349 | m.Offset = 0 350 | m.OffsetRight = len(m.value) 351 | return 352 | } 353 | 354 | // Correct right Offset if we've deleted characters 355 | m.OffsetRight = min(m.OffsetRight, len(m.value)) 356 | 357 | if m.pos < m.Offset { 358 | m.Offset = m.pos 359 | 360 | w := 0 361 | i := 0 362 | runes := m.value[m.Offset:] 363 | 364 | for i < len(runes) && w <= m.Width { 365 | w += rw.RuneWidth(runes[i]) 366 | if w <= m.Width+1 { 367 | i++ 368 | } 369 | } 370 | 371 | m.OffsetRight = m.Offset + i 372 | } else if m.pos >= m.OffsetRight { 373 | m.OffsetRight = m.pos 374 | 375 | w := 0 376 | runes := m.value[:m.OffsetRight] 377 | i := len(runes) - 1 378 | 379 | for i > 0 && w < m.Width { 380 | w += rw.RuneWidth(runes[i]) 381 | if w <= m.Width { 382 | i-- 383 | } 384 | } 385 | 386 | m.Offset = m.OffsetRight - (len(runes) - 1 - i) 387 | } 388 | } 389 | 390 | // deleteBeforeCursor deletes all text before the cursor. Returns whether or 391 | // not the cursor blink should be reset. 392 | func (m *TextInputModel) deleteBeforeCursor() bool { 393 | m.value = m.value[m.pos:] 394 | m.Offset = 0 395 | return m.setCursor(0) 396 | } 397 | 398 | // deleteAfterCursor deletes all text after the cursor. Returns whether or not 399 | // the cursor blink should be reset. If input is masked delete everything after 400 | // the cursor so as not to reveal word breaks in the masked input. 401 | func (m *TextInputModel) deleteAfterCursor() bool { 402 | m.value = m.value[:m.pos] 403 | return m.setCursor(len(m.value)) 404 | } 405 | 406 | // deleteWordLeft deletes the word left to the cursor. Returns whether or not 407 | // the cursor blink should be reset. 408 | func (m *TextInputModel) deleteWordLeft() bool { 409 | if m.pos == 0 || len(m.value) == 0 { 410 | return false 411 | } 412 | 413 | if m.EchoMode != EchoNormal { 414 | return m.deleteBeforeCursor() 415 | } 416 | 417 | i := m.pos 418 | blink := m.setCursor(m.pos - 1) 419 | for unicode.IsSpace(m.value[m.pos]) { 420 | // ignore series of whitespace before cursor 421 | blink = m.setCursor(m.pos - 1) 422 | } 423 | 424 | for m.pos > 0 { 425 | if !unicode.IsSpace(m.value[m.pos]) { 426 | blink = m.setCursor(m.pos - 1) 427 | } else { 428 | if m.pos > 0 { 429 | // keep the previous space 430 | blink = m.setCursor(m.pos + 1) 431 | } 432 | break 433 | } 434 | } 435 | 436 | if i > len(m.value) { 437 | m.value = m.value[:m.pos] 438 | } else { 439 | m.value = append(m.value[:m.pos], m.value[i:]...) 440 | } 441 | 442 | return blink 443 | } 444 | 445 | // deleteWordRight deletes the word right to the cursor. Returns whether or not 446 | // the cursor blink should be reset. If input is masked delete everything after 447 | // the cursor so as not to reveal word breaks in the masked input. 448 | func (m *TextInputModel) deleteWordRight() bool { 449 | if m.pos >= len(m.value) || len(m.value) == 0 { 450 | return false 451 | } 452 | 453 | if m.EchoMode != EchoNormal { 454 | return m.deleteAfterCursor() 455 | } 456 | 457 | i := m.pos 458 | m.setCursor(m.pos + 1) 459 | for unicode.IsSpace(m.value[m.pos]) { 460 | // ignore series of whitespace after cursor 461 | m.setCursor(m.pos + 1) 462 | } 463 | 464 | for m.pos < len(m.value) { 465 | if !unicode.IsSpace(m.value[m.pos]) { 466 | m.setCursor(m.pos + 1) 467 | } else { 468 | break 469 | } 470 | } 471 | 472 | if m.pos > len(m.value) { 473 | m.value = m.value[:i] 474 | } else { 475 | m.value = append(m.value[:i], m.value[m.pos:]...) 476 | } 477 | 478 | return m.setCursor(i) 479 | } 480 | 481 | // wordLeft moves the cursor one word to the left. Returns whether or not the 482 | // cursor blink should be reset. If input is masked, move input to the start 483 | // so as not to reveal word breaks in the masked input. 484 | func (m *TextInputModel) wordLeft() bool { 485 | if m.pos == 0 || len(m.value) == 0 { 486 | return false 487 | } 488 | 489 | if m.EchoMode != EchoNormal { 490 | return m.cursorStart() 491 | } 492 | 493 | blink := false 494 | i := m.pos - 1 495 | for i >= 0 { 496 | if unicode.IsSpace(m.value[i]) { 497 | blink = m.setCursor(m.pos - 1) 498 | i-- 499 | } else { 500 | break 501 | } 502 | } 503 | 504 | for i >= 0 { 505 | if !unicode.IsSpace(m.value[i]) { 506 | blink = m.setCursor(m.pos - 1) 507 | i-- 508 | } else { 509 | break 510 | } 511 | } 512 | 513 | return blink 514 | } 515 | 516 | // wordRight moves the cursor one word to the right. Returns whether or not the 517 | // cursor blink should be reset. If the input is masked, move input to the end 518 | // so as not to reveal word breaks in the masked input. 519 | func (m *TextInputModel) wordRight() bool { 520 | if m.pos >= len(m.value) || len(m.value) == 0 { 521 | return false 522 | } 523 | 524 | if m.EchoMode != EchoNormal { 525 | return m.cursorEnd() 526 | } 527 | 528 | blink := false 529 | i := m.pos 530 | for i < len(m.value) { 531 | if unicode.IsSpace(m.value[i]) { 532 | blink = m.setCursor(m.pos + 1) 533 | i++ 534 | } else { 535 | break 536 | } 537 | } 538 | 539 | for i < len(m.value) { 540 | if !unicode.IsSpace(m.value[i]) { 541 | blink = m.setCursor(m.pos + 1) 542 | i++ 543 | } else { 544 | break 545 | } 546 | } 547 | 548 | return blink 549 | } 550 | 551 | func (m TextInputModel) echoTransform(v string) string { 552 | switch m.EchoMode { 553 | case EchoPassword: 554 | return strings.Repeat(string(m.EchoCharacter), rw.StringWidth(v)) 555 | case EchoNone: 556 | return "" 557 | 558 | default: 559 | return v 560 | } 561 | } 562 | 563 | // Update is the Bubble Tea update loop. 564 | func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { 565 | if !m.Focus { 566 | m.blink = true 567 | return m, nil 568 | } 569 | 570 | var resetBlink bool 571 | 572 | switch msg := msg.(type) { 573 | case tea.KeyMsg: 574 | switch msg.Type { 575 | case tea.KeyBackspace: // delete character before cursor 576 | if msg.Alt { 577 | resetBlink = m.deleteWordLeft() 578 | } else { 579 | if len(m.value) > 0 { 580 | m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) 581 | if m.pos > 0 { 582 | resetBlink = m.setCursor(m.pos - 1) 583 | } 584 | } 585 | } 586 | case tea.KeyLeft, tea.KeyCtrlB: 587 | if msg.Alt { // alt+left arrow, back one word 588 | resetBlink = m.wordLeft() 589 | break 590 | } 591 | if m.pos > 0 { // left arrow, ^F, back one character 592 | resetBlink = m.setCursor(m.pos - 1) 593 | } 594 | case tea.KeyRight, tea.KeyCtrlF: 595 | if msg.Alt { // alt+right arrow, forward one word 596 | resetBlink = m.wordRight() 597 | break 598 | } 599 | if m.pos < len(m.value) { // right arrow, ^F, forward one character 600 | resetBlink = m.setCursor(m.pos + 1) 601 | } 602 | case tea.KeyCtrlW: // ^W, delete word left of cursor 603 | resetBlink = m.deleteWordLeft() 604 | case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning 605 | resetBlink = m.cursorStart() 606 | case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor 607 | if len(m.value) > 0 && m.pos < len(m.value) { 608 | m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) 609 | } 610 | case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end 611 | resetBlink = m.cursorEnd() 612 | case tea.KeyCtrlK: // ^K, kill text after cursor 613 | resetBlink = m.deleteAfterCursor() 614 | case tea.KeyCtrlU: // ^U, kill text before cursor 615 | resetBlink = m.deleteBeforeCursor() 616 | case tea.KeyCtrlV: // ^V paste 617 | return m, Paste 618 | case tea.KeyRunes: // input regular characters 619 | if msg.Alt && len(msg.Runes) == 1 { 620 | if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor 621 | resetBlink = m.deleteWordRight() 622 | break 623 | } 624 | if msg.Runes[0] == 'b' { // alt+b, back one word 625 | resetBlink = m.wordLeft() 626 | break 627 | } 628 | if msg.Runes[0] == 'f' { // alt+f, forward one word 629 | resetBlink = m.wordRight() 630 | break 631 | } 632 | } 633 | 634 | // Input a regular character 635 | if m.CharLimit <= 0 || len(m.value) < m.CharLimit { 636 | m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...) 637 | resetBlink = m.setCursor(m.pos + len(msg.Runes)) 638 | } 639 | } 640 | 641 | case initialBlinkMsg: 642 | // We accept all initialBlinkMsgs genrated by the Blink command. 643 | 644 | if m.cursorMode != CursorBlink || !m.Focus { 645 | return m, nil 646 | } 647 | 648 | cmd := m.blinkCmd() 649 | return m, cmd 650 | 651 | case blinkMsg: 652 | // We're choosy about whether to accept blinkMsgs so that our cursor 653 | // only exactly when it should. 654 | 655 | // Is this model blinkable? 656 | if m.cursorMode != CursorBlink || !m.Focus { 657 | return m, nil 658 | } 659 | 660 | // Were we expecting this blink message? 661 | if msg.id != m.id || msg.tag != m.blinkTag { 662 | return m, nil 663 | } 664 | 665 | var cmd tea.Cmd 666 | if m.cursorMode == CursorBlink { 667 | m.blink = !m.blink 668 | cmd = m.blinkCmd() 669 | } 670 | return m, cmd 671 | 672 | case blinkCanceled: // no-op 673 | return m, nil 674 | 675 | case pasteMsg: 676 | resetBlink = m.handlePaste(string(msg)) 677 | 678 | case pasteErrMsg: 679 | m.Err = msg 680 | } 681 | 682 | var cmd tea.Cmd 683 | if resetBlink { 684 | cmd = m.blinkCmd() 685 | } 686 | 687 | m.handleOverflow() 688 | return m, cmd 689 | } 690 | 691 | // View renders the textinput in its current state. 692 | func (m TextInputModel) View() string { 693 | // Placeholder text 694 | if len(m.value) == 0 && m.Placeholder != "" { 695 | return m.placeholderView() 696 | } 697 | 698 | styleText := m.TextStyle.Inline(true).Render 699 | 700 | value := m.value[m.Offset:m.OffsetRight] 701 | pos := max(0, m.pos-m.Offset) 702 | v := styleText(m.echoTransform(string(value[:pos]))) 703 | 704 | if pos < len(value) { 705 | if Ascii { 706 | v += "¦" 707 | } 708 | v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it 709 | v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor 710 | } else { 711 | v += m.cursorView(" ") 712 | } 713 | 714 | // If a max width and background color were set fill the empty spaces with 715 | // the background color. 716 | valWidth := rw.StringWidth(string(value)) 717 | if m.Width > 0 && valWidth <= m.Width { 718 | padding := max(0, m.Width-valWidth) 719 | if valWidth+padding <= m.Width && pos < len(value) { 720 | padding++ 721 | } 722 | v += styleText(strings.Repeat(" ", padding)) 723 | } 724 | 725 | return m.PromptStyle.Render(m.Prompt) + v 726 | } 727 | 728 | // placeholderView returns the prompt and placeholder view, if any. 729 | func (m TextInputModel) placeholderView() string { 730 | var ( 731 | v string 732 | p = m.Placeholder 733 | style = m.PlaceholderStyle.Inline(true).Render 734 | ) 735 | 736 | // Cursor 737 | if m.blink { 738 | v += m.cursorView(style(p[:1])) 739 | } else { 740 | v += m.cursorView(p[:1]) 741 | } 742 | 743 | // The rest of the placeholder text 744 | v += style(p[1:]) 745 | 746 | return m.PromptStyle.Render(m.Prompt) + v 747 | } 748 | 749 | // cursorView styles the cursor. 750 | func (m TextInputModel) cursorView(v string) string { 751 | if m.blink { 752 | return m.TextStyle.Render(v) 753 | } 754 | s := m.CursorStyle.Inline(true) 755 | if !Ascii { 756 | s = s.Reverse(true) 757 | } 758 | 759 | return s.Render(v) 760 | } 761 | 762 | // blinkCmd is an internal command used to manage cursor blinking. 763 | func (m *TextInputModel) blinkCmd() tea.Cmd { 764 | if m.cursorMode != CursorBlink { 765 | return nil 766 | } 767 | 768 | if m.blinkCtx != nil && m.blinkCtx.cancel != nil { 769 | m.blinkCtx.cancel() 770 | } 771 | 772 | ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed) 773 | m.blinkCtx.cancel = cancel 774 | 775 | m.blinkTag++ 776 | 777 | return func() tea.Msg { 778 | defer cancel() 779 | <-ctx.Done() 780 | if ctx.Err() == context.DeadlineExceeded { 781 | return blinkMsg{id: m.id, tag: m.blinkTag} 782 | } 783 | return blinkCanceled{} 784 | } 785 | } 786 | 787 | // Blink is a command used to initialize cursor blinking. 788 | func Blink() tea.Msg { 789 | return initialBlinkMsg{} 790 | } 791 | 792 | // Paste is a command for pasting from the clipboard into the text input. 793 | func Paste() tea.Msg { 794 | str, err := clipboard.ReadAll() 795 | if err != nil { 796 | return pasteErrMsg{err} 797 | } 798 | return pasteMsg(str) 799 | } 800 | 801 | func Clamp(v, low, high int) int { 802 | return min(high, max(low, v)) 803 | } 804 | 805 | func min(a, b int) int { 806 | if a < b { 807 | return a 808 | } 809 | return b 810 | } 811 | 812 | func max(a, b int) int { 813 | if a > b { 814 | return a 815 | } 816 | return b 817 | } 818 | -------------------------------------------------------------------------------- /tuiutil/theme.go: -------------------------------------------------------------------------------- 1 | package tuiutil 2 | 3 | const ( 4 | HighlightKey = "Highlight" 5 | HeaderBackgroundKey = "HeaderBackground" 6 | HeaderBorderBackgroundKey = "HeaderBorderBackground" 7 | HeaderForegroundKey = "HeaderForeground" 8 | FooterForegroundColorKey = "FooterForeground" 9 | HeaderBottomColorKey = "HeaderBottom" 10 | HeaderTopForegroundColorKey = "HeaderTopForeground" 11 | BorderColorKey = "BorderColor" 12 | TextColorKey = "TextColor" 13 | ) 14 | 15 | // styling functions 16 | var ( 17 | Highlight = func() string { 18 | return ThemesMap[SelectedTheme][HighlightKey] 19 | } // change to whatever 20 | HeaderBackground = func() string { 21 | return ThemesMap[SelectedTheme][HeaderBackgroundKey] 22 | } 23 | HeaderBorderBackground = func() string { 24 | return ThemesMap[SelectedTheme][HeaderBorderBackgroundKey] 25 | } 26 | HeaderForeground = func() string { 27 | return ThemesMap[SelectedTheme][HeaderForegroundKey] 28 | } 29 | FooterForeground = func() string { 30 | return ThemesMap[SelectedTheme][FooterForegroundColorKey] 31 | } 32 | HeaderBottom = func() string { 33 | return ThemesMap[SelectedTheme][HeaderBottomColorKey] 34 | } 35 | HeaderTopForeground = func() string { 36 | return ThemesMap[SelectedTheme][HeaderTopForegroundColorKey] 37 | } 38 | BorderColor = func() string { 39 | return ThemesMap[SelectedTheme][BorderColorKey] 40 | } 41 | TextColor = func() string { 42 | return ThemesMap[SelectedTheme][TextColorKey] 43 | } 44 | ) 45 | 46 | var ( 47 | SelectedTheme = 0 48 | ValidThemes = []string{ 49 | "default", // 0 50 | "nord", // 1 51 | "solarized", // not accurate but whatever 52 | } 53 | ThemesMap = map[int]map[string]string{ 54 | 2: { 55 | HeaderBackgroundKey: "#268bd2", 56 | HeaderBorderBackgroundKey: "#268bd2", 57 | HeaderBottomColorKey: "#586e75", 58 | BorderColorKey: "#586e75", 59 | TextColorKey: "#fdf6e3", 60 | HeaderForegroundKey: "#fdf6e3", 61 | HighlightKey: "#2aa198", 62 | FooterForegroundColorKey: "#d33682", 63 | HeaderTopForegroundColorKey: "#d33682", 64 | }, 65 | 1: { 66 | HeaderBackgroundKey: "#5e81ac", 67 | HeaderBorderBackgroundKey: "#5e81ac", 68 | HeaderBottomColorKey: "#5e81ac", 69 | BorderColorKey: "#eceff4", 70 | TextColorKey: "#eceff4", 71 | HeaderForegroundKey: "#eceff4", 72 | HighlightKey: "#88c0d0", 73 | FooterForegroundColorKey: "#b48ead", 74 | HeaderTopForegroundColorKey: "#b48ead", 75 | }, 76 | 0: { 77 | HeaderBackgroundKey: "#505050", 78 | HeaderBorderBackgroundKey: "#505050", 79 | HeaderBottomColorKey: "#FFFFFF", 80 | BorderColorKey: "#FFFFFF", 81 | TextColorKey: "#FFFFFF", 82 | HeaderForegroundKey: "#FFFFFF", 83 | HighlightKey: "#A0A0A0", 84 | FooterForegroundColorKey: "#C2C2C2", 85 | HeaderTopForegroundColorKey: "#C2C2C2", 86 | }, 87 | } 88 | ) 89 | -------------------------------------------------------------------------------- /tuiutil/wordwrap.go: -------------------------------------------------------------------------------- 1 | package tuiutil 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Indent a string with the given prefix at the start of either the first, or all lines. 8 | // 9 | // input - The input string to indent. 10 | // prefix - The prefix to add. 11 | // prefixAll - If true, prefix all lines with the given prefix. 12 | // 13 | // Example usage: 14 | // 15 | // indented := wordwrap.Indent("Hello\nWorld", "-", true) 16 | func Indent(input string, prefix string, prefixAll bool) string { 17 | lines := strings.Split(input, "\n") 18 | prefixLen := len(prefix) 19 | result := make([]string, len(lines)) 20 | 21 | for i, line := range lines { 22 | if prefixAll || i == 0 { 23 | result[i] = prefix + line 24 | } else { 25 | result[i] = strings.Repeat(" ", prefixLen) + line 26 | } 27 | } 28 | 29 | return strings.Join(result, "\n") 30 | } 31 | -------------------------------------------------------------------------------- /viewer/defs.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/viewport" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/mathaou/termdbms/database" 8 | "github.com/mathaou/termdbms/list" 9 | ) 10 | 11 | type SQLSnippet struct { 12 | Query string `json:"Query"` 13 | Name string `json:"Name"` 14 | } 15 | 16 | type ScrollData struct { 17 | PreScrollYOffset int 18 | PreScrollYPosition int 19 | ScrollXOffset int 20 | } 21 | 22 | // TableState holds everything needed to save/serialize state 23 | type TableState struct { 24 | Database database.Database 25 | Data map[string]interface{} 26 | } 27 | 28 | type UIState struct { 29 | CanFormatScroll bool 30 | RenderSelection bool // render mode 31 | EditModeEnabled bool // edit mode 32 | FormatModeEnabled bool 33 | BorderToggle bool 34 | SQLEdit bool 35 | ShowClipboard bool 36 | ExpandColumn int 37 | CurrentTable int 38 | } 39 | 40 | type UIData struct { 41 | TableHeaders map[string][]string // keeps track of which schema has which headers 42 | TableHeadersSlice []string 43 | TableSlices map[string][]interface{} 44 | TableIndexMap map[int]string // keeps the schemas in order 45 | EditTextBuffer string 46 | } 47 | 48 | type FormatState struct { 49 | EditSlices []*string // the bit to show 50 | Text []string // the master collection of lines to edit 51 | RunningOffsets []int // this is a LUT for where in the original EditTextBuffer each line starts 52 | CursorX int 53 | CursorY int 54 | } 55 | 56 | // TuiModel holds all the necessary state for this app to work the way I designed it to 57 | type TuiModel struct { 58 | DefaultTable TableState // all non-destructive changes are TableStates getting passed around 59 | DefaultData UIData 60 | QueryResult *TableState 61 | QueryData *UIData 62 | Format FormatState 63 | UI UIState 64 | Scroll ScrollData 65 | Ready bool 66 | InitialFileName string // used if saving destructively 67 | Viewport viewport.Model 68 | ClipboardList list.Model 69 | Clipboard []list.Item 70 | TableStyle lipgloss.Style 71 | MouseData tea.MouseEvent 72 | TextInput LineEdit 73 | FormatInput LineEdit 74 | UndoStack []TableState 75 | RedoStack []TableState 76 | } 77 | -------------------------------------------------------------------------------- /viewer/events.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/charmbracelet/bubbles/viewport" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/mathaou/termdbms/list" 13 | "github.com/mathaou/termdbms/tuiutil" 14 | ) 15 | 16 | // HandleMouseEvents does that 17 | func HandleMouseEvents(m *TuiModel, msg *tea.MouseMsg) { 18 | switch msg.Type { 19 | case tea.MouseWheelDown: 20 | if !m.UI.EditModeEnabled { 21 | ScrollDown(m) 22 | } 23 | break 24 | case tea.MouseWheelUp: 25 | if !m.UI.EditModeEnabled { 26 | ScrollUp(m) 27 | } 28 | break 29 | case tea.MouseLeft: 30 | if !m.UI.EditModeEnabled && !m.UI.FormatModeEnabled && m.GetRow() < len(m.GetColumnData()) { 31 | SelectOption(m) 32 | } 33 | break 34 | default: 35 | if !m.UI.RenderSelection && !m.UI.EditModeEnabled && !m.UI.FormatModeEnabled { 36 | m.MouseData = tea.MouseEvent(*msg) 37 | } 38 | break 39 | } 40 | } 41 | 42 | // HandleWindowSizeEvents does that 43 | func HandleWindowSizeEvents(m *TuiModel, msg *tea.WindowSizeMsg) tea.Cmd { 44 | verticalMargins := HeaderHeight + FooterHeight 45 | 46 | if !m.Ready { 47 | width := msg.Width 48 | height := msg.Height 49 | m.Viewport = viewport.Model{ 50 | Width: width, 51 | Height: height - verticalMargins} 52 | 53 | m.ClipboardList.SetWidth(width) 54 | m.ClipboardList.SetHeight(height) 55 | TUIWidth = width 56 | TUIHeight = height 57 | m.Viewport.YPosition = HeaderHeight 58 | m.Viewport.HighPerformanceRendering = true 59 | m.Ready = true 60 | m.MouseData.Y = HeaderHeight 61 | 62 | MaxInputLength = m.Viewport.Width 63 | m.TextInput.Model.CharLimit = -1 64 | m.TextInput.Model.Width = MaxInputLength - lipgloss.Width(m.TextInput.Model.Prompt) 65 | m.TextInput.Model.BlinkSpeed = time.Second 66 | m.TextInput.Model.SetCursorMode(tuiutil.CursorBlink) 67 | 68 | m.TableStyle = m.GetBaseStyle() 69 | m.SetViewSlices() 70 | } else { 71 | m.Viewport.Width = msg.Width 72 | m.Viewport.Height = msg.Height - verticalMargins 73 | } 74 | 75 | if m.Viewport.HighPerformanceRendering { 76 | return viewport.Sync(m.Viewport) 77 | } 78 | 79 | return nil 80 | } 81 | 82 | func HandleClipboardEvents(m *TuiModel, str string, command *tea.Cmd, msg tea.Msg) { 83 | state := m.ClipboardList.FilterState() 84 | if (str == "q" || str == "esc" || str == "enter") && state != list.Filtering { 85 | switch str { 86 | case "enter": 87 | i, ok := m.ClipboardList.SelectedItem().(SQLSnippet) 88 | if ok { 89 | ExitToDefaultView(m) 90 | CreatePopulatedBuffer(m, nil, i.Query) 91 | m.UI.SQLEdit = true 92 | } 93 | break 94 | default: 95 | ExitToDefaultView(m) 96 | } 97 | m.ClipboardList.ResetFilter() 98 | } else { 99 | tmpItems := len(m.ClipboardList.Items()) 100 | m.ClipboardList, *command = m.ClipboardList.Update(msg) 101 | if len(m.ClipboardList.Items()) != tmpItems { // if item removed 102 | m.Clipboard = m.ClipboardList.Items() 103 | b, _ := json.Marshal(m.Clipboard) 104 | snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile) 105 | f, _ := os.OpenFile(snippetsFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775) 106 | f.Write(b) 107 | f.Close() 108 | } 109 | } 110 | } 111 | 112 | // HandleKeyboardEvents does that 113 | func HandleKeyboardEvents(m *TuiModel, msg *tea.KeyMsg) tea.Cmd { 114 | var ( 115 | cmd tea.Cmd 116 | ) 117 | str := msg.String() 118 | 119 | if m.UI.EditModeEnabled { // handle edit mode 120 | HandleEditMode(m, str) 121 | return nil 122 | } else if m.UI.FormatModeEnabled { 123 | if str == "esc" { // cycle focus 124 | if m.TextInput.Model.Focused() { 125 | cmd = m.FormatInput.Model.FocusCommand() 126 | m.TextInput.Model.Blur() 127 | } else { 128 | cmd = m.TextInput.Model.FocusCommand() 129 | m.FormatInput.Model.Blur() 130 | } 131 | return cmd 132 | } 133 | 134 | if m.TextInput.Model.Focused() { 135 | HandleEditMode(m, str) 136 | } else { 137 | HandleFormatMode(m, str) 138 | } 139 | 140 | return nil 141 | } 142 | 143 | for k := range GlobalCommands { 144 | if str == k { 145 | return GlobalCommands[str](m) 146 | } 147 | } 148 | 149 | return nil 150 | } 151 | -------------------------------------------------------------------------------- /viewer/global.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/mathaou/termdbms/database" 10 | "github.com/mathaou/termdbms/tuiutil" 11 | ) 12 | 13 | type Command func(m *TuiModel) tea.Cmd 14 | 15 | var ( 16 | GlobalCommands = make(map[string]Command) 17 | ) 18 | 19 | func init() { 20 | // GLOBAL COMMANDS 21 | GlobalCommands["t"] = func(m *TuiModel) tea.Cmd { 22 | tuiutil.SelectedTheme = (tuiutil.SelectedTheme + 1) % len(tuiutil.ValidThemes) 23 | SetStyles() 24 | themeName := tuiutil.ValidThemes[tuiutil.SelectedTheme] 25 | m.WriteMessage(fmt.Sprintf("Changed themes to %s", themeName)) 26 | return nil 27 | } 28 | GlobalCommands["pgdown"] = func(m *TuiModel) tea.Cmd { 29 | for i := 0; i < m.Viewport.Height; i++ { 30 | ScrollDown(m) 31 | } 32 | 33 | return nil 34 | } 35 | GlobalCommands["pgup"] = func(m *TuiModel) tea.Cmd { 36 | for i := 0; i < m.Viewport.Height; i++ { 37 | ScrollUp(m) 38 | } 39 | 40 | return nil 41 | } 42 | GlobalCommands["r"] = func(m *TuiModel) tea.Cmd { 43 | if len(m.RedoStack) > 0 && m.QueryResult == nil && m.QueryData == nil { // do this after you get undo working, basically just the same thing reversed 44 | // handle undo 45 | deepCopy := m.CopyMap() 46 | // THE GLOBALIST TAKEOVER 47 | deepState := TableState{ 48 | Database: &database.SQLite{ 49 | FileName: m.Table().Database.GetFileName(), 50 | Database: nil, 51 | }, // placeholder for now while testing database copy 52 | Data: deepCopy, 53 | } 54 | m.UndoStack = append(m.UndoStack, deepState) 55 | // handle redo 56 | from := m.RedoStack[len(m.RedoStack)-1] 57 | to := m.Table() 58 | m.SwapTableValues(&from, to) 59 | m.Table().Database.CloseDatabaseReference() 60 | m.Table().Database.SetDatabaseReference(from.Database.GetFileName()) 61 | 62 | m.RedoStack = m.RedoStack[0 : len(m.RedoStack)-1] // pop 63 | } 64 | 65 | return nil 66 | } 67 | GlobalCommands["u"] = func(m *TuiModel) tea.Cmd { 68 | if len(m.UndoStack) > 0 && m.QueryResult == nil && m.QueryData == nil { 69 | // handle redo 70 | deepCopy := m.CopyMap() 71 | t := m.Table() 72 | // THE GLOBALIST TAKEOVER 73 | deepState := TableState{ 74 | Database: &database.SQLite{ 75 | FileName: t.Database.GetFileName(), 76 | Database: nil, 77 | }, // placeholder for now while testing database copy 78 | Data: deepCopy, 79 | } 80 | m.RedoStack = append(m.RedoStack, deepState) 81 | // handle undo 82 | from := m.UndoStack[len(m.UndoStack)-1] 83 | to := t 84 | m.SwapTableValues(&from, to) 85 | t.Database.CloseDatabaseReference() 86 | t.Database.SetDatabaseReference(from.Database.GetFileName()) 87 | 88 | m.UndoStack = m.UndoStack[0 : len(m.UndoStack)-1] // pop 89 | } 90 | 91 | return nil 92 | } 93 | GlobalCommands[":"] = func(m *TuiModel) tea.Cmd { 94 | var ( 95 | cmd tea.Cmd 96 | ) 97 | if m.QueryData != nil || m.QueryResult != nil { // editing not allowed in query view mode 98 | return nil 99 | } 100 | m.UI.EditModeEnabled = true 101 | raw, _, _ := m.GetSelectedOption() 102 | if raw == nil { 103 | m.UI.EditModeEnabled = false 104 | return nil 105 | } 106 | 107 | str := GetStringRepresentationOfInterface(*raw) 108 | // so if the selected text is wider than Viewport width or if it has newlines do format mode 109 | if lipgloss.Width(str+m.TextInput.Model.Prompt) > m.Viewport.Width || 110 | strings.Count(str, "\n") > 0 { // enter format view 111 | PrepareFormatMode(m) 112 | cmd = m.FormatInput.Model.FocusCommand() // get focus 113 | m.Scroll.PreScrollYOffset = m.Viewport.YOffset // store scrolling so state can be restored on exit 114 | m.Scroll.PreScrollYPosition = m.MouseData.Y 115 | d := m.Data() 116 | if conv, err := FormatJson(str); err == nil { // if json prettify 117 | d.EditTextBuffer = conv 118 | } else { 119 | d.EditTextBuffer = str 120 | } 121 | m.FormatInput.Original = raw // pointer to original data 122 | m.Format.Text = GetFormattedTextBuffer(m) 123 | m.SetViewSlices() 124 | m.FormatInput.Model.SetCursor(0) 125 | } else { // otherwise, edit normally up top 126 | m.TextInput.Model.SetValue(str) 127 | m.FormatInput.Model.Focus = false 128 | m.TextInput.Model.Focus = true 129 | } 130 | 131 | return cmd 132 | } 133 | GlobalCommands["p"] = func(m *TuiModel) tea.Cmd { 134 | if m.UI.RenderSelection { 135 | fn, _ := WriteTextFile(m, m.Data().EditTextBuffer) 136 | m.WriteMessage(fmt.Sprintf("Wrote selection to %s", fn)) 137 | } else if m.QueryData != nil || m.QueryResult != nil || database.IsCSV { 138 | WriteCSV(m) 139 | } 140 | go Program.Send(tea.KeyMsg{}) 141 | return nil 142 | } 143 | GlobalCommands["c"] = func(m *TuiModel) tea.Cmd { 144 | ToggleColumn(m) 145 | 146 | return nil 147 | } 148 | GlobalCommands["b"] = func(m *TuiModel) tea.Cmd { 149 | m.UI.BorderToggle = !m.UI.BorderToggle 150 | 151 | return nil 152 | } 153 | GlobalCommands["up"] = func(m *TuiModel) tea.Cmd { 154 | if m.UI.CurrentTable == len(m.Data().TableIndexMap) { 155 | m.UI.CurrentTable = 1 156 | } else { 157 | m.UI.CurrentTable++ 158 | } 159 | 160 | // fix spacing and whatnot 161 | m.TableStyle = m.TableStyle.Width(m.CellWidth()) 162 | m.MouseData.Y = HeaderHeight 163 | m.MouseData.X = 0 164 | m.Viewport.YOffset = 0 165 | m.Scroll.ScrollXOffset = 0 166 | 167 | return nil 168 | } 169 | GlobalCommands["down"] = func(m *TuiModel) tea.Cmd { 170 | if m.UI.CurrentTable == 1 { 171 | m.UI.CurrentTable = len(m.Data().TableIndexMap) 172 | } else { 173 | m.UI.CurrentTable-- 174 | } 175 | 176 | // fix spacing and whatnot 177 | m.TableStyle = m.TableStyle.Width(m.CellWidth()) 178 | m.MouseData.Y = HeaderHeight 179 | m.MouseData.X = 0 180 | m.Viewport.YOffset = 0 181 | m.Scroll.ScrollXOffset = 0 182 | 183 | return nil 184 | } 185 | GlobalCommands["right"] = func(m *TuiModel) tea.Cmd { 186 | headers := m.GetHeaders() 187 | headersLen := len(headers) 188 | if headersLen > maxHeaders && m.Scroll.ScrollXOffset <= headersLen-maxHeaders { 189 | m.Scroll.ScrollXOffset++ 190 | } 191 | 192 | return nil 193 | } 194 | GlobalCommands["left"] = func(m *TuiModel) tea.Cmd { 195 | if m.Scroll.ScrollXOffset > 0 { 196 | m.Scroll.ScrollXOffset-- 197 | } 198 | 199 | return nil 200 | } 201 | GlobalCommands["s"] = func(m *TuiModel) tea.Cmd { 202 | max := len(m.GetSchemaData()[m.GetHeaders()[m.GetColumn()]]) 203 | 204 | if m.MouseData.Y-HeaderHeight+m.Viewport.YOffset < max-1 { 205 | m.MouseData.Y++ 206 | ceiling := m.Viewport.Height + HeaderHeight - 1 207 | tuiutil.Clamp(m.MouseData.Y, m.MouseData.Y+1, ceiling) 208 | if m.MouseData.Y > ceiling { 209 | ScrollDown(m) 210 | m.MouseData.Y = ceiling 211 | } 212 | } 213 | 214 | return nil 215 | } 216 | GlobalCommands["w"] = func(m *TuiModel) tea.Cmd { 217 | pre := m.MouseData.Y 218 | if m.Viewport.YOffset > 0 && m.MouseData.Y == HeaderHeight { 219 | ScrollUp(m) 220 | m.MouseData.Y = pre 221 | } else if m.MouseData.Y > HeaderHeight { 222 | m.MouseData.Y-- 223 | } 224 | 225 | return nil 226 | } 227 | GlobalCommands["d"] = func(m *TuiModel) tea.Cmd { 228 | cw := m.CellWidth() 229 | col := m.GetColumn() 230 | cols := len(m.Data().TableHeadersSlice) - 1 231 | if (m.MouseData.X-m.Viewport.Width) <= cw && m.GetColumn() < cols { // within tolerances 232 | m.MouseData.X += cw 233 | } else if col == cols { 234 | return func() tea.Msg { 235 | return tea.KeyMsg{ 236 | Type: tea.KeyRight, 237 | Alt: false, 238 | } 239 | } 240 | } 241 | 242 | return nil 243 | } 244 | GlobalCommands["a"] = func(m *TuiModel) tea.Cmd { 245 | cw := m.CellWidth() 246 | if m.MouseData.X-cw >= 0 { 247 | m.MouseData.X -= cw 248 | } else if m.GetColumn() == 0 { 249 | return func() tea.Msg { 250 | return tea.KeyMsg{ 251 | Type: tea.KeyLeft, 252 | Alt: false, 253 | } 254 | } 255 | } 256 | return nil 257 | } 258 | GlobalCommands["enter"] = func(m *TuiModel) tea.Cmd { 259 | if !m.UI.EditModeEnabled { 260 | SelectOption(m) 261 | } 262 | 263 | return nil 264 | } 265 | GlobalCommands["esc"] = func(m *TuiModel) tea.Cmd { 266 | m.TextInput.Model.SetValue("") 267 | if !m.UI.RenderSelection { 268 | m.UI.EditModeEnabled = true 269 | return nil 270 | } 271 | 272 | m.UI.RenderSelection = false 273 | m.Data().EditTextBuffer = "" 274 | cmd := m.TextInput.Model.FocusCommand() 275 | m.UI.ExpandColumn = -1 276 | m.MouseData.Y = m.Scroll.PreScrollYPosition 277 | m.Viewport.YOffset = m.Scroll.PreScrollYOffset 278 | 279 | return cmd 280 | } 281 | 282 | GlobalCommands["k"] = GlobalCommands["up"] // dual bind of up/k 283 | GlobalCommands["j"] = GlobalCommands["down"] // dual bind of down/j 284 | GlobalCommands["l"] = GlobalCommands["right"] // dual bind of right/l 285 | GlobalCommands["h"] = GlobalCommands["left"] // dual bind of left/h 286 | GlobalCommands["m"] = func(m *TuiModel) tea.Cmd { 287 | ScrollUp(m) 288 | return nil 289 | } 290 | GlobalCommands["n"] = func(m *TuiModel) tea.Cmd { 291 | ScrollDown(m) 292 | return nil 293 | } 294 | GlobalCommands["?"] = func(m *TuiModel) tea.Cmd { 295 | help := GetHelpText() 296 | m.DisplayMessage(help) 297 | return nil 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /viewer/help.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | func GetHelpText() (help string) { 4 | help = ` 5 | ##### Help: 6 | -p / database path (absolute) 7 | -d / specifies which database driver to use (sqlite/mysql) 8 | -a / enable ascii mode 9 | -h / prints this message 10 | -t / starts app with specific theme (default, nord, solarized) 11 | ##### Controls: 12 | ###### MOUSE 13 | Scroll up + down to navigate table/text 14 | Move cursor to select cells for full screen viewing 15 | ###### KEYBOARD 16 | [WASD] to move around cells, and also move columns if close to edge 17 | [ENTER] to select selected cell for full screen view 18 | [UP/K and DOWN/J] to navigate schemas 19 | [LEFT/H and RIGHT/L] to navigate columns if there are more than the screen allows. 20 | Also to control the cursor of the text editor in edit mode 21 | [BACKSPACE] to delete text before cursor in edit mode 22 | [M(scroll up) and N(scroll down)] to scroll manually 23 | [Q or CTRL+C] to quit program 24 | [B] to toggle borders! 25 | [C] to expand column 26 | [T] to cycle through themes! 27 | [P] in selection mode to write cell to file, or to print query results as CSV. 28 | [R] to redo actions, if applicable 29 | [U] to undo actions, if applicable 30 | [ESC] to exit full screen view, or to enter edit mode 31 | [PGDOWN] to scroll down one views worth of rows 32 | [PGUP] to scroll up one views worth of rows 33 | ###### EDIT MODE (for quick, single line changes and commands) 34 | [ESC] to enter edit mode with no pre-loaded text input from selection 35 | When a cell is selected, press [:] to enter edit mode with selection pre-loaded 36 | The text field in the header will be populated with the selected cells text. Modifications can be made freely 37 | [ESC] to clear text field in edit mode 38 | [ENTER] to save text. Anything besides one of the reserved strings below will overwrite the current cell 39 | [:q] to exit edit mode/ format mode/ SQL mode 40 | [:s] to save database to a new file (SQLite only) 41 | [:s!] to overwrite original database file (SQLite only). A confirmation dialog will be added soon 42 | [:h] to display help text 43 | [:new] opens current cell with a blank buffer 44 | [:edit] opens current cell in format mode 45 | [:sql] opens blank buffer for creating an SQL statement 46 | [:clip] to open clipboard of SQL queries. [/] to filter, [ENTER] to select. 47 | [HOME] to set cursor to end of the text 48 | [END] to set cursor to the end of the text 49 | ###### FORMAT MODE (for editing lines of text) 50 | [ESC] to move between top control bar and format buffer 51 | [HOME] to set cursor to end of the text 52 | [END] to set cursor to the end of the text 53 | [:wq] to save changes and quit to main table view 54 | [:w] to save changes and remain in format view 55 | [:s] to serialize changes, non-destructive (SQLite only) 56 | [:s!] to serialize changes, overwriting original file (SQLite only) 57 | ###### SQL MODE (for querying database) 58 | [ESC] to move between top control bar and text buffer 59 | [:q] to quit out of statement 60 | [:exec] to execute statement. Errors will be displayed in full screen view. 61 | [:stow ] to create a snippet for the clipboard with an optional name. A random number will be used if no name is specified. 62 | ###### QUERY MODE (specifically when viewing query results) 63 | [:d] to reset table data back to original view 64 | [:sql] to query original database again` 65 | 66 | return help 67 | } 68 | -------------------------------------------------------------------------------- /viewer/lineedit.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "math/rand" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/mathaou/termdbms/database" 14 | "github.com/mathaou/termdbms/tuiutil" 15 | ) 16 | 17 | const ( 18 | QueryResultsTableName = "results" 19 | ) 20 | 21 | type EnterFunction func(m *TuiModel, selectedInput *tuiutil.TextInputModel, input string) 22 | 23 | type LineEdit struct { 24 | Model tuiutil.TextInputModel 25 | Original *interface{} 26 | } 27 | 28 | func ExitToDefaultView(m *TuiModel) { 29 | m.UI.RenderSelection = false 30 | m.UI.EditModeEnabled = false 31 | m.UI.FormatModeEnabled = false 32 | m.UI.SQLEdit = false 33 | m.UI.ShowClipboard = false 34 | m.UI.CanFormatScroll = false 35 | m.Format.CursorY = 0 36 | m.Format.CursorX = 0 37 | m.Format.EditSlices = nil 38 | m.Format.Text = nil 39 | m.Format.RunningOffsets = nil 40 | m.FormatInput.Model.Reset() 41 | m.TextInput.Model.Reset() 42 | m.Viewport.YOffset = 0 43 | } 44 | 45 | func CreateEmptyBuffer(m *TuiModel, original *interface{}) { 46 | PrepareFormatMode(m) 47 | m.Data().EditTextBuffer = "\n" 48 | m.FormatInput.Original = original 49 | m.Format.Text = GetFormattedTextBuffer(m) 50 | m.SetViewSlices() 51 | m.FormatInput.Model.SetCursor(0) 52 | return 53 | } 54 | 55 | func CreatePopulatedBuffer(m *TuiModel, original *interface{}, str string) { 56 | PrepareFormatMode(m) 57 | m.Data().EditTextBuffer = str 58 | m.FormatInput.Original = original 59 | m.Format.Text = GetFormattedTextBuffer(m) 60 | m.SetViewSlices() 61 | m.FormatInput.Model.SetCursor(0) 62 | return 63 | } 64 | 65 | func EditEnter(m *TuiModel) { 66 | selectedInput := &m.TextInput.Model 67 | i := selectedInput.Value() 68 | 69 | d := m.Data() 70 | t := m.Table() 71 | 72 | var ( 73 | original *interface{} 74 | input string 75 | ) 76 | 77 | if i == ":q" { // quit mod mode 78 | ExitToDefaultView(m) 79 | return 80 | } 81 | if !m.UI.FormatModeEnabled && !m.UI.SQLEdit && !m.UI.ShowClipboard { 82 | input = i 83 | raw, _, _ := m.GetSelectedOption() 84 | original = raw 85 | if input == ":d" && m.QueryData != nil && m.QueryResult != nil { 86 | m.DefaultTable.Database.SetDatabaseReference(m.QueryResult.Database.GetFileName()) 87 | m.QueryData = nil 88 | m.QueryResult = nil 89 | var c *sql.Rows 90 | defer func() { 91 | if c != nil { 92 | c.Close() 93 | } 94 | }() 95 | err := m.SetModel(c, m.DefaultTable.Database.GetDatabaseReference()) 96 | if err != nil { 97 | m.DisplayMessage(fmt.Sprintf("%v", err)) 98 | } 99 | ExitToDefaultView(m) 100 | return 101 | } 102 | if m.QueryData != nil { 103 | m.TextInput.Model.SetValue("") 104 | m.WriteMessage("Cannot manipulate database through UI while query results are being displayed.") 105 | return 106 | } 107 | if input == ":h" { 108 | m.DisplayMessage(GetHelpText()) 109 | return 110 | } else if input == ":edit" { 111 | str := GetStringRepresentationOfInterface(*original) 112 | PrepareFormatMode(m) 113 | if conv, err := FormatJson(str); err == nil { // if json prettify 114 | d.EditTextBuffer = conv 115 | } else { 116 | d.EditTextBuffer = str 117 | } 118 | m.FormatInput.Original = original 119 | m.Format.Text = GetFormattedTextBuffer(m) 120 | m.SetViewSlices() 121 | m.FormatInput.Model.SetCursor(0) 122 | return 123 | } else if input == ":new" { 124 | CreateEmptyBuffer(m, original) 125 | return 126 | } else if input == ":sql" { 127 | CreateEmptyBuffer(m, original) 128 | m.UI.SQLEdit = true 129 | return 130 | } else if input == ":clip" { 131 | ExitToDefaultView(m) 132 | if len(m.ClipboardList.Items()) == 0 { 133 | return 134 | } 135 | m.UI.ShowClipboard = true 136 | return 137 | } 138 | } else { 139 | input = d.EditTextBuffer 140 | original = m.FormatInput.Original 141 | sqlFlags := m.UI.SQLEdit && !(i == ":exec" || strings.HasPrefix(i, ":stow")) 142 | formatFlags := m.UI.FormatModeEnabled && !(i == ":w" || i == ":wq" || i == ":s" || i == ":s!") 143 | if formatFlags && sqlFlags { 144 | m.TextInput.Model.SetValue("") 145 | return 146 | } 147 | } 148 | 149 | if original != nil && *original == input { 150 | ExitToDefaultView(m) 151 | return 152 | } 153 | 154 | if i == ":s" { // saves copy, default filename + :s _____ will save with that filename in cwd 155 | ExitToDefaultView(m) 156 | newFileName, err := Serialize(m) 157 | if err != nil { 158 | m.DisplayMessage(fmt.Sprintf("%v", err)) 159 | } else { 160 | m.DisplayMessage(fmt.Sprintf("Wrote copy of database to filepath %s.", newFileName)) 161 | } 162 | 163 | return 164 | } else if i == ":s!" { // overwrites original - should add confirmation dialog! 165 | ExitToDefaultView(m) 166 | err := SerializeOverwrite(m) 167 | if err != nil { 168 | m.DisplayMessage(fmt.Sprintf("%v", err)) 169 | } else { 170 | m.DisplayMessage("Overwrote original database file with changes.") 171 | } 172 | 173 | return 174 | } 175 | 176 | if m.UI.SQLEdit { 177 | if i == ":exec" { 178 | handleSQLMode(m, input) 179 | } else if strings.HasPrefix(i, ":stow") { 180 | if len(input) > 0 { 181 | split := strings.Split(i, " ") 182 | rand.Seed(time.Now().UnixNano()) 183 | r := rand.Int() 184 | title := fmt.Sprintf("%d", r) // if no title given then just call it random string 185 | if len(split) == 2 { 186 | title = split[1] 187 | } 188 | m.Clipboard = append(m.Clipboard, SQLSnippet{ 189 | Query: input, 190 | Name: title, 191 | }) 192 | b, _ := json.Marshal(m.Clipboard) 193 | snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile) 194 | f, _ := os.OpenFile(snippetsFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775) 195 | f.Write(b) 196 | f.Close() 197 | m.WriteMessage(fmt.Sprintf("Wrote SQL snippet %s to %s. Total count is %d", title, snippetsFile, len(m.ClipboardList.Items())+1)) 198 | } 199 | m.TextInput.Model.SetValue("") 200 | } 201 | return 202 | } 203 | 204 | old, n := populateUndo(m) 205 | if old == n || n != m.DefaultTable.Database.GetFileName() { 206 | panic(errors.New("could not get database file name")) 207 | } 208 | 209 | if _, err := FormatJson(input); err == nil { // if json uglify 210 | input = strings.ReplaceAll(input, " ", "") 211 | input = strings.ReplaceAll(input, "\n", "") 212 | input = strings.ReplaceAll(input, "\t", "") 213 | input = strings.ReplaceAll(input, "\r", "") 214 | } 215 | 216 | u := GetInterfaceFromString(input, original) 217 | database.ProcessSqlQueryForDatabaseType(&database.Update{ 218 | Update: u, 219 | }, m.GetRowData(), m.GetSchemaName(), m.GetSelectedColumnName(), &t.Database) 220 | 221 | m.UI.EditModeEnabled = false 222 | d.EditTextBuffer = "" 223 | m.FormatInput.Model.SetValue("") 224 | 225 | *original = input 226 | 227 | if m.UI.FormatModeEnabled && i == ":wq" { 228 | ExitToDefaultView(m) 229 | } 230 | } 231 | 232 | func handleSQLMode(m *TuiModel, input string) { 233 | if m.QueryResult != nil { 234 | m.QueryResult = nil 235 | } 236 | m.QueryResult = &TableState{ // perform query 237 | Database: m.Table().Database, 238 | Data: make(map[string]interface{}), 239 | } 240 | m.QueryData = &UIData{} 241 | 242 | firstword := strings.ToLower(strings.Split(input, " ")[0]) 243 | if exec := firstword == "update" || 244 | firstword == "delete" || 245 | firstword == "insert"; exec { 246 | m.QueryData = nil 247 | m.QueryResult = nil 248 | populateUndo(m) 249 | _, err := m.DefaultTable.Database.GetDatabaseReference().Exec(input) 250 | if err != nil { 251 | ExitToDefaultView(m) 252 | m.DisplayMessage(fmt.Sprintf("%v", err)) 253 | return 254 | } 255 | var c *sql.Rows 256 | defer func() { 257 | if c != nil { 258 | c.Close() 259 | } 260 | }() 261 | err = m.SetModel(c, m.DefaultTable.Database.GetDatabaseReference()) 262 | if err != nil { 263 | m.DisplayMessage(fmt.Sprintf("%v", err)) 264 | } else { 265 | ExitToDefaultView(m) 266 | } 267 | } else { // query 268 | c, err := m.QueryResult.Database.GetDatabaseReference().Query(input) 269 | defer func() { 270 | if c != nil { 271 | c.Close() 272 | } 273 | }() 274 | if err != nil { 275 | m.QueryResult = nil 276 | m.QueryData = nil 277 | ExitToDefaultView(m) 278 | m.DisplayMessage(fmt.Sprintf("%v", err)) 279 | return 280 | } 281 | 282 | i := 0 283 | 284 | m.QueryData.TableHeaders = make(map[string][]string) 285 | m.QueryData.TableIndexMap = make(map[int]string) 286 | m.QueryData.TableSlices = make(map[string][]interface{}) 287 | m.QueryData.TableHeadersSlice = []string{} 288 | 289 | m.PopulateDataForResult(c, &i, QueryResultsTableName) 290 | ExitToDefaultView(m) 291 | m.UI.EditModeEnabled = false 292 | m.UI.CurrentTable = 1 293 | m.Data().EditTextBuffer = "" 294 | m.FormatInput.Model.SetValue("") 295 | } 296 | } 297 | 298 | func populateUndo(m *TuiModel) (old string, new string) { 299 | if len(m.UndoStack) >= 10 { 300 | ref := m.UndoStack[len(m.UndoStack)-1] 301 | err := os.Remove(ref.Database.GetFileName()) 302 | if err != nil { 303 | fmt.Printf("%v", err) 304 | os.Exit(1) 305 | } 306 | m.UndoStack = m.UndoStack[1:] // need some more complicated logic to handle dereferencing? 307 | } 308 | 309 | switch m.DefaultTable.Database.(type) { 310 | case *database.SQLite: 311 | deepCopy := m.CopyMap() 312 | // THE GLOBALIST TAKEOVER 313 | deepState := TableState{ 314 | Database: &database.SQLite{ 315 | FileName: m.DefaultTable.Database.GetFileName(), 316 | Database: nil, 317 | }, 318 | Data: deepCopy, 319 | } 320 | m.UndoStack = append(m.UndoStack, deepState) 321 | old = m.DefaultTable.Database.GetFileName() 322 | dst, _, _ := CopyFile(old) 323 | new = dst 324 | m.DefaultTable.Database.CloseDatabaseReference() 325 | m.DefaultTable.Database.SetDatabaseReference(dst) 326 | break 327 | default: 328 | break 329 | } 330 | 331 | return old, new 332 | } 333 | -------------------------------------------------------------------------------- /viewer/mode.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "strings" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | var ( 10 | InputBlacklist = []string{ 11 | "alt+", 12 | "ctrl+", 13 | "up", 14 | "down", 15 | "tab", 16 | "left", 17 | "enter", 18 | "right", 19 | "pgdown", 20 | "pgup", 21 | } 22 | ) 23 | 24 | func PrepareFormatMode(m *TuiModel) { 25 | m.UI.FormatModeEnabled = true 26 | m.UI.EditModeEnabled = false 27 | m.TextInput.Model.SetValue("") 28 | m.FormatInput.Model.SetValue("") 29 | m.FormatInput.Model.Focus = true 30 | m.TextInput.Model.Focus = false 31 | m.TextInput.Model.Blur() 32 | } 33 | 34 | func MoveCursorWithinBounds(m *TuiModel) { 35 | defer func() { 36 | if recover() != nil { 37 | println("whoopsy") 38 | } 39 | }() 40 | offset := GetOffsetForLineNumber(m.Format.CursorY) 41 | l := len(*m.Format.EditSlices[m.Format.CursorY]) 42 | 43 | end := l - 1 - offset 44 | if m.Format.CursorX > end { 45 | m.Format.CursorX = end 46 | } 47 | } 48 | 49 | func HandleEditInput(m *TuiModel, str, val string) (ret bool) { 50 | selectedInput := &m.TextInput.Model 51 | input := selectedInput.Value() 52 | inputLen := len(input) 53 | 54 | if str == "backspace" { 55 | cursor := selectedInput.Cursor() 56 | runes := []rune(input) 57 | if cursor == inputLen && inputLen > 0 { 58 | selectedInput.SetValue(input[0 : inputLen-1]) 59 | } else if cursor > 0 { 60 | min := Max(selectedInput.Cursor(), 0) 61 | min = Min(min, inputLen-1) 62 | first := runes[:min-1] 63 | last := runes[min:] 64 | selectedInput.SetValue(string(first) + string(last)) 65 | selectedInput.SetCursor(selectedInput.Cursor() - 1) 66 | } 67 | 68 | ret = true 69 | } else if str == "enter" { // writes your selection 70 | EditEnter(m) 71 | ret = true 72 | } 73 | 74 | return ret 75 | } 76 | 77 | func HandleEditMovement(m *TuiModel, str, val string) (ret bool) { 78 | selectedInput := &m.TextInput.Model 79 | if str == "home" { 80 | selectedInput.SetCursor(0) 81 | 82 | ret = true 83 | } else if str == "end" { 84 | if len(val) > 0 { 85 | selectedInput.SetCursor(len(val) - 1) 86 | } 87 | 88 | ret = true 89 | } else if str == "left" { 90 | cursorPosition := selectedInput.Cursor() 91 | 92 | if cursorPosition == selectedInput.Offset && cursorPosition != 0 { 93 | selectedInput.Offset-- 94 | selectedInput.OffsetRight-- 95 | } 96 | 97 | if cursorPosition != 0 { 98 | selectedInput.SetCursor(cursorPosition - 1) 99 | } 100 | 101 | ret = true 102 | } else if str == "right" { 103 | cursorPosition := selectedInput.Cursor() 104 | 105 | if cursorPosition == selectedInput.OffsetRight { 106 | selectedInput.Offset++ 107 | selectedInput.OffsetRight++ 108 | } 109 | 110 | selectedInput.SetCursor(cursorPosition + 1) 111 | 112 | ret = true 113 | } 114 | 115 | return ret 116 | } 117 | 118 | func HandleFormatMovement(m *TuiModel, str string) (ret bool) { 119 | lines := 0 120 | for _, v := range m.Format.EditSlices { 121 | if *v != "" { 122 | lines++ 123 | } 124 | } 125 | switch str { 126 | case "pgdown": 127 | l := len(m.Format.Text) - 1 128 | for i := 0; i < m.Viewport.Height && m.Viewport.YOffset < l; i++ { 129 | ScrollDown(m) 130 | } 131 | ret = true 132 | break 133 | case "pgup": 134 | for i := 0; i < 135 | m.Viewport.Height && m.Viewport.YOffset > 0; i++ { 136 | ScrollUp(m) 137 | } 138 | ret = true 139 | break 140 | case "home": 141 | m.Viewport.YOffset = 0 142 | m.Format.CursorX = 0 143 | m.Format.CursorY = 0 144 | ret = true 145 | break 146 | case "end": 147 | m.Viewport.YOffset = len(m.Format.Text) - m.Viewport.Height 148 | m.Format.CursorY = Min(m.Viewport.Height-FooterHeight, strings.Count(m.Data().EditTextBuffer, "\n")) 149 | m.Format.CursorX = m.Format.RunningOffsets[len(m.Format.RunningOffsets)-1] 150 | ret = true 151 | break 152 | case "right": 153 | ret = true 154 | m.Format.CursorX++ 155 | 156 | offset := GetOffsetForLineNumber(m.Format.CursorY) 157 | x := m.Format.CursorX + offset + 1 // for the space at the end 158 | l := len(*m.Format.EditSlices[m.Format.CursorY]) 159 | maxY := lines - 1 160 | if l < x && m.Format.CursorY < maxY { 161 | m.Format.CursorX = 0 162 | m.Format.CursorY++ 163 | } else if l < x && m.Format.CursorY < len(m.Format.Text)-1 { 164 | go Program.Send( 165 | tea.KeyMsg{ 166 | Type: tea.KeyDown, 167 | Alt: false, 168 | }, 169 | ) 170 | } else if m.Format.CursorY > maxY { 171 | m.Format.CursorX = maxY 172 | } 173 | 174 | break 175 | case "left": 176 | ret = true 177 | m.Format.CursorX-- 178 | 179 | if m.Format.CursorX < 0 && m.Format.CursorY > 0 { 180 | m.Format.CursorY-- 181 | 182 | offset := GetOffsetForLineNumber(m.Format.CursorY) 183 | l := len(*m.Format.EditSlices[m.Format.CursorY]) 184 | m.Format.CursorX = l - 1 - offset 185 | } else if m.Format.CursorX < 0 && 186 | m.Format.CursorY == 0 && 187 | m.Viewport.YOffset > 0 { 188 | go Program.Send( 189 | tea.KeyMsg{ 190 | Type: tea.KeyUp, 191 | Alt: false, 192 | }, 193 | ) 194 | } else if m.Format.CursorX < 0 { 195 | m.Format.CursorX = 0 196 | } 197 | 198 | break 199 | case "up": 200 | ret = true 201 | if m.Format.CursorY > 0 { 202 | m.Format.CursorY-- 203 | } else if m.Viewport.YOffset > 0 { 204 | ScrollUp(m) 205 | } 206 | 207 | break 208 | case "down": 209 | ret = true 210 | if m.Format.CursorY < m.Viewport.Height-FooterHeight && m.Format.CursorY < lines-1 { 211 | m.Format.CursorY++ 212 | } else { 213 | ScrollDown(m) 214 | } 215 | } 216 | 217 | return ret 218 | } 219 | 220 | func InsertCharacter(m *TuiModel, newlineOrTab string) { 221 | yOffset := Max(m.Viewport.YOffset, 0) 222 | cursor := m.Format.RunningOffsets[m.Format.CursorY+yOffset] + m.Format.CursorX 223 | runes := []rune(m.Data().EditTextBuffer) 224 | 225 | min := Max(cursor, 0) 226 | min = Min(min, len(m.Data().EditTextBuffer)) 227 | first := runes[:min] 228 | last := runes[min:] 229 | f := string(first) 230 | l := string(last) 231 | m.Data().EditTextBuffer = f + newlineOrTab + l 232 | if len(last) == 0 { // for whatever reason, if you don't double up on newlines if appending to end, it gets removed 233 | m.Data().EditTextBuffer += newlineOrTab 234 | } 235 | numLines := 0 236 | for _, v := range m.Format.Text { 237 | if v != "" { // ignore padding 238 | numLines++ 239 | } 240 | } 241 | if yOffset+m.Viewport.Height == numLines && newlineOrTab == "\n" { 242 | m.Viewport.YOffset++ 243 | } else if newlineOrTab == "\n" { 244 | m.Format.CursorY++ 245 | } 246 | 247 | m.Format.Text = GetFormattedTextBuffer(m) 248 | m.SetViewSlices() 249 | if newlineOrTab == "\n" { 250 | m.Format.CursorX = 0 251 | } else { 252 | m.Format.CursorX++ 253 | } 254 | } 255 | 256 | func HandleFormatInput(m *TuiModel, str string) bool { 257 | switch str { 258 | case "tab": 259 | InsertCharacter(m, "\t") 260 | return true 261 | case "enter": 262 | InsertCharacter(m, "\n") 263 | return true 264 | case "backspace": 265 | cursor := m.Format.CursorX + FormatModeOffset 266 | input := m.Format.EditSlices[m.Format.CursorY] 267 | inputLen := len(*input) 268 | runes := []rune(*input) 269 | if m.Format.CursorX > 0 { // cursor in middle of line 270 | if cursor == inputLen && inputLen > 0 { 271 | *input = (*input)[0 : inputLen-1] 272 | } else if cursor > 0 { 273 | min := Max(cursor, 0) 274 | min = Min(min, inputLen-1) 275 | first := runes[:min-1] 276 | last := runes[min:] 277 | *input = string(first) + string(last) 278 | } 279 | 280 | return false 281 | } else if m.Format.CursorY > 0 && m.Format.CursorX == 0 { // beginning of line 282 | yOffset := Max(m.Viewport.YOffset, 0) 283 | cursor := m.Format.RunningOffsets[m.Format.CursorY+yOffset] + m.Format.CursorX 284 | runes := []rune(m.Data().EditTextBuffer) 285 | min := Max(cursor, 0) 286 | min = Min(min, len(m.Data().EditTextBuffer)-1) 287 | first := runes[:min-1] 288 | last := runes[min:] 289 | m.Data().EditTextBuffer = string(first) + string(last) 290 | if yOffset+m.Viewport.Height == len(m.Format.Text) && yOffset > 0 { 291 | m.Viewport.YOffset-- 292 | } else { 293 | m.Format.CursorY-- 294 | } 295 | m.Format.Text = GetFormattedTextBuffer(m) 296 | m.SetViewSlices() 297 | } 298 | 299 | return true 300 | } 301 | 302 | return false 303 | } 304 | 305 | func HandleFormatMode(m *TuiModel, str string) { 306 | var ( 307 | val string 308 | replacement string 309 | ) 310 | 311 | inputReturn := HandleFormatInput(m, str) 312 | 313 | if HandleFormatMovement(m, str) { 314 | return 315 | } 316 | 317 | for _, v := range InputBlacklist { 318 | if strings.Contains(str, v) { 319 | return 320 | } 321 | } 322 | 323 | lineNumberOffset := GetOffsetForLineNumber(m.Format.CursorY) 324 | 325 | pString := m.Format.EditSlices[m.Format.CursorY] 326 | delta := 1 327 | if str != "backspace" { 328 | // update UI 329 | if *pString != "" { 330 | min := Max(m.Format.CursorX+lineNumberOffset+1, 0) 331 | min = Min(min, len(*pString)) 332 | first := (*pString)[:min] 333 | last := (*pString)[min:] 334 | val = first + str + last 335 | } else { 336 | val = *pString + str 337 | } 338 | } else { 339 | delta = -1 340 | val = *pString 341 | } 342 | 343 | // if json special rules 344 | replacement = m.Data().EditTextBuffer 345 | cursor := m.Format.RunningOffsets[m.Viewport.YOffset+m.Format.CursorY] 346 | 347 | fIndex := Max(cursor, 0) 348 | lIndex := m.Viewport.YOffset + m.Format.CursorY + 1 349 | 350 | defer func() { 351 | if recover() != nil { 352 | println("whoopsy!") // bug happened once, debug... 353 | } 354 | }() 355 | 356 | first := replacement[:fIndex] 357 | middle := val[lineNumberOffset+1:] 358 | last := replacement[Min(m.Format.RunningOffsets[lIndex], len(replacement)):] 359 | 360 | if (first != "" || last != "") && last != "\n" { 361 | middle += "\n" 362 | } 363 | 364 | replacement = first + // replace the entire line the edit appears on 365 | middle + // insert the edit 366 | last // top the edit off with the rest of the string 367 | 368 | m.Data().EditTextBuffer = replacement 369 | if len(*pString) == FormatModeOffset && str != "backspace" { // insert on empty lines behaves funny 370 | *pString = *pString + str 371 | } else { 372 | *pString = val 373 | } 374 | 375 | m.Format.CursorX += delta 376 | 377 | if inputReturn { 378 | return 379 | } 380 | 381 | for i := m.Viewport.YOffset + m.Format.CursorY + 1; i < len(m.Format.RunningOffsets); i++ { 382 | m.Format.RunningOffsets[i] += delta 383 | } 384 | 385 | } 386 | 387 | // HandleEditMode implementation is kind of jank, but we can clean it up later 388 | func HandleEditMode(m *TuiModel, str string) { 389 | var ( 390 | input string 391 | val string 392 | ) 393 | selectedInput := &m.TextInput.Model 394 | input = selectedInput.Value() 395 | if input != "" && selectedInput.Cursor() <= len(input)-1 { 396 | min := Max(selectedInput.Cursor(), 0) 397 | min = Min(min, len(input)-1) 398 | first := input[:min] 399 | last := input[min:] 400 | val = first + str + last 401 | } else { 402 | val = input + str 403 | } 404 | 405 | if str == "esc" { 406 | selectedInput.SetValue("") 407 | return 408 | } 409 | 410 | if HandleEditMovement(m, str, val) || HandleEditInput(m, str, val) { 411 | return 412 | } 413 | 414 | for _, v := range InputBlacklist { 415 | if strings.Contains(str, v) { 416 | return 417 | } 418 | } 419 | 420 | prePos := selectedInput.Cursor() 421 | if val != "" { 422 | selectedInput.SetValue(val) 423 | } else { 424 | selectedInput.SetValue(str) 425 | } 426 | 427 | if prePos != 0 { 428 | prePos = selectedInput.Cursor() 429 | } 430 | selectedInput.SetCursor(prePos + 1) 431 | } 432 | -------------------------------------------------------------------------------- /viewer/modelutil.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/mathaou/termdbms/database" 12 | "github.com/mathaou/termdbms/list" 13 | "github.com/mathaou/termdbms/tuiutil" 14 | ) 15 | 16 | func (m *TuiModel) WriteMessage(s string) { 17 | if Message == "" { 18 | Message = s 19 | MIP = true 20 | go Program.Send(tea.KeyMsg{}) // trigger update 21 | go Program.Send(tea.KeyMsg{}) // trigger update for sure hack gross but w/e 22 | } 23 | } 24 | 25 | func (m *TuiModel) CopyMap() (to map[string]interface{}) { 26 | from := m.Table().Data 27 | to = map[string]interface{}{} 28 | 29 | for k, v := range from { 30 | if copyValues, ok := v.(map[string][]interface{}); ok { 31 | columnNames := m.Data().TableHeaders[k] 32 | columnValues := make(map[string][]interface{}) 33 | // golang wizardry 34 | columns := make([]interface{}, len(columnNames)) 35 | 36 | for i := range columns { 37 | columns[i] = copyValues[columnNames[i]] 38 | } 39 | 40 | for i, colName := range columnNames { 41 | val := columns[i].([]interface{}) 42 | buffer := make([]interface{}, len(val)) 43 | for k := range val { 44 | buffer[k] = val[k] 45 | } 46 | columnValues[colName] = append(columnValues[colName], buffer) 47 | } 48 | 49 | to[k] = columnValues // data for schema, organized by column 50 | } 51 | } 52 | 53 | return to 54 | } 55 | 56 | // GetNewModel returns a TuiModel struct with some fields set 57 | func GetNewModel(baseFileName string, db *sql.DB) TuiModel { 58 | m := TuiModel{ 59 | DefaultTable: TableState{ 60 | Database: &database.SQLite{ 61 | FileName: baseFileName, 62 | Database: db, 63 | }, 64 | Data: make(map[string]interface{}), 65 | }, 66 | Format: FormatState{ 67 | EditSlices: nil, 68 | Text: nil, 69 | RunningOffsets: nil, 70 | CursorX: 0, 71 | CursorY: 0, 72 | }, 73 | UI: UIState{ 74 | CanFormatScroll: false, 75 | RenderSelection: false, 76 | EditModeEnabled: false, 77 | FormatModeEnabled: false, 78 | BorderToggle: false, 79 | CurrentTable: 0, 80 | ExpandColumn: -1, 81 | }, 82 | Scroll: ScrollData{}, 83 | DefaultData: UIData{ 84 | TableHeaders: make(map[string][]string), 85 | TableHeadersSlice: []string{}, 86 | TableSlices: make(map[string][]interface{}), 87 | TableIndexMap: make(map[int]string), 88 | }, 89 | TextInput: LineEdit{ 90 | Model: tuiutil.NewModel(), 91 | }, 92 | FormatInput: LineEdit{ 93 | Model: tuiutil.NewModel(), 94 | }, 95 | Clipboard: []list.Item{}, 96 | } 97 | m.FormatInput.Model.Prompt = "" 98 | 99 | snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile) 100 | 101 | exists, _ := Exists(snippetsFile) 102 | if exists { 103 | contents, _ := os.ReadFile(snippetsFile) 104 | var c []SQLSnippet 105 | json.Unmarshal(contents, &c) 106 | for _, v := range c { 107 | m.Clipboard = append(m.Clipboard, v) 108 | } 109 | } 110 | 111 | m.ClipboardList = list.NewModel(m.Clipboard, itemDelegate{}, 0, 0) 112 | 113 | m.ClipboardList.Title = "SQL Snippets" 114 | m.ClipboardList.SetFilteringEnabled(true) 115 | m.ClipboardList.SetShowPagination(true) 116 | m.ClipboardList.SetShowTitle(true) 117 | 118 | return m 119 | } 120 | 121 | // SetModel creates a model to be used by bubbletea using some golang wizardry 122 | func (m *TuiModel) SetModel(c *sql.Rows, db *sql.DB) error { 123 | var err error 124 | 125 | indexMap := 0 126 | 127 | // gets all the schema names of the database 128 | tableNamesQuery := m.Table().Database.GetTableNamesQuery() 129 | rows, err := db.Query(tableNamesQuery) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | defer rows.Close() 135 | 136 | // for each schema 137 | for rows.Next() { 138 | var schemaName string 139 | rows.Scan(&schemaName) 140 | 141 | // couldn't get prepared statements working and gave up because it was very simple 142 | var statement strings.Builder 143 | statement.WriteString("select * from ") 144 | statement.WriteString(schemaName) 145 | getAll := statement.String() 146 | 147 | if c != nil { 148 | c.Close() 149 | c = nil 150 | } 151 | c, err = db.Query(getAll) 152 | if err != nil { 153 | panic(err) 154 | } 155 | 156 | m.PopulateDataForResult(c, &indexMap, schemaName) 157 | } 158 | 159 | // set the first table to be initial view 160 | m.UI.CurrentTable = 1 161 | 162 | return nil 163 | } 164 | 165 | func (m *TuiModel) PopulateDataForResult(c *sql.Rows, indexMap *int, schemaName string) { 166 | columnNames, _ := c.Columns() 167 | columnValues := make(map[string][]interface{}) 168 | 169 | for c.Next() { // each row of the table 170 | // golang wizardry 171 | columns := make([]interface{}, len(columnNames)) 172 | columnPointers := make([]interface{}, len(columnNames)) 173 | // init interface array 174 | for i := range columns { 175 | columnPointers[i] = &columns[i] 176 | } 177 | 178 | c.Scan(columnPointers...) 179 | 180 | for i, colName := range columnNames { 181 | val := columnPointers[i].(*interface{}) 182 | columnValues[colName] = append(columnValues[colName], *val) 183 | } 184 | } 185 | 186 | // onto the next schema 187 | *indexMap++ 188 | if m.QueryResult != nil && m.QueryData != nil { 189 | m.QueryResult.Data[schemaName] = columnValues 190 | m.QueryData.TableHeaders[schemaName] = columnNames // headers for the schema, for later reference 191 | m.QueryData.TableIndexMap[*indexMap] = schemaName 192 | return 193 | } 194 | m.Table().Data[schemaName] = columnValues // data for schema, organized by column 195 | m.Data().TableHeaders[schemaName] = columnNames // headers for the schema, for later reference 196 | // mapping between schema and an int ( since maps aren't deterministic), for later reference 197 | m.Data().TableIndexMap[*indexMap] = schemaName 198 | } 199 | 200 | func (m *TuiModel) SwapTableValues(f, t *TableState) { 201 | from := &f.Data 202 | to := &t.Data 203 | for k, v := range *from { 204 | if copyValues, ok := v.(map[string][]interface{}); ok { 205 | columnNames := m.Data().TableHeaders[k] 206 | columnValues := make(map[string][]interface{}) 207 | // golang wizardry 208 | columns := make([]interface{}, len(columnNames)) 209 | 210 | for i := range columns { 211 | columns[i] = copyValues[columnNames[i]][0] 212 | } 213 | 214 | for i, colName := range columnNames { 215 | columnValues[colName] = columns[i].([]interface{}) 216 | } 217 | 218 | (*to)[k] = columnValues // data for schema, organized by column 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /viewer/serialize.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "os" 9 | "path" 10 | "strings" 11 | 12 | "github.com/mathaou/termdbms/database" 13 | ) 14 | 15 | var ( 16 | serializationErrorString = fmt.Sprintf("Database driver %s does not support serialization.", database.DriverString) 17 | ) 18 | 19 | func Serialize(m *TuiModel) (string, error) { 20 | switch m.Table().Database.(type) { 21 | case *database.SQLite: 22 | return SerializeSQLiteDB(m.Table().Database.(*database.SQLite), m), nil 23 | default: 24 | return "", errors.New(serializationErrorString) 25 | } 26 | } 27 | 28 | func SerializeOverwrite(m *TuiModel) error { 29 | t := m.Table() 30 | switch t.Database.(type) { 31 | case *database.SQLite: 32 | SerializeOverwriteSQLiteDB(t.Database.(*database.SQLite), m) 33 | return nil 34 | default: 35 | return errors.New(serializationErrorString) 36 | } 37 | } 38 | 39 | // SQLITE 40 | 41 | func SerializeSQLiteDB(db *database.SQLite, m *TuiModel) string { 42 | db.CloseDatabaseReference() 43 | source, err := os.ReadFile(db.GetFileName()) 44 | if err != nil { 45 | panic(err) 46 | } 47 | ext := path.Ext(m.InitialFileName) 48 | newFileName := fmt.Sprintf("%s-%d%s", strings.TrimSuffix(m.InitialFileName, ext), rand.Intn(4), ext) 49 | err = os.WriteFile(newFileName, source, 0777) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | db.SetDatabaseReference(db.GetFileName()) 54 | return newFileName 55 | } 56 | 57 | func SerializeOverwriteSQLiteDB(db *database.SQLite, m *TuiModel) { 58 | db.CloseDatabaseReference() 59 | filename := db.GetFileName() 60 | 61 | source, err := os.ReadFile(filename) 62 | if err != nil { 63 | panic(err) 64 | } 65 | 66 | err = os.WriteFile(m.InitialFileName, source, 0777) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | db.SetDatabaseReference(filename) 71 | } 72 | -------------------------------------------------------------------------------- /viewer/snippets.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/mathaou/termdbms/list" 11 | "github.com/mathaou/termdbms/tuiutil" 12 | ) 13 | 14 | var ( 15 | style = lipgloss.NewStyle() 16 | ) 17 | 18 | func (s SQLSnippet) Title() string { 19 | return s.Name 20 | } 21 | 22 | func (s SQLSnippet) Description() string { 23 | return s.Query 24 | } 25 | 26 | func (s SQLSnippet) FilterValue() string { 27 | return s.Name 28 | } 29 | 30 | type itemDelegate struct{} 31 | 32 | func (d itemDelegate) Height() int { return 1 } 33 | func (d itemDelegate) Spacing() int { return 0 } 34 | func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { 35 | return nil 36 | } 37 | 38 | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 39 | localStyle := style.Copy() 40 | i, ok := listItem.(SQLSnippet) 41 | if !ok { 42 | return 43 | } 44 | 45 | digits := len(fmt.Sprintf("%d", len(m.Items()))) + 1 46 | incomingDigits := len(fmt.Sprintf("%d", index+1)) 47 | 48 | if !tuiutil.Ascii { 49 | localStyle = style.Copy().Faint(true) 50 | } 51 | 52 | str := fmt.Sprintf("%d) %s%s | ", index+1, strings.Repeat(" ", digits-incomingDigits), 53 | i.Title()) 54 | query := localStyle.Render(i.Query[0:Min(TUIWidth-10, Max(len(i.Query)-1, len(i.Query)-1-len(str)))]) // padding + tab + padding 55 | str += strings.ReplaceAll(query, "\n", "") 56 | 57 | localStyle = style.Copy().PaddingLeft(4) 58 | 59 | fn := localStyle.Render 60 | if index == m.Index() { 61 | fn = func(s string) string { 62 | localStyle = style.Copy(). 63 | PaddingLeft(2) 64 | if !tuiutil.Ascii { 65 | localStyle = localStyle. 66 | Foreground(lipgloss.Color(tuiutil.HeaderTopForeground())) 67 | } 68 | 69 | return lipgloss.JoinHorizontal(lipgloss.Left, 70 | localStyle. 71 | Render("> "), 72 | style.Render(s)) 73 | } 74 | } 75 | 76 | fmt.Fprintf(w, fn(str)) 77 | } 78 | -------------------------------------------------------------------------------- /viewer/table.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/mathaou/termdbms/database" 11 | "github.com/mathaou/termdbms/tuiutil" 12 | ) 13 | 14 | type TableAssembly func(m *TuiModel, s *string, c *chan bool) 15 | 16 | var ( 17 | HeaderAssembly TableAssembly 18 | FooterAssembly TableAssembly 19 | Message string 20 | mid *string 21 | MIP bool 22 | ) 23 | 24 | func init() { 25 | tmp := "" 26 | MIP = false 27 | mid = &tmp 28 | HeaderAssembly = func(m *TuiModel, s *string, done *chan bool) { 29 | if m.UI.ShowClipboard { 30 | *done <- true 31 | return 32 | } 33 | 34 | var ( 35 | builder []string 36 | ) 37 | 38 | style := m.GetBaseStyle() 39 | 40 | if !tuiutil.Ascii { 41 | // for column headers 42 | style = style.Foreground(lipgloss.Color(tuiutil.HeaderForeground())). 43 | BorderBackground(lipgloss.Color(tuiutil.HeaderBorderBackground())). 44 | Background(lipgloss.Color(tuiutil.HeaderBackground())) 45 | } 46 | headers := m.Data().TableHeadersSlice 47 | for i, d := range headers { // write all headers 48 | if m.UI.ExpandColumn != -1 && i != m.UI.ExpandColumn { 49 | continue 50 | } 51 | 52 | text := " " + TruncateIfApplicable(m, d) 53 | builder = append(builder, style. 54 | Render(text)) 55 | } 56 | 57 | { 58 | // schema name 59 | var headerTop string 60 | 61 | if m.UI.EditModeEnabled || m.UI.FormatModeEnabled { 62 | headerTop = m.TextInput.Model.View() 63 | if !m.TextInput.Model.Focused() { 64 | headerTop = HeaderStyle.Copy().Faint(true).Render(headerTop) 65 | } 66 | } else { 67 | headerTop = fmt.Sprintf(" %s (%d/%d) - %d record(s) + %d column(s)", 68 | m.GetSchemaName(), 69 | m.UI.CurrentTable, 70 | len(m.Data().TableHeaders), // look at how headers get rendered to get accurate record number 71 | len(m.GetColumnData()), 72 | len(m.GetHeaders())) // this will need to be refactored when filters get added 73 | headerTop = HeaderStyle.Render(headerTop) 74 | } 75 | 76 | headerMid := lipgloss.JoinHorizontal(lipgloss.Left, builder...) 77 | if m.UI.RenderSelection { 78 | headerMid = "" 79 | } 80 | *s = lipgloss.JoinVertical(lipgloss.Left, headerTop, headerMid) 81 | } 82 | 83 | *done <- true 84 | } 85 | FooterAssembly = func(m *TuiModel, s *string, done *chan bool) { 86 | if m.UI.ShowClipboard { 87 | *done <- true 88 | return 89 | } 90 | var ( 91 | row int 92 | col int 93 | ) 94 | if !m.UI.FormatModeEnabled { // reason we flip is because it makes more sense to store things by column for data 95 | row = m.GetRow() + m.Viewport.YOffset 96 | col = m.GetColumn() + m.Scroll.ScrollXOffset 97 | } else { // but for format mode thats just a regular row/col situation 98 | row = m.Format.CursorX 99 | col = m.Format.CursorY + m.Viewport.YOffset 100 | } 101 | footer := fmt.Sprintf(" %d, %d ", row, col) 102 | if m.UI.RenderSelection { 103 | footer = "" 104 | } 105 | undoRedoInfo := fmt.Sprintf(" undo(%d) / redo(%d) ", len(m.UndoStack), len(m.RedoStack)) 106 | switch m.Table().Database.(type) { 107 | case *database.SQLite: 108 | break 109 | default: 110 | undoRedoInfo = "" 111 | break 112 | } 113 | 114 | gapSize := m.Viewport.Width - lipgloss.Width(footer) - lipgloss.Width(undoRedoInfo) - 2 115 | 116 | if MIP { 117 | MIP = false 118 | if !tuiutil.Ascii { 119 | Message = FooterStyle.Render(Message) 120 | } 121 | go func() { 122 | newSize := gapSize - lipgloss.Width(Message) 123 | if newSize < 1 { 124 | newSize = 1 125 | } 126 | half := strings.Repeat("-", newSize/2) 127 | if lipgloss.Width(Message) > gapSize { 128 | Message = Message[0:gapSize-3] + "..." 129 | } 130 | *mid = half + Message + half 131 | time.Sleep(time.Second * 5) 132 | Message = "" 133 | go Program.Send(tea.KeyMsg{}) 134 | }() 135 | } else if Message == "" { 136 | *mid = strings.Repeat("-", gapSize) 137 | } 138 | queryResultsFlag := "├" 139 | if m.QueryData != nil || m.QueryResult != nil { 140 | queryResultsFlag = "*" 141 | } 142 | footer = FooterStyle.Render(undoRedoInfo) + queryResultsFlag + *mid + "┤" + FooterStyle.Render(footer) 143 | *s = footer 144 | 145 | *done <- true 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /viewer/tableutil.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/mathaou/termdbms/tuiutil" 8 | ) 9 | 10 | var maxHeaders int 11 | 12 | // AssembleTable shows either the selection text or the table 13 | func AssembleTable(m *TuiModel) string { 14 | if m.UI.ShowClipboard { 15 | return ShowClipboard(m) 16 | } 17 | if m.UI.RenderSelection { 18 | return DisplaySelection(m) 19 | } 20 | if m.UI.FormatModeEnabled { 21 | return DisplayFormatText(m) 22 | } 23 | 24 | return DisplayTable(m) 25 | } 26 | 27 | // NumHeaders gets the number of columns for the current schema 28 | func (m *TuiModel) NumHeaders() int { 29 | headers := m.GetHeaders() 30 | l := len(headers) 31 | if m.UI.ExpandColumn > -1 || l == 0 { 32 | return 1 33 | } 34 | 35 | maxHeaders = 7 36 | 37 | if l > maxHeaders { // this just looked the best after some trial and error 38 | if l%5 == 0 { 39 | return 5 40 | } else if l%4 == 0 { 41 | return 4 42 | } else if l%3 == 0 { 43 | return 3 44 | } else { 45 | return 6 // primes and shiiiii 46 | } 47 | } 48 | 49 | return l 50 | } 51 | 52 | // CellWidth gets the current cell width for schema 53 | func (m *TuiModel) CellWidth() int { 54 | h := m.NumHeaders() 55 | return m.Viewport.Width/h + 2 56 | } 57 | 58 | // GetBaseStyle returns a new style that is used everywhere 59 | func (m *TuiModel) GetBaseStyle() lipgloss.Style { 60 | cw := m.CellWidth() 61 | s := lipgloss.NewStyle(). 62 | Foreground(lipgloss.Color(tuiutil.TextColor())). 63 | Width(cw). 64 | Align(lipgloss.Left) 65 | 66 | if m.UI.BorderToggle && !tuiutil.Ascii { 67 | s = s.BorderLeft(true). 68 | BorderStyle(lipgloss.NormalBorder()). 69 | BorderForeground(lipgloss.Color(tuiutil.BorderColor())) 70 | } 71 | 72 | return s 73 | } 74 | 75 | // GetColumn gets the column the mouse cursor is in 76 | func (m *TuiModel) GetColumn() int { 77 | baseVal := m.MouseData.X / m.CellWidth() 78 | if m.UI.RenderSelection || m.UI.EditModeEnabled || m.UI.FormatModeEnabled { 79 | return m.Scroll.ScrollXOffset + baseVal 80 | } 81 | 82 | return baseVal 83 | } 84 | 85 | // GetRow does math to get a valid row that's helpful 86 | func (m *TuiModel) GetRow() int { 87 | baseVal := Max(m.MouseData.Y-HeaderHeight, 0) 88 | if m.UI.RenderSelection || m.UI.EditModeEnabled { 89 | return m.Viewport.YOffset + baseVal 90 | } else if m.UI.FormatModeEnabled { 91 | return m.Scroll.PreScrollYOffset + baseVal 92 | } 93 | return baseVal 94 | } 95 | 96 | // GetSchemaName gets the current schema name 97 | func (m *TuiModel) GetSchemaName() string { 98 | return m.Data().TableIndexMap[m.UI.CurrentTable] 99 | } 100 | 101 | // GetHeaders does just that for the current schema 102 | func (m *TuiModel) GetHeaders() []string { 103 | schema := m.GetSchemaName() 104 | d := m.Data() 105 | return d.TableHeaders[schema] 106 | } 107 | 108 | func (m *TuiModel) SetViewSlices() { 109 | d := m.Data() 110 | if m.Viewport.Height < 0 { 111 | return 112 | } 113 | if m.UI.FormatModeEnabled { 114 | var slices []*string 115 | for i := 0; i < m.Viewport.Height; i++ { 116 | yOffset := Max(m.Viewport.YOffset, 0) 117 | if yOffset+i > len(m.Format.Text)-1 { 118 | break 119 | } 120 | pStr := &m.Format.Text[Max(yOffset+i, 0)] 121 | slices = append(slices, pStr) 122 | } 123 | m.Format.EditSlices = slices 124 | m.UI.CanFormatScroll = len(m.Format.Text)-m.Viewport.YOffset-m.Viewport.Height > 0 125 | if m.Format.CursorX < 0 { 126 | m.Format.CursorX = 0 127 | } 128 | } else { 129 | // header slices 130 | headers := d.TableHeaders[m.GetSchemaName()] 131 | headersLen := len(headers) 132 | 133 | if headersLen > maxHeaders { 134 | headers = headers[m.Scroll.ScrollXOffset : maxHeaders+m.Scroll.ScrollXOffset-1] 135 | } 136 | // data slices 137 | defer func() { 138 | if recover() != nil { 139 | panic(errors.New("adsf")) 140 | } 141 | }() 142 | 143 | for _, columnName := range headers { 144 | interfaceValues := m.GetSchemaData()[columnName] 145 | if len(interfaceValues) >= m.Viewport.Height { 146 | min := Min(m.Viewport.YOffset, len(interfaceValues)-m.Viewport.Height) 147 | 148 | d.TableSlices[columnName] = interfaceValues[min : m.Viewport.Height+min] 149 | } else { 150 | d.TableSlices[columnName] = interfaceValues 151 | } 152 | } 153 | 154 | d.TableHeadersSlice = headers 155 | } 156 | // format slices 157 | } 158 | 159 | // GetSchemaData is a helper function to get the data of the current schema 160 | func (m *TuiModel) GetSchemaData() map[string][]interface{} { 161 | n := m.GetSchemaName() 162 | t := m.Table() 163 | d := t.Data 164 | if d[n] == nil { 165 | return map[string][]interface{}{} 166 | } 167 | return d[n].(map[string][]interface{}) 168 | } 169 | 170 | func (m *TuiModel) GetSelectedColumnName() string { 171 | col := m.GetColumn() 172 | headers := m.GetHeaders() 173 | index := Min(m.NumHeaders()-1, col) 174 | if len(headers) == 0 { 175 | return "" 176 | } 177 | return headers[index] 178 | } 179 | 180 | func (m *TuiModel) GetColumnData() []interface{} { 181 | schemaData := m.GetSchemaData() 182 | if schemaData == nil { 183 | return []interface{}{} 184 | } 185 | return schemaData[m.GetSelectedColumnName()] 186 | } 187 | 188 | func (m *TuiModel) GetRowData() map[string]interface{} { 189 | defer func() { 190 | if recover() != nil { 191 | println("Whoopsy!") // TODO, this happened once 192 | } 193 | }() 194 | headers := m.GetHeaders() 195 | schema := m.GetSchemaData() 196 | data := make(map[string]interface{}) 197 | for _, v := range headers { 198 | data[v] = schema[v][m.GetRow()] 199 | } 200 | 201 | return data 202 | } 203 | 204 | func (m *TuiModel) GetSelectedOption() (*interface{}, int, []interface{}) { 205 | if !m.UI.FormatModeEnabled { 206 | m.Scroll.PreScrollYOffset = m.Viewport.YOffset 207 | m.Scroll.PreScrollYPosition = m.MouseData.Y 208 | } 209 | row := m.GetRow() 210 | col := m.GetColumnData() 211 | if row >= len(col) { 212 | return nil, row, col 213 | } 214 | return &col[row], row, col 215 | } 216 | 217 | func (m *TuiModel) DisplayMessage(msg string) { 218 | m.Data().EditTextBuffer = msg 219 | m.UI.EditModeEnabled = false 220 | m.UI.RenderSelection = true 221 | } 222 | 223 | func (m *TuiModel) GetSelectedLineEdit() *LineEdit { 224 | if m.TextInput.Model.Focused() { 225 | return &m.TextInput 226 | } 227 | 228 | return &m.FormatInput 229 | } 230 | 231 | func ToggleColumn(m *TuiModel) { 232 | if m.UI.ExpandColumn > -1 { 233 | m.UI.ExpandColumn = -1 234 | } else { 235 | m.UI.ExpandColumn = m.GetColumn() 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /viewer/ui.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/mathaou/termdbms/tuiutil" 12 | "github.com/muesli/reflow/wordwrap" 13 | ) 14 | 15 | var ( 16 | Program *tea.Program 17 | FormatModeOffset int 18 | TUIWidth int 19 | TUIHeight int 20 | ) 21 | 22 | func GetOffsetForLineNumber(a int) int { 23 | return FormatModeOffset - len(strconv.Itoa(a)) 24 | } 25 | 26 | func SelectOption(m *TuiModel) { 27 | if m.UI.RenderSelection { 28 | return 29 | } 30 | 31 | m.UI.RenderSelection = true 32 | raw, _, col := m.GetSelectedOption() 33 | if raw == nil { 34 | return 35 | } 36 | l := len(col) 37 | row := m.Viewport.YOffset + m.MouseData.Y - HeaderHeight 38 | 39 | if row <= l && l > 0 && 40 | m.MouseData.Y >= HeaderHeight && 41 | m.MouseData.Y < m.Viewport.Height+HeaderHeight && 42 | m.MouseData.X < m.CellWidth()*(len(m.Data().TableHeadersSlice)) { 43 | if conv, ok := (*raw).(string); ok { 44 | m.Data().EditTextBuffer = conv 45 | } else { 46 | m.Data().EditTextBuffer = "" 47 | } 48 | } else { 49 | m.UI.RenderSelection = false 50 | } 51 | } 52 | 53 | // ScrollDown is a simple function to move the Viewport down 54 | func ScrollDown(m *TuiModel) { 55 | if m.UI.FormatModeEnabled && m.UI.CanFormatScroll && m.Viewport.YPosition != 0 { 56 | m.Viewport.YOffset++ 57 | return 58 | } 59 | 60 | max := GetScrollDownMaximumForSelection(m) 61 | 62 | if m.Viewport.YOffset < max-m.Viewport.Height { 63 | m.Viewport.YOffset++ 64 | m.MouseData.Y = Min(m.MouseData.Y, m.Viewport.YOffset) 65 | } 66 | 67 | if !m.UI.RenderSelection { 68 | m.Scroll.PreScrollYPosition = m.MouseData.Y 69 | m.Scroll.PreScrollYOffset = m.Viewport.YOffset 70 | } 71 | } 72 | 73 | // ScrollUp is a simple function to move the Viewport up 74 | func ScrollUp(m *TuiModel) { 75 | if m.UI.FormatModeEnabled && m.UI.CanFormatScroll && m.Viewport.YOffset > 0 && m.Viewport.YPosition != 0 { 76 | m.Viewport.YOffset-- 77 | return 78 | } 79 | 80 | if m.Viewport.YOffset > 0 { 81 | m.Viewport.YOffset-- 82 | m.MouseData.Y = Min(m.MouseData.Y, m.Viewport.YOffset) 83 | } else { 84 | m.MouseData.Y = HeaderHeight 85 | } 86 | 87 | if !m.UI.RenderSelection { 88 | m.Scroll.PreScrollYPosition = m.MouseData.Y 89 | m.Scroll.PreScrollYOffset = m.Viewport.YOffset 90 | } 91 | } 92 | 93 | // TABLE STUFF 94 | 95 | // DisplayTable does some fancy stuff to get a table rendered in text 96 | func DisplayTable(m *TuiModel) string { 97 | var ( 98 | builder []string 99 | ) 100 | 101 | // go through all columns 102 | for c, columnName := range m.Data().TableHeadersSlice { 103 | if m.UI.ExpandColumn > -1 && m.UI.ExpandColumn != c { 104 | continue 105 | } 106 | 107 | var ( 108 | rowBuilder []string 109 | ) 110 | 111 | columnValues := m.Data().TableSlices[columnName] 112 | for r, val := range columnValues { 113 | base := m.GetBaseStyle(). 114 | UnsetBorderLeft(). 115 | UnsetBorderStyle(). 116 | UnsetBorderForeground() 117 | s := GetStringRepresentationOfInterface(val) 118 | s = " " + s 119 | // handle highlighting 120 | if c == m.GetColumn() && r == m.GetRow() { 121 | if !tuiutil.Ascii { 122 | base.Foreground(lipgloss.Color(tuiutil.Highlight())) 123 | } else if tuiutil.Ascii { 124 | s = "|" + s 125 | } 126 | } 127 | // display text based on type 128 | rowBuilder = append(rowBuilder, base.Render(TruncateIfApplicable(m, s))) 129 | } 130 | 131 | for len(rowBuilder) < m.Viewport.Height { // fix spacing issues 132 | rowBuilder = append(rowBuilder, "") 133 | } 134 | 135 | column := lipgloss.JoinVertical(lipgloss.Left, rowBuilder...) 136 | // get a list of columns 137 | builder = append(builder, m.GetBaseStyle().Render(column)) 138 | } 139 | 140 | // join them into rows 141 | return lipgloss.JoinHorizontal(lipgloss.Left, builder...) 142 | } 143 | 144 | func GetFormattedTextBuffer(m *TuiModel) []string { 145 | v := m.Data().EditTextBuffer 146 | 147 | lines := SplitLines(v) 148 | FormatModeOffset = len(strconv.Itoa(len(lines))) + 1 // number of characters in the numeric string 149 | 150 | var ret []string 151 | m.Format.RunningOffsets = []int{} 152 | 153 | total := 0 154 | strlen := 0 155 | for i, v := range lines { 156 | xOffset := len(strconv.Itoa(i)) 157 | totalOffset := Max(FormatModeOffset-xOffset, 0) 158 | //wrap := wordwrap.String(v, m.Viewport.Width-totalOffset) 159 | 160 | right := tuiutil.Indent( 161 | v, 162 | fmt.Sprintf("%d%s", i, strings.Repeat(" ", totalOffset)), 163 | false) 164 | ret = append(ret, right) 165 | m.Format.RunningOffsets = append(m.Format.RunningOffsets, total) 166 | 167 | strlen = len(v) 168 | 169 | total += strlen + 1 170 | } 171 | 172 | lineLength := len(ret) 173 | // need to add this so that the last line can be edited 174 | m.Format.RunningOffsets = append(m.Format.RunningOffsets, 175 | m.Format.RunningOffsets[lineLength-1]+ 176 | len(ret[len(ret)-1][FormatModeOffset:])) 177 | 178 | for i := len(ret); i < m.Viewport.Height; i++ { 179 | ret = append(ret, "") 180 | } 181 | 182 | return ret 183 | } 184 | 185 | func DisplayFormatText(m *TuiModel) string { 186 | cpy := make([]string, len(m.Format.EditSlices)) 187 | for i, v := range m.Format.EditSlices { 188 | cpy[i] = *v 189 | } 190 | newY := "" 191 | line := &cpy[Min(m.Format.CursorY, len(cpy)-1)] 192 | x := 0 193 | offset := FormatModeOffset - 1 194 | for _, r := range *line { 195 | newY += string(r) 196 | if x == m.Format.CursorX+offset { 197 | x++ 198 | break 199 | } 200 | x++ 201 | } 202 | 203 | *line += " " // space at the end 204 | 205 | highlight := string((*line)[x]) 206 | if tuiutil.Ascii { 207 | highlight = "|" + highlight 208 | newY += highlight 209 | } else { 210 | newY += lipgloss.NewStyle().Background(lipgloss.Color("#ffffff")).Render(highlight) 211 | } 212 | 213 | newY += (*line)[x+1:] 214 | *line = newY 215 | 216 | ret := strings.Join( 217 | cpy, 218 | "\n") 219 | 220 | return wordwrap.String(ret, m.Viewport.Width) 221 | } 222 | 223 | func ShowClipboard(m *TuiModel) string { 224 | return m.ClipboardList.View() 225 | } 226 | 227 | // DisplaySelection does that or writes it to a file if the selection is over a limit 228 | func DisplaySelection(m *TuiModel) string { 229 | col := m.GetColumnData() 230 | row := m.GetRow() 231 | m.UI.ExpandColumn = m.GetColumn() 232 | if m.MouseData.Y >= m.Viewport.Height+HeaderHeight && 233 | !m.UI.RenderSelection { // this is for when the selection is outside the bounds 234 | return DisplayTable(m) 235 | } 236 | 237 | base := m.GetBaseStyle() 238 | 239 | if m.Data().EditTextBuffer != "" { // this is basically just if its a string follow these rules 240 | conv := m.Data().EditTextBuffer 241 | if c, err := FormatJson(m.Data().EditTextBuffer); err == nil { 242 | conv = c 243 | } 244 | rows := SplitLines(wordwrap.String(conv, m.Viewport.Width)) 245 | min := 0 246 | if len(rows) > m.Viewport.Height { 247 | min = m.Viewport.YOffset 248 | } 249 | max := min + m.Viewport.Height 250 | rows = rows[min:Min(len(rows), max)] 251 | 252 | for len(rows) < m.Viewport.Height { 253 | rows = append(rows, "") 254 | } 255 | return base.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) 256 | } 257 | 258 | var prettyPrint string 259 | raw := col[row] 260 | 261 | if conv, ok := raw.(int64); ok { 262 | prettyPrint = strconv.Itoa(int(conv)) 263 | } else if i, ok := raw.(float64); ok { 264 | prettyPrint = base.Render(fmt.Sprintf("%.2f", i)) 265 | } else if t, ok := raw.(time.Time); ok { 266 | str := t.String() 267 | prettyPrint = base.Render(str) 268 | } else if raw == nil { 269 | prettyPrint = base.Render("NULL") 270 | } 271 | 272 | lines := SplitLines(prettyPrint) 273 | for len(lines) < m.Viewport.Height { 274 | lines = append(lines, "") 275 | } 276 | 277 | prettyPrint = " " + base.Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) 278 | 279 | return wordwrap.String(prettyPrint, m.Viewport.Width) 280 | } 281 | -------------------------------------------------------------------------------- /viewer/util.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "hash/fnv" 10 | "io" 11 | "math/rand" 12 | "os" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "github.com/charmbracelet/lipgloss" 19 | ) 20 | 21 | const ( 22 | HiddenTmpDirectoryName = ".termdbms" 23 | SQLSnippetsFile = "snippets.termdbms" 24 | ) 25 | 26 | func TruncateIfApplicable(m *TuiModel, conv string) (s string) { 27 | max := 0 28 | viewportWidth := m.Viewport.Width 29 | cellWidth := m.CellWidth() 30 | if m.UI.RenderSelection || m.UI.ExpandColumn > -1 { 31 | max = viewportWidth 32 | } else { 33 | max = cellWidth 34 | } 35 | 36 | if strings.Count(conv, "\n") > 0 { 37 | conv = SplitLines(conv)[0] 38 | } 39 | 40 | textWidth := lipgloss.Width(conv) 41 | minVal := Min(textWidth, max) 42 | 43 | if max == minVal && textWidth >= max { // truncate 44 | s = conv[:minVal] 45 | s = s[:lipgloss.Width(s)-3] + "..." 46 | } else { 47 | s = conv 48 | } 49 | 50 | return s 51 | } 52 | 53 | func GetInterfaceFromString(str string, original *interface{}) interface{} { 54 | switch (*original).(type) { 55 | case bool: 56 | bVal, _ := strconv.ParseBool(str) 57 | return bVal 58 | case int64: 59 | iVal, _ := strconv.ParseInt(str, 10, 64) 60 | return iVal 61 | case int32: 62 | iVal, _ := strconv.ParseInt(str, 10, 64) 63 | return iVal 64 | case float64: 65 | fVal, _ := strconv.ParseFloat(str, 64) 66 | return fVal 67 | case float32: 68 | fVal, _ := strconv.ParseFloat(str, 64) 69 | return fVal 70 | case time.Time: 71 | t := (*original).(time.Time) 72 | return t // TODO figure out how to handle things like time and date 73 | case string: 74 | return str 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func GetStringRepresentationOfInterface(val interface{}) string { 81 | if str, ok := val.(string); ok { 82 | return str 83 | } else if i, ok := val.(int64); ok { // these default to int64 so not sure how this would affect 32 bit systems TODO 84 | return fmt.Sprintf("%d", i) 85 | } else if i, ok := val.(int32); ok { // these default to int32 so not sure how this would affect 32 bit systems TODO 86 | return fmt.Sprintf("%d", i) 87 | } else if i, ok := val.(float64); ok { 88 | return fmt.Sprintf("%.2f", i) 89 | } else if i, ok := val.(float32); ok { 90 | return fmt.Sprintf("%.2f", i) 91 | } else if t, ok := val.(time.Time); ok { 92 | str := t.String() 93 | return str 94 | } else if val == nil { 95 | return "NULL" 96 | } 97 | 98 | return "" 99 | } 100 | 101 | func WriteCSV(m *TuiModel) { // basically display table but without any styling 102 | if m.QueryData == nil || m.QueryResult == nil { 103 | return // should never happen but just making sure 104 | } 105 | 106 | var ( 107 | builder [][]string 108 | buffer strings.Builder 109 | ) 110 | 111 | d := m.Data() 112 | 113 | // go through all columns 114 | for _, columnName := range d.TableHeaders[QueryResultsTableName] { 115 | var ( 116 | rowBuilder []string 117 | ) 118 | 119 | columnValues := m.GetSchemaData()[columnName] 120 | rowBuilder = append(rowBuilder, columnName) 121 | for _, val := range columnValues { 122 | s := GetStringRepresentationOfInterface(val) 123 | // display text based on type 124 | rowBuilder = append(rowBuilder, s) 125 | } 126 | builder = append(builder, rowBuilder) 127 | } 128 | 129 | depth := len(builder[0]) 130 | headers := len(builder) 131 | 132 | for i := 0; i < depth; i++ { 133 | var r []string 134 | for x := 0; x < headers; x++ { 135 | r = append(r, builder[x][i]) 136 | } 137 | buffer.WriteString(strings.Join(r, ",")) 138 | buffer.WriteString("\n") 139 | } 140 | 141 | WriteTextFile(m, buffer.String()) 142 | } 143 | 144 | func WriteTextFile(m *TuiModel, text string) (string, error) { 145 | rand.Seed(time.Now().Unix()) 146 | fileName := m.GetSchemaName() + "_" + "renderView_" + fmt.Sprintf("%d", rand.Int()) + ".txt" 147 | e := os.WriteFile(fileName, []byte(text), 0777) 148 | return fileName, e 149 | } 150 | 151 | // IsUrl is some code I stole off stackoverflow to validate paths 152 | func IsUrl(fp string) bool { 153 | // Check if file already exists 154 | if _, err := os.Stat(fp); err == nil { 155 | return true 156 | } 157 | 158 | // Attempt to create it 159 | var d []byte 160 | if err := os.WriteFile(fp, d, 0644); err == nil { 161 | os.Remove(fp) // And delete it 162 | return true 163 | } 164 | 165 | return false 166 | } 167 | 168 | func FileExists(name string) (bool, error) { 169 | _, err := os.Stat(name) 170 | if err == nil { 171 | return false, nil 172 | } 173 | if errors.Is(err, os.ErrNotExist) { 174 | return true, nil 175 | } 176 | return true, err 177 | } 178 | 179 | func SplitLines(s string) []string { 180 | var lines []string 181 | if strings.Count(s, "\n") == 0 { 182 | return append(lines, s) 183 | } 184 | 185 | reader := strings.NewReader(s) 186 | sc := bufio.NewScanner(reader) 187 | 188 | for sc.Scan() { 189 | lines = append(lines, sc.Text()) 190 | } 191 | return lines 192 | } 193 | 194 | func GetScrollDownMaximumForSelection(m *TuiModel) int { 195 | max := 0 196 | if m.UI.RenderSelection { 197 | conv, _ := FormatJson(m.Data().EditTextBuffer) 198 | lines := SplitLines(conv) 199 | max = len(lines) 200 | } else if m.UI.FormatModeEnabled { 201 | max = len(SplitLines(DisplayFormatText(m))) 202 | } else { 203 | return len(m.GetColumnData()) 204 | } 205 | 206 | return max 207 | } 208 | 209 | // FormatJson is some more code I stole off stackoverflow 210 | func FormatJson(str string) (string, error) { 211 | b := []byte(str) 212 | if !json.Valid(b) { // return original string if not json 213 | return str, errors.New("this is not valid JSON") 214 | } 215 | var formattedJson bytes.Buffer 216 | if err := json.Indent(&formattedJson, b, "", " "); err != nil { 217 | return "", err 218 | } 219 | return formattedJson.String(), nil 220 | } 221 | 222 | func Exists(path string) (bool, error) { 223 | _, err := os.Stat(path) 224 | if err == nil { 225 | return true, nil 226 | } 227 | if os.IsNotExist(err) { 228 | return false, nil 229 | } 230 | return false, err 231 | } 232 | 233 | func Hash(s string) uint32 { 234 | h := fnv.New32a() 235 | h.Write([]byte(s)) 236 | return h.Sum32() 237 | } 238 | 239 | func CopyFile(src string) (string, int64, error) { 240 | sourceFileStat, err := os.Stat(src) 241 | rand.Seed(time.Now().UnixNano()) 242 | dst := fmt.Sprintf(".%d", 243 | Hash(fmt.Sprintf("%s%d", 244 | src, 245 | rand.Uint64()))) 246 | if err != nil { 247 | return "", 0, err 248 | } 249 | 250 | if !sourceFileStat.Mode().IsRegular() { 251 | return "", 0, fmt.Errorf("%s is not a regular file", src) 252 | } 253 | 254 | source, err := os.Open(src) 255 | if err != nil { 256 | return "", 0, err 257 | } 258 | defer source.Close() 259 | 260 | destination, err := os.CreateTemp(HiddenTmpDirectoryName, dst) 261 | if err != nil { 262 | return "", 0, err 263 | } 264 | defer destination.Close() 265 | nBytes, err := io.Copy(destination, source) 266 | info, _ := destination.Stat() 267 | path, _ := filepath.Abs( 268 | fmt.Sprintf("%s/%s", 269 | HiddenTmpDirectoryName, 270 | info.Name())) // platform agnostic 271 | return path, nBytes, err 272 | } 273 | 274 | // MATH YO 275 | 276 | func Min(a, b int) int { 277 | if a < b { 278 | return a 279 | } 280 | 281 | return b 282 | } 283 | 284 | func Max(a, b int) int { 285 | if a > b { 286 | return a 287 | } 288 | 289 | return b 290 | } 291 | 292 | func Abs(a int) int { 293 | if a < 0 { 294 | return a * -1 295 | } 296 | 297 | return a 298 | } 299 | -------------------------------------------------------------------------------- /viewer/viewer.go: -------------------------------------------------------------------------------- 1 | package viewer 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/mathaou/termdbms/list" 9 | "github.com/mathaou/termdbms/tuiutil" 10 | ) 11 | 12 | var ( 13 | HeaderHeight = 2 14 | FooterHeight = 1 15 | MaxInputLength int 16 | HeaderStyle lipgloss.Style 17 | FooterStyle lipgloss.Style 18 | HeaderDividerStyle lipgloss.Style 19 | InitialModel *TuiModel 20 | ) 21 | 22 | func (m *TuiModel) Data() *UIData { 23 | if m.QueryData != nil { 24 | return m.QueryData 25 | } 26 | 27 | return &m.DefaultData 28 | } 29 | 30 | func (m *TuiModel) Table() *TableState { 31 | if m.QueryResult != nil { 32 | return m.QueryResult 33 | } 34 | 35 | return &m.DefaultTable 36 | } 37 | 38 | func SetStyles() { 39 | HeaderStyle = lipgloss.NewStyle() 40 | FooterStyle = lipgloss.NewStyle() 41 | 42 | HeaderDividerStyle = lipgloss.NewStyle(). 43 | Align(lipgloss.Center) 44 | 45 | if !tuiutil.Ascii { 46 | HeaderStyle = HeaderStyle. 47 | Foreground(lipgloss.Color(tuiutil.HeaderTopForeground())) 48 | 49 | FooterStyle = FooterStyle. 50 | Foreground(lipgloss.Color(tuiutil.FooterForeground())) 51 | 52 | HeaderDividerStyle = HeaderDividerStyle. 53 | Foreground(lipgloss.Color(tuiutil.HeaderBottom())) 54 | } 55 | } 56 | 57 | // INIT UPDATE AND RENDER 58 | 59 | // Init currently doesn't do anything but necessary for interface adherence 60 | func (m TuiModel) Init() tea.Cmd { 61 | SetStyles() 62 | 63 | return nil 64 | } 65 | 66 | // Update is where all commands and whatnot get processed 67 | func (m TuiModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { 68 | var ( 69 | command tea.Cmd 70 | commands []tea.Cmd 71 | ) 72 | 73 | if !m.UI.FormatModeEnabled { 74 | m.Viewport, _ = m.Viewport.Update(message) 75 | } 76 | 77 | switch msg := message.(type) { 78 | case list.FilterMatchesMessage: 79 | m.ClipboardList, command = m.ClipboardList.Update(msg) 80 | break 81 | case tea.MouseMsg: 82 | HandleMouseEvents(&m, &msg) 83 | m.SetViewSlices() 84 | break 85 | case tea.WindowSizeMsg: 86 | event := HandleWindowSizeEvents(&m, &msg) 87 | if event != nil { 88 | commands = append(commands, event) 89 | } 90 | break 91 | case tea.KeyMsg: 92 | str := msg.String() 93 | if m.UI.ShowClipboard { 94 | HandleClipboardEvents(&m, str, &command, msg) 95 | break 96 | } 97 | 98 | // when fullscreen selection viewing is in session, don't allow UI manipulation other than quit or exit 99 | s := msg.String() 100 | invalidRenderCommand := m.UI.RenderSelection && 101 | s != "esc" && 102 | s != "ctrl+c" && 103 | s != "q" && 104 | s != "p" && 105 | s != "m" && 106 | s != "n" 107 | if invalidRenderCommand { 108 | break 109 | } 110 | 111 | if s == "ctrl+c" || (s == "q" && (!m.UI.EditModeEnabled && !m.UI.FormatModeEnabled)) { 112 | return m, tea.Quit 113 | } 114 | 115 | event := HandleKeyboardEvents(&m, &msg) 116 | if event != nil { 117 | commands = append(commands, event) 118 | } 119 | if !m.UI.EditModeEnabled && m.Ready { 120 | m.SetViewSlices() 121 | if m.UI.FormatModeEnabled { 122 | MoveCursorWithinBounds(&m) 123 | } 124 | } 125 | 126 | break 127 | case error: 128 | return m, nil 129 | } 130 | 131 | if m.Viewport.HighPerformanceRendering { 132 | commands = append(commands, command) 133 | } 134 | 135 | return m, tea.Batch(commands...) 136 | } 137 | 138 | // View is where all rendering happens 139 | func (m TuiModel) View() string { 140 | if !m.Ready || m.Viewport.Width == 0 { 141 | return "\n\tInitializing..." 142 | } 143 | 144 | // this ensures that all 3 parts can be worked on concurrently(ish) 145 | done := make(chan bool, 3) 146 | defer close(done) // close 147 | 148 | var footer, header, content string 149 | 150 | // body 151 | go func(c *string) { 152 | *c = AssembleTable(&m) 153 | done <- true 154 | }(&content) 155 | 156 | if m.UI.ShowClipboard { 157 | <-done 158 | return content 159 | } 160 | 161 | // header 162 | go HeaderAssembly(&m, &header, &done) 163 | // footer (shows row/col for now) 164 | go FooterAssembly(&m, &footer, &done) 165 | 166 | // block until all 3 done 167 | <-done 168 | <-done 169 | <-done 170 | 171 | return fmt.Sprintf("%s\n%s\n%s", header, content, footer) // render 172 | } 173 | --------------------------------------------------------------------------------