Repository: mathaou/sqlite-tui Branch: master Commit: be6f39719607 Files: 32 Total size: 168.2 KB Directory structure: gitextract_ax31ssze/ ├── .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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ master ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '38 18 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # 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 steps: - name: Checkout repository uses: actions/checkout@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 ================================================ FILE: .gitignore ================================================ *.sh .idea/* .termdbms build/ ================================================ FILE: CHANGELOG.txt ================================================ ## [Unreleased] - MYSQL Support - Database creation tools ##[1.0-alpha] ### Added - Added ability to remove snippets - Line wrapping + horizontal scroll! ### Changed - Unified help text display logic with selection display logic ##[0.7-alpha] - 10-4-2021 ### Added - SQL querying - Clipboard for SQL queries ### Changed - Refactored codebase to be easier to reason with and manage - Many bug fixes I forgot about 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! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Matt Farstad Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # termdbms ## A TUI for viewing and editing databases, written in pure Go #### Installation Instructions ###### Go Install ```go go install github.com/mathaou/termdbms@latest ``` ###### Arch Linux ```bash // pacman sudo pacman -S termdbms-git // yay yay -S termdbms-git ``` --- ###### Database Support SQLite CSV* (see note below) ### made with modernc.org/sqlite, charmbracelet/bubbletea, and charmbracelet/lipgloss #### Works with keyboard and mouse! ![Keyboard Control](https://i.imgur.com/vmK0DVn.gif) #### Navigate tables with any number of columns! ![Columns and Tables](https://i.imgur.com/EqZRPqO.gif) #### Navigate tables with any number of rows! ![Lot of Rows](https://i.imgur.com/yo7DMaa.gif) #### Serialize your changes as a copy or over the database original! (SQLite only) ![Serialization](https://i.imgur.com/GhMcnid.gif) #### Query your database! ![querying](https://i.imgur.com/9FB3ETs.gif) #### Other Features - Run SQL queries and display the results! - Save SQL queries to a clipboard! - Update, delete, or insert with SQL, with undo/redo supported for SQLite - Automatic JSON formatting in selection/format mode - Edit multi-line text with vim-like controls - Undo/Redo of changes (SQLite only) - Themes (press T in table mode) - Output query results as a csv - Convert .csv to SQLite database! Export as a SQLite database or .csv file again! #### Roadmap - MySQL/ PostgreSQL support - Line wrapping / horizontal scroll for format/SQL mode + revamped (faster format mode) ####
How To Build ##### Linux GOOS=linux GOARCH=amd64/386 go build ##### ARM (runs kind of slow depending on the specs of the system) GOOS=linux GOARCH=arm GOARM=7 go build ##### Windows GOOS=windows GOARCH=amd64/386 go build ##### OSX GOOS=darwin GOARCH=amd64 go build
#### Terminal settings 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). #### Known Issues - 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? - The headers wig out sometimes in selection mode - 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! - Weird combinations of newlines + tabs can break stuff. Tabs at beginning of line and mid-line works in a stable manner. ##### Help: -p / database/.csv path -d / specifies which database driver to use (sqlite/mysql) -a / enable ascii mode -h / prints this message -t / starts app with specific theme (default, nord, solarized) ##### Controls: ###### MOUSE Scroll up + down to navigate table/text Move cursor to select cells for full screen viewing ###### KEYBOARD [WASD] to move around cells, and also move columns if close to edge [ENTER] to select selected cell for full screen view [UP/K and DOWN/J] to navigate schemas [LEFT/H and RIGHT/L] to navigate columns if there are more than the screen allows. Also to control the cursor of the text editor in edit mode [BACKSPACE] to delete text before cursor in edit mode [M(scroll up) and N(scroll down)] to scroll manually [Q or CTRL+C] to quit program [B] to toggle borders! [C] to expand column [T] to cycle through themes! [P] in selection mode to write cell to file, or to print query results as CSV. [R] to redo actions, if applicable [U] to undo actions, if applicable [ESC] to exit full screen view, or to enter edit mode [PGDOWN] to scroll down one views worth of rows [PGUP] to scroll up one views worth of rows ###### EDIT MODE (for quick, single line changes and commands) [ESC] to enter edit mode with no pre-loaded text input from selection When a cell is selected, press [:] to enter edit mode with selection pre-loaded The text field in the header will be populated with the selected cells text. Modifications can be made freely [ESC] to clear text field in edit mode [ENTER] to save text. Anything besides one of the reserved strings below will overwrite the current cell [:q] to exit edit mode/ format mode/ SQL mode [:s] to save database to a new file (SQLite only) [:s!] to overwrite original database file (SQLite only). A confirmation dialog will be added soon [:h] to display help text [:new] opens current cell with a blank buffer [:edit] opens current cell in format mode [:sql] opens blank buffer for creating an SQL statement [:clip] to open clipboard of SQL queries. [/] to filter, [ENTER] to select. [HOME] to set cursor to end of the text [END] to set cursor to the end of the text ###### FORMAT MODE (for editing lines of text) [ESC] to move between top control bar and format buffer [HOME] to set cursor to end of the text [END] to set cursor to the end of the text [:wq] to save changes and quit to main table view [:w] to save changes and remain in format view [:s] to serialize changes, non-destructive (SQLite only) [:s!] to serialize changes, overwriting original file (SQLite only) ###### SQL MODE (for querying database) [ESC] to move between top control bar and text buffer [:q] to quit out of statement [:exec] to execute statement. Errors will be displayed in full screen view. [:stow ] to create a snippet for the clipboard with an optional name. A random number will be used if no name is specified. ###### QUERY MODE (specifically when viewing query results) [:d] to reset table data back to original view [:sql] to query original database again ================================================ FILE: database/query.go ================================================ package database import ( "database/sql" "sync" ) var ( DBMutex sync.Mutex Databases map[string]*sql.DB DriverString string IsCSV bool ) func init() { // We keep one connection pool per database. DBMutex = sync.Mutex{} Databases = make(map[string]*sql.DB) } type Query interface { GetValues() map[string]interface{} SetValues(map[string]interface{}) } type Database interface { Update(q *Update) GenerateQuery(u *Update) (string, []string) GetPlaceholderForDatabaseType() string GetFileName() string GetTableNamesQuery() string GetDatabaseReference() *sql.DB CloseDatabaseReference() SetDatabaseReference(dbPath string) } type Update struct { v map[string]interface{} // these are anchors to ensure the right row/col gets updated Column string // this is the header Update interface{} // this is the new cell value TableName string } func (u *Update) GetValues() map[string]interface{} { return u.v } func (u *Update) SetValues(v map[string]interface{}) { u.v = v } // GetDatabaseForFile does what you think it does func GetDatabaseForFile(database string) *sql.DB { DBMutex.Lock() defer DBMutex.Unlock() if db, ok := Databases[database]; ok { return db } db, err := sql.Open(DriverString, database) if err != nil { panic(err) } Databases[database] = db return db } func ProcessSqlQueryForDatabaseType(q Query, rowData map[string]interface{}, schemaName, columnName string, db *Database) { switch conv := q.(type) { case *Update: conv.SetValues(rowData) conv.TableName = schemaName conv.Column = columnName (*db).Update(conv) break } } ================================================ FILE: database/sqlite.go ================================================ package database import ( "database/sql" "fmt" "log" "strings" ) type SQLite struct { FileName string Database *sql.DB } func (db *SQLite) Update(q *Update) { protoQuery, columnOrder := db.GenerateQuery(q) values := make([]interface{}, len(columnOrder)) updateValues := q.GetValues() for i, v := range columnOrder { var u interface{} if i == 0 { u = q.Update } else { u = updateValues[v] } if u == nil { u = "NULL" } values[i] = u } tx, err := db.GetDatabaseReference().Begin() if err != nil { log.Fatal(err) } stmt, err := tx.Prepare(protoQuery) if err != nil { log.Fatal(err) } defer stmt.Close() stmt.Exec(values...) err = tx.Commit() if err != nil { log.Fatal(err) } } func (db *SQLite) GetFileName() string { return db.FileName } func (db *SQLite) GetDatabaseReference() *sql.DB { return db.Database } func (db *SQLite) CloseDatabaseReference() { db.GetDatabaseReference().Close() db.Database = nil } func (db *SQLite) SetDatabaseReference(dbPath string) { database := GetDatabaseForFile(dbPath) db.FileName = dbPath db.Database = database } func (db SQLite) GetPlaceholderForDatabaseType() string { return "?" } func (db SQLite) GetTableNamesQuery() string { val := "SELECT name FROM " val += "sqlite_master" val += " WHERE type='table'" return val } func (db *SQLite) GenerateQuery(u *Update) (string, []string) { var ( query string querySkeleton string valueOrder []string ) placeholder := db.GetPlaceholderForDatabaseType() querySkeleton = fmt.Sprintf("UPDATE %s"+ " SET %s=%s ", u.TableName, u.Column, placeholder) valueOrder = append(valueOrder, u.Column) whereBuilder := strings.Builder{} whereBuilder.WriteString(" WHERE ") uLen := len(u.GetValues()) i := 0 for k := range u.GetValues() { // keep track of order since maps aren't deterministic assertion := fmt.Sprintf("%s=%s ", k, placeholder) valueOrder = append(valueOrder, k) whereBuilder.WriteString(assertion) if uLen > 1 && i < uLen-1 { whereBuilder.WriteString("AND ") } i++ } query = querySkeleton + strings.TrimSpace(whereBuilder.String()) + ";" return query, valueOrder } ================================================ FILE: go.mod ================================================ module github.com/mathaou/termdbms go 1.16 require ( github.com/atotto/clipboard v0.1.2 github.com/charmbracelet/bubbles v0.9.0 github.com/charmbracelet/bubbletea v0.18.0 github.com/charmbracelet/lipgloss v0.4.0 github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74 // indirect github.com/mattn/go-runewidth v0.0.13 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.9.0 github.com/sahilm/fuzzy v0.1.0 modernc.org/sqlite v1.13.0 ) ================================================ FILE: go.sum ================================================ github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/charmbracelet/bubbles v0.9.0 h1:lqJ8FXwoLceQF2J0A+dWo1Cuu1dNyjbW4Opgdi2vkhw= github.com/charmbracelet/bubbles v0.9.0/go.mod h1:NWT/c+0rYEnYChz5qCyX4Lj6fDw9gGToh9EFJPajghU= github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE= github.com/charmbracelet/bubbletea v0.18.0 h1:v9JrrWADDZ5Tk5DV8Rj3MIiqhrrk33RIGBUnYvZsQbc= github.com/charmbracelet/bubbletea v0.18.0/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk= github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE= github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74 h1:3kEhu34+VLPo2YgQ1PXHLQRgMQKtBmq+MmMYEBHGX7U= github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210902050250-f475640dd07b h1:S7hKs0Flbq0bbc9xgYt4stIEG1zNDFqyrPwAX2Wj/sE= golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs= golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= modernc.org/cc/v3 v3.33.9/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= modernc.org/cc/v3 v3.33.11/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= modernc.org/cc/v3 v3.34.0 h1:dFhZc/HKR3qp92sYQxKRRaDMz+sr1bwcFD+m7LSCrAs= modernc.org/cc/v3 v3.34.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= modernc.org/ccgo/v3 v3.9.5/go.mod h1:umuo2EP2oDSBnD3ckjaVUXMrmeAw8C8OSICVa0iFf60= modernc.org/ccgo/v3 v3.10.0/go.mod h1:c0yBmkRFi7uW4J7fwx/JiijwOjeAeR2NoSaRVFPmjMw= modernc.org/ccgo/v3 v3.11.0/go.mod h1:dGNposbDp9TOZ/1KBxghxtUp/bzErD0/0QW4hhSaBMI= modernc.org/ccgo/v3 v3.11.1/go.mod h1:lWHxfsn13L3f7hgGsGlU28D9eUOf6y3ZYHKoPaKU0ag= modernc.org/ccgo/v3 v3.11.2 h1:gqa8PQ2v7SjrhHCgxUO5dzoAJWSLAveJqZTNkPCN0kc= modernc.org/ccgo/v3 v3.11.2/go.mod h1:6kii3AptTDI+nUrM9RFBoIEUEisSWCbdczD9ZwQH2FE= modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w= modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q= modernc.org/libc v1.11.0/go.mod h1:2lOfPmj7cz+g1MrPNmX65QCzVxgNq2C5o0jdLY2gAYg= modernc.org/libc v1.11.2/go.mod h1:ioIyrl3ETkugDO3SGZ+6EOKvlP3zSOycUETe4XM4n8M= modernc.org/libc v1.11.3 h1:q//spBhqp23lC/if8/o8hlyET57P8mCZqrqftzT2WmY= modernc.org/libc v1.11.3/go.mod h1:k3HDCP95A6U111Q5TmG3nAyUcp3kR5YFZTeDS9v8vSU= modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.4.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8= modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc= modernc.org/memory v1.0.5 h1:XRch8trV7GgvTec2i7jc33YlUI0RKVDBvZ5eZ5m8y14= modernc.org/memory v1.0.5/go.mod h1:B7OYswTRnfGg+4tDH1t1OeUNnsy2viGTdME4tzd+IjM= modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A= modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sqlite v1.13.0 h1:cwhUj0jTBgPjk/demWheV+T6xi6ifTfsGIFKFq0g3Ck= modernc.org/sqlite v1.13.0/go.mod h1:2qO/6jZJrcQaxFUHxOwa6Q6WfiGSsiVj6GXX0Ker+Jg= modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs= modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= modernc.org/tcl v1.5.9 h1:DZMfR+RDJRhcrmMEMTJgVIX+Wf5qhfVX0llI0rsc20w= modernc.org/tcl v1.5.9/go.mod h1:bcwjvBJ2u0exY6K35eAmxXBBij5kXb1dHlAWmfhqThE= modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.1.2 h1:IjjzDsIFbl0wuF2KfwvdyUAJVwxD4iwZ6akLNiDoClM= modernc.org/z v1.1.2/go.mod h1:sj9T1AGBG0dm6SCVzldPOHWrif6XBpooJtbttMn1+Js= ================================================ FILE: list/defaultitem.go ================================================ package list import ( "fmt" "io" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/truncate" ) // DefaultItemStyles defines styling for a default list item. // See DefaultItemView for when these come into play. type DefaultItemStyles struct { // The Normal state. NormalTitle lipgloss.Style NormalDesc lipgloss.Style // The selected item state. SelectedTitle lipgloss.Style SelectedDesc lipgloss.Style // The dimmed state, for when the filter input is initially activated. DimmedTitle lipgloss.Style DimmedDesc lipgloss.Style // Charcters matching the current filter, if any. FilterMatch lipgloss.Style } // NewDefaultItemStyles returns style definitions for a default item. See // DefaultItemView for when these come into play. func NewDefaultItemStyles() (s DefaultItemStyles) { s.NormalTitle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}). Padding(0, 0, 0, 2) s.NormalDesc = s.NormalTitle.Copy(). Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}) s.SelectedTitle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, false, true). BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}). Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}). Padding(0, 0, 0, 1) s.SelectedDesc = s.SelectedTitle.Copy(). Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}) s.DimmedTitle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). Padding(0, 0, 0, 2) s.DimmedDesc = s.DimmedTitle.Copy(). Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}) s.FilterMatch = lipgloss.NewStyle().Underline(true) return s } // DefaultItem describes an items designed to work with DefaultDelegate. type DefaultItem interface { Item Title() string Description() string } // DefaultDelegate is a standard delegate designed to work in lists. It's // styled by DefaultItemStyles, which can be customized as you like. // // The description line can be hidden by setting Description to false, which // renders the list as single-line-items. The spacing between items can be set // with the SetSpacing method. // // Setting UpdateFunc is optional. If it's set it will be called when the // ItemDelegate called, which is called when the list's Update function is // invoked. // // Settings ShortHelpFunc and FullHelpFunc is optional. They can can be set to // include items in the list's default short and full help menus. type DefaultDelegate struct { ShowDescription bool Styles DefaultItemStyles UpdateFunc func(tea.Msg, *Model) tea.Cmd ShortHelpFunc func() []key.Binding FullHelpFunc func() [][]key.Binding spacing int } // NewDefaultDelegate creates a new delegate with default styles. func NewDefaultDelegate() DefaultDelegate { return DefaultDelegate{ ShowDescription: true, Styles: NewDefaultItemStyles(), spacing: 1, } } // Height returns the delegate's preferred height. func (d DefaultDelegate) Height() int { if d.ShowDescription { return 2 //nolint:gomnd } return 1 } // SetSpacing set the delegate's spacing. func (d *DefaultDelegate) SetSpacing(i int) { d.spacing = i } // Spacing returns the delegate's spacing. func (d DefaultDelegate) Spacing() int { return d.spacing } // Update checks whether the delegate's UpdateFunc is set and calls it. func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd { if d.UpdateFunc == nil { return nil } return d.UpdateFunc(msg, m) } // Render prints an item. func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) { var ( title, desc string matchedRunes []int s = &d.Styles ) if i, ok := item.(DefaultItem); ok { title = i.Title() desc = i.Description() } else { return } // Prevent text from exceeding list width if m.width > 0 { textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight()) title = truncate.StringWithTail(title, textwidth, ellipsis) desc = truncate.StringWithTail(desc, textwidth, ellipsis) } // Conditions var ( isSelected = index == m.Index() emptyFilter = m.FilterState() == Filtering && m.FilterValue() == "" isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied ) if isFiltered && index < len(m.filteredItems) { // Get indices of matched characters matchedRunes = m.MatchesForItem(index) } if emptyFilter { title = s.DimmedTitle.Render(title) desc = s.DimmedDesc.Render(desc) } else if isSelected && m.FilterState() != Filtering { if isFiltered { // Highlight matches unmatched := s.SelectedTitle.Inline(true) matched := unmatched.Copy().Inherit(s.FilterMatch) title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) } title = s.SelectedTitle.Render(title) desc = s.SelectedDesc.Render(desc) } else { if isFiltered { // Highlight matches unmatched := s.NormalTitle.Inline(true) matched := unmatched.Copy().Inherit(s.FilterMatch) title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) } title = s.NormalTitle.Render(title) desc = s.NormalDesc.Render(desc) } if d.ShowDescription { fmt.Fprintf(w, "%s\n%s", title, desc) return } fmt.Fprintf(w, "%s", title) } // ShortHelp returns the delegate's short help. func (d DefaultDelegate) ShortHelp() []key.Binding { if d.ShortHelpFunc != nil { return d.ShortHelpFunc() } return nil } // FullHelp returns the delegate's full help. func (d DefaultDelegate) FullHelp() [][]key.Binding { if d.FullHelpFunc != nil { return d.FullHelpFunc() } return nil } ================================================ FILE: list/keys.go ================================================ package list import "github.com/charmbracelet/bubbles/key" // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which // is used to render the menu menu. type KeyMap struct { // Keybindings used when browsing the list. CursorUp key.Binding CursorDown key.Binding NextPage key.Binding PrevPage key.Binding GoToStart key.Binding GoToEnd key.Binding Filter key.Binding ClearFilter key.Binding DeleteSelection key.Binding // Keybindings used when setting a filter. CancelWhileFiltering key.Binding AcceptWhileFiltering key.Binding // Help toggle keybindings. ShowFullHelp key.Binding CloseFullHelp key.Binding // The quit keybinding. This won't be caught when filtering. Quit key.Binding // The quit-no-matter-what keybinding. This will be caught when filtering. ForceQuit key.Binding } // DefaultKeyMap returns a default set of keybindings. func DefaultKeyMap() KeyMap { return KeyMap{ DeleteSelection: key.NewBinding( key.WithKeys("r"), key.WithHelp("r", "remove selection")), // Browsing. CursorUp: key.NewBinding( key.WithKeys("up", "k", "w"), key.WithHelp("↑/k", "up"), ), CursorDown: key.NewBinding( key.WithKeys("down", "j", "s"), key.WithHelp("↓/j", "down"), ), PrevPage: key.NewBinding( key.WithKeys("left", "h", "pgup", "b", "u", "a"), key.WithHelp("←/h/pgup", "prev page"), ), NextPage: key.NewBinding( key.WithKeys("right", "l", "pgdown", "f", "d"), key.WithHelp("→/l/pgdn", "next page"), ), GoToStart: key.NewBinding( key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start"), ), GoToEnd: key.NewBinding( key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end"), ), Filter: key.NewBinding( key.WithKeys("/"), key.WithHelp("/", "filter"), ), ClearFilter: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), ), // Filtering. CancelWhileFiltering: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "cancel"), ), AcceptWhileFiltering: key.NewBinding( key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"), key.WithHelp("enter", "apply filter"), ), // Toggle help. ShowFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "more"), ), CloseFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "close help"), ), // Quitting. Quit: key.NewBinding( key.WithKeys("q", "esc"), key.WithHelp("q", "back"), ), ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), } } ================================================ FILE: list/list.go ================================================ // Package list provides a feature-rich Bubble Tea component for browsing // a general purpose list of items. It features optional filtering, pagination, // help, status messages, and a spinner to indicate activity. package list import ( "fmt" "io" "sort" "strings" "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/paginator" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" "github.com/sahilm/fuzzy" ) // Item is an item that appears in the list. type Item interface { // Filter value is the value we use when filtering against this item when // we're filtering the list. FilterValue() string } // ItemDelegate encapsulates the general functionality for all list items. The // benefit to separating this logic from the item itself is that you can change // the functionality of items without changing the actual items themselves. // // Note that if the delegate also implements help.KeyMap delegate-related // help items will be added to the help view. type ItemDelegate interface { // Render renders the item's view. Render(w io.Writer, m Model, index int, item Item) // Height is the height of the list item. Height() int // Spacing is the size of the horizontal gap between list items in cells. Spacing() int // Update is the update loop for items. All messages in the list's update // loop will pass through here except when the user is setting a filter. // Use this method to perform item-level updates appropriate to this // delegate. Update(msg tea.Msg, m *Model) tea.Cmd } type filteredItem struct { item Item // item matched matches []int // rune indices of matched items } type filteredItems []filteredItem func (f filteredItems) items() []Item { agg := make([]Item, len(f)) for i, v := range f { agg[i] = v.item } return agg } func (f filteredItems) matches() [][]int { agg := make([][]int, len(f)) for i, v := range f { agg[i] = v.matches } return agg } type FilterMatchesMessage []filteredItem type statusMessageTimeoutMsg struct{} // FilterState describes the current filtering state on the model. type FilterState int // Possible filter states. const ( Unfiltered FilterState = iota // no filter set Filtering // user is actively setting a filter FilterApplied // a filter is applied and user is not editing filter ) // String returns a human-readable string of the current filter state. func (f FilterState) String() string { return [...]string{ "unfiltered", "filtering", "filter applied", }[f] } // Model contains the state of this component. type Model struct { showTitle bool showFilter bool showStatusBar bool showPagination bool showHelp bool filteringEnabled bool Title string Styles Styles // Key mappings for navigating the list. KeyMap KeyMap // Additional key mappings for the short and full help views. This allows // you to add additional key mappings to the help menu without // re-implementing the help component. Of course, you can also disable the // list's help component and implement a new one if you need more // flexibility. AdditionalShortHelpKeys func() []key.Binding AdditionalFullHelpKeys func() []key.Binding spinner spinner.Model showSpinner bool width int height int Paginator paginator.Model cursor int Help help.Model FilterInput textinput.Model filterState FilterState // How long status messages should stay visible. By default this is // 1 second. StatusMessageLifetime time.Duration statusMessage string statusMessageTimer *time.Timer // The master set of items we're working with. items []Item // Filtered items we're currently displaying. Filtering, toggles and so on // will alter this slice so we can show what is relevant. For that reason, // this field should be considered ephemeral. filteredItems filteredItems delegate ItemDelegate } // NewModel returns a new model with sensible defaults. func NewModel(items []Item, delegate ItemDelegate, width, height int) Model { styles := DefaultStyles() sp := spinner.NewModel() sp.Spinner = spinner.Line sp.Style = styles.Spinner filterInput := textinput.NewModel() filterInput.Prompt = "Filter: " filterInput.PromptStyle = styles.FilterPrompt filterInput.CursorStyle = styles.FilterCursor filterInput.CharLimit = 64 filterInput.Focus() p := paginator.NewModel() p.Type = paginator.Dots p.ActiveDot = styles.ActivePaginationDot.String() p.InactiveDot = styles.InactivePaginationDot.String() m := Model{ showTitle: true, showFilter: true, showStatusBar: true, showPagination: true, showHelp: true, filteringEnabled: true, KeyMap: DefaultKeyMap(), Styles: styles, Title: "List", FilterInput: filterInput, StatusMessageLifetime: time.Second, width: width, height: height, delegate: delegate, items: items, Paginator: p, spinner: sp, Help: help.NewModel(), } m.updatePagination() m.updateKeybindings() return m } // SetFilteringEnabled enables or disables filtering. Note that this is different // from ShowFilter, which merely hides or shows the input view. func (m *Model) SetFilteringEnabled(v bool) { m.filteringEnabled = v if !v { m.resetFiltering() } m.updateKeybindings() } // FilteringEnabled returns whether or not filtering is enabled. func (m Model) FilteringEnabled() bool { return m.filteringEnabled } // SetShowTitle shows or hides the title bar. func (m *Model) SetShowTitle(v bool) { m.showTitle = v m.updatePagination() } // ShowTitle returns whether or not the title bar is set to be rendered. func (m Model) ShowTitle() bool { return m.showTitle } // SetShowFilter shows or hides the filer bar. Note that this does not disable // filtering, it simply hides the built-in filter view. This allows you to // use the FilterInput to render the filtering UI differently without having to // re-implement filtering from scratch. // // To disable filtering entirely use EnableFiltering. func (m *Model) SetShowFilter(v bool) { m.showFilter = v m.updatePagination() } // ShowFilter returns whether or not the filter is set to be rendered. Note // that this is separate from FilteringEnabled, so filtering can be hidden yet // still invoked. This allows you to render filtering differently without // having to re-implement it from scratch. func (m Model) ShowFilter() bool { return m.showFilter } // SetShowStatusBar shows or hides the view that displays metadata about the // list, such as item counts. func (m *Model) SetShowStatusBar(v bool) { m.showStatusBar = v m.updatePagination() } // ShowStatusBar returns whether or not the status bar is set to be rendered. func (m Model) ShowStatusBar() bool { return m.showStatusBar } // ShowingPagination hides or shoes the paginator. Note that pagination will // still be active, it simply won't be displayed. func (m *Model) SetShowPagination(v bool) { m.showPagination = v m.updatePagination() } // ShowPagination returns whether the pagination is visible. func (m *Model) ShowPagination() bool { return m.showPagination } // SetShowHelp shows or hides the help view. func (m *Model) SetShowHelp(v bool) { m.showHelp = v m.updatePagination() } // ShowHelp returns whether or not the help is set to be rendered. func (m Model) ShowHelp() bool { return m.showHelp } // Items returns the items in the list. func (m Model) Items() []Item { return m.items } // Set the items available in the list. This returns a command. func (m *Model) SetItems(i []Item) tea.Cmd { var cmd tea.Cmd m.items = i if m.filterState != Unfiltered { m.filteredItems = nil cmd = filterItems(*m) } m.updatePagination() return cmd } // Select selects the given index of the list and goes to its respective page. func (m *Model) Select(index int) { m.Paginator.Page = index / m.Paginator.PerPage m.cursor = index % m.Paginator.PerPage } // ResetSelected resets the selected item to the first item in the first page of the list. func (m *Model) ResetSelected() { m.Select(0) } // ResetFilter resets the current filtering state. func (m *Model) ResetFilter() { m.resetFiltering() } // Replace an item at the given index. This returns a command. func (m *Model) SetItem(index int, item Item) tea.Cmd { var cmd tea.Cmd m.items[index] = item if m.filterState != Unfiltered { cmd = filterItems(*m) } m.updatePagination() return cmd } // Insert an item at the given index. This returns a command. func (m *Model) InsertItem(index int, item Item) tea.Cmd { var cmd tea.Cmd m.items = insertItemIntoSlice(m.items, item, index) if m.filterState != Unfiltered { cmd = filterItems(*m) } m.updatePagination() return cmd } // RemoveItem removes an item at the given index. If the index is out of bounds // this will be a no-op. O(n) complexity, which probably won't matter in the // case of a TUI. func (m *Model) RemoveItem(index int) { m.items = removeItemFromSlice(m.items, index) if m.filterState != Unfiltered { m.filteredItems = removeFilterMatchFromSlice(m.filteredItems, index) if len(m.filteredItems) == 0 { m.resetFiltering() } } m.updatePagination() } // Set the item delegate. func (m *Model) SetDelegate(d ItemDelegate) { m.delegate = d m.updatePagination() } // VisibleItems returns the total items available to be shown. func (m Model) VisibleItems() []Item { if m.filterState != Unfiltered { return m.filteredItems.items() } return m.items } // SelectedItems returns the current selected item in the list. func (m Model) SelectedItem() Item { i := m.Index() items := m.VisibleItems() if i < 0 || len(items) == 0 || len(items) <= i { return nil } return items[i] } // MatchesForItem returns rune positions matched by the current filter, if any. // Use this to style runes matched by the active filter. // // See DefaultItemView for a usage example. func (m Model) MatchesForItem(index int) []int { if m.filteredItems == nil || index >= len(m.filteredItems) { return nil } return m.filteredItems[index].matches } // Index returns the index of the currently selected item as it appears in the // entire slice of items. func (m Model) Index() int { return m.Paginator.Page*m.Paginator.PerPage + m.cursor } // Cursor returns the index of the cursor on the current page. func (m Model) Cursor() int { return m.cursor } // CursorUp moves the cursor up. This can also move the state to the previous // page. func (m *Model) CursorUp() { m.cursor-- // If we're at the start, stop if m.cursor < 0 && m.Paginator.Page == 0 { m.cursor = 0 return } // Move the cursor as normal if m.cursor >= 0 { return } // Go to the previous page m.Paginator.PrevPage() m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1 } // CursorDown moves the cursor down. This can also advance the state to the // next page. func (m *Model) CursorDown() { itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) m.cursor++ // If we're at the end, stop if m.cursor < itemsOnPage { return } // Go to the next page if !m.Paginator.OnLastPage() { m.Paginator.NextPage() m.cursor = 0 return } // During filtering the cursor position can exceed the number of // itemsOnPage. It's more intuitive to start the cursor at the // topmost position when moving it down in this scenario. if m.cursor > itemsOnPage { m.cursor = 0 return } m.cursor = itemsOnPage - 1 } // PrevPage moves to the previous page, if available. func (m Model) PrevPage() { m.Paginator.PrevPage() } // NextPage moves to the next page, if available. func (m Model) NextPage() { m.Paginator.NextPage() } // FilterState returns the current filter state. func (m Model) FilterState() FilterState { return m.filterState } // FilterValue returns the current value of the filter. func (m Model) FilterValue() string { return m.FilterInput.Value() } // SettingFilter returns whether or not the user is currently editing the // filter value. It's purely a convenience method for the following: // // m.FilterState() == Filtering // // It's included here because it's a common thing to check for when // implementing this component. func (m Model) SettingFilter() bool { return m.filterState == Filtering } // Width returns the current width setting. func (m Model) Width() int { return m.width } // Height returns the current height setting. func (m Model) Height() int { return m.height } // SetSpinner allows to set the spinner style. func (m *Model) SetSpinner(spinner spinner.Spinner) { m.spinner.Spinner = spinner } // Toggle the spinner. Note that this also returns a command. func (m *Model) ToggleSpinner() tea.Cmd { if !m.showSpinner { return m.StartSpinner() } m.StopSpinner() return nil } // StartSpinner starts the spinner. Note that this returns a command. func (m *Model) StartSpinner() tea.Cmd { m.showSpinner = true return spinner.Tick } // StopSpinner stops the spinner. func (m *Model) StopSpinner() { m.showSpinner = false } // Helper for disabling the keybindings used for quitting, incase you want to // handle this elsewhere in your application. func (m *Model) DisableQuitKeybindings() { m.KeyMap.Quit.SetEnabled(false) m.KeyMap.ForceQuit.SetEnabled(false) } // NewStatusMessage sets a new status message, which will show for a limited // amount of time. Note that this also returns a command. func (m *Model) NewStatusMessage(s string) tea.Cmd { m.statusMessage = s if m.statusMessageTimer != nil { m.statusMessageTimer.Stop() } m.statusMessageTimer = time.NewTimer(m.StatusMessageLifetime) // Wait for timeout return func() tea.Msg { <-m.statusMessageTimer.C return statusMessageTimeoutMsg{} } } // SetSize sets the width and height of this component. func (m *Model) SetSize(width, height int) { m.setSize(width, height) } // SetWidth sets the width of this component. func (m *Model) SetWidth(v int) { m.setSize(v, m.height) } // SetHeight sets the height of this component. func (m *Model) SetHeight(v int) { m.setSize(m.width, v) } func (m *Model) setSize(width, height int) { promptWidth := lipgloss.Width(m.Styles.Title.Render(m.FilterInput.Prompt)) m.width = width m.height = height m.Help.Width = width m.FilterInput.Width = width - promptWidth - lipgloss.Width(m.spinnerView()) m.updatePagination() } func (m *Model) resetFiltering() { if m.filterState == Unfiltered { return } m.filterState = Unfiltered m.FilterInput.Reset() m.filteredItems = nil m.updatePagination() m.updateKeybindings() } func (m Model) itemsAsFilterItems() filteredItems { fi := make([]filteredItem, len(m.items)) for i, item := range m.items { fi[i] = filteredItem{ item: item, } } return fi } // Set keybindings according to the filter state. func (m *Model) updateKeybindings() { switch m.filterState { case Filtering: m.KeyMap.DeleteSelection.SetEnabled(false) m.KeyMap.CursorUp.SetEnabled(false) m.KeyMap.CursorDown.SetEnabled(false) m.KeyMap.NextPage.SetEnabled(false) m.KeyMap.PrevPage.SetEnabled(false) m.KeyMap.GoToStart.SetEnabled(false) m.KeyMap.GoToEnd.SetEnabled(false) m.KeyMap.Filter.SetEnabled(false) m.KeyMap.ClearFilter.SetEnabled(false) m.KeyMap.CancelWhileFiltering.SetEnabled(true) m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") m.KeyMap.Quit.SetEnabled(true) m.KeyMap.ShowFullHelp.SetEnabled(false) m.KeyMap.CloseFullHelp.SetEnabled(false) default: m.KeyMap.DeleteSelection.SetEnabled(true) hasItems := m.items != nil m.KeyMap.CursorUp.SetEnabled(hasItems) m.KeyMap.CursorDown.SetEnabled(hasItems) hasPages := m.Paginator.TotalPages > 1 m.KeyMap.NextPage.SetEnabled(hasPages) m.KeyMap.PrevPage.SetEnabled(hasPages) m.KeyMap.GoToStart.SetEnabled(hasItems) m.KeyMap.GoToEnd.SetEnabled(hasItems) m.KeyMap.Filter.SetEnabled(m.filteringEnabled && hasItems) m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied) m.KeyMap.CancelWhileFiltering.SetEnabled(false) m.KeyMap.AcceptWhileFiltering.SetEnabled(false) m.KeyMap.Quit.SetEnabled(true) if m.Help.ShowAll { m.KeyMap.ShowFullHelp.SetEnabled(true) m.KeyMap.CloseFullHelp.SetEnabled(true) } else { minHelp := countEnabledBindings(m.FullHelp()) > 1 m.KeyMap.ShowFullHelp.SetEnabled(minHelp) m.KeyMap.CloseFullHelp.SetEnabled(minHelp) } } } // Update pagination according to the amount of items for the current state. func (m *Model) updatePagination() { index := m.Index() availHeight := m.height if m.showTitle || (m.showFilter && m.filteringEnabled) { availHeight -= lipgloss.Height(m.titleView()) } if m.showStatusBar { availHeight -= lipgloss.Height(m.statusView()) } if m.showPagination { availHeight -= lipgloss.Height(m.paginationView()) } if m.showHelp { availHeight -= lipgloss.Height(m.helpView()) } m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing())) if pages := len(m.VisibleItems()); pages < 1 { m.Paginator.SetTotalPages(1) } else { m.Paginator.SetTotalPages(pages) } // Restore index m.Paginator.Page = index / m.Paginator.PerPage m.cursor = index % m.Paginator.PerPage // Make sure the page stays in bounds if m.Paginator.Page >= m.Paginator.TotalPages-1 { m.Paginator.Page = max(0, m.Paginator.TotalPages-1) } } func (m *Model) hideStatusMessage() { m.statusMessage = "" if m.statusMessageTimer != nil { m.statusMessageTimer.Stop() } } // Update is the Bubble Tea update loop. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: if key.Matches(msg, m.KeyMap.ForceQuit) { return m, tea.Quit } case FilterMatchesMessage: m.filteredItems = filteredItems(msg) return m, nil case spinner.TickMsg: newSpinnerModel, cmd := m.spinner.Update(msg) m.spinner = newSpinnerModel if m.showSpinner { cmds = append(cmds, cmd) } case statusMessageTimeoutMsg: m.hideStatusMessage() } if m.filterState == Filtering { cmds = append(cmds, m.handleFiltering(msg)) } else { cmds = append(cmds, m.handleBrowsing(msg)) } return m, tea.Batch(cmds...) } var ( deleteToggleSwich bool ) // Updates for when a user is browsing the list. func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd { var cmds []tea.Cmd wasDeleteSelection := false numItems := len(m.VisibleItems()) switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, m.KeyMap.DeleteSelection): if len(m.Items()) == 0 { break } wasDeleteSelection = true if deleteToggleSwich { m.RemoveItem(m.Cursor()) m.KeyMap.DeleteSelection.SetHelp("r", "remove selection") } else { m.KeyMap.DeleteSelection.SetHelp("r", "confirm selection removal") } deleteToggleSwich = !deleteToggleSwich // Note: we match clear filter before quit because, by default, they're // both mapped to escape. case key.Matches(msg, m.KeyMap.ClearFilter): m.resetFiltering() case key.Matches(msg, m.KeyMap.Quit): return tea.Quit case key.Matches(msg, m.KeyMap.CursorUp): m.CursorUp() case key.Matches(msg, m.KeyMap.CursorDown): m.CursorDown() case key.Matches(msg, m.KeyMap.PrevPage): m.Paginator.PrevPage() case key.Matches(msg, m.KeyMap.NextPage): m.Paginator.NextPage() case key.Matches(msg, m.KeyMap.GoToStart): m.Paginator.Page = 0 m.cursor = 0 case key.Matches(msg, m.KeyMap.GoToEnd): m.Paginator.Page = m.Paginator.TotalPages - 1 m.cursor = m.Paginator.ItemsOnPage(numItems) - 1 case key.Matches(msg, m.KeyMap.Filter): m.hideStatusMessage() if m.FilterInput.Value() == "" { // Populate filter with all items only if the filter is empty. m.filteredItems = m.itemsAsFilterItems() } m.Paginator.Page = 0 m.cursor = 0 m.filterState = Filtering m.FilterInput.CursorEnd() m.FilterInput.Focus() m.updateKeybindings() return textinput.Blink case key.Matches(msg, m.KeyMap.ShowFullHelp): fallthrough case key.Matches(msg, m.KeyMap.CloseFullHelp): m.Help.ShowAll = !m.Help.ShowAll m.updatePagination() } } if !wasDeleteSelection { // if anything else reset switch deleteToggleSwich = false } cmd := m.delegate.Update(msg, m) cmds = append(cmds, cmd) // Keep the index in bounds when paginating itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) if m.cursor > itemsOnPage-1 { m.cursor = max(0, itemsOnPage-1) } return tea.Batch(cmds...) } // Updates for when a user is in the filter editing interface. func (m *Model) handleFiltering(msg tea.Msg) tea.Cmd { var cmds []tea.Cmd // Handle keys if msg, ok := msg.(tea.KeyMsg); ok { switch { case key.Matches(msg, m.KeyMap.CancelWhileFiltering): m.resetFiltering() m.KeyMap.Filter.SetEnabled(true) m.KeyMap.ClearFilter.SetEnabled(false) case key.Matches(msg, m.KeyMap.AcceptWhileFiltering): m.hideStatusMessage() if len(m.items) == 0 { break } h := m.VisibleItems() // If we've filtered down to nothing, clear the filter if len(h) == 0 { m.resetFiltering() break } m.FilterInput.Blur() m.filterState = FilterApplied m.updateKeybindings() if m.FilterInput.Value() == "" { m.resetFiltering() } } } // Update the filter text input component newFilterInputModel, inputCmd := m.FilterInput.Update(msg) filterChanged := m.FilterInput.Value() != newFilterInputModel.Value() m.FilterInput = newFilterInputModel cmds = append(cmds, inputCmd) // If the filtering input has changed, request updated filtering if filterChanged { cmds = append(cmds, filterItems(*m)) m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") } // Update pagination m.updatePagination() return tea.Batch(cmds...) } // ShortHelp returns bindings to show in the abbreviated help view. It's part // of the help.KeyMap interface. func (m Model) ShortHelp() []key.Binding { kb := []key.Binding{ m.KeyMap.CursorUp, m.KeyMap.CursorDown, } filtering := m.filterState == Filtering // If the delegate implements the help.KeyMap interface add the short help // items to the short help after the cursor movement keys. if !filtering { if b, ok := m.delegate.(help.KeyMap); ok { kb = append(kb, b.ShortHelp()...) } } kb = append(kb, m.KeyMap.Filter, m.KeyMap.ClearFilter, m.KeyMap.AcceptWhileFiltering, m.KeyMap.CancelWhileFiltering, m.KeyMap.DeleteSelection, ) if !filtering && m.AdditionalShortHelpKeys != nil { kb = append(kb, m.AdditionalShortHelpKeys()...) } return append(kb, m.KeyMap.Quit, m.KeyMap.ShowFullHelp, ) } // FullHelp returns bindings to show the full help view. It's part of the // help.KeyMap interface. func (m Model) FullHelp() [][]key.Binding { kb := [][]key.Binding{{ m.KeyMap.CursorUp, m.KeyMap.CursorDown, m.KeyMap.NextPage, m.KeyMap.PrevPage, m.KeyMap.GoToStart, m.KeyMap.GoToEnd, }} filtering := m.filterState == Filtering // If the delegate implements the help.KeyMap interface add full help // keybindings to a special section of the full help. if !filtering { if b, ok := m.delegate.(help.KeyMap); ok { kb = append(kb, b.FullHelp()...) } } listLevelBindings := []key.Binding{ m.KeyMap.Filter, m.KeyMap.ClearFilter, m.KeyMap.AcceptWhileFiltering, m.KeyMap.CancelWhileFiltering, m.KeyMap.DeleteSelection, } if !filtering && m.AdditionalFullHelpKeys != nil { listLevelBindings = append(listLevelBindings, m.AdditionalFullHelpKeys()...) } return append(kb, listLevelBindings, []key.Binding{ m.KeyMap.Quit, m.KeyMap.CloseFullHelp, }) } // View renders the component. func (m Model) View() string { var ( sections []string availHeight = m.height ) if m.showTitle || (m.showFilter && m.filteringEnabled) { v := m.titleView() sections = append(sections, v) availHeight -= lipgloss.Height(v) } if m.showStatusBar { v := m.statusView() sections = append(sections, v) availHeight -= lipgloss.Height(v) } var pagination string if m.showPagination { pagination = m.paginationView() availHeight -= lipgloss.Height(pagination) } var help string if m.showHelp { help = m.helpView() availHeight -= lipgloss.Height(help) } content := lipgloss.NewStyle().Height(availHeight).Render(m.populatedView()) sections = append(sections, content) if m.showPagination { sections = append(sections, pagination) } if m.showHelp { sections = append(sections, help) } return lipgloss.JoinVertical(lipgloss.Left, sections...) } func (m Model) titleView() string { var ( view string titleBarStyle = m.Styles.TitleBar.Copy() // We need to account for the size of the spinner, even if we don't // render it, to reserve some space for it should we turn it on later. spinnerView = m.spinnerView() spinnerWidth = lipgloss.Width(spinnerView) spinnerLeftGap = " " spinnerOnLeft = titleBarStyle.GetPaddingLeft() >= spinnerWidth+lipgloss.Width(spinnerLeftGap) && m.showSpinner ) // If the filter's showing, draw that. Otherwise draw the title. if m.showFilter && m.filterState == Filtering { view += m.FilterInput.View() } else if m.showTitle { if m.showSpinner && spinnerOnLeft { view += spinnerView + spinnerLeftGap titleBarGap := titleBarStyle.GetPaddingLeft() titleBarStyle = titleBarStyle.PaddingLeft(titleBarGap - spinnerWidth - lipgloss.Width(spinnerLeftGap)) } view += m.Styles.Title.Render(m.Title) // Status message if m.filterState != Filtering { view += " " + m.statusMessage view = truncate.StringWithTail(view, uint(m.width-spinnerWidth), ellipsis) } } // Spinner if m.showSpinner && !spinnerOnLeft { // Place spinner on the right availSpace := m.width - lipgloss.Width(m.Styles.TitleBar.Render(view)) if availSpace > spinnerWidth { view += strings.Repeat(" ", availSpace-spinnerWidth) view += spinnerView } } return titleBarStyle.Render(view) } func (m Model) statusView() string { var status string totalItems := len(m.items) visibleItems := len(m.VisibleItems()) plural := "" if visibleItems != 1 { plural = "s" } if m.filterState == Filtering { // Filter results if visibleItems == 0 { status = m.Styles.StatusEmpty.Render("Nothing matched") } else { status = fmt.Sprintf("%d item%s", visibleItems, plural) } } else if len(m.items) == 0 { // Not filtering: no items. status = m.Styles.StatusEmpty.Render("No items") } else { // Normal filtered := m.FilterState() == FilterApplied if filtered { f := strings.TrimSpace(m.FilterInput.Value()) f = truncate.StringWithTail(f, 10, "…") status += fmt.Sprintf("“%s” ", f) } status += fmt.Sprintf("%d item%s", visibleItems, plural) } numFiltered := totalItems - visibleItems if numFiltered > 0 { status += m.Styles.DividerDot.String() status += m.Styles.StatusBarFilterCount.Render(fmt.Sprintf("%d filtered", numFiltered)) } return m.Styles.StatusBar.Render(status) } func (m Model) paginationView() string { if m.Paginator.TotalPages < 2 { //nolint:gomnd return "" } s := m.Paginator.View() // If the dot pagination is wider than the width of the window // use the arabic paginator. if ansi.PrintableRuneWidth(s) > m.width { m.Paginator.Type = paginator.Arabic s = m.Styles.ArabicPagination.Render(m.Paginator.View()) } style := m.Styles.PaginationStyle if m.delegate.Spacing() == 0 && style.GetMarginTop() == 0 { style = style.Copy().MarginTop(1) } return style.Render(s) } func (m Model) populatedView() string { items := m.VisibleItems() b := strings.Builder{} // Empty states if len(items) == 0 { if m.filterState == Filtering { return "" } m.Styles.NoItems.Render("No items found.") } if len(items) > 0 { start, end := m.Paginator.GetSliceBounds(len(items)) docs := items[start:end] for i, item := range docs { m.delegate.Render(&b, m, i+start, item) if i != len(docs)-1 { fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1)) } } } // If there aren't enough items to fill up this page (always the last page) // then we need to add some newlines to fill up the space where items would // have been. //itemsOnPage := m.Paginator.ItemsOnPage(len(items)) //if itemsOnPage < m.Paginator.PerPage { // n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing()) // if len(items) == 0 { // n -= m.delegate.Height() - 1 // } // fmt.Fprint(&b, strings.Repeat("\n", n)) //} ret := b.String() return ret } func (m Model) helpView() string { return m.Styles.HelpStyle.Render(m.Help.View(m)) } func (m Model) spinnerView() string { return m.spinner.View() } func filterItems(m Model) tea.Cmd { return func() tea.Msg { if m.FilterInput.Value() == "" || m.filterState == Unfiltered { return FilterMatchesMessage(m.itemsAsFilterItems()) // return nothing } var targets []string items := m.items for _, t := range items { targets = append(targets, t.FilterValue()) } var ranks = fuzzy.Find(m.FilterInput.Value(), targets) sort.Stable(ranks) var filterMatches []filteredItem for _, r := range ranks { filterMatches = append(filterMatches, filteredItem{ item: items[r.Index], matches: r.MatchedIndexes, }) } return FilterMatchesMessage(filterMatches) } } func insertItemIntoSlice(items []Item, item Item, index int) []Item { if items == nil { return []Item{item} } if index >= len(items) { return append(items, item) } index = max(0, index) items = append(items, nil) copy(items[index+1:], items[index:]) items[index] = item return items } // Remove an item from a slice of items at the given index. This runs in O(n). func removeItemFromSlice(i []Item, index int) []Item { if index >= len(i) { return i // noop } copy(i[index:], i[index+1:]) i[len(i)-1] = nil return i[:len(i)-1] } func removeFilterMatchFromSlice(i []filteredItem, index int) []filteredItem { if index >= len(i) { return i // noop } copy(i[index:], i[index+1:]) i[len(i)-1] = filteredItem{} return i[:len(i)-1] } func countEnabledBindings(groups [][]key.Binding) (agg int) { for _, group := range groups { for _, kb := range group { if kb.Enabled() { agg++ } } } return agg } func max(a, b int) int { if a > b { return a } return b } ================================================ FILE: list/style.go ================================================ package list import ( "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/tuiutil" ) const ( bullet = "•" ellipsis = "…" ) // Styles contains style definitions for this list component. By default, these // values are generated by DefaultStyles. type Styles struct { TitleBar lipgloss.Style Title lipgloss.Style Spinner lipgloss.Style FilterPrompt lipgloss.Style FilterCursor lipgloss.Style // Default styling for matched characters in a filter. This can be // overridden by delegates. DefaultFilterCharacterMatch lipgloss.Style StatusBar lipgloss.Style StatusEmpty lipgloss.Style StatusBarActiveFilter lipgloss.Style StatusBarFilterCount lipgloss.Style NoItems lipgloss.Style PaginationStyle lipgloss.Style HelpStyle lipgloss.Style // Styled characters. ActivePaginationDot lipgloss.Style InactivePaginationDot lipgloss.Style ArabicPagination lipgloss.Style DividerDot lipgloss.Style } // DefaultStyles returns a set of default style definitions for this list // component. func DefaultStyles() (s Styles) { verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"} s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2) s.Title = lipgloss.NewStyle(). Background(lipgloss.Color(tuiutil.HeaderBackground())). Foreground(lipgloss.Color(tuiutil.HeaderForeground())). Padding(0, 1) s.Spinner = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"}) s.FilterPrompt = lipgloss.NewStyle(). Foreground(lipgloss.Color(tuiutil.FooterForeground())) s.FilterCursor = lipgloss.NewStyle(). Foreground(lipgloss.Color(tuiutil.BorderColor())) s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true) s.StatusBar = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). Padding(0, 0, 1, 2) s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor) s.StatusBarActiveFilter = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor) s.NoItems = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}) s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor) s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) s.ActivePaginationDot = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}). SetString(bullet) s.InactivePaginationDot = lipgloss.NewStyle(). Foreground(verySubduedColor). SetString(bullet) s.DividerDot = lipgloss.NewStyle(). Foreground(verySubduedColor). SetString(" " + bullet + " ") return s } ================================================ FILE: main.go ================================================ package main import ( "database/sql" "flag" "fmt" "io/fs" "io/ioutil" "os" "path/filepath" "strings" . "github.com/mathaou/termdbms/tuiutil" . "github.com/mathaou/termdbms/viewer" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/database" "github.com/muesli/termenv" _ "modernc.org/sqlite" ) type DatabaseType string const ( debugPath = "" // set to whatever hardcoded path for testing ) const ( DatabaseSQLite DatabaseType = "sqlite" DatabaseMySQL DatabaseType = "mysql" ) var ( debug bool path string databaseType string theme string help bool ascii bool ) func main() { debug = debugPath != "" flag.Usage = func() { help := GetHelpText() lines := strings.Split(help, "\n") for _, v := range lines { println(v) } } argLength := len(os.Args[1:]) if (argLength > 4 || argLength == 0) && !debug { fmt.Printf("ERROR: Invalid number of arguments supplied: %d\n", argLength) flag.Usage() os.Exit(1) } // flags declaration using flag package flag.StringVar(&databaseType, "d", string(DatabaseSQLite), "Specifies the SQL driver to use. Defaults to SQLite.") flag.StringVar(&path, "p", "", "Path to the database file.") flag.StringVar(&theme, "t", "default", "sets the color theme of the app.") flag.BoolVar(&help, "h", false, "Prints the help message.") flag.BoolVar(&ascii, "a", false, "Denotes that the app should render with minimal styling to remove ANSI sequences.") flag.Parse() handleFlags() var c *sql.Rows defer func() { if c != nil { c.Close() } }() if debug { path = debugPath } for i, v := range ValidThemes { if theme == v { SelectedTheme = i break } } if theme == "" { theme = "default" } // gets a sqlite instance for the database file if exists, _ := FileExists(path); exists { fmt.Printf("ERROR: Database file could not be found at %s\n", path) os.Exit(1) } if valid, _ := Exists(HiddenTmpDirectoryName); valid { filepath.Walk(HiddenTmpDirectoryName, func(path string, info fs.FileInfo, err error) error { if strings.HasPrefix(path, fmt.Sprintf("%s/.", HiddenTmpDirectoryName)) && !info.IsDir() { os.Remove(path) // remove all temp databaess } return nil }) } else { os.Mkdir(HiddenTmpDirectoryName, 0o777) } database.IsCSV = strings.HasSuffix(path, ".csv") dst := path if database.IsCSV { // convert the csv to sql, then run the sql through a database sqlFile := strings.TrimSuffix(path, ".csv") sqlFile = filepath.Base(sqlFile) path = Convert(path, sqlFile, true) csvDBFile := HiddenTmpDirectoryName + "/" + sqlFile + ".db" os.Create(csvDBFile) dst, _ = filepath.Abs(csvDBFile) d, _ := sql.Open(database.DriverString, dst) f, _ := os.Open(path) b, _ := ioutil.ReadAll(f) query := string(b) _, err := d.Exec(query) if err != nil { fmt.Printf("%v", err) os.Exit(1) } d.Close() os.Remove(path) // this deletes the converted .sql file } dst, _, _ = CopyFile(dst) db := database.GetDatabaseForFile(dst) defer func() { if db == nil { db.Close() } }() // initializes the model used by bubbletea m := GetNewModel(dst, db) InitialModel = &m InitialModel.InitialFileName = path err := InitialModel.SetModel(c, db) if err != nil { fmt.Printf("%v", err) os.Exit(1) } // creates the program Program = tea.NewProgram(InitialModel, tea.WithAltScreen(), tea.WithMouseAllMotion()) if err := Program.Start(); err != nil { fmt.Printf("ERROR: Error initializing the sqlite viewer: %v", err) os.Exit(1) } } func handleFlags() { if path == "" && !debug { fmt.Printf("ERROR: no path for database.\n") flag.Usage() os.Exit(1) } if help { flag.Usage() os.Exit(0) } if ascii { Ascii = true lipgloss.SetColorProfile(termenv.Ascii) } if path != "" && !IsUrl(path) { fmt.Printf("ERROR: Invalid path %s\n", path) flag.Usage() os.Exit(1) } if databaseType != string(DatabaseMySQL) && databaseType != string(DatabaseSQLite) { fmt.Printf("Invalid database driver specified: %s", databaseType) os.Exit(1) } database.DriverString = databaseType } ================================================ FILE: tuiutil/csv2sql.go ================================================ package tuiutil import ( "bufio" "bytes" "encoding/csv" "fmt" "io" "os" "path/filepath" "strings" ) /* csv2sql - conversion program to convert a csv file to sql format to allow easy checking / validation, and for import into a SQLite3 database using the SQLite '.read' command author: simon rowe license: open-source released under "New BSD License" created: 16 Apr 2014 - initial outline code written updated: 17 Apr 2014 - add flags and output file handling updated: 27 Apr 2014 - wrap in double quotes instead of single updated: 28 Apr 2014 - add flush io file buffer to fix SQL missing EOF updated: 19 Jul 2014 - add more help text, tidy up comments and code updated: 06 Aug 2014 - enabled the -k flag to alter the table header characters updated: 28 Sep 2014 - changed default output when run with no params, add -h to display the help info and also still call flags.Usage() updated: 09 Dec 2014 - minor tidy up and first 'release' provided on GitHub updated: 27 Aug 2016 - table name and csv file help output minior changes. Minor cosmetic stuff. Version 1.1 */ func SQLFileName(csvFileName string) string { // include the name of the csv file from command line (ie csvFileName) // remove any path etc var justFileName = filepath.Base(csvFileName) // get the files extension too var extension = filepath.Ext(csvFileName) // remove the file extension from the filename justFileName = justFileName[0 : len(justFileName)-len(extension)] sqlOutFile := "./.termdbms/SQL-" + justFileName + ".sql" return sqlOutFile } func Convert(csvFileName, tableName string, keepOrigCols bool) string { // check we have a table name and csv file to work with - otherwise abort if csvFileName == "" || tableName == "" { return "" } // open the CSV file - name provided via command line input - handle 'file' file, err := os.Open(csvFileName) // error - if we have one exit as CSV file not right if err != nil { fmt.Printf("ERROR: %s\n", err) os.Exit(-3) } // now file is open - defer the close of CSV file handle until we return defer file.Close() // connect a CSV reader to the file handle - which is the actual opened // CSV file // TODO : is there an error from this to check? reader := csv.NewReader(file) sqlOutFile := SQLFileName(csvFileName) // open the new file using the name we obtained above - handle 'filesql' filesql, err := os.Create(sqlOutFile) // error - if we have one when trying open & create the new file if err != nil { return "" } // now new file is open - defer the close of the file handle until we return defer filesql.Close() // attach the opened new sql file handle to a buffered file writer // the buffered file writer has the handle 'sqlFileBuffer' sqlFileBuffer := bufio.NewWriter(filesql) //------------------------------------------------------------------------- // prepare to read the each line of the CSV file - and write out to the SQl //------------------------------------------------------------------------- // track the number of lines in the csv file lineCount := 0 // create a buffer to hold each line of the SQL file as we build it // handle to this buffer is called 'strbuffer' var strbuffer bytes.Buffer // START - processing of each line in the CSV input file //------------------------------------------------------------------------- // loop through the csv file until EOF - or until we hit an error in parsing it. // Data is read in for each line of the csv file and held in the variable // 'record'. Build a string for each line - wrapped with the SQL and // then output to the SQL file writer in its completed new form //------------------------------------------------------------------------- for { record, err := reader.Read() // if we hit end of file (EOF) or another unexpected error if err == io.EOF { break } else if err != nil { return "" } // if we are processing the first line - use the record field contents // as the SQL table column names - add to the temp string 'strbuffer' // use the tablename provided by the user if lineCount == 0 { strbuffer.WriteString("CREATE TABLE " + tableName + " (") } // if any line except the first one : // print the start of the SQL insert statement for the record // and - add to the temp string 'strbuffer' // use the tablename provided by the user if lineCount > 0 { strbuffer.WriteString("INSERT INTO " + tableName + " VALUES (") } // loop through each of the csv lines individual fields held in 'record' // len(record) tells us how many fields are on this line - so we loop right number of times for i := 0; i < len(record); i++ { // if we are processing the first line used for the table column name - update the // record field contents to remove the characters: space | - + @ # / \ : ( ) ' // from the SQL table column names. Can be overridden on command line with '-k true' if (lineCount == 0) && (keepOrigCols == false) { // call the function cleanHeader to do clean up on this field record[i] = cleanHeader(record[i]) } // if a csv record field is empty or has the text "NULL" - replace it with actual NULL field in SQLite // otherwise just wrap the existing content with '' // TODO : make sure we don't try to create a 'NULL' table column name? if len(record[i]) == 0 || record[i] == "NULL" { strbuffer.WriteString("NULL") } else { strbuffer.WriteString("\"" + record[i] + "\"") } // if we have not reached the last record yet - add a coma also to the output if i < len(record)-1 { strbuffer.WriteString(",") } } // end of the line - so output SQL format required ');' and newline strbuffer.WriteString(");\n") // line of SQL is complete - so push out to the new SQL file bWritten, err := sqlFileBuffer.WriteString(strbuffer.String()) // check it wrote data ok - otherwise report the error giving the line number affected if (err != nil) || (bWritten != len(strbuffer.Bytes())) { return "" } // reset the string buffer - so it is empty ready for the next line to build strbuffer.Reset() // for debug - show the line number we are processing from the CSV file // increment the line count - and loop back around for next line of the CSV file lineCount += 1 } // write out final line to the SQL file bWritten, err := sqlFileBuffer.WriteString(strbuffer.String()) // check it wrote data ok - otherwise report the error giving the line number affected if (err != nil) || (bWritten != len(strbuffer.Bytes())) { return "" } strbuffer.WriteString("\nCOMMIT;") // finished the SQl file data writing - flush any IO buffers // NB below flush required as the data was being lost otherwise - maybe a bug in go version 1.2 only? sqlFileBuffer.Flush() // reset the string buffer - so it is empty as it is no longer needed strbuffer.Reset() return sqlOutFile } func cleanHeader(headField string) string { // ok - remove any spaces and replace with _ headField = strings.Replace(headField, " ", "_", -1) // ok - remove any | and replace with _ headField = strings.Replace(headField, "|", "_", -1) // ok - remove any - and replace with _ headField = strings.Replace(headField, "-", "_", -1) // ok - remove any + and replace with _ headField = strings.Replace(headField, "+", "_", -1) // ok - remove any @ and replace with _ headField = strings.Replace(headField, "@", "_", -1) // ok - remove any # and replace with _ headField = strings.Replace(headField, "#", "_", -1) // ok - remove any / and replace with _ headField = strings.Replace(headField, "/", "_", -1) // ok - remove any \ and replace with _ headField = strings.Replace(headField, "\\", "_", -1) // ok - remove any : and replace with _ headField = strings.Replace(headField, ":", "_", -1) // ok - remove any ( and replace with _ headField = strings.Replace(headField, "(", "_", -1) // ok - remove any ) and replace with _ headField = strings.Replace(headField, ")", "_", -1) // ok - remove any ' and replace with _ headField = strings.Replace(headField, "'", "_", -1) return headField } ================================================ FILE: tuiutil/textinput.go ================================================ package tuiutil import ( "context" "strings" "sync" "time" "unicode" "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" rw "github.com/mattn/go-runewidth" ) const DefaultBlinkSpeed = time.Millisecond * 530 // Internal ID management for text inputs. Necessary for blink integrity when // multiple text inputs are involved. var ( Ascii bool lastID int idMtx sync.Mutex ) // Return the next ID we should use on the TextInputModel. func nextID() int { idMtx.Lock() defer idMtx.Unlock() lastID++ return lastID } // initialBlinkMsg initializes cursor blinking. type initialBlinkMsg struct{} // blinkMsg signals that the cursor should blink. It contains metadata that // allows us to tell if the blink message is the one we're expecting. type blinkMsg struct { id int tag int } // blinkCanceled is sent when a blink operation is canceled. type blinkCanceled struct{} // Internal messages for clipboard operations. type pasteMsg string type pasteErrMsg struct{ error } // EchoMode sets the input behavior of the text input field. type EchoMode int const ( // EchoNormal displays text as is. This is the default behavior. EchoNormal EchoMode = iota // EchoPassword displays the EchoCharacter mask instead of actual // characters. This is commonly used for password fields. EchoPassword // EchoNone displays nothing as characters are entered. This is commonly // seen for password fields on the command line. EchoNone // EchoOnEdit ) // blinkCtx manages cursor blinking. type blinkCtx struct { ctx context.Context cancel context.CancelFunc } // CursorMode describes the behavior of the cursor. type CursorMode int // Available cursor modes. const ( CursorBlink CursorMode = iota CursorStatic CursorHide ) // String returns a the cursor mode in a human-readable format. This method is // provisional and for informational purposes only. func (c CursorMode) String() string { return [...]string{ "blink", "static", "hidden", }[c] } // TextInputModel is the Bubble Tea model for this text input element. type TextInputModel struct { Err error // General settings. Prompt string Placeholder string BlinkSpeed time.Duration EchoMode EchoMode EchoCharacter rune // Styles. These will be applied as inline styles. // // For an introduction to styling with Lip Gloss see: // https://github.com/charmbracelet/lipgloss PromptStyle lipgloss.Style TextStyle lipgloss.Style BackgroundStyle lipgloss.Style PlaceholderStyle lipgloss.Style CursorStyle lipgloss.Style // CharLimit is the maximum amount of characters this input element will // accept. If 0 or less, there's no limit. CharLimit int // Width is the maximum number of characters that can be displayed at once. // It essentially treats the text field like a horizontally scrolling // Viewport. If 0 or less this setting is ignored. Width int // The ID of this TextInputModel as it relates to other textinput Models. id int // The ID of the blink message we're expecting to receive. blinkTag int // Underlying text value. value []rune // Focus indicates whether user input Focus should be on this input // component. When false, ignore keyboard input and hide the cursor. Focus bool // Cursor blink state. blink bool // Cursor position. pos int // Used to emulate a Viewport when width is set and the content is // overflowing. Offset int OffsetRight int // Used to manage cursor blink blinkCtx *blinkCtx // cursorMode determines the behavior of the cursor cursorMode CursorMode } // NewModel creates a new model with default settings. func NewModel() TextInputModel { m := TextInputModel{ Prompt: "> ", BlinkSpeed: DefaultBlinkSpeed, EchoCharacter: '*', CharLimit: 0, PlaceholderStyle: lipgloss.NewStyle(), id: nextID(), value: nil, Focus: false, blink: true, pos: 0, cursorMode: CursorBlink, blinkCtx: &blinkCtx{ ctx: context.Background(), }, } if !Ascii { m.PlaceholderStyle = m.PlaceholderStyle.Foreground(lipgloss.Color("240")) } return m } // SetValue sets the value of the text input. func (m *TextInputModel) SetValue(s string) { runes := []rune(s) if m.CharLimit > 0 && len(runes) > m.CharLimit { m.value = runes[:m.CharLimit] } else { m.value = runes } if m.pos == 0 || m.pos > len(m.value) { m.setCursor(len(m.value)) } m.handleOverflow() } // Value returns the value of the text input. func (m TextInputModel) Value() string { return string(m.value) } // Cursor returns the cursor position. func (m TextInputModel) Cursor() int { return m.pos } // SetCursor moves the cursor to the given position. If the position is // out of bounds the cursor will be moved to the start or end accordingly. func (m *TextInputModel) SetCursor(pos int) { m.setCursor(pos) } // setCursor moves the cursor to the given position and returns whether or not // the cursor blink should be reset. If the position is out of bounds the // cursor will be moved to the start or end accordingly. func (m *TextInputModel) setCursor(pos int) bool { m.pos = Clamp(pos, 0, len(m.value)) m.handleOverflow() // Show the cursor unless it's been explicitly hidden m.blink = m.cursorMode == CursorHide // Reset cursor blink if necessary return m.cursorMode == CursorBlink } // CursorStart moves the cursor to the start of the input field. func (m *TextInputModel) CursorStart() { m.cursorStart() } // cursorStart moves the cursor to the start of the input field and returns // whether or not the curosr blink should be reset. func (m *TextInputModel) cursorStart() bool { return m.setCursor(0) } // CursorEnd moves the cursor to the end of the input field func (m *TextInputModel) CursorEnd() { m.cursorEnd() } // CursorMode returns the model's cursor mode. For available cursor modes, see // type CursorMode. func (m TextInputModel) CursorMode() CursorMode { return m.cursorMode } // SetCursorMode CursorMode sets the model's cursor mode. This method returns a command. // // For available cursor modes, see type CursorMode. func (m *TextInputModel) SetCursorMode(mode CursorMode) tea.Cmd { m.cursorMode = mode m.blink = m.cursorMode == CursorHide || !m.Focus if mode == CursorBlink { return Blink } return nil } // cursorEnd moves the cursor to the end of the input field and returns whether // the cursor should blink should reset. func (m *TextInputModel) cursorEnd() bool { return m.setCursor(len(m.value)) } // Focused returns the Focus state on the model. func (m TextInputModel) Focused() bool { return m.Focus } // FocusCommand sets the Focus state on the model. When the model is in Focus it can // receive keyboard input and the cursor will be hidden. func (m *TextInputModel) FocusCommand() tea.Cmd { m.Focus = true m.blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it if m.cursorMode == CursorBlink && m.Focus { return m.blinkCmd() } return nil } // Blur removes the Focus state on the model. When the model is blurred it can // not receive keyboard input and the cursor will be hidden. func (m *TextInputModel) Blur() { m.Focus = false m.blink = true } // Reset sets the input to its default state with no input. Returns whether // or not the cursor blink should reset. func (m *TextInputModel) Reset() bool { m.value = nil return m.setCursor(0) } // handle a clipboard paste event, if supported. Returns whether or not the // cursor blink should reset. func (m *TextInputModel) handlePaste(v string) bool { paste := []rune(v) var availSpace int if m.CharLimit > 0 { availSpace = m.CharLimit - len(m.value) } // If the char limit's been reached cancel if m.CharLimit > 0 && availSpace <= 0 { return false } // If there's not enough space to paste the whole thing cut the pasted // runes down so they'll fit if m.CharLimit > 0 && availSpace < len(paste) { paste = paste[:len(paste)-availSpace] } // Stuff before and after the cursor head := m.value[:m.pos] tailSrc := m.value[m.pos:] tail := make([]rune, len(tailSrc)) copy(tail, tailSrc) // Insert pasted runes for _, r := range paste { head = append(head, r) m.pos++ if m.CharLimit > 0 { availSpace-- if availSpace <= 0 { break } } } // Put it all back together m.value = append(head, tail...) // Reset blink state if necessary and run overflow checks return m.setCursor(m.pos) } // If a max width is defined, perform some logic to treat the visible area // as a horizontally scrolling Viewport. func (m *TextInputModel) handleOverflow() { if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width { m.Offset = 0 m.OffsetRight = len(m.value) return } // Correct right Offset if we've deleted characters m.OffsetRight = min(m.OffsetRight, len(m.value)) if m.pos < m.Offset { m.Offset = m.pos w := 0 i := 0 runes := m.value[m.Offset:] for i < len(runes) && w <= m.Width { w += rw.RuneWidth(runes[i]) if w <= m.Width+1 { i++ } } m.OffsetRight = m.Offset + i } else if m.pos >= m.OffsetRight { m.OffsetRight = m.pos w := 0 runes := m.value[:m.OffsetRight] i := len(runes) - 1 for i > 0 && w < m.Width { w += rw.RuneWidth(runes[i]) if w <= m.Width { i-- } } m.Offset = m.OffsetRight - (len(runes) - 1 - i) } } // deleteBeforeCursor deletes all text before the cursor. Returns whether or // not the cursor blink should be reset. func (m *TextInputModel) deleteBeforeCursor() bool { m.value = m.value[m.pos:] m.Offset = 0 return m.setCursor(0) } // deleteAfterCursor deletes all text after the cursor. Returns whether or not // the cursor blink should be reset. If input is masked delete everything after // the cursor so as not to reveal word breaks in the masked input. func (m *TextInputModel) deleteAfterCursor() bool { m.value = m.value[:m.pos] return m.setCursor(len(m.value)) } // deleteWordLeft deletes the word left to the cursor. Returns whether or not // the cursor blink should be reset. func (m *TextInputModel) deleteWordLeft() bool { if m.pos == 0 || len(m.value) == 0 { return false } if m.EchoMode != EchoNormal { return m.deleteBeforeCursor() } i := m.pos blink := m.setCursor(m.pos - 1) for unicode.IsSpace(m.value[m.pos]) { // ignore series of whitespace before cursor blink = m.setCursor(m.pos - 1) } for m.pos > 0 { if !unicode.IsSpace(m.value[m.pos]) { blink = m.setCursor(m.pos - 1) } else { if m.pos > 0 { // keep the previous space blink = m.setCursor(m.pos + 1) } break } } if i > len(m.value) { m.value = m.value[:m.pos] } else { m.value = append(m.value[:m.pos], m.value[i:]...) } return blink } // deleteWordRight deletes the word right to the cursor. Returns whether or not // the cursor blink should be reset. If input is masked delete everything after // the cursor so as not to reveal word breaks in the masked input. func (m *TextInputModel) deleteWordRight() bool { if m.pos >= len(m.value) || len(m.value) == 0 { return false } if m.EchoMode != EchoNormal { return m.deleteAfterCursor() } i := m.pos m.setCursor(m.pos + 1) for unicode.IsSpace(m.value[m.pos]) { // ignore series of whitespace after cursor m.setCursor(m.pos + 1) } for m.pos < len(m.value) { if !unicode.IsSpace(m.value[m.pos]) { m.setCursor(m.pos + 1) } else { break } } if m.pos > len(m.value) { m.value = m.value[:i] } else { m.value = append(m.value[:i], m.value[m.pos:]...) } return m.setCursor(i) } // wordLeft moves the cursor one word to the left. Returns whether or not the // cursor blink should be reset. If input is masked, move input to the start // so as not to reveal word breaks in the masked input. func (m *TextInputModel) wordLeft() bool { if m.pos == 0 || len(m.value) == 0 { return false } if m.EchoMode != EchoNormal { return m.cursorStart() } blink := false i := m.pos - 1 for i >= 0 { if unicode.IsSpace(m.value[i]) { blink = m.setCursor(m.pos - 1) i-- } else { break } } for i >= 0 { if !unicode.IsSpace(m.value[i]) { blink = m.setCursor(m.pos - 1) i-- } else { break } } return blink } // wordRight moves the cursor one word to the right. Returns whether or not the // cursor blink should be reset. If the input is masked, move input to the end // so as not to reveal word breaks in the masked input. func (m *TextInputModel) wordRight() bool { if m.pos >= len(m.value) || len(m.value) == 0 { return false } if m.EchoMode != EchoNormal { return m.cursorEnd() } blink := false i := m.pos for i < len(m.value) { if unicode.IsSpace(m.value[i]) { blink = m.setCursor(m.pos + 1) i++ } else { break } } for i < len(m.value) { if !unicode.IsSpace(m.value[i]) { blink = m.setCursor(m.pos + 1) i++ } else { break } } return blink } func (m TextInputModel) echoTransform(v string) string { switch m.EchoMode { case EchoPassword: return strings.Repeat(string(m.EchoCharacter), rw.StringWidth(v)) case EchoNone: return "" default: return v } } // Update is the Bubble Tea update loop. func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { if !m.Focus { m.blink = true return m, nil } var resetBlink bool switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyBackspace: // delete character before cursor if msg.Alt { resetBlink = m.deleteWordLeft() } else { if len(m.value) > 0 { m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) if m.pos > 0 { resetBlink = m.setCursor(m.pos - 1) } } } case tea.KeyLeft, tea.KeyCtrlB: if msg.Alt { // alt+left arrow, back one word resetBlink = m.wordLeft() break } if m.pos > 0 { // left arrow, ^F, back one character resetBlink = m.setCursor(m.pos - 1) } case tea.KeyRight, tea.KeyCtrlF: if msg.Alt { // alt+right arrow, forward one word resetBlink = m.wordRight() break } if m.pos < len(m.value) { // right arrow, ^F, forward one character resetBlink = m.setCursor(m.pos + 1) } case tea.KeyCtrlW: // ^W, delete word left of cursor resetBlink = m.deleteWordLeft() case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning resetBlink = m.cursorStart() case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor if len(m.value) > 0 && m.pos < len(m.value) { m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) } case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end resetBlink = m.cursorEnd() case tea.KeyCtrlK: // ^K, kill text after cursor resetBlink = m.deleteAfterCursor() case tea.KeyCtrlU: // ^U, kill text before cursor resetBlink = m.deleteBeforeCursor() case tea.KeyCtrlV: // ^V paste return m, Paste case tea.KeyRunes: // input regular characters if msg.Alt && len(msg.Runes) == 1 { if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor resetBlink = m.deleteWordRight() break } if msg.Runes[0] == 'b' { // alt+b, back one word resetBlink = m.wordLeft() break } if msg.Runes[0] == 'f' { // alt+f, forward one word resetBlink = m.wordRight() break } } // Input a regular character if m.CharLimit <= 0 || len(m.value) < m.CharLimit { m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...) resetBlink = m.setCursor(m.pos + len(msg.Runes)) } } case initialBlinkMsg: // We accept all initialBlinkMsgs genrated by the Blink command. if m.cursorMode != CursorBlink || !m.Focus { return m, nil } cmd := m.blinkCmd() return m, cmd case blinkMsg: // We're choosy about whether to accept blinkMsgs so that our cursor // only exactly when it should. // Is this model blinkable? if m.cursorMode != CursorBlink || !m.Focus { return m, nil } // Were we expecting this blink message? if msg.id != m.id || msg.tag != m.blinkTag { return m, nil } var cmd tea.Cmd if m.cursorMode == CursorBlink { m.blink = !m.blink cmd = m.blinkCmd() } return m, cmd case blinkCanceled: // no-op return m, nil case pasteMsg: resetBlink = m.handlePaste(string(msg)) case pasteErrMsg: m.Err = msg } var cmd tea.Cmd if resetBlink { cmd = m.blinkCmd() } m.handleOverflow() return m, cmd } // View renders the textinput in its current state. func (m TextInputModel) View() string { // Placeholder text if len(m.value) == 0 && m.Placeholder != "" { return m.placeholderView() } styleText := m.TextStyle.Inline(true).Render value := m.value[m.Offset:m.OffsetRight] pos := max(0, m.pos-m.Offset) v := styleText(m.echoTransform(string(value[:pos]))) if pos < len(value) { if Ascii { v += "¦" } v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor } else { v += m.cursorView(" ") } // If a max width and background color were set fill the empty spaces with // the background color. valWidth := rw.StringWidth(string(value)) if m.Width > 0 && valWidth <= m.Width { padding := max(0, m.Width-valWidth) if valWidth+padding <= m.Width && pos < len(value) { padding++ } v += styleText(strings.Repeat(" ", padding)) } return m.PromptStyle.Render(m.Prompt) + v } // placeholderView returns the prompt and placeholder view, if any. func (m TextInputModel) placeholderView() string { var ( v string p = m.Placeholder style = m.PlaceholderStyle.Inline(true).Render ) // Cursor if m.blink { v += m.cursorView(style(p[:1])) } else { v += m.cursorView(p[:1]) } // The rest of the placeholder text v += style(p[1:]) return m.PromptStyle.Render(m.Prompt) + v } // cursorView styles the cursor. func (m TextInputModel) cursorView(v string) string { if m.blink { return m.TextStyle.Render(v) } s := m.CursorStyle.Inline(true) if !Ascii { s = s.Reverse(true) } return s.Render(v) } // blinkCmd is an internal command used to manage cursor blinking. func (m *TextInputModel) blinkCmd() tea.Cmd { if m.cursorMode != CursorBlink { return nil } if m.blinkCtx != nil && m.blinkCtx.cancel != nil { m.blinkCtx.cancel() } ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed) m.blinkCtx.cancel = cancel m.blinkTag++ return func() tea.Msg { defer cancel() <-ctx.Done() if ctx.Err() == context.DeadlineExceeded { return blinkMsg{id: m.id, tag: m.blinkTag} } return blinkCanceled{} } } // Blink is a command used to initialize cursor blinking. func Blink() tea.Msg { return initialBlinkMsg{} } // Paste is a command for pasting from the clipboard into the text input. func Paste() tea.Msg { str, err := clipboard.ReadAll() if err != nil { return pasteErrMsg{err} } return pasteMsg(str) } func Clamp(v, low, high int) int { return min(high, max(low, v)) } func min(a, b int) int { if a < b { return a } return b } func max(a, b int) int { if a > b { return a } return b } ================================================ FILE: tuiutil/theme.go ================================================ package tuiutil const ( HighlightKey = "Highlight" HeaderBackgroundKey = "HeaderBackground" HeaderBorderBackgroundKey = "HeaderBorderBackground" HeaderForegroundKey = "HeaderForeground" FooterForegroundColorKey = "FooterForeground" HeaderBottomColorKey = "HeaderBottom" HeaderTopForegroundColorKey = "HeaderTopForeground" BorderColorKey = "BorderColor" TextColorKey = "TextColor" ) // styling functions var ( Highlight = func() string { return ThemesMap[SelectedTheme][HighlightKey] } // change to whatever HeaderBackground = func() string { return ThemesMap[SelectedTheme][HeaderBackgroundKey] } HeaderBorderBackground = func() string { return ThemesMap[SelectedTheme][HeaderBorderBackgroundKey] } HeaderForeground = func() string { return ThemesMap[SelectedTheme][HeaderForegroundKey] } FooterForeground = func() string { return ThemesMap[SelectedTheme][FooterForegroundColorKey] } HeaderBottom = func() string { return ThemesMap[SelectedTheme][HeaderBottomColorKey] } HeaderTopForeground = func() string { return ThemesMap[SelectedTheme][HeaderTopForegroundColorKey] } BorderColor = func() string { return ThemesMap[SelectedTheme][BorderColorKey] } TextColor = func() string { return ThemesMap[SelectedTheme][TextColorKey] } ) var ( SelectedTheme = 0 ValidThemes = []string{ "default", // 0 "nord", // 1 "solarized", // not accurate but whatever } ThemesMap = map[int]map[string]string{ 2: { HeaderBackgroundKey: "#268bd2", HeaderBorderBackgroundKey: "#268bd2", HeaderBottomColorKey: "#586e75", BorderColorKey: "#586e75", TextColorKey: "#fdf6e3", HeaderForegroundKey: "#fdf6e3", HighlightKey: "#2aa198", FooterForegroundColorKey: "#d33682", HeaderTopForegroundColorKey: "#d33682", }, 1: { HeaderBackgroundKey: "#5e81ac", HeaderBorderBackgroundKey: "#5e81ac", HeaderBottomColorKey: "#5e81ac", BorderColorKey: "#eceff4", TextColorKey: "#eceff4", HeaderForegroundKey: "#eceff4", HighlightKey: "#88c0d0", FooterForegroundColorKey: "#b48ead", HeaderTopForegroundColorKey: "#b48ead", }, 0: { HeaderBackgroundKey: "#505050", HeaderBorderBackgroundKey: "#505050", HeaderBottomColorKey: "#FFFFFF", BorderColorKey: "#FFFFFF", TextColorKey: "#FFFFFF", HeaderForegroundKey: "#FFFFFF", HighlightKey: "#A0A0A0", FooterForegroundColorKey: "#C2C2C2", HeaderTopForegroundColorKey: "#C2C2C2", }, } ) ================================================ FILE: tuiutil/wordwrap.go ================================================ package tuiutil import ( "strings" ) // Indent a string with the given prefix at the start of either the first, or all lines. // // input - The input string to indent. // prefix - The prefix to add. // prefixAll - If true, prefix all lines with the given prefix. // // Example usage: // // indented := wordwrap.Indent("Hello\nWorld", "-", true) func Indent(input string, prefix string, prefixAll bool) string { lines := strings.Split(input, "\n") prefixLen := len(prefix) result := make([]string, len(lines)) for i, line := range lines { if prefixAll || i == 0 { result[i] = prefix + line } else { result[i] = strings.Repeat(" ", prefixLen) + line } } return strings.Join(result, "\n") } ================================================ FILE: viewer/defs.go ================================================ package viewer import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/database" "github.com/mathaou/termdbms/list" ) type SQLSnippet struct { Query string `json:"Query"` Name string `json:"Name"` } type ScrollData struct { PreScrollYOffset int PreScrollYPosition int ScrollXOffset int } // TableState holds everything needed to save/serialize state type TableState struct { Database database.Database Data map[string]interface{} } type UIState struct { CanFormatScroll bool RenderSelection bool // render mode EditModeEnabled bool // edit mode FormatModeEnabled bool BorderToggle bool SQLEdit bool ShowClipboard bool ExpandColumn int CurrentTable int } type UIData struct { TableHeaders map[string][]string // keeps track of which schema has which headers TableHeadersSlice []string TableSlices map[string][]interface{} TableIndexMap map[int]string // keeps the schemas in order EditTextBuffer string } type FormatState struct { EditSlices []*string // the bit to show Text []string // the master collection of lines to edit RunningOffsets []int // this is a LUT for where in the original EditTextBuffer each line starts CursorX int CursorY int } // TuiModel holds all the necessary state for this app to work the way I designed it to type TuiModel struct { DefaultTable TableState // all non-destructive changes are TableStates getting passed around DefaultData UIData QueryResult *TableState QueryData *UIData Format FormatState UI UIState Scroll ScrollData Ready bool InitialFileName string // used if saving destructively Viewport viewport.Model ClipboardList list.Model Clipboard []list.Item TableStyle lipgloss.Style MouseData tea.MouseEvent TextInput LineEdit FormatInput LineEdit UndoStack []TableState RedoStack []TableState } ================================================ FILE: viewer/events.go ================================================ package viewer import ( "encoding/json" "fmt" "os" "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/list" "github.com/mathaou/termdbms/tuiutil" ) // HandleMouseEvents does that func HandleMouseEvents(m *TuiModel, msg *tea.MouseMsg) { switch msg.Type { case tea.MouseWheelDown: if !m.UI.EditModeEnabled { ScrollDown(m) } break case tea.MouseWheelUp: if !m.UI.EditModeEnabled { ScrollUp(m) } break case tea.MouseLeft: if !m.UI.EditModeEnabled && !m.UI.FormatModeEnabled && m.GetRow() < len(m.GetColumnData()) { SelectOption(m) } break default: if !m.UI.RenderSelection && !m.UI.EditModeEnabled && !m.UI.FormatModeEnabled { m.MouseData = tea.MouseEvent(*msg) } break } } // HandleWindowSizeEvents does that func HandleWindowSizeEvents(m *TuiModel, msg *tea.WindowSizeMsg) tea.Cmd { verticalMargins := HeaderHeight + FooterHeight if !m.Ready { width := msg.Width height := msg.Height m.Viewport = viewport.Model{ Width: width, Height: height - verticalMargins} m.ClipboardList.SetWidth(width) m.ClipboardList.SetHeight(height) TUIWidth = width TUIHeight = height m.Viewport.YPosition = HeaderHeight m.Viewport.HighPerformanceRendering = true m.Ready = true m.MouseData.Y = HeaderHeight MaxInputLength = m.Viewport.Width m.TextInput.Model.CharLimit = -1 m.TextInput.Model.Width = MaxInputLength - lipgloss.Width(m.TextInput.Model.Prompt) m.TextInput.Model.BlinkSpeed = time.Second m.TextInput.Model.SetCursorMode(tuiutil.CursorBlink) m.TableStyle = m.GetBaseStyle() m.SetViewSlices() } else { m.Viewport.Width = msg.Width m.Viewport.Height = msg.Height - verticalMargins } if m.Viewport.HighPerformanceRendering { return viewport.Sync(m.Viewport) } return nil } func HandleClipboardEvents(m *TuiModel, str string, command *tea.Cmd, msg tea.Msg) { state := m.ClipboardList.FilterState() if (str == "q" || str == "esc" || str == "enter") && state != list.Filtering { switch str { case "enter": i, ok := m.ClipboardList.SelectedItem().(SQLSnippet) if ok { ExitToDefaultView(m) CreatePopulatedBuffer(m, nil, i.Query) m.UI.SQLEdit = true } break default: ExitToDefaultView(m) } m.ClipboardList.ResetFilter() } else { tmpItems := len(m.ClipboardList.Items()) m.ClipboardList, *command = m.ClipboardList.Update(msg) if len(m.ClipboardList.Items()) != tmpItems { // if item removed m.Clipboard = m.ClipboardList.Items() b, _ := json.Marshal(m.Clipboard) snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile) f, _ := os.OpenFile(snippetsFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775) f.Write(b) f.Close() } } } // HandleKeyboardEvents does that func HandleKeyboardEvents(m *TuiModel, msg *tea.KeyMsg) tea.Cmd { var ( cmd tea.Cmd ) str := msg.String() if m.UI.EditModeEnabled { // handle edit mode HandleEditMode(m, str) return nil } else if m.UI.FormatModeEnabled { if str == "esc" { // cycle focus if m.TextInput.Model.Focused() { cmd = m.FormatInput.Model.FocusCommand() m.TextInput.Model.Blur() } else { cmd = m.TextInput.Model.FocusCommand() m.FormatInput.Model.Blur() } return cmd } if m.TextInput.Model.Focused() { HandleEditMode(m, str) } else { HandleFormatMode(m, str) } return nil } for k := range GlobalCommands { if str == k { return GlobalCommands[str](m) } } return nil } ================================================ FILE: viewer/global.go ================================================ package viewer import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/database" "github.com/mathaou/termdbms/tuiutil" ) type Command func(m *TuiModel) tea.Cmd var ( GlobalCommands = make(map[string]Command) ) func init() { // GLOBAL COMMANDS GlobalCommands["t"] = func(m *TuiModel) tea.Cmd { tuiutil.SelectedTheme = (tuiutil.SelectedTheme + 1) % len(tuiutil.ValidThemes) SetStyles() themeName := tuiutil.ValidThemes[tuiutil.SelectedTheme] m.WriteMessage(fmt.Sprintf("Changed themes to %s", themeName)) return nil } GlobalCommands["pgdown"] = func(m *TuiModel) tea.Cmd { for i := 0; i < m.Viewport.Height; i++ { ScrollDown(m) } return nil } GlobalCommands["pgup"] = func(m *TuiModel) tea.Cmd { for i := 0; i < m.Viewport.Height; i++ { ScrollUp(m) } return nil } GlobalCommands["r"] = func(m *TuiModel) tea.Cmd { if len(m.RedoStack) > 0 && m.QueryResult == nil && m.QueryData == nil { // do this after you get undo working, basically just the same thing reversed // handle undo deepCopy := m.CopyMap() // THE GLOBALIST TAKEOVER deepState := TableState{ Database: &database.SQLite{ FileName: m.Table().Database.GetFileName(), Database: nil, }, // placeholder for now while testing database copy Data: deepCopy, } m.UndoStack = append(m.UndoStack, deepState) // handle redo from := m.RedoStack[len(m.RedoStack)-1] to := m.Table() m.SwapTableValues(&from, to) m.Table().Database.CloseDatabaseReference() m.Table().Database.SetDatabaseReference(from.Database.GetFileName()) m.RedoStack = m.RedoStack[0 : len(m.RedoStack)-1] // pop } return nil } GlobalCommands["u"] = func(m *TuiModel) tea.Cmd { if len(m.UndoStack) > 0 && m.QueryResult == nil && m.QueryData == nil { // handle redo deepCopy := m.CopyMap() t := m.Table() // THE GLOBALIST TAKEOVER deepState := TableState{ Database: &database.SQLite{ FileName: t.Database.GetFileName(), Database: nil, }, // placeholder for now while testing database copy Data: deepCopy, } m.RedoStack = append(m.RedoStack, deepState) // handle undo from := m.UndoStack[len(m.UndoStack)-1] to := t m.SwapTableValues(&from, to) t.Database.CloseDatabaseReference() t.Database.SetDatabaseReference(from.Database.GetFileName()) m.UndoStack = m.UndoStack[0 : len(m.UndoStack)-1] // pop } return nil } GlobalCommands[":"] = func(m *TuiModel) tea.Cmd { var ( cmd tea.Cmd ) if m.QueryData != nil || m.QueryResult != nil { // editing not allowed in query view mode return nil } m.UI.EditModeEnabled = true raw, _, _ := m.GetSelectedOption() if raw == nil { m.UI.EditModeEnabled = false return nil } str := GetStringRepresentationOfInterface(*raw) // so if the selected text is wider than Viewport width or if it has newlines do format mode if lipgloss.Width(str+m.TextInput.Model.Prompt) > m.Viewport.Width || strings.Count(str, "\n") > 0 { // enter format view PrepareFormatMode(m) cmd = m.FormatInput.Model.FocusCommand() // get focus m.Scroll.PreScrollYOffset = m.Viewport.YOffset // store scrolling so state can be restored on exit m.Scroll.PreScrollYPosition = m.MouseData.Y d := m.Data() if conv, err := FormatJson(str); err == nil { // if json prettify d.EditTextBuffer = conv } else { d.EditTextBuffer = str } m.FormatInput.Original = raw // pointer to original data m.Format.Text = GetFormattedTextBuffer(m) m.SetViewSlices() m.FormatInput.Model.SetCursor(0) } else { // otherwise, edit normally up top m.TextInput.Model.SetValue(str) m.FormatInput.Model.Focus = false m.TextInput.Model.Focus = true } return cmd } GlobalCommands["p"] = func(m *TuiModel) tea.Cmd { if m.UI.RenderSelection { fn, _ := WriteTextFile(m, m.Data().EditTextBuffer) m.WriteMessage(fmt.Sprintf("Wrote selection to %s", fn)) } else if m.QueryData != nil || m.QueryResult != nil || database.IsCSV { WriteCSV(m) } go Program.Send(tea.KeyMsg{}) return nil } GlobalCommands["c"] = func(m *TuiModel) tea.Cmd { ToggleColumn(m) return nil } GlobalCommands["b"] = func(m *TuiModel) tea.Cmd { m.UI.BorderToggle = !m.UI.BorderToggle return nil } GlobalCommands["up"] = func(m *TuiModel) tea.Cmd { if m.UI.CurrentTable == len(m.Data().TableIndexMap) { m.UI.CurrentTable = 1 } else { m.UI.CurrentTable++ } // fix spacing and whatnot m.TableStyle = m.TableStyle.Width(m.CellWidth()) m.MouseData.Y = HeaderHeight m.MouseData.X = 0 m.Viewport.YOffset = 0 m.Scroll.ScrollXOffset = 0 return nil } GlobalCommands["down"] = func(m *TuiModel) tea.Cmd { if m.UI.CurrentTable == 1 { m.UI.CurrentTable = len(m.Data().TableIndexMap) } else { m.UI.CurrentTable-- } // fix spacing and whatnot m.TableStyle = m.TableStyle.Width(m.CellWidth()) m.MouseData.Y = HeaderHeight m.MouseData.X = 0 m.Viewport.YOffset = 0 m.Scroll.ScrollXOffset = 0 return nil } GlobalCommands["right"] = func(m *TuiModel) tea.Cmd { headers := m.GetHeaders() headersLen := len(headers) if headersLen > maxHeaders && m.Scroll.ScrollXOffset <= headersLen-maxHeaders { m.Scroll.ScrollXOffset++ } return nil } GlobalCommands["left"] = func(m *TuiModel) tea.Cmd { if m.Scroll.ScrollXOffset > 0 { m.Scroll.ScrollXOffset-- } return nil } GlobalCommands["s"] = func(m *TuiModel) tea.Cmd { max := len(m.GetSchemaData()[m.GetHeaders()[m.GetColumn()]]) if m.MouseData.Y-HeaderHeight+m.Viewport.YOffset < max-1 { m.MouseData.Y++ ceiling := m.Viewport.Height + HeaderHeight - 1 tuiutil.Clamp(m.MouseData.Y, m.MouseData.Y+1, ceiling) if m.MouseData.Y > ceiling { ScrollDown(m) m.MouseData.Y = ceiling } } return nil } GlobalCommands["w"] = func(m *TuiModel) tea.Cmd { pre := m.MouseData.Y if m.Viewport.YOffset > 0 && m.MouseData.Y == HeaderHeight { ScrollUp(m) m.MouseData.Y = pre } else if m.MouseData.Y > HeaderHeight { m.MouseData.Y-- } return nil } GlobalCommands["d"] = func(m *TuiModel) tea.Cmd { cw := m.CellWidth() col := m.GetColumn() cols := len(m.Data().TableHeadersSlice) - 1 if (m.MouseData.X-m.Viewport.Width) <= cw && m.GetColumn() < cols { // within tolerances m.MouseData.X += cw } else if col == cols { return func() tea.Msg { return tea.KeyMsg{ Type: tea.KeyRight, Alt: false, } } } return nil } GlobalCommands["a"] = func(m *TuiModel) tea.Cmd { cw := m.CellWidth() if m.MouseData.X-cw >= 0 { m.MouseData.X -= cw } else if m.GetColumn() == 0 { return func() tea.Msg { return tea.KeyMsg{ Type: tea.KeyLeft, Alt: false, } } } return nil } GlobalCommands["enter"] = func(m *TuiModel) tea.Cmd { if !m.UI.EditModeEnabled { SelectOption(m) } return nil } GlobalCommands["esc"] = func(m *TuiModel) tea.Cmd { m.TextInput.Model.SetValue("") if !m.UI.RenderSelection { m.UI.EditModeEnabled = true return nil } m.UI.RenderSelection = false m.Data().EditTextBuffer = "" cmd := m.TextInput.Model.FocusCommand() m.UI.ExpandColumn = -1 m.MouseData.Y = m.Scroll.PreScrollYPosition m.Viewport.YOffset = m.Scroll.PreScrollYOffset return cmd } GlobalCommands["k"] = GlobalCommands["up"] // dual bind of up/k GlobalCommands["j"] = GlobalCommands["down"] // dual bind of down/j GlobalCommands["l"] = GlobalCommands["right"] // dual bind of right/l GlobalCommands["h"] = GlobalCommands["left"] // dual bind of left/h GlobalCommands["m"] = func(m *TuiModel) tea.Cmd { ScrollUp(m) return nil } GlobalCommands["n"] = func(m *TuiModel) tea.Cmd { ScrollDown(m) return nil } GlobalCommands["?"] = func(m *TuiModel) tea.Cmd { help := GetHelpText() m.DisplayMessage(help) return nil } } ================================================ FILE: viewer/help.go ================================================ package viewer func GetHelpText() (help string) { help = ` ##### Help: -p / database path (absolute) -d / specifies which database driver to use (sqlite/mysql) -a / enable ascii mode -h / prints this message -t / starts app with specific theme (default, nord, solarized) ##### Controls: ###### MOUSE Scroll up + down to navigate table/text Move cursor to select cells for full screen viewing ###### KEYBOARD [WASD] to move around cells, and also move columns if close to edge [ENTER] to select selected cell for full screen view [UP/K and DOWN/J] to navigate schemas [LEFT/H and RIGHT/L] to navigate columns if there are more than the screen allows. Also to control the cursor of the text editor in edit mode [BACKSPACE] to delete text before cursor in edit mode [M(scroll up) and N(scroll down)] to scroll manually [Q or CTRL+C] to quit program [B] to toggle borders! [C] to expand column [T] to cycle through themes! [P] in selection mode to write cell to file, or to print query results as CSV. [R] to redo actions, if applicable [U] to undo actions, if applicable [ESC] to exit full screen view, or to enter edit mode [PGDOWN] to scroll down one views worth of rows [PGUP] to scroll up one views worth of rows ###### EDIT MODE (for quick, single line changes and commands) [ESC] to enter edit mode with no pre-loaded text input from selection When a cell is selected, press [:] to enter edit mode with selection pre-loaded The text field in the header will be populated with the selected cells text. Modifications can be made freely [ESC] to clear text field in edit mode [ENTER] to save text. Anything besides one of the reserved strings below will overwrite the current cell [:q] to exit edit mode/ format mode/ SQL mode [:s] to save database to a new file (SQLite only) [:s!] to overwrite original database file (SQLite only). A confirmation dialog will be added soon [:h] to display help text [:new] opens current cell with a blank buffer [:edit] opens current cell in format mode [:sql] opens blank buffer for creating an SQL statement [:clip] to open clipboard of SQL queries. [/] to filter, [ENTER] to select. [HOME] to set cursor to end of the text [END] to set cursor to the end of the text ###### FORMAT MODE (for editing lines of text) [ESC] to move between top control bar and format buffer [HOME] to set cursor to end of the text [END] to set cursor to the end of the text [:wq] to save changes and quit to main table view [:w] to save changes and remain in format view [:s] to serialize changes, non-destructive (SQLite only) [:s!] to serialize changes, overwriting original file (SQLite only) ###### SQL MODE (for querying database) [ESC] to move between top control bar and text buffer [:q] to quit out of statement [:exec] to execute statement. Errors will be displayed in full screen view. [:stow ] to create a snippet for the clipboard with an optional name. A random number will be used if no name is specified. ###### QUERY MODE (specifically when viewing query results) [:d] to reset table data back to original view [:sql] to query original database again` return help } ================================================ FILE: viewer/lineedit.go ================================================ package viewer import ( "database/sql" "encoding/json" "errors" "fmt" "math/rand" "os" "strings" "time" "github.com/mathaou/termdbms/database" "github.com/mathaou/termdbms/tuiutil" ) const ( QueryResultsTableName = "results" ) type EnterFunction func(m *TuiModel, selectedInput *tuiutil.TextInputModel, input string) type LineEdit struct { Model tuiutil.TextInputModel Original *interface{} } func ExitToDefaultView(m *TuiModel) { m.UI.RenderSelection = false m.UI.EditModeEnabled = false m.UI.FormatModeEnabled = false m.UI.SQLEdit = false m.UI.ShowClipboard = false m.UI.CanFormatScroll = false m.Format.CursorY = 0 m.Format.CursorX = 0 m.Format.EditSlices = nil m.Format.Text = nil m.Format.RunningOffsets = nil m.FormatInput.Model.Reset() m.TextInput.Model.Reset() m.Viewport.YOffset = 0 } func CreateEmptyBuffer(m *TuiModel, original *interface{}) { PrepareFormatMode(m) m.Data().EditTextBuffer = "\n" m.FormatInput.Original = original m.Format.Text = GetFormattedTextBuffer(m) m.SetViewSlices() m.FormatInput.Model.SetCursor(0) return } func CreatePopulatedBuffer(m *TuiModel, original *interface{}, str string) { PrepareFormatMode(m) m.Data().EditTextBuffer = str m.FormatInput.Original = original m.Format.Text = GetFormattedTextBuffer(m) m.SetViewSlices() m.FormatInput.Model.SetCursor(0) return } func EditEnter(m *TuiModel) { selectedInput := &m.TextInput.Model i := selectedInput.Value() d := m.Data() t := m.Table() var ( original *interface{} input string ) if i == ":q" { // quit mod mode ExitToDefaultView(m) return } if !m.UI.FormatModeEnabled && !m.UI.SQLEdit && !m.UI.ShowClipboard { input = i raw, _, _ := m.GetSelectedOption() original = raw if input == ":d" && m.QueryData != nil && m.QueryResult != nil { m.DefaultTable.Database.SetDatabaseReference(m.QueryResult.Database.GetFileName()) m.QueryData = nil m.QueryResult = nil var c *sql.Rows defer func() { if c != nil { c.Close() } }() err := m.SetModel(c, m.DefaultTable.Database.GetDatabaseReference()) if err != nil { m.DisplayMessage(fmt.Sprintf("%v", err)) } ExitToDefaultView(m) return } if m.QueryData != nil { m.TextInput.Model.SetValue("") m.WriteMessage("Cannot manipulate database through UI while query results are being displayed.") return } if input == ":h" { m.DisplayMessage(GetHelpText()) return } else if input == ":edit" { str := GetStringRepresentationOfInterface(*original) PrepareFormatMode(m) if conv, err := FormatJson(str); err == nil { // if json prettify d.EditTextBuffer = conv } else { d.EditTextBuffer = str } m.FormatInput.Original = original m.Format.Text = GetFormattedTextBuffer(m) m.SetViewSlices() m.FormatInput.Model.SetCursor(0) return } else if input == ":new" { CreateEmptyBuffer(m, original) return } else if input == ":sql" { CreateEmptyBuffer(m, original) m.UI.SQLEdit = true return } else if input == ":clip" { ExitToDefaultView(m) if len(m.ClipboardList.Items()) == 0 { return } m.UI.ShowClipboard = true return } } else { input = d.EditTextBuffer original = m.FormatInput.Original sqlFlags := m.UI.SQLEdit && !(i == ":exec" || strings.HasPrefix(i, ":stow")) formatFlags := m.UI.FormatModeEnabled && !(i == ":w" || i == ":wq" || i == ":s" || i == ":s!") if formatFlags && sqlFlags { m.TextInput.Model.SetValue("") return } } if original != nil && *original == input { ExitToDefaultView(m) return } if i == ":s" { // saves copy, default filename + :s _____ will save with that filename in cwd ExitToDefaultView(m) newFileName, err := Serialize(m) if err != nil { m.DisplayMessage(fmt.Sprintf("%v", err)) } else { m.DisplayMessage(fmt.Sprintf("Wrote copy of database to filepath %s.", newFileName)) } return } else if i == ":s!" { // overwrites original - should add confirmation dialog! ExitToDefaultView(m) err := SerializeOverwrite(m) if err != nil { m.DisplayMessage(fmt.Sprintf("%v", err)) } else { m.DisplayMessage("Overwrote original database file with changes.") } return } if m.UI.SQLEdit { if i == ":exec" { handleSQLMode(m, input) } else if strings.HasPrefix(i, ":stow") { if len(input) > 0 { split := strings.Split(i, " ") rand.Seed(time.Now().UnixNano()) r := rand.Int() title := fmt.Sprintf("%d", r) // if no title given then just call it random string if len(split) == 2 { title = split[1] } m.Clipboard = append(m.Clipboard, SQLSnippet{ Query: input, Name: title, }) b, _ := json.Marshal(m.Clipboard) snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile) f, _ := os.OpenFile(snippetsFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775) f.Write(b) f.Close() m.WriteMessage(fmt.Sprintf("Wrote SQL snippet %s to %s. Total count is %d", title, snippetsFile, len(m.ClipboardList.Items())+1)) } m.TextInput.Model.SetValue("") } return } old, n := populateUndo(m) if old == n || n != m.DefaultTable.Database.GetFileName() { panic(errors.New("could not get database file name")) } if _, err := FormatJson(input); err == nil { // if json uglify input = strings.ReplaceAll(input, " ", "") input = strings.ReplaceAll(input, "\n", "") input = strings.ReplaceAll(input, "\t", "") input = strings.ReplaceAll(input, "\r", "") } u := GetInterfaceFromString(input, original) database.ProcessSqlQueryForDatabaseType(&database.Update{ Update: u, }, m.GetRowData(), m.GetSchemaName(), m.GetSelectedColumnName(), &t.Database) m.UI.EditModeEnabled = false d.EditTextBuffer = "" m.FormatInput.Model.SetValue("") *original = input if m.UI.FormatModeEnabled && i == ":wq" { ExitToDefaultView(m) } } func handleSQLMode(m *TuiModel, input string) { if m.QueryResult != nil { m.QueryResult = nil } m.QueryResult = &TableState{ // perform query Database: m.Table().Database, Data: make(map[string]interface{}), } m.QueryData = &UIData{} firstword := strings.ToLower(strings.Split(input, " ")[0]) if exec := firstword == "update" || firstword == "delete" || firstword == "insert"; exec { m.QueryData = nil m.QueryResult = nil populateUndo(m) _, err := m.DefaultTable.Database.GetDatabaseReference().Exec(input) if err != nil { ExitToDefaultView(m) m.DisplayMessage(fmt.Sprintf("%v", err)) return } var c *sql.Rows defer func() { if c != nil { c.Close() } }() err = m.SetModel(c, m.DefaultTable.Database.GetDatabaseReference()) if err != nil { m.DisplayMessage(fmt.Sprintf("%v", err)) } else { ExitToDefaultView(m) } } else { // query c, err := m.QueryResult.Database.GetDatabaseReference().Query(input) defer func() { if c != nil { c.Close() } }() if err != nil { m.QueryResult = nil m.QueryData = nil ExitToDefaultView(m) m.DisplayMessage(fmt.Sprintf("%v", err)) return } i := 0 m.QueryData.TableHeaders = make(map[string][]string) m.QueryData.TableIndexMap = make(map[int]string) m.QueryData.TableSlices = make(map[string][]interface{}) m.QueryData.TableHeadersSlice = []string{} m.PopulateDataForResult(c, &i, QueryResultsTableName) ExitToDefaultView(m) m.UI.EditModeEnabled = false m.UI.CurrentTable = 1 m.Data().EditTextBuffer = "" m.FormatInput.Model.SetValue("") } } func populateUndo(m *TuiModel) (old string, new string) { if len(m.UndoStack) >= 10 { ref := m.UndoStack[len(m.UndoStack)-1] err := os.Remove(ref.Database.GetFileName()) if err != nil { fmt.Printf("%v", err) os.Exit(1) } m.UndoStack = m.UndoStack[1:] // need some more complicated logic to handle dereferencing? } switch m.DefaultTable.Database.(type) { case *database.SQLite: deepCopy := m.CopyMap() // THE GLOBALIST TAKEOVER deepState := TableState{ Database: &database.SQLite{ FileName: m.DefaultTable.Database.GetFileName(), Database: nil, }, Data: deepCopy, } m.UndoStack = append(m.UndoStack, deepState) old = m.DefaultTable.Database.GetFileName() dst, _, _ := CopyFile(old) new = dst m.DefaultTable.Database.CloseDatabaseReference() m.DefaultTable.Database.SetDatabaseReference(dst) break default: break } return old, new } ================================================ FILE: viewer/mode.go ================================================ package viewer import ( "strings" tea "github.com/charmbracelet/bubbletea" ) var ( InputBlacklist = []string{ "alt+", "ctrl+", "up", "down", "tab", "left", "enter", "right", "pgdown", "pgup", } ) func PrepareFormatMode(m *TuiModel) { m.UI.FormatModeEnabled = true m.UI.EditModeEnabled = false m.TextInput.Model.SetValue("") m.FormatInput.Model.SetValue("") m.FormatInput.Model.Focus = true m.TextInput.Model.Focus = false m.TextInput.Model.Blur() } func MoveCursorWithinBounds(m *TuiModel) { defer func() { if recover() != nil { println("whoopsy") } }() offset := GetOffsetForLineNumber(m.Format.CursorY) l := len(*m.Format.EditSlices[m.Format.CursorY]) end := l - 1 - offset if m.Format.CursorX > end { m.Format.CursorX = end } } func HandleEditInput(m *TuiModel, str, val string) (ret bool) { selectedInput := &m.TextInput.Model input := selectedInput.Value() inputLen := len(input) if str == "backspace" { cursor := selectedInput.Cursor() runes := []rune(input) if cursor == inputLen && inputLen > 0 { selectedInput.SetValue(input[0 : inputLen-1]) } else if cursor > 0 { min := Max(selectedInput.Cursor(), 0) min = Min(min, inputLen-1) first := runes[:min-1] last := runes[min:] selectedInput.SetValue(string(first) + string(last)) selectedInput.SetCursor(selectedInput.Cursor() - 1) } ret = true } else if str == "enter" { // writes your selection EditEnter(m) ret = true } return ret } func HandleEditMovement(m *TuiModel, str, val string) (ret bool) { selectedInput := &m.TextInput.Model if str == "home" { selectedInput.SetCursor(0) ret = true } else if str == "end" { if len(val) > 0 { selectedInput.SetCursor(len(val) - 1) } ret = true } else if str == "left" { cursorPosition := selectedInput.Cursor() if cursorPosition == selectedInput.Offset && cursorPosition != 0 { selectedInput.Offset-- selectedInput.OffsetRight-- } if cursorPosition != 0 { selectedInput.SetCursor(cursorPosition - 1) } ret = true } else if str == "right" { cursorPosition := selectedInput.Cursor() if cursorPosition == selectedInput.OffsetRight { selectedInput.Offset++ selectedInput.OffsetRight++ } selectedInput.SetCursor(cursorPosition + 1) ret = true } return ret } func HandleFormatMovement(m *TuiModel, str string) (ret bool) { lines := 0 for _, v := range m.Format.EditSlices { if *v != "" { lines++ } } switch str { case "pgdown": l := len(m.Format.Text) - 1 for i := 0; i < m.Viewport.Height && m.Viewport.YOffset < l; i++ { ScrollDown(m) } ret = true break case "pgup": for i := 0; i < m.Viewport.Height && m.Viewport.YOffset > 0; i++ { ScrollUp(m) } ret = true break case "home": m.Viewport.YOffset = 0 m.Format.CursorX = 0 m.Format.CursorY = 0 ret = true break case "end": m.Viewport.YOffset = len(m.Format.Text) - m.Viewport.Height m.Format.CursorY = Min(m.Viewport.Height-FooterHeight, strings.Count(m.Data().EditTextBuffer, "\n")) m.Format.CursorX = m.Format.RunningOffsets[len(m.Format.RunningOffsets)-1] ret = true break case "right": ret = true m.Format.CursorX++ offset := GetOffsetForLineNumber(m.Format.CursorY) x := m.Format.CursorX + offset + 1 // for the space at the end l := len(*m.Format.EditSlices[m.Format.CursorY]) maxY := lines - 1 if l < x && m.Format.CursorY < maxY { m.Format.CursorX = 0 m.Format.CursorY++ } else if l < x && m.Format.CursorY < len(m.Format.Text)-1 { go Program.Send( tea.KeyMsg{ Type: tea.KeyDown, Alt: false, }, ) } else if m.Format.CursorY > maxY { m.Format.CursorX = maxY } break case "left": ret = true m.Format.CursorX-- if m.Format.CursorX < 0 && m.Format.CursorY > 0 { m.Format.CursorY-- offset := GetOffsetForLineNumber(m.Format.CursorY) l := len(*m.Format.EditSlices[m.Format.CursorY]) m.Format.CursorX = l - 1 - offset } else if m.Format.CursorX < 0 && m.Format.CursorY == 0 && m.Viewport.YOffset > 0 { go Program.Send( tea.KeyMsg{ Type: tea.KeyUp, Alt: false, }, ) } else if m.Format.CursorX < 0 { m.Format.CursorX = 0 } break case "up": ret = true if m.Format.CursorY > 0 { m.Format.CursorY-- } else if m.Viewport.YOffset > 0 { ScrollUp(m) } break case "down": ret = true if m.Format.CursorY < m.Viewport.Height-FooterHeight && m.Format.CursorY < lines-1 { m.Format.CursorY++ } else { ScrollDown(m) } } return ret } func InsertCharacter(m *TuiModel, newlineOrTab string) { yOffset := Max(m.Viewport.YOffset, 0) cursor := m.Format.RunningOffsets[m.Format.CursorY+yOffset] + m.Format.CursorX runes := []rune(m.Data().EditTextBuffer) min := Max(cursor, 0) min = Min(min, len(m.Data().EditTextBuffer)) first := runes[:min] last := runes[min:] f := string(first) l := string(last) m.Data().EditTextBuffer = f + newlineOrTab + l if len(last) == 0 { // for whatever reason, if you don't double up on newlines if appending to end, it gets removed m.Data().EditTextBuffer += newlineOrTab } numLines := 0 for _, v := range m.Format.Text { if v != "" { // ignore padding numLines++ } } if yOffset+m.Viewport.Height == numLines && newlineOrTab == "\n" { m.Viewport.YOffset++ } else if newlineOrTab == "\n" { m.Format.CursorY++ } m.Format.Text = GetFormattedTextBuffer(m) m.SetViewSlices() if newlineOrTab == "\n" { m.Format.CursorX = 0 } else { m.Format.CursorX++ } } func HandleFormatInput(m *TuiModel, str string) bool { switch str { case "tab": InsertCharacter(m, "\t") return true case "enter": InsertCharacter(m, "\n") return true case "backspace": cursor := m.Format.CursorX + FormatModeOffset input := m.Format.EditSlices[m.Format.CursorY] inputLen := len(*input) runes := []rune(*input) if m.Format.CursorX > 0 { // cursor in middle of line if cursor == inputLen && inputLen > 0 { *input = (*input)[0 : inputLen-1] } else if cursor > 0 { min := Max(cursor, 0) min = Min(min, inputLen-1) first := runes[:min-1] last := runes[min:] *input = string(first) + string(last) } return false } else if m.Format.CursorY > 0 && m.Format.CursorX == 0 { // beginning of line yOffset := Max(m.Viewport.YOffset, 0) cursor := m.Format.RunningOffsets[m.Format.CursorY+yOffset] + m.Format.CursorX runes := []rune(m.Data().EditTextBuffer) min := Max(cursor, 0) min = Min(min, len(m.Data().EditTextBuffer)-1) first := runes[:min-1] last := runes[min:] m.Data().EditTextBuffer = string(first) + string(last) if yOffset+m.Viewport.Height == len(m.Format.Text) && yOffset > 0 { m.Viewport.YOffset-- } else { m.Format.CursorY-- } m.Format.Text = GetFormattedTextBuffer(m) m.SetViewSlices() } return true } return false } func HandleFormatMode(m *TuiModel, str string) { var ( val string replacement string ) inputReturn := HandleFormatInput(m, str) if HandleFormatMovement(m, str) { return } for _, v := range InputBlacklist { if strings.Contains(str, v) { return } } lineNumberOffset := GetOffsetForLineNumber(m.Format.CursorY) pString := m.Format.EditSlices[m.Format.CursorY] delta := 1 if str != "backspace" { // update UI if *pString != "" { min := Max(m.Format.CursorX+lineNumberOffset+1, 0) min = Min(min, len(*pString)) first := (*pString)[:min] last := (*pString)[min:] val = first + str + last } else { val = *pString + str } } else { delta = -1 val = *pString } // if json special rules replacement = m.Data().EditTextBuffer cursor := m.Format.RunningOffsets[m.Viewport.YOffset+m.Format.CursorY] fIndex := Max(cursor, 0) lIndex := m.Viewport.YOffset + m.Format.CursorY + 1 defer func() { if recover() != nil { println("whoopsy!") // bug happened once, debug... } }() first := replacement[:fIndex] middle := val[lineNumberOffset+1:] last := replacement[Min(m.Format.RunningOffsets[lIndex], len(replacement)):] if (first != "" || last != "") && last != "\n" { middle += "\n" } replacement = first + // replace the entire line the edit appears on middle + // insert the edit last // top the edit off with the rest of the string m.Data().EditTextBuffer = replacement if len(*pString) == FormatModeOffset && str != "backspace" { // insert on empty lines behaves funny *pString = *pString + str } else { *pString = val } m.Format.CursorX += delta if inputReturn { return } for i := m.Viewport.YOffset + m.Format.CursorY + 1; i < len(m.Format.RunningOffsets); i++ { m.Format.RunningOffsets[i] += delta } } // HandleEditMode implementation is kind of jank, but we can clean it up later func HandleEditMode(m *TuiModel, str string) { var ( input string val string ) selectedInput := &m.TextInput.Model input = selectedInput.Value() if input != "" && selectedInput.Cursor() <= len(input)-1 { min := Max(selectedInput.Cursor(), 0) min = Min(min, len(input)-1) first := input[:min] last := input[min:] val = first + str + last } else { val = input + str } if str == "esc" { selectedInput.SetValue("") return } if HandleEditMovement(m, str, val) || HandleEditInput(m, str, val) { return } for _, v := range InputBlacklist { if strings.Contains(str, v) { return } } prePos := selectedInput.Cursor() if val != "" { selectedInput.SetValue(val) } else { selectedInput.SetValue(str) } if prePos != 0 { prePos = selectedInput.Cursor() } selectedInput.SetCursor(prePos + 1) } ================================================ FILE: viewer/modelutil.go ================================================ package viewer import ( "database/sql" "encoding/json" "fmt" "os" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/mathaou/termdbms/database" "github.com/mathaou/termdbms/list" "github.com/mathaou/termdbms/tuiutil" ) func (m *TuiModel) WriteMessage(s string) { if Message == "" { Message = s MIP = true go Program.Send(tea.KeyMsg{}) // trigger update go Program.Send(tea.KeyMsg{}) // trigger update for sure hack gross but w/e } } func (m *TuiModel) CopyMap() (to map[string]interface{}) { from := m.Table().Data to = map[string]interface{}{} for k, v := range from { if copyValues, ok := v.(map[string][]interface{}); ok { columnNames := m.Data().TableHeaders[k] columnValues := make(map[string][]interface{}) // golang wizardry columns := make([]interface{}, len(columnNames)) for i := range columns { columns[i] = copyValues[columnNames[i]] } for i, colName := range columnNames { val := columns[i].([]interface{}) buffer := make([]interface{}, len(val)) for k := range val { buffer[k] = val[k] } columnValues[colName] = append(columnValues[colName], buffer) } to[k] = columnValues // data for schema, organized by column } } return to } // GetNewModel returns a TuiModel struct with some fields set func GetNewModel(baseFileName string, db *sql.DB) TuiModel { m := TuiModel{ DefaultTable: TableState{ Database: &database.SQLite{ FileName: baseFileName, Database: db, }, Data: make(map[string]interface{}), }, Format: FormatState{ EditSlices: nil, Text: nil, RunningOffsets: nil, CursorX: 0, CursorY: 0, }, UI: UIState{ CanFormatScroll: false, RenderSelection: false, EditModeEnabled: false, FormatModeEnabled: false, BorderToggle: false, CurrentTable: 0, ExpandColumn: -1, }, Scroll: ScrollData{}, DefaultData: UIData{ TableHeaders: make(map[string][]string), TableHeadersSlice: []string{}, TableSlices: make(map[string][]interface{}), TableIndexMap: make(map[int]string), }, TextInput: LineEdit{ Model: tuiutil.NewModel(), }, FormatInput: LineEdit{ Model: tuiutil.NewModel(), }, Clipboard: []list.Item{}, } m.FormatInput.Model.Prompt = "" snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile) exists, _ := Exists(snippetsFile) if exists { contents, _ := os.ReadFile(snippetsFile) var c []SQLSnippet json.Unmarshal(contents, &c) for _, v := range c { m.Clipboard = append(m.Clipboard, v) } } m.ClipboardList = list.NewModel(m.Clipboard, itemDelegate{}, 0, 0) m.ClipboardList.Title = "SQL Snippets" m.ClipboardList.SetFilteringEnabled(true) m.ClipboardList.SetShowPagination(true) m.ClipboardList.SetShowTitle(true) return m } // SetModel creates a model to be used by bubbletea using some golang wizardry func (m *TuiModel) SetModel(c *sql.Rows, db *sql.DB) error { var err error indexMap := 0 // gets all the schema names of the database tableNamesQuery := m.Table().Database.GetTableNamesQuery() rows, err := db.Query(tableNamesQuery) if err != nil { return err } defer rows.Close() // for each schema for rows.Next() { var schemaName string rows.Scan(&schemaName) // couldn't get prepared statements working and gave up because it was very simple var statement strings.Builder statement.WriteString("select * from ") statement.WriteString(schemaName) getAll := statement.String() if c != nil { c.Close() c = nil } c, err = db.Query(getAll) if err != nil { panic(err) } m.PopulateDataForResult(c, &indexMap, schemaName) } // set the first table to be initial view m.UI.CurrentTable = 1 return nil } func (m *TuiModel) PopulateDataForResult(c *sql.Rows, indexMap *int, schemaName string) { columnNames, _ := c.Columns() columnValues := make(map[string][]interface{}) for c.Next() { // each row of the table // golang wizardry columns := make([]interface{}, len(columnNames)) columnPointers := make([]interface{}, len(columnNames)) // init interface array for i := range columns { columnPointers[i] = &columns[i] } c.Scan(columnPointers...) for i, colName := range columnNames { val := columnPointers[i].(*interface{}) columnValues[colName] = append(columnValues[colName], *val) } } // onto the next schema *indexMap++ if m.QueryResult != nil && m.QueryData != nil { m.QueryResult.Data[schemaName] = columnValues m.QueryData.TableHeaders[schemaName] = columnNames // headers for the schema, for later reference m.QueryData.TableIndexMap[*indexMap] = schemaName return } m.Table().Data[schemaName] = columnValues // data for schema, organized by column m.Data().TableHeaders[schemaName] = columnNames // headers for the schema, for later reference // mapping between schema and an int ( since maps aren't deterministic), for later reference m.Data().TableIndexMap[*indexMap] = schemaName } func (m *TuiModel) SwapTableValues(f, t *TableState) { from := &f.Data to := &t.Data for k, v := range *from { if copyValues, ok := v.(map[string][]interface{}); ok { columnNames := m.Data().TableHeaders[k] columnValues := make(map[string][]interface{}) // golang wizardry columns := make([]interface{}, len(columnNames)) for i := range columns { columns[i] = copyValues[columnNames[i]][0] } for i, colName := range columnNames { columnValues[colName] = columns[i].([]interface{}) } (*to)[k] = columnValues // data for schema, organized by column } } } ================================================ FILE: viewer/serialize.go ================================================ package viewer import ( "errors" "fmt" "log" "math/rand" "os" "path" "strings" "github.com/mathaou/termdbms/database" ) var ( serializationErrorString = fmt.Sprintf("Database driver %s does not support serialization.", database.DriverString) ) func Serialize(m *TuiModel) (string, error) { switch m.Table().Database.(type) { case *database.SQLite: return SerializeSQLiteDB(m.Table().Database.(*database.SQLite), m), nil default: return "", errors.New(serializationErrorString) } } func SerializeOverwrite(m *TuiModel) error { t := m.Table() switch t.Database.(type) { case *database.SQLite: SerializeOverwriteSQLiteDB(t.Database.(*database.SQLite), m) return nil default: return errors.New(serializationErrorString) } } // SQLITE func SerializeSQLiteDB(db *database.SQLite, m *TuiModel) string { db.CloseDatabaseReference() source, err := os.ReadFile(db.GetFileName()) if err != nil { panic(err) } ext := path.Ext(m.InitialFileName) newFileName := fmt.Sprintf("%s-%d%s", strings.TrimSuffix(m.InitialFileName, ext), rand.Intn(4), ext) err = os.WriteFile(newFileName, source, 0777) if err != nil { log.Fatal(err) } db.SetDatabaseReference(db.GetFileName()) return newFileName } func SerializeOverwriteSQLiteDB(db *database.SQLite, m *TuiModel) { db.CloseDatabaseReference() filename := db.GetFileName() source, err := os.ReadFile(filename) if err != nil { panic(err) } err = os.WriteFile(m.InitialFileName, source, 0777) if err != nil { log.Fatal(err) } db.SetDatabaseReference(filename) } ================================================ FILE: viewer/snippets.go ================================================ package viewer import ( "fmt" "io" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/list" "github.com/mathaou/termdbms/tuiutil" ) var ( style = lipgloss.NewStyle() ) func (s SQLSnippet) Title() string { return s.Name } func (s SQLSnippet) Description() string { return s.Query } func (s SQLSnippet) FilterValue() string { return s.Name } type itemDelegate struct{} func (d itemDelegate) Height() int { return 1 } func (d itemDelegate) Spacing() int { return 0 } func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { localStyle := style.Copy() i, ok := listItem.(SQLSnippet) if !ok { return } digits := len(fmt.Sprintf("%d", len(m.Items()))) + 1 incomingDigits := len(fmt.Sprintf("%d", index+1)) if !tuiutil.Ascii { localStyle = style.Copy().Faint(true) } str := fmt.Sprintf("%d) %s%s | ", index+1, strings.Repeat(" ", digits-incomingDigits), i.Title()) query := localStyle.Render(i.Query[0:Min(TUIWidth-10, Max(len(i.Query)-1, len(i.Query)-1-len(str)))]) // padding + tab + padding str += strings.ReplaceAll(query, "\n", "") localStyle = style.Copy().PaddingLeft(4) fn := localStyle.Render if index == m.Index() { fn = func(s string) string { localStyle = style.Copy(). PaddingLeft(2) if !tuiutil.Ascii { localStyle = localStyle. Foreground(lipgloss.Color(tuiutil.HeaderTopForeground())) } return lipgloss.JoinHorizontal(lipgloss.Left, localStyle. Render("> "), style.Render(s)) } } fmt.Fprintf(w, fn(str)) } ================================================ FILE: viewer/table.go ================================================ package viewer import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/database" "github.com/mathaou/termdbms/tuiutil" ) type TableAssembly func(m *TuiModel, s *string, c *chan bool) var ( HeaderAssembly TableAssembly FooterAssembly TableAssembly Message string mid *string MIP bool ) func init() { tmp := "" MIP = false mid = &tmp HeaderAssembly = func(m *TuiModel, s *string, done *chan bool) { if m.UI.ShowClipboard { *done <- true return } var ( builder []string ) style := m.GetBaseStyle() if !tuiutil.Ascii { // for column headers style = style.Foreground(lipgloss.Color(tuiutil.HeaderForeground())). BorderBackground(lipgloss.Color(tuiutil.HeaderBorderBackground())). Background(lipgloss.Color(tuiutil.HeaderBackground())) } headers := m.Data().TableHeadersSlice for i, d := range headers { // write all headers if m.UI.ExpandColumn != -1 && i != m.UI.ExpandColumn { continue } text := " " + TruncateIfApplicable(m, d) builder = append(builder, style. Render(text)) } { // schema name var headerTop string if m.UI.EditModeEnabled || m.UI.FormatModeEnabled { headerTop = m.TextInput.Model.View() if !m.TextInput.Model.Focused() { headerTop = HeaderStyle.Copy().Faint(true).Render(headerTop) } } else { headerTop = fmt.Sprintf(" %s (%d/%d) - %d record(s) + %d column(s)", m.GetSchemaName(), m.UI.CurrentTable, len(m.Data().TableHeaders), // look at how headers get rendered to get accurate record number len(m.GetColumnData()), len(m.GetHeaders())) // this will need to be refactored when filters get added headerTop = HeaderStyle.Render(headerTop) } headerMid := lipgloss.JoinHorizontal(lipgloss.Left, builder...) if m.UI.RenderSelection { headerMid = "" } *s = lipgloss.JoinVertical(lipgloss.Left, headerTop, headerMid) } *done <- true } FooterAssembly = func(m *TuiModel, s *string, done *chan bool) { if m.UI.ShowClipboard { *done <- true return } var ( row int col int ) if !m.UI.FormatModeEnabled { // reason we flip is because it makes more sense to store things by column for data row = m.GetRow() + m.Viewport.YOffset col = m.GetColumn() + m.Scroll.ScrollXOffset } else { // but for format mode thats just a regular row/col situation row = m.Format.CursorX col = m.Format.CursorY + m.Viewport.YOffset } footer := fmt.Sprintf(" %d, %d ", row, col) if m.UI.RenderSelection { footer = "" } undoRedoInfo := fmt.Sprintf(" undo(%d) / redo(%d) ", len(m.UndoStack), len(m.RedoStack)) switch m.Table().Database.(type) { case *database.SQLite: break default: undoRedoInfo = "" break } gapSize := m.Viewport.Width - lipgloss.Width(footer) - lipgloss.Width(undoRedoInfo) - 2 if MIP { MIP = false if !tuiutil.Ascii { Message = FooterStyle.Render(Message) } go func() { newSize := gapSize - lipgloss.Width(Message) if newSize < 1 { newSize = 1 } half := strings.Repeat("-", newSize/2) if lipgloss.Width(Message) > gapSize { Message = Message[0:gapSize-3] + "..." } *mid = half + Message + half time.Sleep(time.Second * 5) Message = "" go Program.Send(tea.KeyMsg{}) }() } else if Message == "" { *mid = strings.Repeat("-", gapSize) } queryResultsFlag := "├" if m.QueryData != nil || m.QueryResult != nil { queryResultsFlag = "*" } footer = FooterStyle.Render(undoRedoInfo) + queryResultsFlag + *mid + "┤" + FooterStyle.Render(footer) *s = footer *done <- true } } ================================================ FILE: viewer/tableutil.go ================================================ package viewer import ( "errors" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/tuiutil" ) var maxHeaders int // AssembleTable shows either the selection text or the table func AssembleTable(m *TuiModel) string { if m.UI.ShowClipboard { return ShowClipboard(m) } if m.UI.RenderSelection { return DisplaySelection(m) } if m.UI.FormatModeEnabled { return DisplayFormatText(m) } return DisplayTable(m) } // NumHeaders gets the number of columns for the current schema func (m *TuiModel) NumHeaders() int { headers := m.GetHeaders() l := len(headers) if m.UI.ExpandColumn > -1 || l == 0 { return 1 } maxHeaders = 7 if l > maxHeaders { // this just looked the best after some trial and error if l%5 == 0 { return 5 } else if l%4 == 0 { return 4 } else if l%3 == 0 { return 3 } else { return 6 // primes and shiiiii } } return l } // CellWidth gets the current cell width for schema func (m *TuiModel) CellWidth() int { h := m.NumHeaders() return m.Viewport.Width/h + 2 } // GetBaseStyle returns a new style that is used everywhere func (m *TuiModel) GetBaseStyle() lipgloss.Style { cw := m.CellWidth() s := lipgloss.NewStyle(). Foreground(lipgloss.Color(tuiutil.TextColor())). Width(cw). Align(lipgloss.Left) if m.UI.BorderToggle && !tuiutil.Ascii { s = s.BorderLeft(true). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(tuiutil.BorderColor())) } return s } // GetColumn gets the column the mouse cursor is in func (m *TuiModel) GetColumn() int { baseVal := m.MouseData.X / m.CellWidth() if m.UI.RenderSelection || m.UI.EditModeEnabled || m.UI.FormatModeEnabled { return m.Scroll.ScrollXOffset + baseVal } return baseVal } // GetRow does math to get a valid row that's helpful func (m *TuiModel) GetRow() int { baseVal := Max(m.MouseData.Y-HeaderHeight, 0) if m.UI.RenderSelection || m.UI.EditModeEnabled { return m.Viewport.YOffset + baseVal } else if m.UI.FormatModeEnabled { return m.Scroll.PreScrollYOffset + baseVal } return baseVal } // GetSchemaName gets the current schema name func (m *TuiModel) GetSchemaName() string { return m.Data().TableIndexMap[m.UI.CurrentTable] } // GetHeaders does just that for the current schema func (m *TuiModel) GetHeaders() []string { schema := m.GetSchemaName() d := m.Data() return d.TableHeaders[schema] } func (m *TuiModel) SetViewSlices() { d := m.Data() if m.Viewport.Height < 0 { return } if m.UI.FormatModeEnabled { var slices []*string for i := 0; i < m.Viewport.Height; i++ { yOffset := Max(m.Viewport.YOffset, 0) if yOffset+i > len(m.Format.Text)-1 { break } pStr := &m.Format.Text[Max(yOffset+i, 0)] slices = append(slices, pStr) } m.Format.EditSlices = slices m.UI.CanFormatScroll = len(m.Format.Text)-m.Viewport.YOffset-m.Viewport.Height > 0 if m.Format.CursorX < 0 { m.Format.CursorX = 0 } } else { // header slices headers := d.TableHeaders[m.GetSchemaName()] headersLen := len(headers) if headersLen > maxHeaders { headers = headers[m.Scroll.ScrollXOffset : maxHeaders+m.Scroll.ScrollXOffset-1] } // data slices defer func() { if recover() != nil { panic(errors.New("adsf")) } }() for _, columnName := range headers { interfaceValues := m.GetSchemaData()[columnName] if len(interfaceValues) >= m.Viewport.Height { min := Min(m.Viewport.YOffset, len(interfaceValues)-m.Viewport.Height) d.TableSlices[columnName] = interfaceValues[min : m.Viewport.Height+min] } else { d.TableSlices[columnName] = interfaceValues } } d.TableHeadersSlice = headers } // format slices } // GetSchemaData is a helper function to get the data of the current schema func (m *TuiModel) GetSchemaData() map[string][]interface{} { n := m.GetSchemaName() t := m.Table() d := t.Data if d[n] == nil { return map[string][]interface{}{} } return d[n].(map[string][]interface{}) } func (m *TuiModel) GetSelectedColumnName() string { col := m.GetColumn() headers := m.GetHeaders() index := Min(m.NumHeaders()-1, col) if len(headers) == 0 { return "" } return headers[index] } func (m *TuiModel) GetColumnData() []interface{} { schemaData := m.GetSchemaData() if schemaData == nil { return []interface{}{} } return schemaData[m.GetSelectedColumnName()] } func (m *TuiModel) GetRowData() map[string]interface{} { defer func() { if recover() != nil { println("Whoopsy!") // TODO, this happened once } }() headers := m.GetHeaders() schema := m.GetSchemaData() data := make(map[string]interface{}) for _, v := range headers { data[v] = schema[v][m.GetRow()] } return data } func (m *TuiModel) GetSelectedOption() (*interface{}, int, []interface{}) { if !m.UI.FormatModeEnabled { m.Scroll.PreScrollYOffset = m.Viewport.YOffset m.Scroll.PreScrollYPosition = m.MouseData.Y } row := m.GetRow() col := m.GetColumnData() if row >= len(col) { return nil, row, col } return &col[row], row, col } func (m *TuiModel) DisplayMessage(msg string) { m.Data().EditTextBuffer = msg m.UI.EditModeEnabled = false m.UI.RenderSelection = true } func (m *TuiModel) GetSelectedLineEdit() *LineEdit { if m.TextInput.Model.Focused() { return &m.TextInput } return &m.FormatInput } func ToggleColumn(m *TuiModel) { if m.UI.ExpandColumn > -1 { m.UI.ExpandColumn = -1 } else { m.UI.ExpandColumn = m.GetColumn() } } ================================================ FILE: viewer/ui.go ================================================ package viewer import ( "fmt" "strconv" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/tuiutil" "github.com/muesli/reflow/wordwrap" ) var ( Program *tea.Program FormatModeOffset int TUIWidth int TUIHeight int ) func GetOffsetForLineNumber(a int) int { return FormatModeOffset - len(strconv.Itoa(a)) } func SelectOption(m *TuiModel) { if m.UI.RenderSelection { return } m.UI.RenderSelection = true raw, _, col := m.GetSelectedOption() if raw == nil { return } l := len(col) row := m.Viewport.YOffset + m.MouseData.Y - HeaderHeight if row <= l && l > 0 && m.MouseData.Y >= HeaderHeight && m.MouseData.Y < m.Viewport.Height+HeaderHeight && m.MouseData.X < m.CellWidth()*(len(m.Data().TableHeadersSlice)) { if conv, ok := (*raw).(string); ok { m.Data().EditTextBuffer = conv } else { m.Data().EditTextBuffer = "" } } else { m.UI.RenderSelection = false } } // ScrollDown is a simple function to move the Viewport down func ScrollDown(m *TuiModel) { if m.UI.FormatModeEnabled && m.UI.CanFormatScroll && m.Viewport.YPosition != 0 { m.Viewport.YOffset++ return } max := GetScrollDownMaximumForSelection(m) if m.Viewport.YOffset < max-m.Viewport.Height { m.Viewport.YOffset++ m.MouseData.Y = Min(m.MouseData.Y, m.Viewport.YOffset) } if !m.UI.RenderSelection { m.Scroll.PreScrollYPosition = m.MouseData.Y m.Scroll.PreScrollYOffset = m.Viewport.YOffset } } // ScrollUp is a simple function to move the Viewport up func ScrollUp(m *TuiModel) { if m.UI.FormatModeEnabled && m.UI.CanFormatScroll && m.Viewport.YOffset > 0 && m.Viewport.YPosition != 0 { m.Viewport.YOffset-- return } if m.Viewport.YOffset > 0 { m.Viewport.YOffset-- m.MouseData.Y = Min(m.MouseData.Y, m.Viewport.YOffset) } else { m.MouseData.Y = HeaderHeight } if !m.UI.RenderSelection { m.Scroll.PreScrollYPosition = m.MouseData.Y m.Scroll.PreScrollYOffset = m.Viewport.YOffset } } // TABLE STUFF // DisplayTable does some fancy stuff to get a table rendered in text func DisplayTable(m *TuiModel) string { var ( builder []string ) // go through all columns for c, columnName := range m.Data().TableHeadersSlice { if m.UI.ExpandColumn > -1 && m.UI.ExpandColumn != c { continue } var ( rowBuilder []string ) columnValues := m.Data().TableSlices[columnName] for r, val := range columnValues { base := m.GetBaseStyle(). UnsetBorderLeft(). UnsetBorderStyle(). UnsetBorderForeground() s := GetStringRepresentationOfInterface(val) s = " " + s // handle highlighting if c == m.GetColumn() && r == m.GetRow() { if !tuiutil.Ascii { base.Foreground(lipgloss.Color(tuiutil.Highlight())) } else if tuiutil.Ascii { s = "|" + s } } // display text based on type rowBuilder = append(rowBuilder, base.Render(TruncateIfApplicable(m, s))) } for len(rowBuilder) < m.Viewport.Height { // fix spacing issues rowBuilder = append(rowBuilder, "") } column := lipgloss.JoinVertical(lipgloss.Left, rowBuilder...) // get a list of columns builder = append(builder, m.GetBaseStyle().Render(column)) } // join them into rows return lipgloss.JoinHorizontal(lipgloss.Left, builder...) } func GetFormattedTextBuffer(m *TuiModel) []string { v := m.Data().EditTextBuffer lines := SplitLines(v) FormatModeOffset = len(strconv.Itoa(len(lines))) + 1 // number of characters in the numeric string var ret []string m.Format.RunningOffsets = []int{} total := 0 strlen := 0 for i, v := range lines { xOffset := len(strconv.Itoa(i)) totalOffset := Max(FormatModeOffset-xOffset, 0) //wrap := wordwrap.String(v, m.Viewport.Width-totalOffset) right := tuiutil.Indent( v, fmt.Sprintf("%d%s", i, strings.Repeat(" ", totalOffset)), false) ret = append(ret, right) m.Format.RunningOffsets = append(m.Format.RunningOffsets, total) strlen = len(v) total += strlen + 1 } lineLength := len(ret) // need to add this so that the last line can be edited m.Format.RunningOffsets = append(m.Format.RunningOffsets, m.Format.RunningOffsets[lineLength-1]+ len(ret[len(ret)-1][FormatModeOffset:])) for i := len(ret); i < m.Viewport.Height; i++ { ret = append(ret, "") } return ret } func DisplayFormatText(m *TuiModel) string { cpy := make([]string, len(m.Format.EditSlices)) for i, v := range m.Format.EditSlices { cpy[i] = *v } newY := "" line := &cpy[Min(m.Format.CursorY, len(cpy)-1)] x := 0 offset := FormatModeOffset - 1 for _, r := range *line { newY += string(r) if x == m.Format.CursorX+offset { x++ break } x++ } *line += " " // space at the end highlight := string((*line)[x]) if tuiutil.Ascii { highlight = "|" + highlight newY += highlight } else { newY += lipgloss.NewStyle().Background(lipgloss.Color("#ffffff")).Render(highlight) } newY += (*line)[x+1:] *line = newY ret := strings.Join( cpy, "\n") return wordwrap.String(ret, m.Viewport.Width) } func ShowClipboard(m *TuiModel) string { return m.ClipboardList.View() } // DisplaySelection does that or writes it to a file if the selection is over a limit func DisplaySelection(m *TuiModel) string { col := m.GetColumnData() row := m.GetRow() m.UI.ExpandColumn = m.GetColumn() if m.MouseData.Y >= m.Viewport.Height+HeaderHeight && !m.UI.RenderSelection { // this is for when the selection is outside the bounds return DisplayTable(m) } base := m.GetBaseStyle() if m.Data().EditTextBuffer != "" { // this is basically just if its a string follow these rules conv := m.Data().EditTextBuffer if c, err := FormatJson(m.Data().EditTextBuffer); err == nil { conv = c } rows := SplitLines(wordwrap.String(conv, m.Viewport.Width)) min := 0 if len(rows) > m.Viewport.Height { min = m.Viewport.YOffset } max := min + m.Viewport.Height rows = rows[min:Min(len(rows), max)] for len(rows) < m.Viewport.Height { rows = append(rows, "") } return base.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) } var prettyPrint string raw := col[row] if conv, ok := raw.(int64); ok { prettyPrint = strconv.Itoa(int(conv)) } else if i, ok := raw.(float64); ok { prettyPrint = base.Render(fmt.Sprintf("%.2f", i)) } else if t, ok := raw.(time.Time); ok { str := t.String() prettyPrint = base.Render(str) } else if raw == nil { prettyPrint = base.Render("NULL") } lines := SplitLines(prettyPrint) for len(lines) < m.Viewport.Height { lines = append(lines, "") } prettyPrint = " " + base.Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) return wordwrap.String(prettyPrint, m.Viewport.Width) } ================================================ FILE: viewer/util.go ================================================ package viewer import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "hash/fnv" "io" "math/rand" "os" "path/filepath" "strconv" "strings" "time" "github.com/charmbracelet/lipgloss" ) const ( HiddenTmpDirectoryName = ".termdbms" SQLSnippetsFile = "snippets.termdbms" ) func TruncateIfApplicable(m *TuiModel, conv string) (s string) { max := 0 viewportWidth := m.Viewport.Width cellWidth := m.CellWidth() if m.UI.RenderSelection || m.UI.ExpandColumn > -1 { max = viewportWidth } else { max = cellWidth } if strings.Count(conv, "\n") > 0 { conv = SplitLines(conv)[0] } textWidth := lipgloss.Width(conv) minVal := Min(textWidth, max) if max == minVal && textWidth >= max { // truncate s = conv[:minVal] s = s[:lipgloss.Width(s)-3] + "..." } else { s = conv } return s } func GetInterfaceFromString(str string, original *interface{}) interface{} { switch (*original).(type) { case bool: bVal, _ := strconv.ParseBool(str) return bVal case int64: iVal, _ := strconv.ParseInt(str, 10, 64) return iVal case int32: iVal, _ := strconv.ParseInt(str, 10, 64) return iVal case float64: fVal, _ := strconv.ParseFloat(str, 64) return fVal case float32: fVal, _ := strconv.ParseFloat(str, 64) return fVal case time.Time: t := (*original).(time.Time) return t // TODO figure out how to handle things like time and date case string: return str } return nil } func GetStringRepresentationOfInterface(val interface{}) string { if str, ok := val.(string); ok { return str } else if i, ok := val.(int64); ok { // these default to int64 so not sure how this would affect 32 bit systems TODO return fmt.Sprintf("%d", i) } else if i, ok := val.(int32); ok { // these default to int32 so not sure how this would affect 32 bit systems TODO return fmt.Sprintf("%d", i) } else if i, ok := val.(float64); ok { return fmt.Sprintf("%.2f", i) } else if i, ok := val.(float32); ok { return fmt.Sprintf("%.2f", i) } else if t, ok := val.(time.Time); ok { str := t.String() return str } else if val == nil { return "NULL" } return "" } func WriteCSV(m *TuiModel) { // basically display table but without any styling if m.QueryData == nil || m.QueryResult == nil { return // should never happen but just making sure } var ( builder [][]string buffer strings.Builder ) d := m.Data() // go through all columns for _, columnName := range d.TableHeaders[QueryResultsTableName] { var ( rowBuilder []string ) columnValues := m.GetSchemaData()[columnName] rowBuilder = append(rowBuilder, columnName) for _, val := range columnValues { s := GetStringRepresentationOfInterface(val) // display text based on type rowBuilder = append(rowBuilder, s) } builder = append(builder, rowBuilder) } depth := len(builder[0]) headers := len(builder) for i := 0; i < depth; i++ { var r []string for x := 0; x < headers; x++ { r = append(r, builder[x][i]) } buffer.WriteString(strings.Join(r, ",")) buffer.WriteString("\n") } WriteTextFile(m, buffer.String()) } func WriteTextFile(m *TuiModel, text string) (string, error) { rand.Seed(time.Now().Unix()) fileName := m.GetSchemaName() + "_" + "renderView_" + fmt.Sprintf("%d", rand.Int()) + ".txt" e := os.WriteFile(fileName, []byte(text), 0777) return fileName, e } // IsUrl is some code I stole off stackoverflow to validate paths func IsUrl(fp string) bool { // Check if file already exists if _, err := os.Stat(fp); err == nil { return true } // Attempt to create it var d []byte if err := os.WriteFile(fp, d, 0644); err == nil { os.Remove(fp) // And delete it return true } return false } func FileExists(name string) (bool, error) { _, err := os.Stat(name) if err == nil { return false, nil } if errors.Is(err, os.ErrNotExist) { return true, nil } return true, err } func SplitLines(s string) []string { var lines []string if strings.Count(s, "\n") == 0 { return append(lines, s) } reader := strings.NewReader(s) sc := bufio.NewScanner(reader) for sc.Scan() { lines = append(lines, sc.Text()) } return lines } func GetScrollDownMaximumForSelection(m *TuiModel) int { max := 0 if m.UI.RenderSelection { conv, _ := FormatJson(m.Data().EditTextBuffer) lines := SplitLines(conv) max = len(lines) } else if m.UI.FormatModeEnabled { max = len(SplitLines(DisplayFormatText(m))) } else { return len(m.GetColumnData()) } return max } // FormatJson is some more code I stole off stackoverflow func FormatJson(str string) (string, error) { b := []byte(str) if !json.Valid(b) { // return original string if not json return str, errors.New("this is not valid JSON") } var formattedJson bytes.Buffer if err := json.Indent(&formattedJson, b, "", " "); err != nil { return "", err } return formattedJson.String(), nil } func Exists(path string) (bool, error) { _, err := os.Stat(path) if err == nil { return true, nil } if os.IsNotExist(err) { return false, nil } return false, err } func Hash(s string) uint32 { h := fnv.New32a() h.Write([]byte(s)) return h.Sum32() } func CopyFile(src string) (string, int64, error) { sourceFileStat, err := os.Stat(src) rand.Seed(time.Now().UnixNano()) dst := fmt.Sprintf(".%d", Hash(fmt.Sprintf("%s%d", src, rand.Uint64()))) if err != nil { return "", 0, err } if !sourceFileStat.Mode().IsRegular() { return "", 0, fmt.Errorf("%s is not a regular file", src) } source, err := os.Open(src) if err != nil { return "", 0, err } defer source.Close() destination, err := os.CreateTemp(HiddenTmpDirectoryName, dst) if err != nil { return "", 0, err } defer destination.Close() nBytes, err := io.Copy(destination, source) info, _ := destination.Stat() path, _ := filepath.Abs( fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, info.Name())) // platform agnostic return path, nBytes, err } // MATH YO func Min(a, b int) int { if a < b { return a } return b } func Max(a, b int) int { if a > b { return a } return b } func Abs(a int) int { if a < 0 { return a * -1 } return a } ================================================ FILE: viewer/viewer.go ================================================ package viewer import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mathaou/termdbms/list" "github.com/mathaou/termdbms/tuiutil" ) var ( HeaderHeight = 2 FooterHeight = 1 MaxInputLength int HeaderStyle lipgloss.Style FooterStyle lipgloss.Style HeaderDividerStyle lipgloss.Style InitialModel *TuiModel ) func (m *TuiModel) Data() *UIData { if m.QueryData != nil { return m.QueryData } return &m.DefaultData } func (m *TuiModel) Table() *TableState { if m.QueryResult != nil { return m.QueryResult } return &m.DefaultTable } func SetStyles() { HeaderStyle = lipgloss.NewStyle() FooterStyle = lipgloss.NewStyle() HeaderDividerStyle = lipgloss.NewStyle(). Align(lipgloss.Center) if !tuiutil.Ascii { HeaderStyle = HeaderStyle. Foreground(lipgloss.Color(tuiutil.HeaderTopForeground())) FooterStyle = FooterStyle. Foreground(lipgloss.Color(tuiutil.FooterForeground())) HeaderDividerStyle = HeaderDividerStyle. Foreground(lipgloss.Color(tuiutil.HeaderBottom())) } } // INIT UPDATE AND RENDER // Init currently doesn't do anything but necessary for interface adherence func (m TuiModel) Init() tea.Cmd { SetStyles() return nil } // Update is where all commands and whatnot get processed func (m TuiModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { var ( command tea.Cmd commands []tea.Cmd ) if !m.UI.FormatModeEnabled { m.Viewport, _ = m.Viewport.Update(message) } switch msg := message.(type) { case list.FilterMatchesMessage: m.ClipboardList, command = m.ClipboardList.Update(msg) break case tea.MouseMsg: HandleMouseEvents(&m, &msg) m.SetViewSlices() break case tea.WindowSizeMsg: event := HandleWindowSizeEvents(&m, &msg) if event != nil { commands = append(commands, event) } break case tea.KeyMsg: str := msg.String() if m.UI.ShowClipboard { HandleClipboardEvents(&m, str, &command, msg) break } // when fullscreen selection viewing is in session, don't allow UI manipulation other than quit or exit s := msg.String() invalidRenderCommand := m.UI.RenderSelection && s != "esc" && s != "ctrl+c" && s != "q" && s != "p" && s != "m" && s != "n" if invalidRenderCommand { break } if s == "ctrl+c" || (s == "q" && (!m.UI.EditModeEnabled && !m.UI.FormatModeEnabled)) { return m, tea.Quit } event := HandleKeyboardEvents(&m, &msg) if event != nil { commands = append(commands, event) } if !m.UI.EditModeEnabled && m.Ready { m.SetViewSlices() if m.UI.FormatModeEnabled { MoveCursorWithinBounds(&m) } } break case error: return m, nil } if m.Viewport.HighPerformanceRendering { commands = append(commands, command) } return m, tea.Batch(commands...) } // View is where all rendering happens func (m TuiModel) View() string { if !m.Ready || m.Viewport.Width == 0 { return "\n\tInitializing..." } // this ensures that all 3 parts can be worked on concurrently(ish) done := make(chan bool, 3) defer close(done) // close var footer, header, content string // body go func(c *string) { *c = AssembleTable(&m) done <- true }(&content) if m.UI.ShowClipboard { <-done return content } // header go HeaderAssembly(&m, &header, &done) // footer (shows row/col for now) go FooterAssembly(&m, &footer, &done) // block until all 3 done <-done <-done <-done return fmt.Sprintf("%s\n%s\n%s", header, content, footer) // render }