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