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