Repository: mathaou/sqlite-tui
Branch: master
Commit: be6f39719607
Files: 32
Total size: 168.2 KB
Directory structure:
gitextract_ax31ssze/
├── .github/
│ └── workflows/
│ └── codeql-analysis.yml
├── .gitignore
├── CHANGELOG.txt
├── LICENSE
├── README.md
├── database/
│ ├── query.go
│ └── sqlite.go
├── go.mod
├── go.sum
├── list/
│ ├── defaultitem.go
│ ├── keys.go
│ ├── list.go
│ └── style.go
├── main.go
├── tuiutil/
│ ├── csv2sql.go
│ ├── textinput.go
│ ├── theme.go
│ └── wordwrap.go
└── viewer/
├── defs.go
├── events.go
├── global.go
├── help.go
├── lineedit.go
├── mode.go
├── modelutil.go
├── serialize.go
├── snippets.go
├── table.go
├── tableutil.go
├── ui.go
├── util.go
└── viewer.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '38 18 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
================================================
FILE: .gitignore
================================================
*.sh
.idea/*
.termdbms
build/
================================================
FILE: CHANGELOG.txt
================================================
## [Unreleased]
- MYSQL Support
- Database creation tools
##[1.0-alpha]
### Added
- Added ability to remove snippets
- Line wrapping + horizontal scroll!
### Changed
- Unified help text display logic with selection display logic
##[0.7-alpha] - 10-4-2021
### Added
- SQL querying
- Clipboard for SQL queries
### Changed
- Refactored codebase to be easier to reason with and manage
- Many bug fixes I forgot about
This changelog was started with 0.7-alpha, so the details before this are a little hazy. But here's to better transparency going forward!
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 Matt Farstad
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# termdbms
## A TUI for viewing and editing databases, written in pure Go
#### Installation Instructions
###### Go Install
```go
go install github.com/mathaou/termdbms@latest
```
###### Arch Linux
```bash
// pacman
sudo pacman -S termdbms-git
// yay
yay -S termdbms-git
```
---
###### Database Support
SQLite
CSV* (see note below)
### made with modernc.org/sqlite, charmbracelet/bubbletea, and charmbracelet/lipgloss
#### Works with keyboard and mouse!

#### Navigate tables with any number of columns!

#### Navigate tables with any number of rows!

#### Serialize your changes as a copy or over the database original! (SQLite only)

#### Query your database!

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