Full Code of mathaou/sqlite-tui for AI

master be6f39719607 cached
32 files
168.2 KB
51.4k tokens
292 symbols
1 requests
Download .txt
Repository: mathaou/sqlite-tui
Branch: master
Commit: be6f39719607
Files: 32
Total size: 168.2 KB

Directory structure:
gitextract_ax31ssze/

├── .github/
│   └── workflows/
│       └── codeql-analysis.yml
├── .gitignore
├── CHANGELOG.txt
├── LICENSE
├── README.md
├── database/
│   ├── query.go
│   └── sqlite.go
├── go.mod
├── go.sum
├── list/
│   ├── defaultitem.go
│   ├── keys.go
│   ├── list.go
│   └── style.go
├── main.go
├── tuiutil/
│   ├── csv2sql.go
│   ├── textinput.go
│   ├── theme.go
│   └── wordwrap.go
└── viewer/
    ├── defs.go
    ├── events.go
    ├── global.go
    ├── help.go
    ├── lineedit.go
    ├── mode.go
    ├── modelutil.go
    ├── serialize.go
    ├── snippets.go
    ├── table.go
    ├── tableutil.go
    ├── ui.go
    ├── util.go
    └── viewer.go

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"

on:
  push:
    branches: [ master ]
  pull_request:
    # The branches below must be a subset of the branches above
    branches: [ master ]
  schedule:
    - cron: '38 18 * * 1'

jobs:
  analyze:
    name: Analyze
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: read
      security-events: write

    strategy:
      fail-fast: false
      matrix:
        language: [ 'go' ]
        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
        # Learn more:
        # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed

    steps:
    - name: Checkout repository
      uses: actions/checkout@v2

    # Initializes the CodeQL tools for scanning.
    - name: Initialize CodeQL
      uses: github/codeql-action/init@v1
      with:
        languages: ${{ matrix.language }}
        # If you wish to specify custom queries, you can do so here or in a config file.
        # By default, queries listed here will override any specified in a config file.
        # Prefix the list here with "+" to use these queries and those in the config file.
        # queries: ./path/to/local/query, your-org/your-repo/queries@main

    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
    # If this step fails, then you should remove it and run the build manually (see below)
    - name: Autobuild
      uses: github/codeql-action/autobuild@v1

    # ℹ️ Command-line programs to run using the OS shell.
    # 📚 https://git.io/JvXDl

    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
    #    and modify them (or add more) to build your code if your project
    #    uses a compiled language

    #- run: |
    #   make bootstrap
    #   make release

    - name: Perform CodeQL Analysis
      uses: github/codeql-action/analyze@v1


================================================
FILE: .gitignore
================================================
*.sh
.idea/*
.termdbms
build/

================================================
FILE: CHANGELOG.txt
================================================
## [Unreleased]
 - MYSQL Support
 - Database creation tools

##[1.0-alpha]
### Added
 - Added ability to remove snippets
 - Line wrapping + horizontal scroll!

### Changed
 - Unified help text display logic with selection display logic

##[0.7-alpha] - 10-4-2021
### Added
 - SQL querying
 - Clipboard for SQL queries

### Changed
 - Refactored codebase to be easier to reason with and manage
 - Many bug fixes I forgot about

 This changelog was started with 0.7-alpha, so the details before this are a little hazy. But here's to better transparency going forward!

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2021 Matt Farstad

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

================================================
FILE: README.md
================================================
# termdbms

## A TUI for viewing and editing databases, written in pure Go

#### Installation Instructions

###### Go Install

```go
go install github.com/mathaou/termdbms@latest
```

###### Arch Linux

```bash
// pacman
sudo pacman -S termdbms-git
// yay
yay -S termdbms-git
```

---

###### Database Support
    SQLite
    CSV* (see note below)
### made with modernc.org/sqlite, charmbracelet/bubbletea, and charmbracelet/lipgloss

#### Works with keyboard and mouse!

![Keyboard Control](https://i.imgur.com/vmK0DVn.gif)

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

![Columns and Tables](https://i.imgur.com/EqZRPqO.gif)

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

![Lot of Rows](https://i.imgur.com/yo7DMaa.gif)

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

![Serialization](https://i.imgur.com/GhMcnid.gif)

#### Query your database!

![querying](https://i.imgur.com/9FB3ETs.gif)

#### Other Features

- Run SQL queries and display the results!
- Save SQL queries to a clipboard!
- Update, delete, or insert with SQL, with undo/redo supported for SQLite
- Automatic JSON formatting in selection/format mode
- Edit multi-line text with vim-like controls
- Undo/Redo of changes (SQLite only)
- Themes (press T in table mode)
- Output query results as a csv
- Convert .csv to SQLite database! Export as a SQLite database or .csv file again!

#### Roadmap

- MySQL/ PostgreSQL support
- Line wrapping / horizontal scroll for format/SQL mode + revamped (faster format mode)

#### 
<details>
    <summary>How To Build</summary>

##### 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

</details>

#### 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 <NAME>] 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 <simon@wiremoons.com>
	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 <NAME>] 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
}
Download .txt
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
Download .txt
SYMBOL INDEX (292 symbols across 25 files)

FILE: database/query.go
  function init (line 15) | func init() {
  type Query (line 21) | type Query interface
  type Database (line 26) | type Database interface
  type Update (line 37) | type Update struct
    method GetValues (line 44) | func (u *Update) GetValues() map[string]interface{} {
    method SetValues (line 48) | func (u *Update) SetValues(v map[string]interface{}) {
  function GetDatabaseForFile (line 53) | func GetDatabaseForFile(database string) *sql.DB {
  function ProcessSqlQueryForDatabaseType (line 67) | func ProcessSqlQueryForDatabaseType(q Query, rowData map[string]interfac...

FILE: database/sqlite.go
  type SQLite (line 10) | type SQLite struct
    method Update (line 15) | func (db *SQLite) Update(q *Update) {
    method GetFileName (line 49) | func (db *SQLite) GetFileName() string {
    method GetDatabaseReference (line 53) | func (db *SQLite) GetDatabaseReference() *sql.DB {
    method CloseDatabaseReference (line 57) | func (db *SQLite) CloseDatabaseReference() {
    method SetDatabaseReference (line 62) | func (db *SQLite) SetDatabaseReference(dbPath string) {
    method GetPlaceholderForDatabaseType (line 68) | func (db SQLite) GetPlaceholderForDatabaseType() string {
    method GetTableNamesQuery (line 72) | func (db SQLite) GetTableNamesQuery() string {
    method GenerateQuery (line 80) | func (db *SQLite) GenerateQuery(u *Update) (string, []string) {

FILE: list/defaultitem.go
  type DefaultItemStyles (line 15) | type DefaultItemStyles struct
  function NewDefaultItemStyles (line 34) | func NewDefaultItemStyles() (s DefaultItemStyles) {
  type DefaultItem (line 64) | type DefaultItem interface
  type DefaultDelegate (line 83) | type DefaultDelegate struct
    method Height (line 102) | func (d DefaultDelegate) Height() int {
    method SetSpacing (line 110) | func (d *DefaultDelegate) SetSpacing(i int) {
    method Spacing (line 115) | func (d DefaultDelegate) Spacing() int {
    method Update (line 120) | func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd {
    method Render (line 128) | func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item ...
    method ShortHelp (line 192) | func (d DefaultDelegate) ShortHelp() []key.Binding {
    method FullHelp (line 200) | func (d DefaultDelegate) FullHelp() [][]key.Binding {
  function NewDefaultDelegate (line 93) | func NewDefaultDelegate() DefaultDelegate {

FILE: list/keys.go
  type KeyMap (line 7) | type KeyMap struct
  function DefaultKeyMap (line 35) | func DefaultKeyMap() KeyMap {

FILE: list/list.go
  type Item (line 26) | type Item interface
  type ItemDelegate (line 38) | type ItemDelegate interface
  type filteredItem (line 55) | type filteredItem struct
  type filteredItems (line 60) | type filteredItems
    method items (line 62) | func (f filteredItems) items() []Item {
    method matches (line 70) | func (f filteredItems) matches() [][]int {
  type FilterMatchesMessage (line 78) | type FilterMatchesMessage
  type statusMessageTimeoutMsg (line 80) | type statusMessageTimeoutMsg struct
  type FilterState (line 83) | type FilterState
    method String (line 93) | func (f FilterState) String() string {
  constant Unfiltered (line 87) | Unfiltered    FilterState = iota
  constant Filtering (line 88) | Filtering
  constant FilterApplied (line 89) | FilterApplied
  type Model (line 102) | type Model struct
    method SetFilteringEnabled (line 201) | func (m *Model) SetFilteringEnabled(v bool) {
    method FilteringEnabled (line 210) | func (m Model) FilteringEnabled() bool {
    method SetShowTitle (line 215) | func (m *Model) SetShowTitle(v bool) {
    method ShowTitle (line 221) | func (m Model) ShowTitle() bool {
    method SetShowFilter (line 231) | func (m *Model) SetShowFilter(v bool) {
    method ShowFilter (line 240) | func (m Model) ShowFilter() bool {
    method SetShowStatusBar (line 246) | func (m *Model) SetShowStatusBar(v bool) {
    method ShowStatusBar (line 252) | func (m Model) ShowStatusBar() bool {
    method SetShowPagination (line 258) | func (m *Model) SetShowPagination(v bool) {
    method ShowPagination (line 264) | func (m *Model) ShowPagination() bool {
    method SetShowHelp (line 269) | func (m *Model) SetShowHelp(v bool) {
    method ShowHelp (line 275) | func (m Model) ShowHelp() bool {
    method Items (line 280) | func (m Model) Items() []Item {
    method SetItems (line 285) | func (m *Model) SetItems(i []Item) tea.Cmd {
    method Select (line 299) | func (m *Model) Select(index int) {
    method ResetSelected (line 305) | func (m *Model) ResetSelected() {
    method ResetFilter (line 310) | func (m *Model) ResetFilter() {
    method SetItem (line 315) | func (m *Model) SetItem(index int, item Item) tea.Cmd {
    method InsertItem (line 328) | func (m *Model) InsertItem(index int, item Item) tea.Cmd {
    method RemoveItem (line 343) | func (m *Model) RemoveItem(index int) {
    method SetDelegate (line 355) | func (m *Model) SetDelegate(d ItemDelegate) {
    method VisibleItems (line 361) | func (m Model) VisibleItems() []Item {
    method SelectedItem (line 369) | func (m Model) SelectedItem() Item {
    method MatchesForItem (line 384) | func (m Model) MatchesForItem(index int) []int {
    method Index (line 393) | func (m Model) Index() int {
    method Cursor (line 398) | func (m Model) Cursor() int {
    method CursorUp (line 404) | func (m *Model) CursorUp() {
    method CursorDown (line 425) | func (m *Model) CursorDown() {
    method PrevPage (line 454) | func (m Model) PrevPage() {
    method NextPage (line 459) | func (m Model) NextPage() {
    method FilterState (line 464) | func (m Model) FilterState() FilterState {
    method FilterValue (line 469) | func (m Model) FilterValue() string {
    method SettingFilter (line 480) | func (m Model) SettingFilter() bool {
    method Width (line 485) | func (m Model) Width() int {
    method Height (line 490) | func (m Model) Height() int {
    method SetSpinner (line 495) | func (m *Model) SetSpinner(spinner spinner.Spinner) {
    method ToggleSpinner (line 500) | func (m *Model) ToggleSpinner() tea.Cmd {
    method StartSpinner (line 509) | func (m *Model) StartSpinner() tea.Cmd {
    method StopSpinner (line 515) | func (m *Model) StopSpinner() {
    method DisableQuitKeybindings (line 521) | func (m *Model) DisableQuitKeybindings() {
    method NewStatusMessage (line 528) | func (m *Model) NewStatusMessage(s string) tea.Cmd {
    method SetSize (line 544) | func (m *Model) SetSize(width, height int) {
    method SetWidth (line 549) | func (m *Model) SetWidth(v int) {
    method SetHeight (line 554) | func (m *Model) SetHeight(v int) {
    method setSize (line 558) | func (m *Model) setSize(width, height int) {
    method resetFiltering (line 568) | func (m *Model) resetFiltering() {
    method itemsAsFilterItems (line 580) | func (m Model) itemsAsFilterItems() filteredItems {
    method updateKeybindings (line 592) | func (m *Model) updateKeybindings() {
    method updatePagination (line 641) | func (m *Model) updatePagination() {
    method hideStatusMessage (line 676) | func (m *Model) hideStatusMessage() {
    method Update (line 684) | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
    method handleBrowsing (line 722) | func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd {
    method handleFiltering (line 809) | func (m *Model) handleFiltering(msg tea.Msg) tea.Cmd {
    method ShortHelp (line 865) | func (m Model) ShortHelp() []key.Binding {
    method FullHelp (line 901) | func (m Model) FullHelp() [][]key.Binding {
    method View (line 942) | func (m Model) View() string {
    method titleView (line 986) | func (m Model) titleView() string {
    method statusView (line 1031) | func (m Model) statusView() string {
    method paginationView (line 1074) | func (m Model) paginationView() string {
    method populatedView (line 1096) | func (m Model) populatedView() string {
    method helpView (line 1137) | func (m Model) helpView() string {
    method spinnerView (line 1141) | func (m Model) spinnerView() string {
  function NewModel (line 153) | func NewModel(items []Item, delegate ItemDelegate, width, height int) Mo...
  function filterItems (line 1145) | func filterItems(m Model) tea.Cmd {
  function insertItemIntoSlice (line 1173) | func insertItemIntoSlice(items []Item, item Item, index int) []Item {
  function removeItemFromSlice (line 1190) | func removeItemFromSlice(i []Item, index int) []Item {
  function removeFilterMatchFromSlice (line 1199) | func removeFilterMatchFromSlice(i []filteredItem, index int) []filteredI...
  function countEnabledBindings (line 1208) | func countEnabledBindings(groups [][]key.Binding) (agg int) {
  function max (line 1219) | func max(a, b int) int {

FILE: list/style.go
  constant bullet (line 9) | bullet   = "•"
  constant ellipsis (line 10) | ellipsis = "…"
  type Styles (line 15) | type Styles struct
  function DefaultStyles (line 45) | func DefaultStyles() (s Styles) {

FILE: main.go
  type DatabaseType (line 23) | type DatabaseType
  constant debugPath (line 26) | debugPath = ""
  constant DatabaseSQLite (line 30) | DatabaseSQLite DatabaseType = "sqlite"
  constant DatabaseMySQL (line 31) | DatabaseMySQL  DatabaseType = "mysql"
  function main (line 43) | func main() {
  function handleFlags (line 162) | func handleFlags() {

FILE: tuiutil/csv2sql.go
  function SQLFileName (line 35) | func SQLFileName(csvFileName string) string {
  function Convert (line 48) | func Convert(csvFileName, tableName string, keepOrigCols bool) string {
  function cleanHeader (line 176) | func cleanHeader(headField string) string {

FILE: tuiutil/textinput.go
  constant DefaultBlinkSpeed (line 16) | DefaultBlinkSpeed = time.Millisecond * 530
  function nextID (line 27) | func nextID() int {
  type initialBlinkMsg (line 35) | type initialBlinkMsg struct
  type blinkMsg (line 39) | type blinkMsg struct
  type blinkCanceled (line 45) | type blinkCanceled struct
  type pasteMsg (line 48) | type pasteMsg
  type pasteErrMsg (line 49) | type pasteErrMsg struct
  type EchoMode (line 52) | type EchoMode
  constant EchoNormal (line 56) | EchoNormal EchoMode = iota
  constant EchoPassword (line 60) | EchoPassword
  constant EchoNone (line 64) | EchoNone
  type blinkCtx (line 70) | type blinkCtx struct
  type CursorMode (line 76) | type CursorMode
    method String (line 87) | func (c CursorMode) String() string {
  constant CursorBlink (line 80) | CursorBlink CursorMode = iota
  constant CursorStatic (line 81) | CursorStatic
  constant CursorHide (line 82) | CursorHide
  type TextInputModel (line 96) | type TextInputModel struct
    method SetValue (line 185) | func (m *TextInputModel) SetValue(s string) {
    method Value (line 199) | func (m TextInputModel) Value() string {
    method Cursor (line 204) | func (m TextInputModel) Cursor() int {
    method SetCursor (line 210) | func (m *TextInputModel) SetCursor(pos int) {
    method setCursor (line 217) | func (m *TextInputModel) setCursor(pos int) bool {
    method CursorStart (line 229) | func (m *TextInputModel) CursorStart() {
    method cursorStart (line 235) | func (m *TextInputModel) cursorStart() bool {
    method CursorEnd (line 240) | func (m *TextInputModel) CursorEnd() {
    method CursorMode (line 246) | func (m TextInputModel) CursorMode() CursorMode {
    method SetCursorMode (line 253) | func (m *TextInputModel) SetCursorMode(mode CursorMode) tea.Cmd {
    method cursorEnd (line 264) | func (m *TextInputModel) cursorEnd() bool {
    method Focused (line 269) | func (m TextInputModel) Focused() bool {
    method FocusCommand (line 275) | func (m *TextInputModel) FocusCommand() tea.Cmd {
    method Blur (line 287) | func (m *TextInputModel) Blur() {
    method Reset (line 294) | func (m *TextInputModel) Reset() bool {
    method handlePaste (line 301) | func (m *TextInputModel) handlePaste(v string) bool {
    method handleOverflow (line 347) | func (m *TextInputModel) handleOverflow() {
    method deleteBeforeCursor (line 392) | func (m *TextInputModel) deleteBeforeCursor() bool {
    method deleteAfterCursor (line 401) | func (m *TextInputModel) deleteAfterCursor() bool {
    method deleteWordLeft (line 408) | func (m *TextInputModel) deleteWordLeft() bool {
    method deleteWordRight (line 448) | func (m *TextInputModel) deleteWordRight() bool {
    method wordLeft (line 484) | func (m *TextInputModel) wordLeft() bool {
    method wordRight (line 519) | func (m *TextInputModel) wordRight() bool {
    method echoTransform (line 551) | func (m TextInputModel) echoTransform(v string) string {
    method Update (line 564) | func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) {
    method View (line 692) | func (m TextInputModel) View() string {
    method placeholderView (line 729) | func (m TextInputModel) placeholderView() string {
    method cursorView (line 750) | func (m TextInputModel) cursorView(v string) string {
    method blinkCmd (line 763) | func (m *TextInputModel) blinkCmd() tea.Cmd {
  function NewModel (line 157) | func NewModel() TextInputModel {
  function Blink (line 788) | func Blink() tea.Msg {
  function Paste (line 793) | func Paste() tea.Msg {
  function Clamp (line 801) | func Clamp(v, low, high int) int {
  function min (line 805) | func min(a, b int) int {
  function max (line 812) | func max(a, b int) int {

FILE: tuiutil/theme.go
  constant HighlightKey (line 4) | HighlightKey                = "Highlight"
  constant HeaderBackgroundKey (line 5) | HeaderBackgroundKey         = "HeaderBackground"
  constant HeaderBorderBackgroundKey (line 6) | HeaderBorderBackgroundKey   = "HeaderBorderBackground"
  constant HeaderForegroundKey (line 7) | HeaderForegroundKey         = "HeaderForeground"
  constant FooterForegroundColorKey (line 8) | FooterForegroundColorKey    = "FooterForeground"
  constant HeaderBottomColorKey (line 9) | HeaderBottomColorKey        = "HeaderBottom"
  constant HeaderTopForegroundColorKey (line 10) | HeaderTopForegroundColorKey = "HeaderTopForeground"
  constant BorderColorKey (line 11) | BorderColorKey              = "BorderColor"
  constant TextColorKey (line 12) | TextColorKey                = "TextColor"

FILE: tuiutil/wordwrap.go
  function Indent (line 16) | func Indent(input string, prefix string, prefixAll bool) string {

FILE: viewer/defs.go
  type SQLSnippet (line 11) | type SQLSnippet struct
  type ScrollData (line 16) | type ScrollData struct
  type TableState (line 23) | type TableState struct
  type UIState (line 28) | type UIState struct
  type UIData (line 40) | type UIData struct
  type FormatState (line 48) | type FormatState struct
  type TuiModel (line 57) | type TuiModel struct

FILE: viewer/events.go
  function HandleMouseEvents (line 17) | func HandleMouseEvents(m *TuiModel, msg *tea.MouseMsg) {
  function HandleWindowSizeEvents (line 43) | func HandleWindowSizeEvents(m *TuiModel, msg *tea.WindowSizeMsg) tea.Cmd {
  function HandleClipboardEvents (line 82) | func HandleClipboardEvents(m *TuiModel, str string, command *tea.Cmd, ms...
  function HandleKeyboardEvents (line 113) | func HandleKeyboardEvents(m *TuiModel, msg *tea.KeyMsg) tea.Cmd {

FILE: viewer/global.go
  type Command (line 13) | type Command
  function init (line 19) | func init() {

FILE: viewer/help.go
  function GetHelpText (line 3) | func GetHelpText() (help string) {

FILE: viewer/lineedit.go
  constant QueryResultsTableName (line 18) | QueryResultsTableName = "results"
  type EnterFunction (line 21) | type EnterFunction
  type LineEdit (line 23) | type LineEdit struct
  function ExitToDefaultView (line 28) | func ExitToDefaultView(m *TuiModel) {
  function CreateEmptyBuffer (line 45) | func CreateEmptyBuffer(m *TuiModel, original *interface{}) {
  function CreatePopulatedBuffer (line 55) | func CreatePopulatedBuffer(m *TuiModel, original *interface{}, str strin...
  function EditEnter (line 65) | func EditEnter(m *TuiModel) {
  function handleSQLMode (line 232) | func handleSQLMode(m *TuiModel, input string) {
  function populateUndo (line 298) | func populateUndo(m *TuiModel) (old string, new string) {

FILE: viewer/mode.go
  function PrepareFormatMode (line 24) | func PrepareFormatMode(m *TuiModel) {
  function MoveCursorWithinBounds (line 34) | func MoveCursorWithinBounds(m *TuiModel) {
  function HandleEditInput (line 49) | func HandleEditInput(m *TuiModel, str, val string) (ret bool) {
  function HandleEditMovement (line 77) | func HandleEditMovement(m *TuiModel, str, val string) (ret bool) {
  function HandleFormatMovement (line 118) | func HandleFormatMovement(m *TuiModel, str string) (ret bool) {
  function InsertCharacter (line 220) | func InsertCharacter(m *TuiModel, newlineOrTab string) {
  function HandleFormatInput (line 256) | func HandleFormatInput(m *TuiModel, str string) bool {
  function HandleFormatMode (line 305) | func HandleFormatMode(m *TuiModel, str string) {
  function HandleEditMode (line 388) | func HandleEditMode(m *TuiModel, str string) {

FILE: viewer/modelutil.go
  method WriteMessage (line 16) | func (m *TuiModel) WriteMessage(s string) {
  method CopyMap (line 25) | func (m *TuiModel) CopyMap() (to map[string]interface{}) {
  function GetNewModel (line 57) | func GetNewModel(baseFileName string, db *sql.DB) TuiModel {
  method SetModel (line 122) | func (m *TuiModel) SetModel(c *sql.Rows, db *sql.DB) error {
  method PopulateDataForResult (line 165) | func (m *TuiModel) PopulateDataForResult(c *sql.Rows, indexMap *int, sch...
  method SwapTableValues (line 200) | func (m *TuiModel) SwapTableValues(f, t *TableState) {

FILE: viewer/serialize.go
  function Serialize (line 19) | func Serialize(m *TuiModel) (string, error) {
  function SerializeOverwrite (line 28) | func SerializeOverwrite(m *TuiModel) error {
  function SerializeSQLiteDB (line 41) | func SerializeSQLiteDB(db *database.SQLite, m *TuiModel) string {
  function SerializeOverwriteSQLiteDB (line 57) | func SerializeOverwriteSQLiteDB(db *database.SQLite, m *TuiModel) {

FILE: viewer/snippets.go
  method Title (line 18) | func (s SQLSnippet) Title() string {
  method Description (line 22) | func (s SQLSnippet) Description() string {
  method FilterValue (line 26) | func (s SQLSnippet) FilterValue() string {
  type itemDelegate (line 30) | type itemDelegate struct
    method Height (line 32) | func (d itemDelegate) Height() int  { return 1 }
    method Spacing (line 33) | func (d itemDelegate) Spacing() int { return 0 }
    method Update (line 34) | func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
    method Render (line 38) | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, lis...

FILE: viewer/table.go
  type TableAssembly (line 14) | type TableAssembly
  function init (line 24) | func init() {

FILE: viewer/tableutil.go
  function AssembleTable (line 13) | func AssembleTable(m *TuiModel) string {
  method NumHeaders (line 28) | func (m *TuiModel) NumHeaders() int {
  method CellWidth (line 53) | func (m *TuiModel) CellWidth() int {
  method GetBaseStyle (line 59) | func (m *TuiModel) GetBaseStyle() lipgloss.Style {
  method GetColumn (line 76) | func (m *TuiModel) GetColumn() int {
  method GetRow (line 86) | func (m *TuiModel) GetRow() int {
  method GetSchemaName (line 97) | func (m *TuiModel) GetSchemaName() string {
  method GetHeaders (line 102) | func (m *TuiModel) GetHeaders() []string {
  method SetViewSlices (line 108) | func (m *TuiModel) SetViewSlices() {
  method GetSchemaData (line 160) | func (m *TuiModel) GetSchemaData() map[string][]interface{} {
  method GetSelectedColumnName (line 170) | func (m *TuiModel) GetSelectedColumnName() string {
  method GetColumnData (line 180) | func (m *TuiModel) GetColumnData() []interface{} {
  method GetRowData (line 188) | func (m *TuiModel) GetRowData() map[string]interface{} {
  method GetSelectedOption (line 204) | func (m *TuiModel) GetSelectedOption() (*interface{}, int, []interface{}) {
  method DisplayMessage (line 217) | func (m *TuiModel) DisplayMessage(msg string) {
  method GetSelectedLineEdit (line 223) | func (m *TuiModel) GetSelectedLineEdit() *LineEdit {
  function ToggleColumn (line 231) | func ToggleColumn(m *TuiModel) {

FILE: viewer/ui.go
  function GetOffsetForLineNumber (line 22) | func GetOffsetForLineNumber(a int) int {
  function SelectOption (line 26) | func SelectOption(m *TuiModel) {
  function ScrollDown (line 54) | func ScrollDown(m *TuiModel) {
  function ScrollUp (line 74) | func ScrollUp(m *TuiModel) {
  function DisplayTable (line 96) | func DisplayTable(m *TuiModel) string {
  function GetFormattedTextBuffer (line 144) | func GetFormattedTextBuffer(m *TuiModel) []string {
  function DisplayFormatText (line 185) | func DisplayFormatText(m *TuiModel) string {
  function ShowClipboard (line 223) | func ShowClipboard(m *TuiModel) string {
  function DisplaySelection (line 228) | func DisplaySelection(m *TuiModel) string {

FILE: viewer/util.go
  constant HiddenTmpDirectoryName (line 22) | HiddenTmpDirectoryName = ".termdbms"
  constant SQLSnippetsFile (line 23) | SQLSnippetsFile        = "snippets.termdbms"
  function TruncateIfApplicable (line 26) | func TruncateIfApplicable(m *TuiModel, conv string) (s string) {
  function GetInterfaceFromString (line 53) | func GetInterfaceFromString(str string, original *interface{}) interface...
  function GetStringRepresentationOfInterface (line 80) | func GetStringRepresentationOfInterface(val interface{}) string {
  function WriteCSV (line 101) | func WriteCSV(m *TuiModel) { // basically display table but without any ...
  function WriteTextFile (line 144) | func WriteTextFile(m *TuiModel, text string) (string, error) {
  function IsUrl (line 152) | func IsUrl(fp string) bool {
  function FileExists (line 168) | func FileExists(name string) (bool, error) {
  function SplitLines (line 179) | func SplitLines(s string) []string {
  function GetScrollDownMaximumForSelection (line 194) | func GetScrollDownMaximumForSelection(m *TuiModel) int {
  function FormatJson (line 210) | func FormatJson(str string) (string, error) {
  function Exists (line 222) | func Exists(path string) (bool, error) {
  function Hash (line 233) | func Hash(s string) uint32 {
  function CopyFile (line 239) | func CopyFile(src string) (string, int64, error) {
  function Min (line 276) | func Min(a, b int) int {
  function Max (line 284) | func Max(a, b int) int {
  function Abs (line 292) | func Abs(a int) int {

FILE: viewer/viewer.go
  method Data (line 22) | func (m *TuiModel) Data() *UIData {
  method Table (line 30) | func (m *TuiModel) Table() *TableState {
  function SetStyles (line 38) | func SetStyles() {
  method Init (line 60) | func (m TuiModel) Init() tea.Cmd {
  method Update (line 67) | func (m TuiModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
  method View (line 139) | func (m TuiModel) View() string {
Condensed preview — 32 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (189K chars).
[
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "chars": 2433,
    "preview": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# Y"
  },
  {
    "path": ".gitignore",
    "chars": 29,
    "preview": "*.sh\n.idea/*\n.termdbms\nbuild/"
  },
  {
    "path": "CHANGELOG.txt",
    "chars": 565,
    "preview": "## [Unreleased]\n - MYSQL Support\n - Database creation tools\n\n##[1.0-alpha]\n### Added\n - Added ability to remove snippets"
  },
  {
    "path": "LICENSE",
    "chars": 1068,
    "preview": "MIT License\n\nCopyright (c) 2021 Matt Farstad\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "README.md",
    "chars": 5814,
    "preview": "# termdbms\n\n## A TUI for viewing and editing databases, written in pure Go\n\n#### Installation Instructions\n\n###### Go In"
  },
  {
    "path": "database/query.go",
    "chars": 1653,
    "preview": "package database\n\nimport (\n\t\"database/sql\"\n\t\"sync\"\n)\n\nvar (\n\tDBMutex      sync.Mutex\n\tDatabases    map[string]*sql.DB\n\tD"
  },
  {
    "path": "database/sqlite.go",
    "chars": 2172,
    "preview": "package database\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n)\n\ntype SQLite struct {\n\tFileName string\n\tDatabase *"
  },
  {
    "path": "go.mod",
    "chars": 464,
    "preview": "module github.com/mathaou/termdbms\n\ngo 1.16\n\nrequire (\n\tgithub.com/atotto/clipboard v0.1.2\n\tgithub.com/charmbracelet/bub"
  },
  {
    "path": "go.sum",
    "chars": 11730,
    "preview": "github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=\ngithub.com/atotto/clipboard v0.1.2/go"
  },
  {
    "path": "list/defaultitem.go",
    "chars": 5805,
    "preview": "package list\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/charmbracelet/bubbles/key\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t"
  },
  {
    "path": "list/keys.go",
    "chars": 2597,
    "preview": "package list\n\nimport \"github.com/charmbracelet/bubbles/key\"\n\n// KeyMap defines keybindings. It satisfies to the help.Key"
  },
  {
    "path": "list/list.go",
    "chars": 30699,
    "preview": "// Package list provides a feature-rich Bubble Tea component for browsing\n// a general purpose list of items. It feature"
  },
  {
    "path": "list/style.go",
    "chars": 2910,
    "preview": "package list\n\nimport (\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/mathaou/termdbms/tuiutil\"\n)\n\nconst (\n\tbullet   "
  },
  {
    "path": "main.go",
    "chars": 4175,
    "preview": "package main\n\nimport (\n\t\"database/sql\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"io/ioutil\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t. \"githu"
  },
  {
    "path": "tuiutil/csv2sql.go",
    "chars": 8195,
    "preview": "package tuiutil\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/csv\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n/*\n\n   csv"
  },
  {
    "path": "tuiutil/textinput.go",
    "chars": 19159,
    "preview": "package tuiutil\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/atotto/clipboard\"\n\ttea \"github."
  },
  {
    "path": "tuiutil/theme.go",
    "chars": 2730,
    "preview": "package tuiutil\n\nconst (\n\tHighlightKey                = \"Highlight\"\n\tHeaderBackgroundKey         = \"HeaderBackground\"\n\tH"
  },
  {
    "path": "tuiutil/wordwrap.go",
    "chars": 722,
    "preview": "package tuiutil\n\nimport (\n\t\"strings\"\n)\n\n// Indent a string with the given prefix at the start of either the first, or al"
  },
  {
    "path": "viewer/defs.go",
    "chars": 2101,
    "preview": "package viewer\n\nimport (\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github"
  },
  {
    "path": "viewer/events.go",
    "chars": 3562,
    "preview": "package viewer\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"githu"
  },
  {
    "path": "viewer/global.go",
    "chars": 7925,
    "preview": "package viewer\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipglos"
  },
  {
    "path": "viewer/help.go",
    "chars": 3296,
    "preview": "package viewer\n\nfunc GetHelpText() (help string) {\n\thelp = `\n##### Help:\n    -p / database path (absolute)\n    -d / spec"
  },
  {
    "path": "viewer/lineedit.go",
    "chars": 8365,
    "preview": "package viewer\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"gith"
  },
  {
    "path": "viewer/mode.go",
    "chars": 9623,
    "preview": "package viewer\n\nimport (\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\nvar (\n\tInputBlacklist = []string{\n\t\t\"a"
  },
  {
    "path": "viewer/modelutil.go",
    "chars": 5638,
    "preview": "package viewer\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubble"
  },
  {
    "path": "viewer/serialize.go",
    "chars": 1554,
    "preview": "package viewer\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/mathaou/termdbms/da"
  },
  {
    "path": "viewer/snippets.go",
    "chars": 1684,
    "preview": "package viewer\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/l"
  },
  {
    "path": "viewer/table.go",
    "chars": 3720,
    "preview": "package viewer\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet"
  },
  {
    "path": "viewer/tableutil.go",
    "chars": 5435,
    "preview": "package viewer\n\nimport (\n\t\"errors\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/mathaou/termdbms/tuiutil\"\n)\n\nvar m"
  },
  {
    "path": "viewer/ui.go",
    "chars": 6729,
    "preview": "package viewer\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/ch"
  },
  {
    "path": "viewer/util.go",
    "chars": 6135,
    "preview": "package viewer\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"hash/fnv\"\n\t\"io\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path"
  },
  {
    "path": "viewer/viewer.go",
    "chars": 3525,
    "preview": "package viewer\n\nimport (\n\t\"fmt\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github"
  }
]

About this extraction

This page contains the full source code of the mathaou/sqlite-tui GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 32 files (168.2 KB), approximately 51.4k tokens, and a symbol index with 292 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!