0
0
forked from toolshed/abra

Compare commits

...

1 Commits

Author SHA1 Message Date
4ef849767f WIP 2025-11-02 14:13:40 +01:00
50 changed files with 942 additions and 5553 deletions

3
go.mod
View File

@ -16,7 +16,6 @@ require (
github.com/docker/cli v28.4.0+incompatible
github.com/docker/docker v28.4.0+incompatible
github.com/docker/go-units v0.5.0
github.com/evertras/bubble-table v0.19.2
github.com/go-git/go-git/v5 v5.16.2
github.com/google/go-cmp v0.7.0
github.com/leonelquinteros/gotext v1.7.2
@ -35,7 +34,6 @@ require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@ -94,7 +92,6 @@ require (
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect

9
go.sum
View File

@ -99,8 +99,6 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
@ -373,8 +371,6 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evertras/bubble-table v0.19.2 h1:u77oiM6JlRR+CvS5FZc3Hz+J6iEsvEDcR5kO8OFb1Yw=
github.com/evertras/bubble-table v0.19.2/go.mod h1:ifHujS1YxwnYSOgcR2+m3GnJ84f7CVU/4kUOxUCjEbQ=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@ -640,7 +636,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
@ -702,8 +697,6 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
@ -818,8 +811,6 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=

View File

@ -7,7 +7,7 @@
msgid ""
msgstr "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2025-11-02 11:41+0100\n"
"POT-Creation-Date: 2025-11-02 14:13+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -469,7 +469,7 @@ msgstr ""
msgid "%s: %s (new)"
msgstr ""
#: ./pkg/ui/deploy.go:344
#: ./pkg/ui/deploy.go:406
#, c-format
msgid "%s: %s (retries: %v, healthcheck: %s)"
msgstr ""
@ -1659,7 +1659,7 @@ msgid "\n"
"lint %s: %s"
msgstr ""
#: ./pkg/ui/deploy.go:121
#: ./pkg/ui/deploy.go:132
#, c-format
msgid "^%s"
msgstr ""
@ -2662,7 +2662,7 @@ msgstr ""
msgid "env file for %s has issues: %s"
msgstr ""
#: ./pkg/ui/deploy.go:83
#: ./pkg/ui/deploy.go:94
#, c-format
msgid "err: %v, "
msgstr ""
@ -3166,7 +3166,7 @@ msgstr ""
msgid "i"
msgstr ""
#: ./pkg/ui/deploy.go:84
#: ./pkg/ui/deploy.go:95
#, c-format
msgid "id: %s, "
msgstr ""
@ -3605,7 +3605,7 @@ msgstr ""
msgid "name a servce 'app'"
msgstr ""
#: ./pkg/ui/deploy.go:85
#: ./pkg/ui/deploy.go:96
#, c-format
msgid "name: %s, "
msgstr ""
@ -4147,7 +4147,7 @@ msgstr ""
msgid "read v:%s k: %s"
msgstr ""
#: ./pkg/ui/deploy.go:86
#: ./pkg/ui/deploy.go:97
#, c-format
msgid "reader: %v, "
msgstr ""
@ -4475,7 +4475,7 @@ msgstr ""
msgid "rollback <domain> [version] [flags]"
msgstr ""
#: ./pkg/ui/deploy.go:336
#: ./pkg/ui/deploy.go:398
msgid "rolled back"
msgstr ""
@ -4890,7 +4890,7 @@ msgstr ""
msgid "status"
msgstr ""
#: ./pkg/ui/deploy.go:88
#: ./pkg/ui/deploy.go:99
#, c-format
msgid "status: %s}"
msgstr ""
@ -4912,7 +4912,7 @@ msgstr ""
msgid "stripped %s to %s for parsing"
msgstr ""
#: ./pkg/ui/deploy.go:333
#: ./pkg/ui/deploy.go:395
msgid "succeeded"
msgstr ""
@ -5730,7 +5730,7 @@ msgstr ""
msgid "wire up healthchecks"
msgstr ""
#: ./pkg/ui/deploy.go:87
#: ./pkg/ui/deploy.go:98
#, c-format
msgid "writer: %v, "
msgstr ""
@ -5764,7 +5764,7 @@ msgstr ""
msgid "z"
msgstr ""
#: ./pkg/ui/deploy.go:82
#: ./pkg/ui/deploy.go:93
#, c-format
msgid "{decoder: %v, "
msgstr ""

View File

@ -2,10 +2,11 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2025-11-02 11:41+0100\n"
"POT-Creation-Date: 2025-11-02 14:13+0100\n"
"PO-Revision-Date: 2025-09-04 08:14+0000\n"
"Last-Translator: chasqui <chasqui@cryptolab.net>\n"
"Language-Team: Spanish <https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/>\n"
"Language-Team: Spanish <https://translate.coopcloud.tech/projects/co-op-"
"cloud/abra/es/>\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@ -297,7 +298,9 @@ msgstr ""
#: pkg/catalogue/catalogue.go:40
#, c-format
msgid "%s has locally unstaged changes? please commit/remove your changes before proceeding"
msgid ""
"%s has locally unstaged changes? please commit/remove your changes before "
"proceeding"
msgstr ""
#: cli/internal/recipe.go:114
@ -419,7 +422,9 @@ msgstr ""
#: cli/app/new.go:210
#, c-format
msgid "%s requires secret generation before deploy, run \"abra app secret generate %s --all\""
msgid ""
"%s requires secret generation before deploy, run \"abra app secret generate "
"%s --all\""
msgstr ""
#: cli/app/new.go:214
@ -482,7 +487,7 @@ msgstr ""
msgid "%s: %s (new)"
msgstr ""
#: pkg/ui/deploy.go:344
#: pkg/ui/deploy.go:406
#, c-format
msgid "%s: %s (retries: %v, healthcheck: %s)"
msgstr ""
@ -573,7 +578,9 @@ msgid "'%s' is not a known version for %s"
msgstr ""
#: cli/app/volume.go:175
msgid "'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode is enabled"
msgid ""
"'x' indicates selected, enter / return to confirm, ctrl-c to exit, vim mode "
"is enabled"
msgstr ""
#: cli/app/cmd.go:125
@ -593,12 +600,15 @@ msgstr ""
msgid ""
"A recipe is a blueprint for an app.\n"
"\n"
"It is a bunch of config files which describe how to deploy and maintain an app.\n"
"It is a bunch of config files which describe how to deploy and maintain an "
"app.\n"
"Recipes are maintained by the Co-op Cloud community and you can use Abra to\n"
"read them, deploy them and create apps for you.\n"
"\n"
"Anyone who uses a recipe can become a maintainer. Maintainers typically make\n"
"sure the recipe is in good working order and the config upgraded in a timely\n"
"Anyone who uses a recipe can become a maintainer. Maintainers typically "
"make\n"
"sure the recipe is in good working order and the config upgraded in a "
"timely\n"
"manner."
msgstr ""
@ -617,7 +627,8 @@ msgid ""
"Add a new server to your configuration so that it can be managed by Abra.\n"
"\n"
"Abra relies on the standard SSH command-line and ~/.ssh/config for client\n"
"connection details. You must configure an entry per-host in your ~/.ssh/config\n"
"connection details. You must configure an entry per-host in your ~/.ssh/"
"config\n"
"for each server:\n"
"\n"
" Host 1312.net 1312\n"
@ -626,8 +637,10 @@ msgid ""
" Port 12345\n"
" IdentityFile ~/.ssh/antifa@somewhere\n"
"\n"
"If \"--local\" is passed, then Abra assumes that the current local server is\n"
"intended as the target server. This is useful when you want to have your entire\n"
"If \"--local\" is passed, then Abra assumes that the current local server "
"is\n"
"intended as the target server. This is useful when you want to have your "
"entire\n"
"Co-op Cloud config located on the server itself, and not on your local\n"
"developer machine. The domain is then set to \"default\"."
msgstr ""
@ -698,12 +711,16 @@ msgstr "💕 Clona la receta(s) 🧑‍🍳 en local"
msgid ""
"Compare env vars in both the app \".env\" and recipe \".env.sample\" file.\n"
"\n"
"The goal is to ensure that recipe \".env.sample\" env vars are defined in your\n"
"app \".env\" file. Only env var definitions in the \".env.sample\" which are\n"
"uncommented, e.g. \"FOO=bar\" are checked. If an app \".env\" file does not include\n"
"The goal is to ensure that recipe \".env.sample\" env vars are defined in "
"your\n"
"app \".env\" file. Only env var definitions in the \".env.sample\" which "
"are\n"
"uncommented, e.g. \"FOO=bar\" are checked. If an app \".env\" file does not "
"include\n"
"these env vars, then \"check\" will complain.\n"
"\n"
"Recipe maintainers may or may not provide defaults for env vars within their\n"
"Recipe maintainers may or may not provide defaults for env vars within "
"their\n"
"recipes regardless of commenting or not (e.g. through the use of\n"
"${FOO:<default>} syntax). \"check\" does not confirm or deny this for you."
msgstr ""
@ -732,24 +749,31 @@ msgstr "📸 Crea una nueva captura o instantánea"
msgid ""
"Create a new version of a recipe.\n"
"\n"
"These versions are then published on the Co-op Cloud recipe catalogue. These\n"
"These versions are then published on the Co-op Cloud recipe catalogue. "
"These\n"
"versions take the following form:\n"
"\n"
" a.b.c+x.y.z\n"
"\n"
"Where the \"a.b.c\" part is a semantic version determined by the maintainer. The\n"
"\"x.y.z\" part is the image tag of the recipe \"app\" service (the main container\n"
"Where the \"a.b.c\" part is a semantic version determined by the maintainer. "
"The\n"
"\"x.y.z\" part is the image tag of the recipe \"app\" service (the main "
"container\n"
"which contains the software to be used, by naming convention).\n"
"\n"
"We maintain a semantic versioning scheme (\"a.b.c\") alongside the recipe\n"
"versioning scheme (\"x.y.z\") in order to maximise the chances that the nature of\n"
"versioning scheme (\"x.y.z\") in order to maximise the chances that the "
"nature of\n"
"recipe updates are properly communicated. I.e. developers of an app might\n"
"publish a minor version but that might lead to changes in the recipe which are\n"
"publish a minor version but that might lead to changes in the recipe which "
"are\n"
"major and therefore require intervention while doing the upgrade work.\n"
"\n"
"Publish your new release to git.coopcloud.tech with \"--publish/-p\". This\n"
"requires that you have permission to git push to these repositories and have\n"
"your SSH keys configured on your account. Enable ssh-agent and make sure to add\n"
"requires that you have permission to git push to these repositories and "
"have\n"
"your SSH keys configured on your account. Enable ssh-agent and make sure to "
"add\n"
"your private key and enter your passphrase beforehand.\n"
"\n"
" eval `ssh-agent`\n"
@ -767,21 +791,27 @@ msgid ""
"This new app configuration is stored in your $ABRA_DIR directory under the\n"
"appropriate server.\n"
"\n"
"This command does not deploy your app for you. You will need to run \"abra app\n"
"This command does not deploy your app for you. You will need to run \"abra "
"app\n"
"deploy <domain>\" to do so.\n"
"\n"
"You can see what recipes are available (i.e. values for the [recipe] argument)\n"
"You can see what recipes are available (i.e. values for the [recipe] "
"argument)\n"
"by running \"abra recipe ls\".\n"
"\n"
"Recipe commit hashes are supported values for \"[version]\".\n"
"\n"
"Passing the \"--secrets/-S\" flag will automatically generate secrets for your\n"
"Passing the \"--secrets/-S\" flag will automatically generate secrets for "
"your\n"
"app and store them encrypted at rest on the chosen target server. These\n"
"generated secrets are only visible at generation time, so please take care to\n"
"generated secrets are only visible at generation time, so please take care "
"to\n"
"store them somewhere safe.\n"
"\n"
"You can use the \"--pass/-P\" to store these generated passwords locally in a\n"
"pass store (see passwordstore.org for more). The pass command must be available\n"
"You can use the \"--pass/-P\" to store these generated passwords locally in "
"a\n"
"pass store (see passwordstore.org for more). The pass command must be "
"available\n"
"on your $PATH."
msgstr ""
@ -815,9 +845,11 @@ msgstr "📤 Despliega una plataforma 🚀"
msgid ""
"Deploy an app.\n"
"\n"
"This command supports chaos operations. Use \"--chaos/-C\" to deploy your recipe\n"
"This command supports chaos operations. Use \"--chaos/-C\" to deploy your "
"recipe\n"
"checkout as-is. Recipe commit hashes are also supported as values for\n"
"\"[version]\". Please note, \"upgrade\"/\"rollback\" do not support chaos operations."
"\"[version]\". Please note, \"upgrade\"/\"rollback\" do not support chaos "
"operations."
msgstr ""
#. translators: Short description for `app services` command
@ -834,7 +866,8 @@ msgstr "⬇️📸 Descarga una captura o instantánea"
msgid ""
"Downloads a backup.tar.gz to the current working directory.\n"
"\n"
"\"--volumes/-v\" includes data contained in volumes alongide paths specified in\n"
"\"--volumes/-v\" includes data contained in volumes alongide paths specified "
"in\n"
"\"backupbot.backup.path\" labels."
msgstr ""
@ -873,21 +906,28 @@ msgstr ""
msgid ""
"Generate a new copy of the recipe catalogue.\n"
"\n"
"N.B. this command **will** wipe local unstaged changes from your local recipes\n"
"if present. \"--chaos/-C\" on this command refers to the catalogue repository\n"
"(\"$ABRA_DIR/catalogue\") and not the recipes. Please take care not to lose your\n"
"N.B. this command **will** wipe local unstaged changes from your local "
"recipes\n"
"if present. \"--chaos/-C\" on this command refers to the catalogue "
"repository\n"
"(\"$ABRA_DIR/catalogue\") and not the recipes. Please take care not to lose "
"your\n"
"changes.\n"
"\n"
"It is possible to generate new metadata for a single recipe by passing\n"
"[recipe]. The existing local catalogue will be updated, not overwritten.\n"
"\n"
"It is quite easy to get rate limited by Docker Hub when running this command.\n"
"If you have a Hub account you can \"docker login\" and Abra will automatically\n"
"It is quite easy to get rate limited by Docker Hub when running this "
"command.\n"
"If you have a Hub account you can \"docker login\" and Abra will "
"automatically\n"
"use those details.\n"
"\n"
"Publish your new release to git.coopcloud.tech with \"--publish/-p\". This\n"
"requires that you have permission to git push to these repositories and have\n"
"your SSH keys configured on your account. Enable ssh-agent and make sure to add\n"
"requires that you have permission to git push to these repositories and "
"have\n"
"your SSH keys configured on your account. Enable ssh-agent and make sure to "
"add\n"
"your private key and enter your passphrase beforehand.\n"
"\n"
" eval `ssh-agent`\n"
@ -1085,17 +1125,22 @@ msgstr "⚙️ Administrar catálogo 📋 de recetas 🧑‍🍳"
msgid ""
"Move an app to a differnt server.\n"
"\n"
"This command will migrate an app config and copy secrets and volumes from the\n"
"old server to the new one. The app MUST be deployed on the old server before\n"
"This command will migrate an app config and copy secrets and volumes from "
"the\n"
"old server to the new one. The app MUST be deployed on the old server "
"before\n"
"doing the move. The app will be undeployed from the current server but not\n"
"deployed on the new server.\n"
"\n"
"The \"tar\" command is required on both the old and new server as well as \"sudo\"\n"
"The \"tar\" command is required on both the old and new server as well as "
"\"sudo\"\n"
"permissions. The \"rsync\" command is required on your local machine for\n"
"transferring volumes.\n"
"\n"
"Do not forget to update your DNS records. Don't panic, it might take a while\n"
"for the dust to settle after you move an app. If anything goes wrong, you can\n"
"Do not forget to update your DNS records. Don't panic, it might take a "
"while\n"
"for the dust to settle after you move an app. If anything goes wrong, you "
"can\n"
"always move the app config file to the original server and deploy it there\n"
"again. No data is removed from the old server.\n"
"\n"
@ -1157,7 +1202,8 @@ msgstr "🧹 Limpiar recursos en un servidor (huerta digital) 🕋"
msgid ""
"Prunes unused containers, networks, and dangling images.\n"
"\n"
"Use \"--volumes/-v\" to remove volumes that are not associated with a deployed\n"
"Use \"--volumes/-v\" to remove volumes that are not associated with a "
"deployed\n"
"app. This can result in unwanted data loss if not used carefully."
msgstr ""
@ -1170,7 +1216,8 @@ msgstr ""
msgid ""
"Pull app environment values from a deploymed app.\n"
"\n"
"A convenient command for when you've lost your app environment file or want to\n"
"A convenient command for when you've lost your app environment file or want "
"to\n"
"synchronize your local app environment values with what is deployed live."
msgstr ""
@ -1201,7 +1248,8 @@ msgid ""
"Remove a managed server.\n"
"\n"
"Abra will remove the internal bookkeeping ($ABRA_DIR/servers/...) and\n"
"underlying client connection context. This server will then be lost in time,\n"
"underlying client connection context. This server will then be lost in "
"time,\n"
"like tears in rain."
msgstr ""
@ -1218,7 +1266,9 @@ msgstr "💀 Borrar todos los datos de una plataforma 🚀, local y remotamente
#. translators: Short description for `recipe reset` command
#: cli/recipe/reset.go:23
msgid "Remove all unstaged changes from recipe config"
msgstr "💀 Borra todos los cambios no actualizados ⤵️ de la configuración de la receta 🧑‍🍳"
msgstr ""
"💀 Borra todos los cambios no actualizados ⤵️ de la configuración de la "
"receta 🧑‍🍳"
#: cli/app/remove.go:29
msgid ""
@ -1227,16 +1277,19 @@ msgid ""
"By default, it will prompt for confirmation before proceeding. All secrets,\n"
"volumes and the local app env file will be deleted.\n"
"\n"
"Only run this command when you are sure you want to completely remove the app\n"
"Only run this command when you are sure you want to completely remove the "
"app\n"
"and all associated app data. This is a destructive action, Be Careful!\n"
"\n"
"If you would like to delete specific volumes or secrets, please use removal\n"
"sub-commands under \"app volume\" and \"app secret\" instead.\n"
"\n"
"Please note, if you delete the local app env file without removing volumes and\n"
"Please note, if you delete the local app env file without removing volumes "
"and\n"
"secrets first, Abra will *not* be able to help you remove them afterwards.\n"
"\n"
"To delete everything without prompt, use the \"--force/-f\" or the \"--no-input/n\"\n"
"To delete everything without prompt, use the \"--force/-f\" or the \"--no-"
"input/n\"\n"
"flag."
msgstr ""
@ -1249,11 +1302,14 @@ msgstr "💀 Borra el volúmen(es) 📦 asociados a una plataforma 🚀🚨"
msgid ""
"Remove volumes associated with an app.\n"
"\n"
"The app in question must be undeployed before you try to remove volumes. See\n"
"The app in question must be undeployed before you try to remove volumes. "
"See\n"
"\"abra app undeploy <domain>\" for more.\n"
"\n"
"The command is interactive and will show a multiple select input which allows\n"
"you to make a seclection. Use the \"?\" key to see more help on navigating this\n"
"The command is interactive and will show a multiple select input which "
"allows\n"
"you to make a seclection. Use the \"?\" key to see more help on navigating "
"this\n"
"interface.\n"
"\n"
"Passing \"--force/-f\" will select all volumes for removal. Be careful."
@ -1277,19 +1333,25 @@ msgstr "⏪ Revertir una plataforma 🚀 una versión anterior"
#. translators: Short description for `app run` command
#: cli/app/run.go:30
msgid "Run a command inside a service container"
msgstr "💻 Ejecuta un comando dentro de un contenedor 🐋 creado solo para esa tarea"
msgstr ""
"💻 Ejecuta un comando dentro de un contenedor 🐋 creado solo para esa tarea"
#: cli/app/cmd.go:31
msgid ""
"Run an app specific command.\n"
"\n"
"These commands are bash functions, defined in the abra.sh of the recipe itself.\n"
"They can be run within the context of a service (e.g. app) or locally on your\n"
"These commands are bash functions, defined in the abra.sh of the recipe "
"itself.\n"
"They can be run within the context of a service (e.g. app) or locally on "
"your\n"
"work station by passing \"--local/-l\".\n"
"\n"
"N.B. If using the \"--\" style to pass arguments, flags (e.g. \"--local/-l\") must\n"
"be passed *before* the \"--\". It is possible to pass arguments without the \"--\"\n"
"as long as no dashes are present (i.e. \"foo\" works without \"--\", \"-foo\"\n"
"N.B. If using the \"--\" style to pass arguments, flags (e.g. \"--local/-"
"l\") must\n"
"be passed *before* the \"--\". It is possible to pass arguments without the "
"\"--\"\n"
"as long as no dashes are present (i.e. \"foo\" works without \"--\", \"-"
"foo\"\n"
"does not)."
msgstr ""
@ -1357,7 +1419,8 @@ msgstr "📋 Muestra las etiquetas 🛂 desplegadas (proxy)"
#. translators: Short description for `recipe diff` command
#: cli/recipe/diff.go:23
msgid "Show unstaged changes in recipe config"
msgstr "📋 Muestra cambios sin actualizar ⤵️ en la configuración ⚙️ de la receta 🧑‍🍳"
msgstr ""
"📋 Muestra cambios sin actualizar ⤵️ en la configuración ⚙️ de la receta 🧑‍🍳"
#: cli/app/restore.go:25
msgid ""
@ -1425,8 +1488,10 @@ msgid ""
"Arbitrary secret insertion is not supported. Secrets that are inserted must\n"
"match those configured in the recipe beforehand.\n"
"\n"
"This command can be useful when you want to manually generate secrets for an app\n"
"environment. Typically, you can let Abra generate them for you on app creation\n"
"This command can be useful when you want to manually generate secrets for an "
"app\n"
"environment. Typically, you can let Abra generate them for you on app "
"creation\n"
"(see \"abra app new --secrets/-S\" for more)."
msgstr ""
@ -1455,17 +1520,22 @@ msgstr ""
msgid ""
"This command rolls an app back to a previous version.\n"
"\n"
"Unlike \"abra app deploy\", chaos operations are not supported here. Only recipe\n"
"Unlike \"abra app deploy\", chaos operations are not supported here. Only "
"recipe\n"
"versions are supported values for \"[version]\".\n"
"\n"
"It is possible to \"--force/-f\" an downgrade if you want to re-deploy a specific\n"
"It is possible to \"--force/-f\" an downgrade if you want to re-deploy a "
"specific\n"
"version.\n"
"\n"
"Only the deployed version is consulted when trying to determine what downgrades\n"
"are available. The live deployment version is the \"source of truth\" in this\n"
"Only the deployed version is consulted when trying to determine what "
"downgrades\n"
"are available. The live deployment version is the \"source of truth\" in "
"this\n"
"case. The stored .env version is not consulted.\n"
"\n"
"A downgrade can be destructive, please ensure you have a copy of your app data\n"
"A downgrade can be destructive, please ensure you have a copy of your app "
"data\n"
"beforehand. See \"abra app backup\" for more."
msgstr ""
@ -1473,7 +1543,8 @@ msgstr ""
msgid ""
"This does not destroy any application data.\n"
"\n"
"However, you should remain vigilant, as your swarm installation will consider\n"
"However, you should remain vigilant, as your swarm installation will "
"consider\n"
"any previously attached volumes as eligible for pruning once undeployed.\n"
"\n"
"Passing \"--prune/-p\" does not remove those volumes."
@ -1491,7 +1562,8 @@ msgid ""
" # Linux:\n"
" $ abra autocomplete bash | sudo tee /etc/bash_completion.d/abra\n"
" # macOS:\n"
" $ abra autocomplete bash | sudo tee $(brew --prefix)/etc/bash_completion.d/abra\n"
" $ abra autocomplete bash | sudo tee $(brew --prefix)/etc/bash_completion.d/"
"abra\n"
"\n"
"Zsh:\n"
" # If shell autocompletion is not already enabled in your environment,\n"
@ -1546,7 +1618,8 @@ msgid ""
"It will update the relevant compose file tags on the local file system.\n"
"\n"
"Some image tags cannot be parsed because they do not follow some sort of\n"
"semver-like convention. In this case, all possible tags will be listed and it\n"
"semver-like convention. In this case, all possible tags will be listed and "
"it\n"
"is up to the end-user to decide.\n"
"\n"
"The command is interactive and will show a select input which allows you to\n"
@ -1567,8 +1640,10 @@ msgid ""
"\n"
"By default, the latest stable release is downloaded.\n"
"\n"
"Use \"--rc/-r\" to install the latest release candidate. Please bear in mind that\n"
"it may contain absolutely catastrophic deal-breaker bugs. Thank you very much\n"
"Use \"--rc/-r\" to install the latest release candidate. Please bear in mind "
"that\n"
"it may contain absolutely catastrophic deal-breaker bugs. Thank you very "
"much\n"
"for the testing efforts 💗"
msgstr ""
@ -1581,17 +1656,22 @@ msgstr "📨 Actualizar una plataforma 🚀"
msgid ""
"Upgrade an app.\n"
"\n"
"Unlike \"abra app deploy\", chaos operations are not supported here. Only recipe\n"
"Unlike \"abra app deploy\", chaos operations are not supported here. Only "
"recipe\n"
"versions are supported values for \"[version]\".\n"
"\n"
"It is possible to \"--force/-f\" an upgrade if you want to re-deploy a specific\n"
"It is possible to \"--force/-f\" an upgrade if you want to re-deploy a "
"specific\n"
"version.\n"
"\n"
"Only the deployed version is consulted when trying to determine what upgrades\n"
"are available. The live deployment version is the \"source of truth\" in this\n"
"Only the deployed version is consulted when trying to determine what "
"upgrades\n"
"are available. The live deployment version is the \"source of truth\" in "
"this\n"
"case. The stored .env version is not consulted.\n"
"\n"
"An upgrade can be destructive, please ensure you have a copy of your app data\n"
"An upgrade can be destructive, please ensure you have a copy of your app "
"data\n"
"beforehand. See \"abra app backup\" for more."
msgstr ""
@ -1614,23 +1694,32 @@ msgid ""
"Examples:\n"
"{{.Example}}{{end}}{{if .HasAvailableSubCommands}}\n"
"\n"
"Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name \"help\"))}}\n"
" {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}\n"
"Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "
"\"help\"))}}\n"
" {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if ."
"HasAvailableLocalFlags}}\n"
"\n"
"Flags:\n"
"{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}\n"
"{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if ."
"HasAvailableInheritedFlags}}\n"
"\n"
"Global Flags:\n"
"{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}\n"
"{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if ."
"HasHelpSubCommands}}\n"
"\n"
"Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}\n"
" {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}\n"
"Additional help topics:{{range .Commands}}{{if ."
"IsAdditionalHelpTopicCommand}}\n"
" {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}"
"{{if .HasAvailableSubCommands}}\n"
"\n"
"Use \"{{.CommandPath}} [command] --help\" for more information about a command.{{end}}\n"
"Use \"{{.CommandPath}} [command] --help\" for more information about a "
"command.{{end}}\n"
msgstr ""
#: cli/recipe/fetch.go:28
msgid "Using \"--force/-f\" Git syncs an existing recipe. It does not erase unstaged changes."
msgid ""
"Using \"--force/-f\" Git syncs an existing recipe. It does not erase "
"unstaged changes."
msgstr ""
#: cli/app/secret.go:117
@ -1662,20 +1751,27 @@ msgstr ""
#, c-format
msgid ""
"\n"
"The following options are two types of initial semantic version that you can\n"
"pick for %s that will be published in the recipe catalogue. This follows the\n"
"The following options are two types of initial semantic version that you "
"can\n"
"pick for %s that will be published in the recipe catalogue. This follows "
"the\n"
"semver convention (more on https://semver.org), here is a short cheatsheet\n"
"\n"
" 0.1.0: development release, still hacking. when you make a major upgrade\n"
" you increment the \"y\" part (i.e. 0.1.0 -> 0.2.0) and only move to\n"
" 0.1.0: development release, still hacking. when you make a major "
"upgrade\n"
" you increment the \"y\" part (i.e. 0.1.0 -> 0.2.0) and only move "
"to\n"
" using the \"x\" part when things are stable.\n"
"\n"
" 1.0.0: public release, assumed to be working. you already have a stable\n"
" and reliable deployment of this app and feel relatively confident\n"
" and reliable deployment of this app and feel relatively "
"confident\n"
" about it.\n"
"\n"
"If you want people to be able alpha test your current config for %s but don't\n"
"think it is quite reliable, go with 0.1.0 and people will know that things are\n"
"If you want people to be able alpha test your current config for %s but "
"don't\n"
"think it is quite reliable, go with 0.1.0 and people will know that things "
"are\n"
"likely to change.\n"
"\n"
msgstr ""
@ -1686,7 +1782,8 @@ msgid ""
"\n"
"You need to make a decision about what kind of an update this new recipe\n"
"version is. If someone else performs this upgrade, do they have to do some\n"
"migration work or take care of some breaking changes? This can be signaled in\n"
"migration work or take care of some breaking changes? This can be signaled "
"in\n"
"the version you specify on the recipe deploy label and is called a semantic\n"
"version.\n"
"\n"
@ -1696,15 +1793,20 @@ msgid ""
"\n"
"Here is a semver cheat sheet (more on https://semver.org):\n"
"\n"
" major: new features/bug fixes, backwards incompatible (e.g 1.0.0 -> 2.0.0).\n"
" the upgrade won't work without some preparation work and others need\n"
" major: new features/bug fixes, backwards incompatible (e.g 1.0.0 -> "
"2.0.0).\n"
" the upgrade won't work without some preparation work and others "
"need\n"
" to take care when performing it. \"it could go wrong\".\n"
"\n"
" minor: new features/bug fixes, backwards compatible (e.g. 0.1.0 -> 0.2.0).\n"
" the upgrade should Just Work and there are no breaking changes in\n"
" minor: new features/bug fixes, backwards compatible (e.g. 0.1.0 -> "
"0.2.0).\n"
" the upgrade should Just Work and there are no breaking changes "
"in\n"
" the app and the recipe config. \"it should go fine\".\n"
"\n"
" patch: bug fixes, backwards compatible (e.g. 0.0.1 -> 0.0.2). this upgrade\n"
" patch: bug fixes, backwards compatible (e.g. 0.0.1 -> 0.0.2). this "
"upgrade\n"
" should also Just Work and is mostly to do with minor bug fixes\n"
" and/or security patches. \"nothing to worry about\".\n"
"\n"
@ -1717,7 +1819,7 @@ msgid ""
"lint %s: %s"
msgstr ""
#: pkg/ui/deploy.go:121
#: pkg/ui/deploy.go:132
#, c-format
msgid "^%s"
msgstr ""
@ -2043,14 +2145,17 @@ msgstr ""
#: pkg/recipe/git.go:52
#, c-format
msgid "cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?"
msgid ""
"cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?"
msgstr ""
#: cli/app/deploy.go:369
#, c-format
msgid ""
"cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?\n"
" to return to a regular release, specify a release tag, commit SHA or use \"--latest\""
"cannot redeploy previous chaos version (%s), did you mean to use \"--"
"chaos\"?\n"
" to return to a regular release, specify a release tag, commit SHA or "
"use \"--latest\""
msgstr ""
#: pkg/dns/dns.go:38 pkg/dns/dns.go:47
@ -2211,13 +2316,19 @@ msgstr ""
#: pkg/upstream/commandconn/commandconn.go:172
#, c-format
msgid "command %v has exited with %v, please make sure the URL is valid, and Docker 18.09 or later is installed on the remote host: stderr=%s"
msgid ""
"command %v has exited with %v, please make sure the URL is valid, and Docker "
"18.09 or later is installed on the remote host: stderr=%s"
msgstr ""
#. translators: `app command` command
#: cli/app/cmd.go:27
msgid "command <domain> [service | --local] <cmd> [[args] [flags] | [flags] -- [args]]"
msgstr "ejecutar <domain> [service | --local] <cmd> [[args] [flags] | [flags] -- [args]]"
msgid ""
"command <domain> [service | --local] <cmd> [[args] [flags] | [flags] -- "
"[args]]"
msgstr ""
"ejecutar <domain> [service | --local] <cmd> [[args] [flags] | [flags] -- "
"[args]]"
#: pkg/upstream/commandconn/commandconn.go:239
#, c-format
@ -2718,7 +2829,9 @@ msgid "ensuring env version %s"
msgstr ""
#: cli/recipe/upgrade.go:286
msgid "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled"
msgid ""
"enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode "
"is enabled"
msgstr ""
#. translators: `app env` command group
@ -2737,7 +2850,7 @@ msgstr ""
msgid "env file for %s has issues: %s"
msgstr ""
#: pkg/ui/deploy.go:83
#: pkg/ui/deploy.go:94
#, c-format
msgid "err: %v, "
msgstr ""
@ -3248,7 +3361,7 @@ msgstr ""
msgid "i"
msgstr ""
#: pkg/ui/deploy.go:84
#: pkg/ui/deploy.go:95
#, c-format
msgid "id: %s, "
msgstr ""
@ -3417,7 +3530,9 @@ msgstr ""
#: pkg/upstream/convert/service.go:834
#, c-format
msgid "invalid credential spec: spec specifies config %v, but no such config can be found"
msgid ""
"invalid credential spec: spec specifies config %v, but no such config can be "
"found"
msgstr ""
#: pkg/upstream/container/hijack.go:100
@ -3705,7 +3820,7 @@ msgstr ""
msgid "name a servce 'app'"
msgstr ""
#: pkg/ui/deploy.go:85
#: pkg/ui/deploy.go:96
#, c-format
msgid "name: %s, "
msgstr ""
@ -3716,12 +3831,17 @@ msgstr ""
#: pkg/upstream/stack/stack.go:348
#, c-format
msgid "network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed, which you can do by running this on the server: docker network create -d overlay proxy"
msgid ""
"network %q is declared as external, but could not be found. You need to "
"create a swarm-scoped network before the stack is deployed, which you can do "
"by running this on the server: docker network create -d overlay proxy"
msgstr ""
#: pkg/upstream/stack/stack.go:352
#, c-format
msgid "network %q is declared as external, but it is not in the right scope: %q instead of \"swarm\""
msgid ""
"network %q is declared as external, but it is not in the right scope: %q "
"instead of \"swarm\""
msgstr ""
#: cli/app/undeploy.go:148 cli/server/prune.go:60
@ -3841,7 +3961,9 @@ msgstr ""
#: cli/recipe/upgrade.go:183
#, c-format
msgid "no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)"
msgid ""
"no new versions available for %s, assuming %s is the latest (use -a/--all-"
"tags to see all anyway)"
msgstr ""
#: cli/internal/validate.go:64
@ -3908,7 +4030,9 @@ msgstr ""
#: cli/recipe/release.go:180
#, c-format
msgid "no tag specified and no previous tag available for %s, assuming initial release"
msgid ""
"no tag specified and no previous tag available for %s, assuming initial "
"release"
msgstr ""
#: pkg/lint/recipe.go:89
@ -3974,7 +4098,9 @@ msgstr ""
#: cli/recipe/upgrade.go:245
#, c-format
msgid "not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants"
msgid ""
"not upgrading from %s to %s for %s, because the upgrade type is more serious "
"than what user wants"
msgstr ""
#: pkg/upstream/stack/remove.go:73
@ -4261,7 +4387,7 @@ msgstr ""
msgid "read v:%s k: %s"
msgstr ""
#: pkg/ui/deploy.go:86
#: pkg/ui/deploy.go:97
#, c-format
msgid "reader: %v, "
msgstr ""
@ -4592,7 +4718,7 @@ msgstr ""
msgid "rollback <domain> [version] [flags]"
msgstr "revertir <domain> [version] [flags]"
#: pkg/ui/deploy.go:336
#: pkg/ui/deploy.go:398
msgid "rolled back"
msgstr ""
@ -4801,7 +4927,9 @@ msgstr ""
#: cli/recipe/upgrade.go:228
#, c-format
msgid "service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!"
msgid ""
"service %s is at version %s, but pinned to %s, please correct your compose."
"yml file manually!"
msgstr ""
#: cli/recipe/upgrade.go:224
@ -5006,14 +5134,16 @@ msgid "ssh-agent not found. see \"abra recipe release --help\" and try again"
msgstr ""
#: cli/catalogue/catalogue.go:98
msgid "ssh: SSH_AUTH_SOCK missing, --publish/-p will fail. see \"abra catalogue generate --help\""
msgid ""
"ssh: SSH_AUTH_SOCK missing, --publish/-p will fail. see \"abra catalogue "
"generate --help\""
msgstr ""
#: cli/app/list.go:291 cli/recipe/list.go:45
msgid "status"
msgstr ""
#: pkg/ui/deploy.go:88
#: pkg/ui/deploy.go:99
#, c-format
msgid "status: %s}"
msgstr ""
@ -5035,7 +5165,7 @@ msgstr ""
msgid "stripped %s to %s for parsing"
msgstr ""
#: pkg/ui/deploy.go:333
#: pkg/ui/deploy.go:395
msgid "succeeded"
msgstr ""
@ -5392,7 +5522,9 @@ msgstr ""
#: cli/recipe/release.go:621
#, c-format
msgid "unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?"
msgid ""
"unable to read version for %s from synced label. Did you try running \"abra "
"recipe sync %s\" already?"
msgstr ""
#: cli/app/move.go:210
@ -5701,7 +5833,8 @@ msgstr ""
#: cli/app/deploy.go:116
#, c-format
msgid "version '%s' appears to be a chaos commit, but --chaos/-C was not provided"
msgid ""
"version '%s' appears to be a chaos commit, but --chaos/-C was not provided"
msgstr ""
#: pkg/recipe/recipe.go:200
@ -5864,7 +5997,7 @@ msgstr ""
msgid "wire up healthchecks"
msgstr ""
#: pkg/ui/deploy.go:87
#: pkg/ui/deploy.go:98
#, c-format
msgid "writer: %v, "
msgstr ""
@ -5899,7 +6032,7 @@ msgstr ""
msgid "z"
msgstr ""
#: pkg/ui/deploy.go:82
#: pkg/ui/deploy.go:93
#, c-format
msgid "{decoder: %v, "
msgstr ""

View File

@ -11,6 +11,7 @@ import (
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/logs"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/docker/cli/cli/command/service/progress"
containerTypes "github.com/docker/docker/api/types/container"
@ -41,6 +42,12 @@ type ServiceMeta struct {
ID string
}
const (
statusMode = iota
logsMode = iota
errorsMode = iota
)
type Model struct {
appName string
cl *dockerClient.Client
@ -49,6 +56,10 @@ type Model struct {
timeout time.Duration
width int
filters filters.Args
mode int
logsViewport viewport.Model
logsViewportReady bool
Streams *[]stream
Logs *[]string
@ -236,7 +247,10 @@ func deployTimeout(m Model) tea.Msg {
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.KeyMsg:
@ -244,11 +258,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+c", "q":
m.Quit = true
return m, tea.Quit
case "s":
m.mode = statusMode
case "l":
m.mode = logsMode
case "e":
m.mode = errorsMode
}
case tea.WindowSizeMsg:
m.width = msg.Width
if !m.logsViewportReady {
m.logsViewport = viewport.New(msg.Width, 20)
m.logsViewportReady = true
} else {
m.logsViewport.Width = msg.Width
m.logsViewport.Height = 20
}
case progressCompleteMsg:
if msg.failed {
m.Failed = true
@ -256,9 +284,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.count += 1
if m.complete() {
return m, tea.Quit
}
// if m.complete() {
// return m, tea.Quit
// }
case timeoutMsg:
m.TimedOut = true
@ -318,12 +346,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
)
}
m.logsViewport, cmd = m.logsViewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
body := strings.Builder{}
body.WriteString("menu: [s]tatus [l]ogs [e]rrors\n")
var res string
switch {
case m.mode == statusMode:
res = statusView(m)
case m.mode == logsMode:
res = logsView(m)
}
return body.String() + res
}
func logsView(m Model) string {
body := strings.Builder{}
m.logsViewport.SetContent(strings.Join(*m.Logs, "\n"))
m.logsViewport.GotoBottom()
body.WriteString(m.logsViewport.View())
return body.String()
}
func errorsView(m Model) string {
body := strings.Builder{}
body.WriteString("ERRORS COMING SOON")
return body.String()
}
func statusView(m Model) string {
body := strings.Builder{}
for _, stream := range *m.Streams {
split := strings.Split(stream.Name, "_")
short := split[len(split)-1]

View File

@ -1,22 +0,0 @@
language: go
os:
- linux
- osx
- windows
go:
- go1.13.x
- go1.x
services:
- xvfb
before_install:
- export DISPLAY=:99.0
script:
- if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo apt-get install xsel; fi
- go test -v .
- if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo apt-get install xclip; fi
- go test -v .

View File

@ -1,27 +0,0 @@
Copyright (c) 2013 Ato Araki. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of @atotto. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -1,48 +0,0 @@
[![Build Status](https://travis-ci.org/atotto/clipboard.svg?branch=master)](https://travis-ci.org/atotto/clipboard)
[![GoDoc](https://godoc.org/github.com/atotto/clipboard?status.svg)](http://godoc.org/github.com/atotto/clipboard)
# Clipboard for Go
Provide copying and pasting to the Clipboard for Go.
Build:
$ go get github.com/atotto/clipboard
Platforms:
* OSX
* Windows 7 (probably work on other Windows)
* Linux, Unix (requires 'xclip' or 'xsel' command to be installed)
Document:
* http://godoc.org/github.com/atotto/clipboard
Notes:
* Text string only
* UTF-8 text encoding only (no conversion)
TODO:
* Clipboard watcher(?)
## Commands:
paste shell command:
$ go get github.com/atotto/clipboard/cmd/gopaste
$ # example:
$ gopaste > document.txt
copy shell command:
$ go get github.com/atotto/clipboard/cmd/gocopy
$ # example:
$ cat document.txt | gocopy

View File

@ -1,20 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package clipboard read/write on clipboard
package clipboard
// ReadAll read string from clipboard
func ReadAll() (string, error) {
return readAll()
}
// WriteAll write string to clipboard
func WriteAll(text string) error {
return writeAll(text)
}
// Unsupported might be set true during clipboard init, to help callers decide
// whether or not to offer clipboard options.
var Unsupported bool

View File

@ -1,52 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build darwin
package clipboard
import (
"os/exec"
)
var (
pasteCmdArgs = "pbpaste"
copyCmdArgs = "pbcopy"
)
func getPasteCommand() *exec.Cmd {
return exec.Command(pasteCmdArgs)
}
func getCopyCommand() *exec.Cmd {
return exec.Command(copyCmdArgs)
}
func readAll() (string, error) {
pasteCmd := getPasteCommand()
out, err := pasteCmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
func writeAll(text string) error {
copyCmd := getCopyCommand()
in, err := copyCmd.StdinPipe()
if err != nil {
return err
}
if err := copyCmd.Start(); err != nil {
return err
}
if _, err := in.Write([]byte(text)); err != nil {
return err
}
if err := in.Close(); err != nil {
return err
}
return copyCmd.Wait()
}

View File

@ -1,42 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build plan9
package clipboard
import (
"os"
"io/ioutil"
)
func readAll() (string, error) {
f, err := os.Open("/dev/snarf")
if err != nil {
return "", err
}
defer f.Close()
str, err := ioutil.ReadAll(f)
if err != nil {
return "", err
}
return string(str), nil
}
func writeAll(text string) error {
f, err := os.OpenFile("/dev/snarf", os.O_WRONLY, 0666)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write([]byte(text))
if err != nil {
return err
}
return nil
}

View File

@ -1,149 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build freebsd linux netbsd openbsd solaris dragonfly
package clipboard
import (
"errors"
"os"
"os/exec"
)
const (
xsel = "xsel"
xclip = "xclip"
powershellExe = "powershell.exe"
clipExe = "clip.exe"
wlcopy = "wl-copy"
wlpaste = "wl-paste"
termuxClipboardGet = "termux-clipboard-get"
termuxClipboardSet = "termux-clipboard-set"
)
var (
Primary bool
trimDos bool
pasteCmdArgs []string
copyCmdArgs []string
xselPasteArgs = []string{xsel, "--output", "--clipboard"}
xselCopyArgs = []string{xsel, "--input", "--clipboard"}
xclipPasteArgs = []string{xclip, "-out", "-selection", "clipboard"}
xclipCopyArgs = []string{xclip, "-in", "-selection", "clipboard"}
powershellExePasteArgs = []string{powershellExe, "Get-Clipboard"}
clipExeCopyArgs = []string{clipExe}
wlpasteArgs = []string{wlpaste, "--no-newline"}
wlcopyArgs = []string{wlcopy}
termuxPasteArgs = []string{termuxClipboardGet}
termuxCopyArgs = []string{termuxClipboardSet}
missingCommands = errors.New("No clipboard utilities available. Please install xsel, xclip, wl-clipboard or Termux:API add-on for termux-clipboard-get/set.")
)
func init() {
if os.Getenv("WAYLAND_DISPLAY") != "" {
pasteCmdArgs = wlpasteArgs
copyCmdArgs = wlcopyArgs
if _, err := exec.LookPath(wlcopy); err == nil {
if _, err := exec.LookPath(wlpaste); err == nil {
return
}
}
}
pasteCmdArgs = xclipPasteArgs
copyCmdArgs = xclipCopyArgs
if _, err := exec.LookPath(xclip); err == nil {
return
}
pasteCmdArgs = xselPasteArgs
copyCmdArgs = xselCopyArgs
if _, err := exec.LookPath(xsel); err == nil {
return
}
pasteCmdArgs = termuxPasteArgs
copyCmdArgs = termuxCopyArgs
if _, err := exec.LookPath(termuxClipboardSet); err == nil {
if _, err := exec.LookPath(termuxClipboardGet); err == nil {
return
}
}
pasteCmdArgs = powershellExePasteArgs
copyCmdArgs = clipExeCopyArgs
trimDos = true
if _, err := exec.LookPath(clipExe); err == nil {
if _, err := exec.LookPath(powershellExe); err == nil {
return
}
}
Unsupported = true
}
func getPasteCommand() *exec.Cmd {
if Primary {
pasteCmdArgs = pasteCmdArgs[:1]
}
return exec.Command(pasteCmdArgs[0], pasteCmdArgs[1:]...)
}
func getCopyCommand() *exec.Cmd {
if Primary {
copyCmdArgs = copyCmdArgs[:1]
}
return exec.Command(copyCmdArgs[0], copyCmdArgs[1:]...)
}
func readAll() (string, error) {
if Unsupported {
return "", missingCommands
}
pasteCmd := getPasteCommand()
out, err := pasteCmd.Output()
if err != nil {
return "", err
}
result := string(out)
if trimDos && len(result) > 1 {
result = result[:len(result)-2]
}
return result, nil
}
func writeAll(text string) error {
if Unsupported {
return missingCommands
}
copyCmd := getCopyCommand()
in, err := copyCmd.StdinPipe()
if err != nil {
return err
}
if err := copyCmd.Start(); err != nil {
return err
}
if _, err := in.Write([]byte(text)); err != nil {
return err
}
if err := in.Close(); err != nil {
return err
}
return copyCmd.Wait()
}

View File

@ -1,157 +0,0 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build windows
package clipboard
import (
"runtime"
"syscall"
"time"
"unsafe"
)
const (
cfUnicodetext = 13
gmemMoveable = 0x0002
)
var (
user32 = syscall.MustLoadDLL("user32")
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
openClipboard = user32.MustFindProc("OpenClipboard")
closeClipboard = user32.MustFindProc("CloseClipboard")
emptyClipboard = user32.MustFindProc("EmptyClipboard")
getClipboardData = user32.MustFindProc("GetClipboardData")
setClipboardData = user32.MustFindProc("SetClipboardData")
kernel32 = syscall.NewLazyDLL("kernel32")
globalAlloc = kernel32.NewProc("GlobalAlloc")
globalFree = kernel32.NewProc("GlobalFree")
globalLock = kernel32.NewProc("GlobalLock")
globalUnlock = kernel32.NewProc("GlobalUnlock")
lstrcpy = kernel32.NewProc("lstrcpyW")
)
// waitOpenClipboard opens the clipboard, waiting for up to a second to do so.
func waitOpenClipboard() error {
started := time.Now()
limit := started.Add(time.Second)
var r uintptr
var err error
for time.Now().Before(limit) {
r, _, err = openClipboard.Call(0)
if r != 0 {
return nil
}
time.Sleep(time.Millisecond)
}
return err
}
func readAll() (string, error) {
// LockOSThread ensure that the whole method will keep executing on the same thread from begin to end (it actually locks the goroutine thread attribution).
// Otherwise if the goroutine switch thread during execution (which is a common practice), the OpenClipboard and CloseClipboard will happen on two different threads, and it will result in a clipboard deadlock.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if formatAvailable, _, err := isClipboardFormatAvailable.Call(cfUnicodetext); formatAvailable == 0 {
return "", err
}
err := waitOpenClipboard()
if err != nil {
return "", err
}
h, _, err := getClipboardData.Call(cfUnicodetext)
if h == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
l, _, err := globalLock.Call(h)
if l == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
text := syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(l))[:])
r, _, err := globalUnlock.Call(h)
if r == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
closed, _, err := closeClipboard.Call()
if closed == 0 {
return "", err
}
return text, nil
}
func writeAll(text string) error {
// LockOSThread ensure that the whole method will keep executing on the same thread from begin to end (it actually locks the goroutine thread attribution).
// Otherwise if the goroutine switch thread during execution (which is a common practice), the OpenClipboard and CloseClipboard will happen on two different threads, and it will result in a clipboard deadlock.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
err := waitOpenClipboard()
if err != nil {
return err
}
r, _, err := emptyClipboard.Call(0)
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
data := syscall.StringToUTF16(text)
// "If the hMem parameter identifies a memory object, the object must have
// been allocated using the function with the GMEM_MOVEABLE flag."
h, _, err := globalAlloc.Call(gmemMoveable, uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
if h == 0 {
_, _, _ = closeClipboard.Call()
return err
}
defer func() {
if h != 0 {
globalFree.Call(h)
}
}()
l, _, err := globalLock.Call(h)
if l == 0 {
_, _, _ = closeClipboard.Call()
return err
}
r, _, err = lstrcpy.Call(l, uintptr(unsafe.Pointer(&data[0])))
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
r, _, err = globalUnlock.Call(h)
if r == 0 {
if err.(syscall.Errno) != 0 {
_, _, _ = closeClipboard.Call()
return err
}
}
r, _, err = setClipboardData.Call(cfUnicodetext, h)
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
h = 0 // suppress deferred cleanup
closed, _, err := closeClipboard.Call()
if closed == 0 {
return err
}
return nil
}

View File

@ -1,219 +0,0 @@
// Package cursor provides cursor functionality for Bubble Tea applications.
package cursor
import (
"context"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const defaultBlinkSpeed = time.Millisecond * 530
// initialBlinkMsg initializes cursor blinking.
type initialBlinkMsg struct{}
// BlinkMsg signals that the cursor should blink. It contains metadata that
// allows us to tell if the blink message is the one we're expecting.
type BlinkMsg struct {
id int
tag int
}
// blinkCanceled is sent when a blink operation is canceled.
type blinkCanceled struct{}
// blinkCtx manages cursor blinking.
type blinkCtx struct {
ctx context.Context
cancel context.CancelFunc
}
// Mode describes the behavior of the cursor.
type Mode int
// Available cursor modes.
const (
CursorBlink Mode = iota
CursorStatic
CursorHide
)
// String returns the cursor mode in a human-readable format. This method is
// provisional and for informational purposes only.
func (c Mode) String() string {
return [...]string{
"blink",
"static",
"hidden",
}[c]
}
// Model is the Bubble Tea model for this cursor element.
type Model struct {
BlinkSpeed time.Duration
// Style for styling the cursor block.
Style lipgloss.Style
// TextStyle is the style used for the cursor when it is hidden (when blinking).
// I.e. displaying normal text.
TextStyle lipgloss.Style
// char is the character under the cursor
char string
// The ID of this Model as it relates to other cursors
id int
// focus indicates whether the containing input is focused
focus bool
// Cursor Blink state.
Blink bool
// Used to manage cursor blink
blinkCtx *blinkCtx
// The ID of the blink message we're expecting to receive.
blinkTag int
// mode determines the behavior of the cursor
mode Mode
}
// New creates a new model with default settings.
func New() Model {
return Model{
BlinkSpeed: defaultBlinkSpeed,
Blink: true,
mode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
}
}
// Update updates the cursor.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case initialBlinkMsg:
// We accept all initialBlinkMsgs generated by the Blink command.
if m.mode != CursorBlink || !m.focus {
return m, nil
}
cmd := m.BlinkCmd()
return m, cmd
case tea.FocusMsg:
return m, m.Focus()
case tea.BlurMsg:
m.Blur()
return m, nil
case BlinkMsg:
// We're choosy about whether to accept blinkMsgs so that our cursor
// only exactly when it should.
// Is this model blink-able?
if m.mode != CursorBlink || !m.focus {
return m, nil
}
// Were we expecting this blink message?
if msg.id != m.id || msg.tag != m.blinkTag {
return m, nil
}
var cmd tea.Cmd
if m.mode == CursorBlink {
m.Blink = !m.Blink
cmd = m.BlinkCmd()
}
return m, cmd
case blinkCanceled: // no-op
return m, nil
}
return m, nil
}
// Mode returns the model's cursor mode. For available cursor modes, see
// type Mode.
func (m Model) Mode() Mode {
return m.mode
}
// SetMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *Model) SetMode(mode Mode) tea.Cmd {
// Adjust the mode value if it's value is out of range
if mode < CursorBlink || mode > CursorHide {
return nil
}
m.mode = mode
m.Blink = m.mode == CursorHide || !m.focus
if mode == CursorBlink {
return Blink
}
return nil
}
// BlinkCmd is a command used to manage cursor blinking.
func (m *Model) BlinkCmd() tea.Cmd {
if m.mode != CursorBlink {
return nil
}
if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
m.blinkCtx.cancel()
}
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
m.blinkCtx.cancel = cancel
m.blinkTag++
return func() tea.Msg {
defer cancel()
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
return BlinkMsg{id: m.id, tag: m.blinkTag}
}
return blinkCanceled{}
}
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return initialBlinkMsg{}
}
// Focus focuses the cursor to allow it to blink if desired.
func (m *Model) Focus() tea.Cmd {
m.focus = true
m.Blink = m.mode == CursorHide // show the cursor unless we've explicitly hidden it
if m.mode == CursorBlink && m.focus {
return m.BlinkCmd()
}
return nil
}
// Blur blurs the cursor.
func (m *Model) Blur() {
m.focus = false
m.Blink = true
}
// SetChar sets the character under the cursor.
func (m *Model) SetChar(char string) {
m.char = char
}
// View displays the cursor.
func (m Model) View() string {
if m.Blink {
return m.TextStyle.Inline(true).Render(m.char)
}
return m.Style.Inline(true).Reverse(true).Render(m.char)
}

View File

@ -1,102 +0,0 @@
// Package runeutil provides a utility function for use in Bubbles
// that can process Key messages containing runes.
package runeutil
import (
"unicode"
"unicode/utf8"
)
// Sanitizer is a helper for bubble widgets that want to process
// Runes from input key messages.
type Sanitizer interface {
// Sanitize removes control characters from runes in a KeyRunes
// message, and optionally replaces newline/carriage return/tabs by a
// specified character.
//
// The rune array is modified in-place if possible. In that case, the
// returned slice is the original slice shortened after the control
// characters have been removed/translated.
Sanitize(runes []rune) []rune
}
// NewSanitizer constructs a rune sanitizer.
func NewSanitizer(opts ...Option) Sanitizer {
s := sanitizer{
replaceNewLine: []rune("\n"),
replaceTab: []rune(" "),
}
for _, o := range opts {
s = o(s)
}
return &s
}
// Option is the type of option that can be passed to Sanitize().
type Option func(sanitizer) sanitizer
// ReplaceTabs replaces tabs by the specified string.
func ReplaceTabs(tabRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceTab = []rune(tabRepl)
return s
}
}
// ReplaceNewlines replaces newline characters by the specified string.
func ReplaceNewlines(nlRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceNewLine = []rune(nlRepl)
return s
}
}
func (s *sanitizer) Sanitize(runes []rune) []rune {
// dstrunes are where we are storing the result.
dstrunes := runes[:0:len(runes)]
// copied indicates whether dstrunes is an alias of runes
// or a copy. We need a copy when dst moves past src.
// We use this as an optimization to avoid allocating
// a new rune slice in the common case where the output
// is smaller or equal to the input.
copied := false
for src := 0; src < len(runes); src++ {
r := runes[src]
switch {
case r == utf8.RuneError:
// skip
case r == '\r' || r == '\n':
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceNewLine...)
case r == '\t':
if len(dstrunes)+len(s.replaceTab) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceTab...)
case unicode.IsControl(r):
// Other control characters: skip.
default:
// Keep the character.
dstrunes = append(dstrunes, runes[src])
}
}
return dstrunes
}
type sanitizer struct {
replaceNewLine []rune
replaceTab []rune
}

View File

@ -1,224 +0,0 @@
// Package spinner provides a spinner component for Bubble Tea applications.
package spinner
import (
"sync/atomic"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Internal ID management. Used during animating to ensure that frame messages
// are received only by spinner components that sent them.
var lastID int64
func nextID() int {
return int(atomic.AddInt64(&lastID, 1))
}
// Spinner is a set of frames used in animating the spinner.
type Spinner struct {
Frames []string
FPS time.Duration
}
// Some spinners to choose from. You could also make your own.
var (
Line = Spinner{
Frames: []string{"|", "/", "-", "\\"},
FPS: time.Second / 10, //nolint:mnd
}
Dot = Spinner{
Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
FPS: time.Second / 10, //nolint:mnd
}
MiniDot = Spinner{
Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
FPS: time.Second / 12, //nolint:mnd
}
Jump = Spinner{
Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
FPS: time.Second / 10, //nolint:mnd
}
Pulse = Spinner{
Frames: []string{"█", "▓", "▒", "░"},
FPS: time.Second / 8, //nolint:mnd
}
Points = Spinner{
Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
FPS: time.Second / 7, //nolint:mnd
}
Globe = Spinner{
Frames: []string{"🌍", "🌎", "🌏"},
FPS: time.Second / 4, //nolint:mnd
}
Moon = Spinner{
Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
FPS: time.Second / 8, //nolint:mnd
}
Monkey = Spinner{
Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3, //nolint:mnd
}
Meter = Spinner{
Frames: []string{
"▱▱▱",
"▰▱▱",
"▰▰▱",
"▰▰▰",
"▰▰▱",
"▰▱▱",
"▱▱▱",
},
FPS: time.Second / 7, //nolint:mnd
}
Hamburger = Spinner{
Frames: []string{"☱", "☲", "☴", "☲"},
FPS: time.Second / 3, //nolint:mnd
}
Ellipsis = Spinner{
Frames: []string{"", ".", "..", "..."},
FPS: time.Second / 3, //nolint:mnd
}
)
// Model contains the state for the spinner. Use New to create new models
// rather than using Model as a struct literal.
type Model struct {
// Spinner settings to use. See type Spinner.
Spinner Spinner
// Style sets the styling for the spinner. Most of the time you'll just
// want foreground and background coloring, and potentially some padding.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
Style lipgloss.Style
frame int
id int
tag int
}
// ID returns the spinner's unique ID.
func (m Model) ID() int {
return m.id
}
// New returns a model with default values.
func New(opts ...Option) Model {
m := Model{
Spinner: Line,
id: nextID(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
// NewModel returns a model with default values.
//
// Deprecated: use [New] instead.
var NewModel = New
// TickMsg indicates that the timer has ticked and we should render a frame.
type TickMsg struct {
Time time.Time
tag int
ID int
}
// Update is the Tea update function.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case TickMsg:
// If an ID is set, and the ID doesn't belong to this spinner, reject
// the message.
if msg.ID > 0 && msg.ID != m.id {
return m, nil
}
// If a tag is set, and it's not the one we expect, reject the message.
// This prevents the spinner from receiving too many messages and
// thus spinning too fast.
if msg.tag > 0 && msg.tag != m.tag {
return m, nil
}
m.frame++
if m.frame >= len(m.Spinner.Frames) {
m.frame = 0
}
m.tag++
return m, m.tick(m.id, m.tag)
default:
return m, nil
}
}
// View renders the model's view.
func (m Model) View() string {
if m.frame >= len(m.Spinner.Frames) {
return "(error)"
}
return m.Style.Render(m.Spinner.Frames[m.frame])
}
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
func (m Model) Tick() tea.Msg {
return TickMsg{
// The time at which the tick occurred.
Time: time.Now(),
// The ID of the spinner that this message belongs to. This can be
// helpful when routing messages, however bear in mind that spinners
// will ignore messages that don't contain ID by default.
ID: m.id,
tag: m.tag,
}
}
func (m Model) tick(id, tag int) tea.Cmd {
return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
return TickMsg{
Time: t,
ID: id,
tag: tag,
}
})
}
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
//
// Deprecated: Use [Model.Tick] instead.
func Tick() tea.Msg {
return TickMsg{Time: time.Now()}
}
// Option is used to set options in New. For example:
//
// spinner := New(WithSpinner(Dot))
type Option func(*Model)
// WithSpinner is an option to set the spinner.
func WithSpinner(spinner Spinner) Option {
return func(m *Model) {
m.Spinner = spinner
}
}
// WithStyle is an option to set the spinner style.
func WithStyle(style lipgloss.Style) Option {
return func(m *Model) {
m.Style = style
}
}

View File

@ -1,898 +0,0 @@
// Package textinput provides a text input component for Bubble Tea
// applications.
package textinput
import (
"reflect"
"strings"
"time"
"unicode"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/runeutil"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
rw "github.com/mattn/go-runewidth"
"github.com/rivo/uniseg"
)
// Internal messages for clipboard operations.
type (
pasteMsg string
pasteErrMsg struct{ error }
)
// EchoMode sets the input behavior of the text input field.
type EchoMode int
const (
// EchoNormal displays text as is. This is the default behavior.
EchoNormal EchoMode = iota
// EchoPassword displays the EchoCharacter mask instead of actual
// characters. This is commonly used for password fields.
EchoPassword
// EchoNone displays nothing as characters are entered. This is commonly
// seen for password fields on the command line.
EchoNone
)
// ValidateFunc is a function that returns an error if the input is invalid.
type ValidateFunc func(string) error
// KeyMap is the key bindings for different actions within the textinput.
type KeyMap struct {
CharacterForward key.Binding
CharacterBackward key.Binding
WordForward key.Binding
WordBackward key.Binding
DeleteWordBackward key.Binding
DeleteWordForward key.Binding
DeleteAfterCursor key.Binding
DeleteBeforeCursor key.Binding
DeleteCharacterBackward key.Binding
DeleteCharacterForward key.Binding
LineStart key.Binding
LineEnd key.Binding
Paste key.Binding
AcceptSuggestion key.Binding
NextSuggestion key.Binding
PrevSuggestion key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
// upon the textinput.
var DefaultKeyMap = KeyMap{
CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f")),
CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b")),
WordForward: key.NewBinding(key.WithKeys("alt+right", "ctrl+right", "alt+f")),
WordBackward: key.NewBinding(key.WithKeys("alt+left", "ctrl+left", "alt+b")),
DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")),
DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d")),
DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")),
DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u")),
DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h")),
DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d")),
LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")),
LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")),
Paste: key.NewBinding(key.WithKeys("ctrl+v")),
AcceptSuggestion: key.NewBinding(key.WithKeys("tab")),
NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")),
PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")),
}
// Model is the Bubble Tea model for this text input element.
type Model struct {
Err error
// General settings.
Prompt string
Placeholder string
EchoMode EchoMode
EchoCharacter rune
Cursor cursor.Model
// Deprecated: use [cursor.BlinkSpeed] instead.
BlinkSpeed time.Duration
// Styles. These will be applied as inline styles.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
PromptStyle lipgloss.Style
TextStyle lipgloss.Style
PlaceholderStyle lipgloss.Style
CompletionStyle lipgloss.Style
// Deprecated: use Cursor.Style instead.
CursorStyle lipgloss.Style
// CharLimit is the maximum amount of characters this input element will
// accept. If 0 or less, there's no limit.
CharLimit int
// Width is the maximum number of characters that can be displayed at once.
// It essentially treats the text field like a horizontally scrolling
// viewport. If 0 or less this setting is ignored.
Width int
// KeyMap encodes the keybindings recognized by the widget.
KeyMap KeyMap
// Underlying text value.
value []rune
// focus indicates whether user input focus should be on this input
// component. When false, ignore keyboard input and hide the cursor.
focus bool
// Cursor position.
pos int
// Used to emulate a viewport when width is set and the content is
// overflowing.
offset int
offsetRight int
// Validate is a function that checks whether or not the text within the
// input is valid. If it is not valid, the `Err` field will be set to the
// error returned by the function. If the function is not defined, all
// input is considered valid.
Validate ValidateFunc
// rune sanitizer for input.
rsan runeutil.Sanitizer
// Should the input suggest to complete
ShowSuggestions bool
// suggestions is a list of suggestions that may be used to complete the
// input.
suggestions [][]rune
matchedSuggestions [][]rune
currentSuggestionIndex int
}
// New creates a new model with default settings.
func New() Model {
return Model{
Prompt: "> ",
EchoCharacter: '*',
CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
ShowSuggestions: false,
CompletionStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Cursor: cursor.New(),
KeyMap: DefaultKeyMap,
suggestions: [][]rune{},
value: nil,
focus: false,
pos: 0,
}
}
// NewModel creates a new model with default settings.
//
// Deprecated: Use [New] instead.
var NewModel = New
// SetValue sets the value of the text input.
func (m *Model) SetValue(s string) {
// Clean up any special characters in the input provided by the
// caller. This avoids bugs due to e.g. tab characters and whatnot.
runes := m.san().Sanitize([]rune(s))
err := m.validate(runes)
m.setValueInternal(runes, err)
}
func (m *Model) setValueInternal(runes []rune, err error) {
m.Err = err
empty := len(m.value) == 0
if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
} else {
m.value = runes
}
if (m.pos == 0 && empty) || m.pos > len(m.value) {
m.SetCursor(len(m.value))
}
m.handleOverflow()
}
// Value returns the value of the text input.
func (m Model) Value() string {
return string(m.value)
}
// Position returns the cursor position.
func (m Model) Position() int {
return m.pos
}
// SetCursor moves the cursor to the given position. If the position is
// out of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) {
m.pos = clamp(pos, 0, len(m.value))
m.handleOverflow()
}
// CursorStart moves the cursor to the start of the input field.
func (m *Model) CursorStart() {
m.SetCursor(0)
}
// CursorEnd moves the cursor to the end of the input field.
func (m *Model) CursorEnd() {
m.SetCursor(len(m.value))
}
// Focused returns the focus state on the model.
func (m Model) Focused() bool {
return m.focus
}
// Focus sets the focus state on the model. When the model is in focus it can
// receive keyboard input and the cursor will be shown.
func (m *Model) Focus() tea.Cmd {
m.focus = true
return m.Cursor.Focus()
}
// Blur removes the focus state on the model. When the model is blurred it can
// not receive keyboard input and the cursor will be hidden.
func (m *Model) Blur() {
m.focus = false
m.Cursor.Blur()
}
// Reset sets the input to its default state with no input.
func (m *Model) Reset() {
m.value = nil
m.SetCursor(0)
}
// SetSuggestions sets the suggestions for the input.
func (m *Model) SetSuggestions(suggestions []string) {
m.suggestions = make([][]rune, len(suggestions))
for i, s := range suggestions {
m.suggestions[i] = []rune(s)
}
m.updateSuggestions()
}
// rsan initializes or retrieves the rune sanitizer.
func (m *Model) san() runeutil.Sanitizer {
if m.rsan == nil {
// Textinput has all its input on a single line so collapse
// newlines/tabs to single spaces.
m.rsan = runeutil.NewSanitizer(
runeutil.ReplaceTabs(" "), runeutil.ReplaceNewlines(" "))
}
return m.rsan
}
func (m *Model) insertRunesFromUserInput(v []rune) {
// Clean up any special characters in the input provided by the
// clipboard. This avoids bugs due to e.g. tab characters and
// whatnot.
paste := m.san().Sanitize(v)
var availSpace int
if m.CharLimit > 0 {
availSpace = m.CharLimit - len(m.value)
// If the char limit's been reached, cancel.
if availSpace <= 0 {
return
}
// If there's not enough space to paste the whole thing cut the pasted
// runes down so they'll fit.
if availSpace < len(paste) {
paste = paste[:availSpace]
}
}
// Stuff before and after the cursor
head := m.value[:m.pos]
tailSrc := m.value[m.pos:]
tail := make([]rune, len(tailSrc))
copy(tail, tailSrc)
// Insert pasted runes
for _, r := range paste {
head = append(head, r)
m.pos++
if m.CharLimit > 0 {
availSpace--
if availSpace <= 0 {
break
}
}
}
// Put it all back together
value := append(head, tail...)
inputErr := m.validate(value)
m.setValueInternal(value, inputErr)
}
// If a max width is defined, perform some logic to treat the visible area
// as a horizontally scrolling viewport.
func (m *Model) handleOverflow() {
if m.Width <= 0 || uniseg.StringWidth(string(m.value)) <= m.Width {
m.offset = 0
m.offsetRight = len(m.value)
return
}
// Correct right offset if we've deleted characters
m.offsetRight = min(m.offsetRight, len(m.value))
if m.pos < m.offset {
m.offset = m.pos
w := 0
i := 0
runes := m.value[m.offset:]
for i < len(runes) && w <= m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width+1 {
i++
}
}
m.offsetRight = m.offset + i
} else if m.pos >= m.offsetRight {
m.offsetRight = m.pos
w := 0
runes := m.value[:m.offsetRight]
i := len(runes) - 1
for i > 0 && w < m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width {
i--
}
}
m.offset = m.offsetRight - (len(runes) - 1 - i)
}
}
// deleteBeforeCursor deletes all text before the cursor.
func (m *Model) deleteBeforeCursor() {
m.value = m.value[m.pos:]
m.Err = m.validate(m.value)
m.offset = 0
m.SetCursor(0)
}
// deleteAfterCursor deletes all text after the cursor. If input is masked
// delete everything after the cursor so as not to reveal word breaks in the
// masked input.
func (m *Model) deleteAfterCursor() {
m.value = m.value[:m.pos]
m.Err = m.validate(m.value)
m.SetCursor(len(m.value))
}
// deleteWordBackward deletes the word left to the cursor.
func (m *Model) deleteWordBackward() {
if m.pos == 0 || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.deleteBeforeCursor()
return
}
// Linter note: it's critical that we acquire the initial cursor position
// here prior to altering it via SetCursor() below. As such, moving this
// call into the corresponding if clause does not apply here.
oldPos := m.pos //nolint:ifshort
m.SetCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) {
if m.pos <= 0 {
break
}
// ignore series of whitespace before cursor
m.SetCursor(m.pos - 1)
}
for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) {
m.SetCursor(m.pos - 1)
} else {
if m.pos > 0 {
// keep the previous space
m.SetCursor(m.pos + 1)
}
break
}
}
if oldPos > len(m.value) {
m.value = m.value[:m.pos]
} else {
m.value = append(m.value[:m.pos], m.value[oldPos:]...)
}
m.Err = m.validate(m.value)
}
// deleteWordForward deletes the word right to the cursor. If input is masked
// delete everything after the cursor so as not to reveal word breaks in the
// masked input.
func (m *Model) deleteWordForward() {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.deleteAfterCursor()
return
}
oldPos := m.pos
m.SetCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor
m.SetCursor(m.pos + 1)
if m.pos >= len(m.value) {
break
}
}
for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) {
m.SetCursor(m.pos + 1)
} else {
break
}
}
if m.pos > len(m.value) {
m.value = m.value[:oldPos]
} else {
m.value = append(m.value[:oldPos], m.value[m.pos:]...)
}
m.Err = m.validate(m.value)
m.SetCursor(oldPos)
}
// wordBackward moves the cursor one word to the left. If input is masked, move
// input to the start so as not to reveal word breaks in the masked input.
func (m *Model) wordBackward() {
if m.pos == 0 || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.CursorStart()
return
}
i := m.pos - 1
for i >= 0 {
if unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos - 1)
i--
} else {
break
}
}
for i >= 0 {
if !unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos - 1)
i--
} else {
break
}
}
}
// wordForward moves the cursor one word to the right. If the input is masked,
// move input to the end so as not to reveal word breaks in the masked input.
func (m *Model) wordForward() {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.CursorEnd()
return
}
i := m.pos
for i < len(m.value) {
if unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos + 1)
i++
} else {
break
}
}
for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos + 1)
i++
} else {
break
}
}
}
func (m Model) echoTransform(v string) string {
switch m.EchoMode {
case EchoPassword:
return strings.Repeat(string(m.EchoCharacter), uniseg.StringWidth(v))
case EchoNone:
return ""
case EchoNormal:
return v
default:
return v
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
// Need to check for completion before, because key is configurable and might be double assigned
keyMsg, ok := msg.(tea.KeyMsg)
if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) {
if m.canAcceptSuggestion() {
m.value = append(m.value, m.matchedSuggestions[m.currentSuggestionIndex][len(m.value):]...)
m.CursorEnd()
}
}
// Let's remember where the position of the cursor currently is so that if
// the cursor position changes, we can reset the blink.
oldPos := m.pos
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.DeleteWordBackward):
m.deleteWordBackward()
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
m.Err = nil
if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
m.Err = m.validate(m.value)
if m.pos > 0 {
m.SetCursor(m.pos - 1)
}
}
case key.Matches(msg, m.KeyMap.WordBackward):
m.wordBackward()
case key.Matches(msg, m.KeyMap.CharacterBackward):
if m.pos > 0 {
m.SetCursor(m.pos - 1)
}
case key.Matches(msg, m.KeyMap.WordForward):
m.wordForward()
case key.Matches(msg, m.KeyMap.CharacterForward):
if m.pos < len(m.value) {
m.SetCursor(m.pos + 1)
}
case key.Matches(msg, m.KeyMap.LineStart):
m.CursorStart()
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
if len(m.value) > 0 && m.pos < len(m.value) {
m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
m.Err = m.validate(m.value)
}
case key.Matches(msg, m.KeyMap.LineEnd):
m.CursorEnd()
case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
m.deleteAfterCursor()
case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
m.deleteBeforeCursor()
case key.Matches(msg, m.KeyMap.Paste):
return m, Paste
case key.Matches(msg, m.KeyMap.DeleteWordForward):
m.deleteWordForward()
case key.Matches(msg, m.KeyMap.NextSuggestion):
m.nextSuggestion()
case key.Matches(msg, m.KeyMap.PrevSuggestion):
m.previousSuggestion()
default:
// Input one or more regular characters.
m.insertRunesFromUserInput(msg.Runes)
}
// Check again if can be completed
// because value might be something that does not match the completion prefix
m.updateSuggestions()
case pasteMsg:
m.insertRunesFromUserInput([]rune(msg))
case pasteErrMsg:
m.Err = msg
}
var cmds []tea.Cmd
var cmd tea.Cmd
m.Cursor, cmd = m.Cursor.Update(msg)
cmds = append(cmds, cmd)
if oldPos != m.pos && m.Cursor.Mode() == cursor.CursorBlink {
m.Cursor.Blink = false
cmds = append(cmds, m.Cursor.BlinkCmd())
}
m.handleOverflow()
return m, tea.Batch(cmds...)
}
// View renders the textinput in its current state.
func (m Model) View() string {
// Placeholder text
if len(m.value) == 0 && m.Placeholder != "" {
return m.placeholderView()
}
styleText := m.TextStyle.Inline(true).Render
value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset)
v := styleText(m.echoTransform(string(value[:pos])))
if pos < len(value) { //nolint:nestif
char := m.echoTransform(string(value[pos]))
m.Cursor.SetChar(char)
v += m.Cursor.View() // cursor and text under it
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
v += m.completionView(0) // suggested completion
} else {
if m.focus && m.canAcceptSuggestion() {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
m.Cursor.TextStyle = m.CompletionStyle
m.Cursor.SetChar(m.echoTransform(string(suggestion[pos])))
v += m.Cursor.View()
v += m.completionView(1)
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
}
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
}
}
// If a max width and background color were set fill the empty spaces with
// the background color.
valWidth := uniseg.StringWidth(string(value))
if m.Width > 0 && valWidth <= m.Width {
padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) {
padding++
}
v += styleText(strings.Repeat(" ", padding))
}
return m.PromptStyle.Render(m.Prompt) + v
}
// placeholderView returns the prompt and placeholder view, if any.
func (m Model) placeholderView() string {
var (
v string
style = m.PlaceholderStyle.Inline(true).Render
)
p := make([]rune, m.Width+1)
copy(p, []rune(m.Placeholder))
m.Cursor.TextStyle = m.PlaceholderStyle
m.Cursor.SetChar(string(p[:1]))
v += m.Cursor.View()
// If the entire placeholder is already set and no padding is needed, finish
if m.Width < 1 && len(p) <= 1 {
return m.PromptStyle.Render(m.Prompt) + v
}
// If Width is set then size placeholder accordingly
if m.Width > 0 {
// available width is width - len + cursor offset of 1
minWidth := lipgloss.Width(m.Placeholder)
availWidth := m.Width - minWidth + 1
// if width < len, 'subtract'(add) number to len and dont add padding
if availWidth < 0 {
minWidth += availWidth
availWidth = 0
}
// append placeholder[len] - cursor, append padding
v += style(string(p[1:minWidth]))
v += style(strings.Repeat(" ", availWidth))
} else {
// if there is no width, the placeholder can be any length
v += style(string(p[1:]))
}
return m.PromptStyle.Render(m.Prompt) + v
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return cursor.Blink()
}
// Paste is a command for pasting from the clipboard into the text input.
func Paste() tea.Msg {
str, err := clipboard.ReadAll()
if err != nil {
return pasteErrMsg{err}
}
return pasteMsg(str)
}
func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v))
}
// Deprecated.
// Deprecated: use [cursor.Mode].
//
//nolint:revive
type CursorMode int
//nolint:revive
const (
// Deprecated: use [cursor.CursorBlink].
CursorBlink = CursorMode(cursor.CursorBlink)
// Deprecated: use [cursor.CursorStatic].
CursorStatic = CursorMode(cursor.CursorStatic)
// Deprecated: use [cursor.CursorHide].
CursorHide = CursorMode(cursor.CursorHide)
)
func (c CursorMode) String() string {
return cursor.Mode(c).String()
}
// Deprecated: use [cursor.Mode].
//
//nolint:revive
func (m Model) CursorMode() CursorMode {
return CursorMode(m.Cursor.Mode())
}
// Deprecated: use cursor.SetMode().
//
//nolint:revive
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
return m.Cursor.SetMode(cursor.Mode(mode))
}
func (m Model) completionView(offset int) string {
var (
value = m.value
style = m.PlaceholderStyle.Inline(true).Render
)
if m.canAcceptSuggestion() {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
return style(string(suggestion[len(value)+offset:]))
}
}
return ""
}
func (m *Model) getSuggestions(sugs [][]rune) []string {
suggestions := make([]string, len(sugs))
for i, s := range sugs {
suggestions[i] = string(s)
}
return suggestions
}
// AvailableSuggestions returns the list of available suggestions.
func (m *Model) AvailableSuggestions() []string {
return m.getSuggestions(m.suggestions)
}
// MatchedSuggestions returns the list of matched suggestions.
func (m *Model) MatchedSuggestions() []string {
return m.getSuggestions(m.matchedSuggestions)
}
// CurrentSuggestionIndex returns the currently selected suggestion index.
func (m *Model) CurrentSuggestionIndex() int {
return m.currentSuggestionIndex
}
// CurrentSuggestion returns the currently selected suggestion.
func (m *Model) CurrentSuggestion() string {
if m.currentSuggestionIndex >= len(m.matchedSuggestions) {
return ""
}
return string(m.matchedSuggestions[m.currentSuggestionIndex])
}
// canAcceptSuggestion returns whether there is an acceptable suggestion to
// autocomplete the current value.
func (m *Model) canAcceptSuggestion() bool {
return len(m.matchedSuggestions) > 0
}
// updateSuggestions refreshes the list of matching suggestions.
func (m *Model) updateSuggestions() {
if !m.ShowSuggestions {
return
}
if len(m.value) <= 0 || len(m.suggestions) <= 0 {
m.matchedSuggestions = [][]rune{}
return
}
matches := [][]rune{}
for _, s := range m.suggestions {
suggestion := string(s)
if strings.HasPrefix(strings.ToLower(suggestion), strings.ToLower(string(m.value))) {
matches = append(matches, []rune(suggestion))
}
}
if !reflect.DeepEqual(matches, m.matchedSuggestions) {
m.currentSuggestionIndex = 0
}
m.matchedSuggestions = matches
}
// nextSuggestion selects the next suggestion.
func (m *Model) nextSuggestion() {
m.currentSuggestionIndex = (m.currentSuggestionIndex + 1)
if m.currentSuggestionIndex >= len(m.matchedSuggestions) {
m.currentSuggestionIndex = 0
}
}
// previousSuggestion selects the previous suggestion.
func (m *Model) previousSuggestion() {
m.currentSuggestionIndex = (m.currentSuggestionIndex - 1)
if m.currentSuggestionIndex < 0 {
m.currentSuggestionIndex = len(m.matchedSuggestions) - 1
}
}
func (m Model) validate(v []rune) error {
if m.Validate != nil {
return m.Validate(string(v))
}
return nil
}

View File

@ -0,0 +1,60 @@
// Package viewport provides a component for rendering a viewport in a Bubble
// Tea.
package viewport
import "github.com/charmbracelet/bubbles/key"
const spacebar = " "
// KeyMap defines the keybindings for the viewport. Note that you don't
// necessary need to use keybindings at all; the viewport can be controlled
// programmatically with methods like Model.LineDown(1). See the GoDocs for
// details.
type KeyMap struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
Down key.Binding
Up key.Binding
Left key.Binding
Right key.Binding
}
// DefaultKeyMap returns a set of pager-like default keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
PageDown: key.NewBinding(
key.WithKeys("pgdown", spacebar, "f"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup", "b"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←/h", "move left"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→/l", "move right"),
),
}
}

View File

@ -0,0 +1,544 @@
package viewport
import (
"math"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// New returns a new model with the given width and height as well as default
// key mappings.
func New(width, height int) (m Model) {
m.Width = width
m.Height = height
m.setInitialValues()
return m
}
// Model is the Bubble Tea model for this viewport element.
type Model struct {
Width int
Height int
KeyMap KeyMap
// Whether or not to respond to the mouse. The mouse must be enabled in
// Bubble Tea for this to work. For details, see the Bubble Tea docs.
MouseWheelEnabled bool
// The number of lines the mouse wheel will scroll. By default, this is 3.
MouseWheelDelta int
// YOffset is the vertical scroll position.
YOffset int
// xOffset is the horizontal scroll position.
xOffset int
// horizontalStep is the number of columns we move left or right during a
// default horizontal scroll.
horizontalStep int
// YPosition is the position of the viewport in relation to the terminal
// window. It's used in high performance rendering only.
YPosition int
// Style applies a lipgloss style to the viewport. Realistically, it's most
// useful for setting borders, margins and padding.
Style lipgloss.Style
// HighPerformanceRendering bypasses the normal Bubble Tea renderer to
// provide higher performance rendering. Most of the time the normal Bubble
// Tea rendering methods will suffice, but if you're passing content with
// a lot of ANSI escape codes you may see improved rendering in certain
// terminals with this enabled.
//
// This should only be used in program occupying the entire terminal,
// which is usually via the alternate screen buffer.
//
// Deprecated: high performance rendering is now deprecated in Bubble Tea.
HighPerformanceRendering bool
initialized bool
lines []string
longestLineWidth int
}
func (m *Model) setInitialValues() {
m.KeyMap = DefaultKeyMap()
m.MouseWheelEnabled = true
m.MouseWheelDelta = 3
m.initialized = true
}
// Init exists to satisfy the tea.Model interface for composability purposes.
func (m Model) Init() tea.Cmd {
return nil
}
// AtTop returns whether or not the viewport is at the very top position.
func (m Model) AtTop() bool {
return m.YOffset <= 0
}
// AtBottom returns whether or not the viewport is at or past the very bottom
// position.
func (m Model) AtBottom() bool {
return m.YOffset >= m.maxYOffset()
}
// PastBottom returns whether or not the viewport is scrolled beyond the last
// line. This can happen when adjusting the viewport height.
func (m Model) PastBottom() bool {
return m.YOffset > m.maxYOffset()
}
// ScrollPercent returns the amount scrolled as a float between 0 and 1.
func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) {
return 1.0
}
y := float64(m.YOffset)
h := float64(m.Height)
t := float64(len(m.lines))
v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v))
}
// HorizontalScrollPercent returns the amount horizontally scrolled as a float
// between 0 and 1.
func (m Model) HorizontalScrollPercent() float64 {
if m.xOffset >= m.longestLineWidth-m.Width {
return 1.0
}
y := float64(m.xOffset)
h := float64(m.Width)
t := float64(m.longestLineWidth)
v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v))
}
// SetContent set the pager's text content.
func (m *Model) SetContent(s string) {
s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings
m.lines = strings.Split(s, "\n")
m.longestLineWidth = findLongestLineWidth(m.lines)
if m.YOffset > len(m.lines)-1 {
m.GotoBottom()
}
}
// maxYOffset returns the maximum possible value of the y-offset based on the
// viewport's content and set height.
func (m Model) maxYOffset() int {
return max(0, len(m.lines)-m.Height+m.Style.GetVerticalFrameSize())
}
// visibleLines returns the lines that should currently be visible in the
// viewport.
func (m Model) visibleLines() (lines []string) {
h := m.Height - m.Style.GetVerticalFrameSize()
w := m.Width - m.Style.GetHorizontalFrameSize()
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := clamp(m.YOffset+h, top, len(m.lines))
lines = m.lines[top:bottom]
}
if (m.xOffset == 0 && m.longestLineWidth <= w) || w == 0 {
return lines
}
cutLines := make([]string, len(lines))
for i := range lines {
cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+w)
}
return cutLines
}
// scrollArea returns the scrollable boundaries for high performance rendering.
//
// Deprecated: high performance rendering is deprecated in Bubble Tea.
func (m Model) scrollArea() (top, bottom int) {
top = max(0, m.YPosition)
bottom = max(top, top+m.Height)
if top > 0 && bottom > top {
bottom--
}
return top, bottom
}
// SetYOffset sets the Y offset.
func (m *Model) SetYOffset(n int) {
m.YOffset = clamp(n, 0, m.maxYOffset())
}
// ViewDown moves the view down by the number of lines in the viewport.
// Basically, "page down".
//
// Deprecated: use [Model.PageDown] instead.
func (m *Model) ViewDown() []string {
return m.PageDown()
}
// PageDown moves the view down by the number of lines in the viewport.
func (m *Model) PageDown() []string {
if m.AtBottom() {
return nil
}
return m.ScrollDown(m.Height)
}
// ViewUp moves the view up by one height of the viewport.
// Basically, "page up".
//
// Deprecated: use [Model.PageUp] instead.
func (m *Model) ViewUp() []string {
return m.PageUp()
}
// PageUp moves the view up by one height of the viewport.
func (m *Model) PageUp() []string {
if m.AtTop() {
return nil
}
return m.ScrollUp(m.Height)
}
// HalfViewDown moves the view down by half the height of the viewport.
//
// Deprecated: use [Model.HalfPageDown] instead.
func (m *Model) HalfViewDown() (lines []string) {
return m.HalfPageDown()
}
// HalfPageDown moves the view down by half the height of the viewport.
func (m *Model) HalfPageDown() (lines []string) {
if m.AtBottom() {
return nil
}
return m.ScrollDown(m.Height / 2) //nolint:mnd
}
// HalfViewUp moves the view up by half the height of the viewport.
//
// Deprecated: use [Model.HalfPageUp] instead.
func (m *Model) HalfViewUp() (lines []string) {
return m.HalfPageUp()
}
// HalfPageUp moves the view up by half the height of the viewport.
func (m *Model) HalfPageUp() (lines []string) {
if m.AtTop() {
return nil
}
return m.ScrollUp(m.Height / 2) //nolint:mnd
}
// LineDown moves the view down by the given number of lines.
//
// Deprecated: use [Model.ScrollDown] instead.
func (m *Model) LineDown(n int) (lines []string) {
return m.ScrollDown(n)
}
// ScrollDown moves the view down by the given number of lines.
func (m *Model) ScrollDown(n int) (lines []string) {
if m.AtBottom() || n == 0 || len(m.lines) == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we actually have left before we reach
// the bottom.
m.SetYOffset(m.YOffset + n)
// Gather lines to send off for performance scrolling.
//
// XXX: high performance rendering is deprecated in Bubble Tea.
bottom := clamp(m.YOffset+m.Height, 0, len(m.lines))
top := clamp(m.YOffset+m.Height-n, 0, bottom)
return m.lines[top:bottom]
}
// LineUp moves the view down by the given number of lines. Returns the new
// lines to show.
//
// Deprecated: use [Model.ScrollUp] instead.
func (m *Model) LineUp(n int) (lines []string) {
return m.ScrollUp(n)
}
// ScrollUp moves the view down by the given number of lines. Returns the new
// lines to show.
func (m *Model) ScrollUp(n int) (lines []string) {
if m.AtTop() || n == 0 || len(m.lines) == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we are from the top.
m.SetYOffset(m.YOffset - n)
// Gather lines to send off for performance scrolling.
//
// XXX: high performance rendering is deprecated in Bubble Tea.
top := max(0, m.YOffset)
bottom := clamp(m.YOffset+n, 0, m.maxYOffset())
return m.lines[top:bottom]
}
// SetHorizontalStep sets the default amount of columns to scroll left or right
// with the default viewport key map.
//
// If set to 0 or less, horizontal scrolling is disabled.
//
// On v1, horizontal scrolling is disabled by default.
func (m *Model) SetHorizontalStep(n int) {
m.horizontalStep = max(n, 0)
}
// SetXOffset sets the X offset.
func (m *Model) SetXOffset(n int) {
m.xOffset = clamp(n, 0, m.longestLineWidth-m.Width)
}
// ScrollLeft moves the viewport to the left by the given number of columns.
func (m *Model) ScrollLeft(n int) {
m.SetXOffset(m.xOffset - n)
}
// ScrollRight moves viewport to the right by the given number of columns.
func (m *Model) ScrollRight(n int) {
m.SetXOffset(m.xOffset + n)
}
// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
func (m Model) TotalLineCount() int {
return len(m.lines)
}
// VisibleLineCount returns the number of the visible lines within the viewport.
func (m Model) VisibleLineCount() int {
return len(m.visibleLines())
}
// GotoTop sets the viewport to the top position.
func (m *Model) GotoTop() (lines []string) {
if m.AtTop() {
return nil
}
m.SetYOffset(0)
return m.visibleLines()
}
// GotoBottom sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.SetYOffset(m.maxYOffset())
return m.visibleLines()
}
// Sync tells the renderer where the viewport will be located and requests
// a render of the current state of the viewport. It should be called for the
// first render and after a window resize.
//
// For high performance rendering only.
//
// Deprecated: high performance rendering is deprecated in Bubble Tea.
func Sync(m Model) tea.Cmd {
if len(m.lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
return tea.SyncScrollArea(m.visibleLines(), top, bottom)
}
// ViewDown is a high performance command that moves the viewport up by a given
// number of lines. Use Model.ViewDown to get the lines that should be rendered.
// For example:
//
// lines := model.ViewDown(1)
// cmd := ViewDown(m, lines)
//
// Deprecated: high performance rendering is deprecated in Bubble Tea.
func ViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
// XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we
// won't need to return a command here.
return tea.ScrollDown(lines, top, bottom)
}
// ViewUp is a high performance command the moves the viewport down by a given
// number of lines height. Use Model.ViewUp to get the lines that should be
// rendered.
//
// Deprecated: high performance rendering is deprecated in Bubble Tea.
func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
// XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we
// won't need to return a command here.
return tea.ScrollUp(lines, top, bottom)
}
// Update handles standard message-based viewport updates.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
m, cmd = m.updateAsModel(msg)
return m, cmd
}
// Author's note: this method has been broken out to make it easier to
// potentially transition Update to satisfy tea.Model.
func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) {
if !m.initialized {
m.setInitialValues()
}
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.PageDown):
lines := m.PageDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.PageUp):
lines := m.PageUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case key.Matches(msg, m.KeyMap.HalfPageDown):
lines := m.HalfPageDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.HalfPageUp):
lines := m.HalfPageUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case key.Matches(msg, m.KeyMap.Down):
lines := m.ScrollDown(1)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.Up):
lines := m.ScrollUp(1)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case key.Matches(msg, m.KeyMap.Left):
m.ScrollLeft(m.horizontalStep)
case key.Matches(msg, m.KeyMap.Right):
m.ScrollRight(m.horizontalStep)
}
case tea.MouseMsg:
if !m.MouseWheelEnabled || msg.Action != tea.MouseActionPress {
break
}
switch msg.Button { //nolint:exhaustive
case tea.MouseButtonWheelUp:
if msg.Shift {
// Note that not every terminal emulator sends the shift event for mouse actions by default (looking at you Konsole)
m.ScrollLeft(m.horizontalStep)
} else {
lines := m.ScrollUp(m.MouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
}
case tea.MouseButtonWheelDown:
if msg.Shift {
m.ScrollRight(m.horizontalStep)
} else {
lines := m.ScrollDown(m.MouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
}
// Note that not every terminal emulator sends the horizontal wheel events by default (looking at you Konsole)
case tea.MouseButtonWheelLeft:
m.ScrollLeft(m.horizontalStep)
case tea.MouseButtonWheelRight:
m.ScrollRight(m.horizontalStep)
}
}
return m, cmd
}
// View renders the viewport into a string.
func (m Model) View() string {
if m.HighPerformanceRendering {
// Just send newlines since we're going to be rendering the actual
// content separately. We still need to send something that equals the
// height of this view so that the Bubble Tea standard renderer can
// position anything below this view properly.
return strings.Repeat("\n", max(0, m.Height-1))
}
w, h := m.Width, m.Height
if sw := m.Style.GetWidth(); sw != 0 {
w = min(w, sw)
}
if sh := m.Style.GetHeight(); sh != 0 {
h = min(h, sh)
}
contentWidth := w - m.Style.GetHorizontalFrameSize()
contentHeight := h - m.Style.GetVerticalFrameSize()
contents := lipgloss.NewStyle().
Width(contentWidth). // pad to width.
Height(contentHeight). // pad to height.
MaxHeight(contentHeight). // truncate height if taller.
MaxWidth(contentWidth). // truncate width if wider.
Render(strings.Join(m.visibleLines(), "\n"))
return m.Style.
UnsetWidth().UnsetHeight(). // Style size already applied in contents.
Render(contents)
}
func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v))
}
func findLongestLineWidth(lines []string) int {
w := 0
for _, l := range lines {
if ww := ansi.StringWidth(l); ww > w {
w = ww
}
}
return w
}

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Brandon Fulljames
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,439 +0,0 @@
package table
import "github.com/charmbracelet/lipgloss"
// Border defines the borders in and around the table.
type Border struct {
Top string
Left string
Right string
Bottom string
TopRight string
TopLeft string
BottomRight string
BottomLeft string
TopJunction string
LeftJunction string
RightJunction string
BottomJunction string
InnerJunction string
InnerDivider string
// Styles for 2x2 tables and larger
styleMultiTopLeft lipgloss.Style
styleMultiTop lipgloss.Style
styleMultiTopRight lipgloss.Style
styleMultiRight lipgloss.Style
styleMultiBottomRight lipgloss.Style
styleMultiBottom lipgloss.Style
styleMultiBottomLeft lipgloss.Style
styleMultiLeft lipgloss.Style
styleMultiInner lipgloss.Style
// Styles for a single column table
styleSingleColumnTop lipgloss.Style
styleSingleColumnInner lipgloss.Style
styleSingleColumnBottom lipgloss.Style
// Styles for a single row table
styleSingleRowLeft lipgloss.Style
styleSingleRowInner lipgloss.Style
styleSingleRowRight lipgloss.Style
// Style for a table with only one cell
styleSingleCell lipgloss.Style
// Style for the footer
styleFooter lipgloss.Style
}
var (
// https://www.w3.org/TR/xml-entity-names/025.html
borderDefault = Border{
Top: "━",
Left: "┃",
Right: "┃",
Bottom: "━",
TopRight: "┓",
TopLeft: "┏",
BottomRight: "┛",
BottomLeft: "┗",
TopJunction: "┳",
LeftJunction: "┣",
RightJunction: "┫",
BottomJunction: "┻",
InnerJunction: "╋",
InnerDivider: "┃",
}
borderRounded = Border{
Top: "─",
Left: "│",
Right: "│",
Bottom: "─",
TopRight: "╮",
TopLeft: "╭",
BottomRight: "╯",
BottomLeft: "╰",
TopJunction: "┬",
LeftJunction: "├",
RightJunction: "┤",
BottomJunction: "┴",
InnerJunction: "┼",
InnerDivider: "│",
}
)
func init() {
borderDefault.generateStyles()
borderRounded.generateStyles()
}
func (b *Border) generateStyles() {
b.generateMultiStyles()
b.generateSingleColumnStyles()
b.generateSingleRowStyles()
b.generateSingleCellStyle()
// The footer is a single cell with the top taken off... usually. We can
// re-enable the top if needed this way for certain format configurations.
b.styleFooter = b.styleSingleCell.Copy().
Align(lipgloss.Right).
BorderBottom(true).
BorderRight(true).
BorderLeft(true)
}
func (b *Border) styleLeftWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomLeft = b.LeftJunction
return original.Copy().BorderStyle(border)
}
func (b *Border) styleRightWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomRight = b.RightJunction
return original.Copy().BorderStyle(border)
}
func (b *Border) styleBothWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomLeft = b.LeftJunction
border.BottomRight = b.RightJunction
return original.Copy().BorderStyle(border)
}
// This function is long, but it's just repetitive...
//
//nolint:funlen
func (b *Border) generateMultiStyles() {
b.styleMultiTopLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
TopLeft: b.TopLeft,
Top: b.Top,
TopRight: b.TopJunction,
Right: b.InnerDivider,
BottomRight: b.InnerJunction,
Bottom: b.Bottom,
BottomLeft: b.LeftJunction,
Left: b.Left,
},
)
b.styleMultiTop = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.InnerDivider,
Bottom: b.Bottom,
TopRight: b.TopJunction,
BottomRight: b.InnerJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleMultiTopRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.Right,
Bottom: b.Bottom,
TopRight: b.TopRight,
BottomRight: b.RightJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleMultiLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.InnerDivider,
},
).BorderRight(true).BorderLeft(true)
b.styleMultiRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.Right,
},
).BorderRight(true)
b.styleMultiInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.InnerDivider,
},
).BorderRight(true)
b.styleMultiBottomLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomJunction,
},
).BorderLeft(true).BorderBottom(true).BorderRight(true)
b.styleMultiBottom = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomRight: b.BottomJunction,
},
).BorderBottom(true).BorderRight(true)
b.styleMultiBottomRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.Right,
Bottom: b.Bottom,
BottomRight: b.BottomRight,
},
).BorderBottom(true).BorderRight(true)
}
func (b *Border) generateSingleColumnStyles() {
b.styleSingleColumnTop = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
TopLeft: b.TopLeft,
TopRight: b.TopRight,
BottomLeft: b.LeftJunction,
BottomRight: b.RightJunction,
},
)
b.styleSingleColumnInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.Right,
},
).BorderRight(true).BorderLeft(true)
b.styleSingleColumnBottom = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomRight,
},
).BorderRight(true).BorderLeft(true).BorderBottom(true)
}
func (b *Border) generateSingleRowStyles() {
b.styleSingleRowLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomJunction,
TopRight: b.TopJunction,
TopLeft: b.TopLeft,
},
)
b.styleSingleRowInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomRight: b.BottomJunction,
TopRight: b.TopJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleSingleRowRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.Right,
Bottom: b.Bottom,
BottomRight: b.BottomRight,
TopRight: b.TopRight,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
}
func (b *Border) generateSingleCellStyle() {
b.styleSingleCell = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomRight,
TopRight: b.TopRight,
TopLeft: b.TopLeft,
},
)
}
// BorderDefault uses the basic square border, useful to reset the border if
// it was changed somehow.
func (m Model) BorderDefault() Model {
// Already generated styles
m.border = borderDefault
return m
}
// BorderRounded uses a thin, rounded border.
func (m Model) BorderRounded() Model {
// Already generated styles
m.border = borderRounded
return m
}
// Border uses the given border components to render the table.
func (m Model) Border(border Border) Model {
border.generateStyles()
m.border = border
return m
}
type borderStyleRow struct {
left lipgloss.Style
inner lipgloss.Style
right lipgloss.Style
}
func (b *borderStyleRow) inherit(s lipgloss.Style) {
b.left = b.left.Copy().Inherit(s)
b.inner = b.inner.Copy().Inherit(s)
b.right = b.right.Copy().Inherit(s)
}
// There's a lot of branches here, but splitting it up further would make it
// harder to follow. So just be careful with comments and make sure it's tested!
//
//nolint:nestif
func (m Model) styleHeaders() borderStyleRow {
hasRows := len(m.GetVisibleRows()) > 0 || m.calculatePadding(0) > 0
singleColumn := len(m.columns) == 1
styles := borderStyleRow{}
// Possible configurations:
// - Single cell
// - Single row
// - Single column
// - Multi
if singleColumn {
if hasRows {
// Single column
styles.left = m.border.styleSingleColumnTop
styles.inner = styles.left
styles.right = styles.left
} else {
// Single cell
styles.left = m.border.styleSingleCell
styles.inner = styles.left
styles.right = styles.left
if m.hasFooter() {
styles.left = m.border.styleBothWithFooter(styles.left)
}
}
} else if !hasRows {
// Single row
styles.left = m.border.styleSingleRowLeft
styles.inner = m.border.styleSingleRowInner
styles.right = m.border.styleSingleRowRight
if m.hasFooter() {
styles.left = m.border.styleLeftWithFooter(styles.left)
styles.right = m.border.styleRightWithFooter(styles.right)
}
} else {
// Multi
styles.left = m.border.styleMultiTopLeft
styles.inner = m.border.styleMultiTop
styles.right = m.border.styleMultiTopRight
}
styles.inherit(m.headerStyle)
return styles
}
func (m Model) styleRows() (inner borderStyleRow, last borderStyleRow) {
if len(m.columns) == 1 {
inner.left = m.border.styleSingleColumnInner
inner.inner = inner.left
inner.right = inner.left
last.left = m.border.styleSingleColumnBottom
if m.hasFooter() {
last.left = m.border.styleBothWithFooter(last.left)
}
last.inner = last.left
last.right = last.left
} else {
inner.left = m.border.styleMultiLeft
inner.inner = m.border.styleMultiInner
inner.right = m.border.styleMultiRight
last.left = m.border.styleMultiBottomLeft
last.inner = m.border.styleMultiBottom
last.right = m.border.styleMultiBottomRight
if m.hasFooter() {
last.left = m.border.styleLeftWithFooter(last.left)
last.right = m.border.styleRightWithFooter(last.right)
}
}
return inner, last
}

View File

@ -1,36 +0,0 @@
package table
// Keep compatibility with Go 1.21 by re-declaring min.
//
//nolint:predeclared
func min(x, y int) int {
if x < y {
return x
}
return y
}
// Keep compatibility with Go 1.21 by re-declaring max.
//
//nolint:predeclared
func max(x, y int) int {
if x > y {
return x
}
return y
}
// These var names are fine for this little function
//
//nolint:varnamelen
func gcd(x, y int) int {
if x == 0 {
return y
} else if y == 0 {
return x
}
return gcd(y%x, x)
}

View File

@ -1,60 +0,0 @@
package table
import "github.com/charmbracelet/lipgloss"
// StyledCell represents a cell in the table that has a particular style applied.
// The cell style takes highest precedence and will overwrite more general styles
// from the row, column, or table as a whole. This style should be generally
// limited to colors, font style, and alignments - spacing style such as margin
// will break the table format.
type StyledCell struct {
// Data is the content of the cell.
Data any
// Style is the specific style to apply. This is ignored if StyleFunc is not nil.
Style lipgloss.Style
// StyleFunc is a function that takes the row/column of the cell and
// returns a lipgloss.Style allowing for dynamic styling based on the cell's
// content or position. Overrides Style if set.
StyleFunc StyledCellFunc
}
// StyledCellFuncInput is the input to the StyledCellFunc. Sent as a struct
// to allow for future additions without breaking changes.
type StyledCellFuncInput struct {
// Data is the data in the cell.
Data any
// Column is the column that the cell belongs to.
Column Column
// Row is the row that the cell belongs to.
Row Row
// GlobalMetadata is the global table metadata that's been set by WithGlobalMetadata
GlobalMetadata map[string]any
}
// StyledCellFunc is a function that takes various information about the cell and
// returns a lipgloss.Style allowing for easier dynamic styling based on the cell's
// content or position.
type StyledCellFunc = func(input StyledCellFuncInput) lipgloss.Style
// NewStyledCell creates an entry that can be set in the row data and show as
// styled with the given style.
func NewStyledCell(data any, style lipgloss.Style) StyledCell {
return StyledCell{
Data: data,
Style: style,
}
}
// NewStyledCellWithStyleFunc creates an entry that can be set in the row data and show as
// styled with the given style function.
func NewStyledCellWithStyleFunc(data any, styleFunc StyledCellFunc) StyledCell {
return StyledCell{
Data: data,
StyleFunc: styleFunc,
}
}

View File

@ -1,118 +0,0 @@
package table
import (
"github.com/charmbracelet/lipgloss"
)
// Column is a column in the table.
type Column struct {
title string
key string
width int
flexFactor int
filterable bool
style lipgloss.Style
fmtString string
}
// NewColumn creates a new fixed-width column with the given information.
func NewColumn(key, title string, width int) Column {
return Column{
key: key,
title: title,
width: width,
filterable: false,
}
}
// NewFlexColumn creates a new flexible width column that tries to fill in the
// total table width. If multiple flex columns exist, each will measure against
// each other depending on their flexFactor. For example, if both have a flexFactor
// of 1, they will have equal width. If one has a flexFactor of 1 and the other
// has a flexFactor of 3, the second will be 3 times larger than the first. You
// must use WithTargetWidth if you have any flex columns, so that the table knows
// how much width it should fill.
func NewFlexColumn(key, title string, flexFactor int) Column {
return Column{
key: key,
title: title,
flexFactor: max(flexFactor, 1),
}
}
// WithStyle applies a style to the column as a whole.
func (c Column) WithStyle(style lipgloss.Style) Column {
c.style = style.Copy().Width(c.width)
return c
}
// WithFiltered sets whether the column should be considered for filtering (true)
// or not (false).
func (c Column) WithFiltered(filterable bool) Column {
c.filterable = filterable
return c
}
// WithFormatString sets the format string used by fmt.Sprintf to display the data.
// If not set, the default is "%v" for all data types. Intended mainly for
// numeric formatting.
//
// Since data is of the any type, make sure that all data in the column
// is of the expected type or the format may fail. For example, hardcoding '3'
// instead of '3.0' and using '%.2f' will fail because '3' is an integer.
func (c Column) WithFormatString(fmtString string) Column {
c.fmtString = fmtString
return c
}
func (c *Column) isFlex() bool {
return c.flexFactor != 0
}
// Title returns the title of the column.
func (c Column) Title() string {
return c.title
}
// Key returns the key of the column.
func (c Column) Key() string {
return c.key
}
// Width returns the width of the column.
func (c Column) Width() int {
return c.width
}
// FlexFactor returns the flex factor of the column.
func (c Column) FlexFactor() int {
return c.flexFactor
}
// IsFlex returns whether the column is a flex column.
func (c Column) IsFlex() bool {
return c.isFlex()
}
// Filterable returns whether the column is filterable.
func (c Column) Filterable() bool {
return c.filterable
}
// Style returns the style of the column.
func (c Column) Style() lipgloss.Style {
return c.style
}
// FmtString returns the format string of the column.
func (c Column) FmtString() string {
return c.fmtString
}

View File

@ -1,67 +0,0 @@
package table
import "time"
// This is just a bunch of data type checks, so... no linting here
//
//nolint:cyclop
func asInt(data any) (int64, bool) {
switch val := data.(type) {
case int:
return int64(val), true
case int8:
return int64(val), true
case int16:
return int64(val), true
case int32:
return int64(val), true
case int64:
return val, true
case uint:
// #nosec: G115
return int64(val), true
case uint8:
return int64(val), true
case uint16:
return int64(val), true
case uint32:
return int64(val), true
case uint64:
// #nosec: G115
return int64(val), true
case time.Duration:
return int64(val), true
case StyledCell:
return asInt(val.Data)
}
return 0, false
}
func asNumber(data any) (float64, bool) {
switch val := data.(type) {
case float32:
return float64(val), true
case float64:
return val, true
case StyledCell:
return asNumber(val.Data)
}
intVal, isInt := asInt(data)
return float64(intVal), isInt
}

View File

@ -1,116 +0,0 @@
package table
import (
"github.com/charmbracelet/lipgloss"
)
func (m *Model) recalculateWidth() {
if m.targetTotalWidth != 0 {
m.totalWidth = m.targetTotalWidth
} else {
total := 0
for _, column := range m.columns {
total += column.width
}
m.totalWidth = total + len(m.columns) + 1
}
updateColumnWidths(m.columns, m.targetTotalWidth)
m.recalculateLastHorizontalColumn()
}
// Updates column width in-place. This could be optimized but should be called
// very rarely so we prioritize simplicity over performance here.
func updateColumnWidths(cols []Column, totalWidth int) {
totalFlexWidth := totalWidth - len(cols) - 1
totalFlexFactor := 0
flexGCD := 0
for index, col := range cols {
if !col.isFlex() {
totalFlexWidth -= col.width
cols[index].style = col.style.Width(col.width)
} else {
totalFlexFactor += col.flexFactor
flexGCD = gcd(flexGCD, col.flexFactor)
}
}
if totalFlexFactor == 0 {
return
}
// We use the GCD here because otherwise very large values won't divide
// nicely as ints
totalFlexFactor /= flexGCD
flexUnit := totalFlexWidth / totalFlexFactor
leftoverWidth := totalFlexWidth % totalFlexFactor
for index := range cols {
if !cols[index].isFlex() {
continue
}
width := flexUnit * (cols[index].flexFactor / flexGCD)
if leftoverWidth > 0 {
width++
leftoverWidth--
}
if index == len(cols)-1 {
width += leftoverWidth
leftoverWidth = 0
}
width = max(width, 1)
cols[index].width = width
// Take borders into account for the actual style
cols[index].style = cols[index].style.Width(width)
}
}
func (m *Model) recalculateHeight() {
header := m.renderHeaders()
headerHeight := 1 // Header always has the top border
if m.headerVisible {
headerHeight = lipgloss.Height(header)
}
footer := m.renderFooter(lipgloss.Width(header), false)
var footerHeight int
if footer != "" {
footerHeight = lipgloss.Height(footer)
}
m.metaHeight = headerHeight + footerHeight
}
func (m *Model) calculatePadding(numRows int) int {
if m.minimumHeight == 0 {
return 0
}
padding := m.minimumHeight - m.metaHeight - numRows - 1 // additional 1 for bottom border
if padding == 0 && numRows == 0 {
// This is an edge case where we want to add 1 additional line of height, i.e.
// add a border without an empty row. However, this is not possible, so we need
// to add an extra row which will result in the table being 1 row taller than
// the requested minimum height.
return 1
}
if padding < 0 {
// Table is already larger than minimum height, do nothing.
return 0
}
return padding
}

View File

@ -1,39 +0,0 @@
/*
Package table contains a Bubble Tea component for an interactive and customizable
table.
The simplest useful table can be created with table.New(...).WithRows(...). Row
data should map to the column keys, as shown below. Note that extra data will
simply not be shown, while missing data will be safely blank in the row's cell.
const (
// This is not necessary, but recommended to avoid typos
columnKeyName = "name"
columnKeyCount = "count"
)
// Define the columns and how they appear
columns := []table.Column{
table.NewColumn(columnKeyName, "Name", 10),
table.NewColumn(columnKeyCount, "Count", 6),
}
// Define the data that will be in the table, mapping to the column keys
rows := []table.Row{
table.NewRow(table.RowData{
columnKeyName: "Cheeseburger",
columnKeyCount: 3,
}),
table.NewRow(table.RowData{
columnKeyName: "Fries",
columnKeyCount: 2,
}),
}
// Create the table
tbl := table.New(columns).WithRows(rows)
// Use it like any Bubble Tea component in your view
tbl.View()
*/
package table

View File

@ -1,60 +0,0 @@
package table
// UserEvent is some state change that has occurred due to user input. These will
// ONLY be generated when a user has interacted directly with the table. These
// will NOT be generated when code programmatically changes values in the table.
type UserEvent any
func (m *Model) appendUserEvent(e UserEvent) {
m.lastUpdateUserEvents = append(m.lastUpdateUserEvents, e)
}
func (m *Model) clearUserEvents() {
m.lastUpdateUserEvents = nil
}
// GetLastUpdateUserEvents returns a list of events that happened due to user
// input in the last Update call. This is useful to look for triggers such as
// whether the user moved to a new highlighted row.
func (m *Model) GetLastUpdateUserEvents() []UserEvent {
// Most common case
if len(m.lastUpdateUserEvents) == 0 {
return nil
}
returned := make([]UserEvent, len(m.lastUpdateUserEvents))
// Slightly wasteful but helps guarantee immutability, and this should only
// have data very rarely so this is fine
copy(returned, m.lastUpdateUserEvents)
return returned
}
// UserEventHighlightedIndexChanged indicates that the user has scrolled to a new
// row.
type UserEventHighlightedIndexChanged struct {
// PreviousRow is the row that was selected before the change.
PreviousRowIndex int
// SelectedRow is the row index that is now selected
SelectedRowIndex int
}
// UserEventRowSelectToggled indicates that the user has either selected or
// deselected a row by toggling the selection. The event contains information
// about which row index was selected and whether it was selected or deselected.
type UserEventRowSelectToggled struct {
RowIndex int
IsSelected bool
}
// UserEventFilterInputFocused indicates that the user has focused the filter
// text input, so that any other typing will type into the filter field. Only
// activates for the built-in filter text box.
type UserEventFilterInputFocused struct{}
// UserEventFilterInputUnfocused indicates that the user has unfocused the filter
// text input, which means the user is done typing into the filter field. Only
// activates for the built-in filter text box.
type UserEventFilterInputUnfocused struct{}

View File

@ -1,164 +0,0 @@
package table
import (
"fmt"
"strings"
)
// FilterFuncInput is the input to a FilterFunc. It's a struct so we can add more things later
// without breaking compatibility.
type FilterFuncInput struct {
// Columns is a list of the columns of the table
Columns []Column
// Row is the row that's being considered for filtering
Row Row
// GlobalMetadata is an arbitrary set of metadata from the table set by WithGlobalMetadata
GlobalMetadata map[string]any
// Filter is the filter string input to consider
Filter string
}
// FilterFunc takes a FilterFuncInput and returns true if the row should be visible,
// or false if the row should be hidden.
type FilterFunc func(FilterFuncInput) bool
func (m Model) getFilteredRows(rows []Row) []Row {
filterInputValue := m.filterTextInput.Value()
if !m.filtered || filterInputValue == "" {
return rows
}
filteredRows := make([]Row, 0)
for _, row := range rows {
var availableFilterFunc FilterFunc
if m.filterFunc != nil {
availableFilterFunc = m.filterFunc
} else {
availableFilterFunc = filterFuncContains
}
if availableFilterFunc(FilterFuncInput{
Columns: m.columns,
Row: row,
Filter: filterInputValue,
GlobalMetadata: m.metadata,
}) {
filteredRows = append(filteredRows, row)
}
}
return filteredRows
}
// filterFuncContains returns a filterFunc that performs case-insensitive
// "contains" matching over all filterable columns in a row.
func filterFuncContains(input FilterFuncInput) bool {
if input.Filter == "" {
return true
}
checkedAny := false
filterLower := strings.ToLower(input.Filter)
for _, column := range input.Columns {
if !column.filterable {
continue
}
checkedAny = true
data, ok := input.Row.Data[column.key]
if !ok {
continue
}
// Extract internal StyledCell data
switch dataV := data.(type) {
case StyledCell:
data = dataV.Data
}
var target string
switch dataV := data.(type) {
case string:
target = dataV
case fmt.Stringer:
target = dataV.String()
default:
target = fmt.Sprintf("%v", data)
}
if strings.Contains(strings.ToLower(target), filterLower) {
return true
}
}
return !checkedAny
}
// filterFuncFuzzy returns a filterFunc that performs case-insensitive fuzzy
// matching (subsequence) over the concatenation of all filterable column values.
func filterFuncFuzzy(input FilterFuncInput) bool {
filter := strings.TrimSpace(input.Filter)
if filter == "" {
return true
}
var builder strings.Builder
for _, col := range input.Columns {
if !col.filterable {
continue
}
value, ok := input.Row.Data[col.key]
if !ok {
continue
}
if sc, ok := value.(StyledCell); ok {
value = sc.Data
}
builder.WriteString(fmt.Sprint(value)) // uses Stringer if implemented
builder.WriteByte(' ')
}
haystack := strings.ToLower(builder.String())
if haystack == "" {
return false
}
for _, token := range strings.Fields(strings.ToLower(filter)) {
if !fuzzySubsequenceMatch(haystack, token) {
return false
}
}
return true
}
// fuzzySubsequenceMatch returns true if all runes in needle appear in order
// within haystack (not necessarily contiguously). Case must be normalized by caller.
func fuzzySubsequenceMatch(haystack, needle string) bool {
if needle == "" {
return true
}
haystackIndex, needleIndex := 0, 0
haystackRunes := []rune(haystack)
needleRunes := []rune(needle)
for haystackIndex < len(haystackRunes) && needleIndex < len(needleRunes) {
if haystackRunes[haystackIndex] == needleRunes[needleIndex] {
needleIndex++
}
haystackIndex++
}
return needleIndex == len(needleRunes)
}

View File

@ -1,51 +0,0 @@
package table
import (
"fmt"
"strings"
)
func (m Model) hasFooter() bool {
return m.footerVisible && (m.staticFooter != "" || m.pageSize != 0 || m.filtered)
}
func (m Model) renderFooter(width int, includeTop bool) string {
if !m.hasFooter() {
return ""
}
const borderAdjustment = 2
styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(width - borderAdjustment)
if includeTop {
styleFooter = styleFooter.BorderTop(true)
}
if m.staticFooter != "" {
return styleFooter.Render(m.staticFooter)
}
sections := []string{}
if m.filtered && (m.filterTextInput.Focused() || m.filterTextInput.Value() != "") {
sections = append(sections, m.filterTextInput.View())
}
// paged feature enabled
if m.pageSize != 0 {
str := fmt.Sprintf("%d/%d", m.CurrentPage(), m.MaxPages())
if m.filtered && m.filterTextInput.Focused() {
// Need to apply inline style here in case of filter input cursor, because
// the input cursor resets the style after rendering. Note that Inline(true)
// creates a copy, so it's safe to use here without mutating the underlying
// base style.
str = m.baseStyle.Inline(true).Render(str)
}
sections = append(sections, str)
}
footerText := strings.Join(sections, " ")
return styleFooter.Render(footerText)
}

View File

@ -1,93 +0,0 @@
package table
import "github.com/charmbracelet/lipgloss"
// This is long and could use some refactoring in the future, but unsure of how
// to pick it apart right now.
//
//nolint:funlen,cyclop
func (m Model) renderHeaders() string {
headerStrings := []string{}
totalRenderedWidth := 0
headerStyles := m.styleHeaders()
renderHeader := func(column Column, borderStyle lipgloss.Style) string {
borderStyle = borderStyle.Inherit(column.style).Inherit(m.baseStyle)
headerSection := limitStr(column.title, column.width)
return borderStyle.Render(headerSection)
}
for columnIndex, column := range m.columns {
var borderStyle lipgloss.Style
if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
if columnIndex == 0 {
borderStyle = headerStyles.left.Copy()
} else {
borderStyle = headerStyles.inner.Copy()
}
rendered := renderHeader(genOverflowColumnLeft(1), borderStyle)
totalRenderedWidth += lipgloss.Width(rendered)
headerStrings = append(headerStrings, rendered)
}
if columnIndex >= m.horizontalScrollFreezeColumnsCount &&
columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount {
continue
}
if len(headerStrings) == 0 {
borderStyle = headerStyles.left.Copy()
} else if columnIndex < len(m.columns)-1 {
borderStyle = headerStyles.inner.Copy()
} else {
borderStyle = headerStyles.right.Copy()
}
rendered := renderHeader(column, borderStyle)
if m.maxTotalWidth != 0 {
renderedWidth := lipgloss.Width(rendered)
const (
borderAdjustment = 1
overflowColWidth = 2
)
targetWidth := m.maxTotalWidth - overflowColWidth
if columnIndex == len(m.columns)-1 {
// If this is the last header, we don't need to account for the
// overflow arrow column
targetWidth = m.maxTotalWidth
}
if totalRenderedWidth+renderedWidth > targetWidth {
overflowWidth := m.maxTotalWidth - totalRenderedWidth - borderAdjustment
overflowStyle := genOverflowStyle(headerStyles.right, overflowWidth)
overflowColumn := genOverflowColumnRight(overflowWidth)
overflowStr := renderHeader(overflowColumn, overflowStyle)
headerStrings = append(headerStrings, overflowStr)
break
}
totalRenderedWidth += renderedWidth
}
headerStrings = append(headerStrings, rendered)
}
headerBlock := lipgloss.JoinHorizontal(lipgloss.Bottom, headerStrings...)
return headerBlock
}

View File

@ -1,120 +0,0 @@
package table
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines the keybindings for the table when it's focused.
type KeyMap struct {
RowDown key.Binding
RowUp key.Binding
RowSelectToggle key.Binding
PageDown key.Binding
PageUp key.Binding
PageFirst key.Binding
PageLast key.Binding
// Filter allows the user to start typing and filter the rows.
Filter key.Binding
// FilterBlur is the key that stops the user's input from typing into the filter.
FilterBlur key.Binding
// FilterClear will clear the filter while it's blurred.
FilterClear key.Binding
// ScrollRight will move one column to the right when overflow occurs.
ScrollRight key.Binding
// ScrollLeft will move one column to the left when overflow occurs.
ScrollLeft key.Binding
}
// DefaultKeyMap returns a set of sensible defaults for controlling a focused table with help text.
func DefaultKeyMap() KeyMap {
return KeyMap{
RowDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "move down"),
),
RowUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "move up"),
),
RowSelectToggle: key.NewBinding(
key.WithKeys(" ", "enter"),
key.WithHelp("<space>/enter", "select row"),
),
PageDown: key.NewBinding(
key.WithKeys("right", "l", "pgdown"),
key.WithHelp("→/h/page down", "next page"),
),
PageUp: key.NewBinding(
key.WithKeys("left", "h", "pgup"),
key.WithHelp("←/h/page up", "previous page"),
),
PageFirst: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("home/g", "first page"),
),
PageLast: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("end/G", "last page"),
),
Filter: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "filter"),
),
FilterBlur: key.NewBinding(
key.WithKeys("enter", "esc"),
key.WithHelp("enter/esc", "unfocus"),
),
FilterClear: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "clear filter"),
),
ScrollRight: key.NewBinding(
key.WithKeys("shift+right"),
key.WithHelp("shift+→", "scroll right"),
),
ScrollLeft: key.NewBinding(
key.WithKeys("shift+left"),
key.WithHelp("shift+←", "scroll left"),
),
}
}
// FullHelp returns a multi row view of all the helpkeys that are defined. Needed to fullfil the 'help.Model' interface.
// Also appends all user defined extra keys to the help.
func (m Model) FullHelp() [][]key.Binding {
keyBinds := [][]key.Binding{
{m.keyMap.RowDown, m.keyMap.RowUp, m.keyMap.RowSelectToggle},
{m.keyMap.PageDown, m.keyMap.PageUp, m.keyMap.PageFirst, m.keyMap.PageLast},
{m.keyMap.Filter, m.keyMap.FilterBlur, m.keyMap.FilterClear, m.keyMap.ScrollRight, m.keyMap.ScrollLeft},
}
if m.additionalFullHelpKeys != nil {
keyBinds = append(keyBinds, m.additionalFullHelpKeys())
}
return keyBinds
}
// ShortHelp just returns a single row of help views. Needed to fullfil the 'help.Model' interface.
// Also appends all user defined extra keys to the help.
func (m Model) ShortHelp() []key.Binding {
keyBinds := []key.Binding{
m.keyMap.RowDown,
m.keyMap.RowUp,
m.keyMap.RowSelectToggle,
m.keyMap.PageDown,
m.keyMap.PageUp,
m.keyMap.Filter,
m.keyMap.FilterBlur,
m.keyMap.FilterClear,
}
if m.additionalShortHelpKeys != nil {
keyBinds = append(keyBinds, m.additionalShortHelpKeys()...)
}
return keyBinds
}

View File

@ -1,148 +0,0 @@
package table
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
columnKeySelect = "___select___"
)
var (
defaultHighlightStyle = lipgloss.NewStyle().Background(lipgloss.Color("#334"))
)
// Model is the main table model. Create using New().
type Model struct {
// Data
columns []Column
rows []Row
metadata map[string]any
// Caches for optimizations
visibleRowCacheUpdated bool
visibleRowCache []Row
// Shown when data is missing from a row
missingDataIndicator any
// Interaction
focused bool
keyMap KeyMap
// Taken from: 'Bubbles/List'
// Additional key mappings for the short and full help views. This allows
// you to add additional key mappings to the help menu without
// re-implementing the help component. Of course, you can also disable the
// list's help component and implement a new one if you need more
// flexibility.
// You have to supply a keybinding like this:
// key.NewBinding( key.WithKeys("shift+left"), key.WithHelp("shift+←", "scroll left"))
// It needs both 'WithKeys' and 'WithHelp'
additionalShortHelpKeys func() []key.Binding
additionalFullHelpKeys func() []key.Binding
selectableRows bool
rowCursorIndex int
// Events
lastUpdateUserEvents []UserEvent
// Styles
baseStyle lipgloss.Style
highlightStyle lipgloss.Style
headerStyle lipgloss.Style
rowStyleFunc func(RowStyleFuncInput) lipgloss.Style
border Border
selectedText string
unselectedText string
// Header
headerVisible bool
// Footers
footerVisible bool
staticFooter string
// Pagination
pageSize int
currentPage int
paginationWrapping bool
// Sorting, where a stable sort is applied from first element to last so
// that elements are grouped by the later elements.
sortOrder []SortColumn
// Filter
filtered bool
filterTextInput textinput.Model
filterFunc FilterFunc
// For flex columns
targetTotalWidth int
// The maximum total width for overflow/scrolling
maxTotalWidth int
// Internal cached calculations for reference, may be higher than
// maxTotalWidth. If this is the case, we need to adjust the view
totalWidth int
// How far to scroll to the right, in columns
horizontalScrollOffsetCol int
// How many columns to freeze when scrolling horizontally
horizontalScrollFreezeColumnsCount int
// Calculated maximum column we can scroll to before the last is displayed
maxHorizontalColumnIndex int
// Minimum total height of the table
minimumHeight int
// Internal cached calculation, the height of the header and footer
// including borders. Used to determine how many padding rows to add.
metaHeight int
// If true, the table will be multiline
multiline bool
}
// New creates a new table ready for further modifications.
func New(columns []Column) Model {
filterInput := textinput.New()
filterInput.Prompt = "/"
model := Model{
columns: make([]Column, len(columns)),
metadata: make(map[string]any),
highlightStyle: defaultHighlightStyle.Copy(),
border: borderDefault,
headerVisible: true,
footerVisible: true,
keyMap: DefaultKeyMap(),
selectedText: "[x]",
unselectedText: "[ ]",
filterTextInput: filterInput,
filterFunc: filterFuncContains,
baseStyle: lipgloss.NewStyle().Align(lipgloss.Right),
paginationWrapping: true,
}
// Do a full deep copy to avoid unexpected edits
copy(model.columns, columns)
model.recalculateWidth()
return model
}
// Init initializes the table per the Bubble Tea architecture.
func (m Model) Init() tea.Cmd {
return nil
}

View File

@ -1,510 +0,0 @@
package table
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// RowStyleFuncInput is the input to the style function that can
// be applied to each row. This is useful for things like zebra
// striping or other data-based styles.
//
// Note that we use a struct here to allow for future expansion
// while keeping backwards compatibility.
type RowStyleFuncInput struct {
// Index is the index of the row, starting at 0.
Index int
// Row is the full row data.
Row Row
// IsHighlighted is true if the row is currently highlighted.
IsHighlighted bool
}
// WithRowStyleFunc sets a function that can be used to apply a style to each row
// based on the row data. This is useful for things like zebra striping or other
// data-based styles. It can be safely set to nil to remove it later.
// This style is applied after the base style and before individual row styles.
// This will override any HighlightStyle settings.
func (m Model) WithRowStyleFunc(f func(RowStyleFuncInput) lipgloss.Style) Model {
m.rowStyleFunc = f
return m
}
// WithHighlightedRow sets the highlighted row to the given index.
func (m Model) WithHighlightedRow(index int) Model {
m.rowCursorIndex = index
if m.rowCursorIndex >= len(m.GetVisibleRows()) {
m.rowCursorIndex = len(m.GetVisibleRows()) - 1
}
if m.rowCursorIndex < 0 {
m.rowCursorIndex = 0
}
m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex)
return m
}
// HeaderStyle sets the style to apply to the header text, such as color or bold.
func (m Model) HeaderStyle(style lipgloss.Style) Model {
m.headerStyle = style.Copy()
return m
}
// WithRows sets the rows to show as data in the table.
func (m Model) WithRows(rows []Row) Model {
m.rows = rows
m.visibleRowCacheUpdated = false
if m.rowCursorIndex >= len(m.rows) {
m.rowCursorIndex = len(m.rows) - 1
}
if m.rowCursorIndex < 0 {
m.rowCursorIndex = 0
}
if m.pageSize != 0 {
maxPage := m.MaxPages()
// MaxPages is 1-index, currentPage is 0 index
if maxPage <= m.currentPage {
m.pageLast()
}
}
return m
}
// WithKeyMap sets the key map to use for controls when focused.
func (m Model) WithKeyMap(keyMap KeyMap) Model {
m.keyMap = keyMap
return m
}
// KeyMap returns a copy of the current key map in use.
func (m Model) KeyMap() KeyMap {
return m.keyMap
}
// SelectableRows sets whether or not rows are selectable. If set, adds a column
// in the front that acts as a checkbox and responds to controls if Focused.
func (m Model) SelectableRows(selectable bool) Model {
m.selectableRows = selectable
hasSelectColumn := len(m.columns) > 0 && m.columns[0].key == columnKeySelect
if hasSelectColumn != selectable {
if selectable {
m.columns = append([]Column{
NewColumn(columnKeySelect, m.selectedText, len([]rune(m.selectedText))),
}, m.columns...)
} else {
m.columns = m.columns[1:]
}
}
m.recalculateWidth()
return m
}
// HighlightedRow returns the full Row that's currently highlighted by the user.
func (m Model) HighlightedRow() Row {
if len(m.GetVisibleRows()) > 0 {
return m.GetVisibleRows()[m.rowCursorIndex]
}
// TODO: Better way to do this without pointers/nil? Or should it be nil?
return Row{}
}
// SelectedRows returns all rows that have been set as selected by the user.
func (m Model) SelectedRows() []Row {
selectedRows := []Row{}
for _, row := range m.GetVisibleRows() {
if row.selected {
selectedRows = append(selectedRows, row)
}
}
return selectedRows
}
// HighlightStyle sets a custom style to use when the row is being highlighted
// by the cursor. This should not be used with WithRowStyleFunc. Instead, use
// the IsHighlighted field in the style function.
func (m Model) HighlightStyle(style lipgloss.Style) Model {
m.highlightStyle = style
return m
}
// Focused allows the table to show highlighted rows and take in controls of
// up/down/space/etc to let the user navigate the table and interact with it.
func (m Model) Focused(focused bool) Model {
m.focused = focused
return m
}
// Filtered allows the table to show rows that match the filter.
func (m Model) Filtered(filtered bool) Model {
m.filtered = filtered
m.visibleRowCacheUpdated = false
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// StartFilterTyping focuses the text input to allow user typing to filter.
func (m Model) StartFilterTyping() Model {
m.filterTextInput.Focus()
return m
}
// WithStaticFooter adds a footer that only displays the given text.
func (m Model) WithStaticFooter(footer string) Model {
m.staticFooter = footer
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithPageSize enables pagination using the given page size. This can be called
// again at any point to resize the height of the table.
func (m Model) WithPageSize(pageSize int) Model {
m.pageSize = pageSize
maxPages := m.MaxPages()
if m.currentPage >= maxPages {
m.currentPage = maxPages - 1
}
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithNoPagination disables pagination in the table.
func (m Model) WithNoPagination() Model {
m.pageSize = 0
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithPaginationWrapping sets whether to wrap around from the beginning to the
// end when navigating through pages. Defaults to true.
func (m Model) WithPaginationWrapping(wrapping bool) Model {
m.paginationWrapping = wrapping
return m
}
// WithSelectedText describes what text to show when selectable rows are enabled.
// The selectable column header will use the selected text string.
func (m Model) WithSelectedText(unselected, selected string) Model {
m.selectedText = selected
m.unselectedText = unselected
if len(m.columns) > 0 && m.columns[0].key == columnKeySelect {
m.columns[0] = NewColumn(columnKeySelect, m.selectedText, len([]rune(m.selectedText)))
m.recalculateWidth()
}
return m
}
// WithBaseStyle applies a base style as the default for everything in the table.
// This is useful for border colors, default alignment, default color, etc.
func (m Model) WithBaseStyle(style lipgloss.Style) Model {
m.baseStyle = style
return m
}
// WithTargetWidth sets the total target width of the table, including borders.
// This only takes effect when using flex columns. When using flex columns,
// columns will stretch to fill out to the total width given here.
func (m Model) WithTargetWidth(totalWidth int) Model {
m.targetTotalWidth = totalWidth
m.recalculateWidth()
return m
}
// WithMinimumHeight sets the minimum total height of the table, including borders.
func (m Model) WithMinimumHeight(minimumHeight int) Model {
m.minimumHeight = minimumHeight
m.recalculateHeight()
return m
}
// PageDown goes to the next page of a paginated table, wrapping to the first
// page if the table is already on the last page.
func (m Model) PageDown() Model {
m.pageDown()
return m
}
// PageUp goes to the previous page of a paginated table, wrapping to the
// last page if the table is already on the first page.
func (m Model) PageUp() Model {
m.pageUp()
return m
}
// PageLast goes to the last page of a paginated table.
func (m Model) PageLast() Model {
m.pageLast()
return m
}
// PageFirst goes to the first page of a paginated table.
func (m Model) PageFirst() Model {
m.pageFirst()
return m
}
// WithCurrentPage sets the current page (1 as the first page) of a paginated
// table, bounded to the total number of pages. The current selected row will
// be set to the top row of the page if the page changed.
func (m Model) WithCurrentPage(currentPage int) Model {
if m.pageSize == 0 || currentPage == m.CurrentPage() {
return m
}
if currentPage < 1 {
currentPage = 1
} else {
maxPages := m.MaxPages()
if currentPage > maxPages {
currentPage = maxPages
}
}
m.currentPage = currentPage - 1
m.rowCursorIndex = m.currentPage * m.pageSize
return m
}
// WithColumns sets the visible columns for the table, so that columns can be
// added/removed/resized or headers rewritten.
func (m Model) WithColumns(columns []Column) Model {
// Deep copy to avoid edits
m.columns = make([]Column, len(columns))
copy(m.columns, columns)
m.recalculateWidth()
if m.selectableRows {
// Re-add the selectable column
m = m.SelectableRows(true)
}
return m
}
// WithFilterInput makes the table use the provided text input bubble for
// filtering rather than using the built-in default. This allows for external
// text input controls to be used.
func (m Model) WithFilterInput(input textinput.Model) Model {
if m.filterTextInput.Value() != input.Value() {
m.pageFirst()
}
m.filterTextInput = input
m.visibleRowCacheUpdated = false
return m
}
// WithFilterInputValue sets the filter value to the given string, immediately
// applying it as if the user had typed it in. Useful for external filter inputs
// that are not necessarily a text input.
func (m Model) WithFilterInputValue(value string) Model {
if m.filterTextInput.Value() != value {
m.pageFirst()
}
m.filterTextInput.SetValue(value)
m.filterTextInput.Blur()
m.visibleRowCacheUpdated = false
return m
}
// WithFilterFunc adds a filter function to the model. If the function returns
// true, the row will be included in the filtered results. If the function
// is nil, the function won't be used and instead the default filtering will be applied,
// if any.
func (m Model) WithFilterFunc(shouldInclude FilterFunc) Model {
m.filterFunc = shouldInclude
m.visibleRowCacheUpdated = false
return m
}
// WithFuzzyFilter enables fuzzy filtering for the table.
func (m Model) WithFuzzyFilter() Model {
return m.WithFilterFunc(filterFuncFuzzy)
}
// WithFooterVisibility sets the visibility of the footer.
func (m Model) WithFooterVisibility(visibility bool) Model {
m.footerVisible = visibility
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithHeaderVisibility sets the visibility of the header.
func (m Model) WithHeaderVisibility(visibility bool) Model {
m.headerVisible = visibility
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithMaxTotalWidth sets the maximum total width that the table should render.
// If this width is exceeded by either the target width or by the total width
// of all the columns (including borders!), anything extra will be treated as
// overflow and horizontal scrolling will be enabled to see the rest.
func (m Model) WithMaxTotalWidth(maxTotalWidth int) Model {
m.maxTotalWidth = maxTotalWidth
m.recalculateWidth()
return m
}
// WithHorizontalFreezeColumnCount freezes the given number of columns to the
// left side. This is useful for things like ID or Name columns that should
// always be visible even when scrolling.
func (m Model) WithHorizontalFreezeColumnCount(columnsToFreeze int) Model {
m.horizontalScrollFreezeColumnsCount = columnsToFreeze
m.recalculateWidth()
return m
}
// ScrollRight moves one column to the right. Use with WithMaxTotalWidth.
func (m Model) ScrollRight() Model {
m.scrollRight()
return m
}
// ScrollLeft moves one column to the left. Use with WithMaxTotalWidth.
func (m Model) ScrollLeft() Model {
m.scrollLeft()
return m
}
// WithMissingDataIndicator sets an indicator to use when data for a column is
// not found in a given row. Note that this is for completely missing data,
// an empty string or other zero value that is explicitly set is not considered
// to be missing.
func (m Model) WithMissingDataIndicator(str string) Model {
m.missingDataIndicator = str
return m
}
// WithMissingDataIndicatorStyled sets a styled indicator to use when data for
// a column is not found in a given row. Note that this is for completely
// missing data, an empty string or other zero value that is explicitly set is
// not considered to be missing.
func (m Model) WithMissingDataIndicatorStyled(styled StyledCell) Model {
m.missingDataIndicator = styled
return m
}
// WithAllRowsDeselected deselects any rows that are currently selected.
func (m Model) WithAllRowsDeselected() Model {
rows := m.GetVisibleRows()
for i, row := range rows {
if row.selected {
rows[i] = row.Selected(false)
}
}
m.rows = rows
return m
}
// WithMultiline sets whether or not to wrap text in cells to multiple lines.
func (m Model) WithMultiline(multiline bool) Model {
m.multiline = multiline
return m
}
// WithAdditionalShortHelpKeys enables you to add more keybindings to the 'short help' view.
func (m Model) WithAdditionalShortHelpKeys(keys []key.Binding) Model {
m.additionalShortHelpKeys = func() []key.Binding {
return keys
}
return m
}
// WithAdditionalFullHelpKeys enables you to add more keybindings to the 'full help' view.
func (m Model) WithAdditionalFullHelpKeys(keys []key.Binding) Model {
m.additionalFullHelpKeys = func() []key.Binding {
return keys
}
return m
}
// WithGlobalMetadata applies the given metadata to the table. This metadata is passed to
// some functions in FilterFuncInput and StyleFuncInput to enable more advanced decisions,
// such as setting some global theme variable to reference, etc. Has no effect otherwise.
func (m Model) WithGlobalMetadata(metadata map[string]any) Model {
m.metadata = metadata
return m
}

View File

@ -1,18 +0,0 @@
package table
import "github.com/charmbracelet/lipgloss"
const columnKeyOverflowRight = "___overflow_r___"
const columnKeyOverflowLeft = "___overflow_l__"
func genOverflowStyle(base lipgloss.Style, width int) lipgloss.Style {
return base.Width(width).Align(lipgloss.Right)
}
func genOverflowColumnRight(width int) Column {
return NewColumn(columnKeyOverflowRight, ">", width)
}
func genOverflowColumnLeft(width int) Column {
return NewColumn(columnKeyOverflowLeft, "<", width)
}

View File

@ -1,112 +0,0 @@
package table
// PageSize returns the current page size for the table, or 0 if there is no
// pagination enabled.
func (m *Model) PageSize() int {
return m.pageSize
}
// CurrentPage returns the current page that the table is on, starting from an
// index of 1.
func (m *Model) CurrentPage() int {
return m.currentPage + 1
}
// MaxPages returns the maximum number of pages that are visible.
func (m *Model) MaxPages() int {
totalRows := len(m.GetVisibleRows())
if m.pageSize == 0 || totalRows == 0 {
return 1
}
return (totalRows-1)/m.pageSize + 1
}
// TotalRows returns the current total row count of the table. If the table is
// paginated, this is the total number of rows across all pages.
func (m *Model) TotalRows() int {
return len(m.GetVisibleRows())
}
// VisibleIndices returns the current visible rows by their 0 based index.
// Useful for custom pagination footers.
func (m *Model) VisibleIndices() (start, end int) {
totalRows := len(m.GetVisibleRows())
if m.pageSize == 0 {
start = 0
end = totalRows - 1
return start, end
}
start = m.pageSize * m.currentPage
end = start + m.pageSize - 1
if end >= totalRows {
end = totalRows - 1
}
return start, end
}
func (m *Model) pageDown() {
if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize {
return
}
m.currentPage++
maxPageIndex := m.MaxPages() - 1
if m.currentPage > maxPageIndex {
if m.paginationWrapping {
m.currentPage = 0
} else {
m.currentPage = maxPageIndex
}
}
m.rowCursorIndex = m.currentPage * m.pageSize
}
func (m *Model) pageUp() {
if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize {
return
}
m.currentPage--
maxPageIndex := m.MaxPages() - 1
if m.currentPage < 0 {
if m.paginationWrapping {
m.currentPage = maxPageIndex
} else {
m.currentPage = 0
}
}
m.rowCursorIndex = m.currentPage * m.pageSize
}
func (m *Model) pageFirst() {
m.currentPage = 0
m.rowCursorIndex = 0
}
func (m *Model) pageLast() {
m.currentPage = m.MaxPages() - 1
m.rowCursorIndex = m.currentPage * m.pageSize
}
func (m *Model) expectedPageForRowIndex(rowIndex int) int {
if m.pageSize == 0 {
return 0
}
expectedPage := rowIndex / m.pageSize
return expectedPage
}

View File

@ -1,96 +0,0 @@
package table
// GetColumnSorting returns the current sorting rules for the table as a list of
// SortColumns, which are applied from first to last. This means that data will
// be grouped by the later elements in the list. The returned list is a copy
// and modifications will have no effect.
func (m *Model) GetColumnSorting() []SortColumn {
c := make([]SortColumn, len(m.sortOrder))
copy(c, m.sortOrder)
return c
}
// GetCanFilter returns true if the table enables filtering at all. This does
// not say whether a filter is currently active, only that the feature is enabled.
func (m *Model) GetCanFilter() bool {
return m.filtered
}
// GetIsFilterActive returns true if the table is currently being filtered. This
// does not say whether the table CAN be filtered, only whether or not a filter
// is actually currently being applied.
func (m *Model) GetIsFilterActive() bool {
return m.filterTextInput.Value() != ""
}
// GetIsFilterInputFocused returns true if the table's built-in filter input is
// currently focused.
func (m *Model) GetIsFilterInputFocused() bool {
return m.filterTextInput.Focused()
}
// GetCurrentFilter returns the current filter text being applied, or an empty
// string if none is applied.
func (m *Model) GetCurrentFilter() string {
return m.filterTextInput.Value()
}
// GetVisibleRows returns sorted and filtered rows.
func (m *Model) GetVisibleRows() []Row {
if m.visibleRowCacheUpdated {
return m.visibleRowCache
}
rows := make([]Row, len(m.rows))
copy(rows, m.rows)
if m.filtered {
rows = m.getFilteredRows(rows)
}
rows = getSortedRows(m.sortOrder, rows)
m.visibleRowCache = rows
m.visibleRowCacheUpdated = true
return rows
}
// GetHighlightedRowIndex returns the index of the Row that's currently highlighted
// by the user.
func (m *Model) GetHighlightedRowIndex() int {
return m.rowCursorIndex
}
// GetFocused returns whether or not the table is focused and is receiving inputs.
func (m *Model) GetFocused() bool {
return m.focused
}
// GetHorizontalScrollColumnOffset returns how many columns to the right the table
// has been scrolled. 0 means the table is all the way to the left, which is
// the starting default.
func (m *Model) GetHorizontalScrollColumnOffset() int {
return m.horizontalScrollOffsetCol
}
// GetHeaderVisibility returns true if the header has been set to visible (default)
// or false if the header has been set to hidden.
func (m *Model) GetHeaderVisibility() bool {
return m.headerVisible
}
// GetFooterVisibility returns true if the footer has been set to
// visible (default) or false if the footer has been set to hidden.
// Note that even if the footer is visible it will only be rendered if
// it has contents.
func (m *Model) GetFooterVisibility() bool {
return m.footerVisible
}
// GetPaginationWrapping returns true if pagination wrapping is enabled, or false
// if disabled. If disabled, navigating through pages will stop at the first
// and last pages.
func (m *Model) GetPaginationWrapping() bool {
return m.paginationWrapping
}

View File

@ -1,252 +0,0 @@
package table
import (
"fmt"
"sync/atomic"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/wordwrap"
)
// RowData is a map of string column keys to arbitrary data. Data with a key
// that matches a column key will be displayed. Data with a key that does not
// match a column key will not be displayed, but will remain attached to the Row.
// This can be useful for attaching hidden metadata for future reference when
// retrieving rows.
type RowData map[string]any
// Row represents a row in the table with some data keyed to the table columns>
// Can have a style applied to it such as color/bold. Create using NewRow().
type Row struct {
Style lipgloss.Style
Data RowData
selected bool
// id is an internal unique ID to match rows after they're copied
id uint32
}
var lastRowID uint32 = 1
// NewRow creates a new row and copies the given row data.
func NewRow(data RowData) Row {
row := Row{
Data: make(map[string]any),
id: lastRowID,
}
atomic.AddUint32(&lastRowID, 1)
for key, val := range data {
// Doesn't deep copy val, but close enough for now...
row.Data[key] = val
}
return row
}
// WithStyle uses the given style for the text in the row.
func (r Row) WithStyle(style lipgloss.Style) Row {
r.Style = style.Copy()
return r
}
//nolint:cyclop,funlen // Breaking this up will be more complicated than it's worth for now
func (m Model) renderRowColumnData(row Row, column Column, rowStyle lipgloss.Style, borderStyle lipgloss.Style) string {
cellStyle := rowStyle.Copy().Inherit(column.style).Inherit(m.baseStyle)
var str string
switch column.key {
case columnKeySelect:
if row.selected {
str = m.selectedText
} else {
str = m.unselectedText
}
case columnKeyOverflowRight:
cellStyle = cellStyle.Align(lipgloss.Right)
str = ">"
case columnKeyOverflowLeft:
str = "<"
default:
fmtString := "%v"
var data any
if entry, exists := row.Data[column.key]; exists {
data = entry
if column.fmtString != "" {
fmtString = column.fmtString
}
} else if m.missingDataIndicator != nil {
data = m.missingDataIndicator
} else {
data = ""
}
switch entry := data.(type) {
case StyledCell:
str = fmt.Sprintf(fmtString, entry.Data)
if entry.StyleFunc != nil {
cellStyle = entry.StyleFunc(StyledCellFuncInput{
Column: column,
Data: entry.Data,
Row: row,
GlobalMetadata: m.metadata,
}).Copy().Inherit(cellStyle)
} else {
cellStyle = entry.Style.Copy().Inherit(cellStyle)
}
default:
str = fmt.Sprintf(fmtString, entry)
}
}
if m.multiline {
str = wordwrap.String(str, column.width)
cellStyle = cellStyle.Align(lipgloss.Top)
} else {
str = limitStr(str, column.width)
}
cellStyle = cellStyle.Inherit(borderStyle)
cellStr := cellStyle.Render(str)
return cellStr
}
func (m Model) renderRow(rowIndex int, last bool) string {
row := m.GetVisibleRows()[rowIndex]
highlighted := rowIndex == m.rowCursorIndex
rowStyle := row.Style.Copy()
if m.rowStyleFunc != nil {
styleResult := m.rowStyleFunc(RowStyleFuncInput{
Index: rowIndex,
Row: row,
IsHighlighted: m.focused && highlighted,
})
rowStyle = rowStyle.Inherit(styleResult)
} else if m.focused && highlighted {
rowStyle = rowStyle.Inherit(m.highlightStyle)
}
return m.renderRowData(row, rowStyle, last)
}
func (m Model) renderBlankRow(last bool) string {
return m.renderRowData(NewRow(nil), lipgloss.NewStyle(), last)
}
// This is long and could use some refactoring in the future, but not quite sure
// how to pick it apart yet.
//
//nolint:funlen, cyclop
func (m Model) renderRowData(row Row, rowStyle lipgloss.Style, last bool) string {
numColumns := len(m.columns)
columnStrings := []string{}
totalRenderedWidth := 0
stylesInner, stylesLast := m.styleRows()
maxCellHeight := 1
if m.multiline {
for _, column := range m.columns {
cellStr := m.renderRowColumnData(row, column, rowStyle, lipgloss.NewStyle())
maxCellHeight = max(maxCellHeight, lipgloss.Height(cellStr))
}
}
for columnIndex, column := range m.columns {
var borderStyle lipgloss.Style
var rowStyles borderStyleRow
if !last {
rowStyles = stylesInner
} else {
rowStyles = stylesLast
}
rowStyle = rowStyle.Copy().Height(maxCellHeight)
if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
var borderStyle lipgloss.Style
if columnIndex == 0 {
borderStyle = rowStyles.left.Copy()
} else {
borderStyle = rowStyles.inner.Copy()
}
rendered := m.renderRowColumnData(row, genOverflowColumnLeft(1), rowStyle, borderStyle)
totalRenderedWidth += lipgloss.Width(rendered)
columnStrings = append(columnStrings, rendered)
}
if columnIndex >= m.horizontalScrollFreezeColumnsCount &&
columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount {
continue
}
if len(columnStrings) == 0 {
borderStyle = rowStyles.left
} else if columnIndex < numColumns-1 {
borderStyle = rowStyles.inner
} else {
borderStyle = rowStyles.right
}
cellStr := m.renderRowColumnData(row, column, rowStyle, borderStyle)
if m.maxTotalWidth != 0 {
renderedWidth := lipgloss.Width(cellStr)
const (
borderAdjustment = 1
overflowColWidth = 2
)
targetWidth := m.maxTotalWidth - overflowColWidth
if columnIndex == len(m.columns)-1 {
// If this is the last header, we don't need to account for the
// overflow arrow column
targetWidth = m.maxTotalWidth
}
if totalRenderedWidth+renderedWidth > targetWidth {
overflowWidth := m.maxTotalWidth - totalRenderedWidth - borderAdjustment
overflowStyle := genOverflowStyle(rowStyles.right, overflowWidth)
overflowColumn := genOverflowColumnRight(overflowWidth)
overflowStr := m.renderRowColumnData(row, overflowColumn, rowStyle, overflowStyle)
columnStrings = append(columnStrings, overflowStr)
break
}
totalRenderedWidth += renderedWidth
}
columnStrings = append(columnStrings, cellStr)
}
return lipgloss.JoinHorizontal(lipgloss.Bottom, columnStrings...)
}
// Selected returns a copy of the row that's set to be selected or deselected.
// The old row is not changed in-place.
func (r Row) Selected(selected bool) Row {
r.selected = selected
return r
}

View File

@ -1,50 +0,0 @@
package table
func (m *Model) scrollRight() {
if m.horizontalScrollOffsetCol < m.maxHorizontalColumnIndex {
m.horizontalScrollOffsetCol++
}
}
func (m *Model) scrollLeft() {
if m.horizontalScrollOffsetCol > 0 {
m.horizontalScrollOffsetCol--
}
}
func (m *Model) recalculateLastHorizontalColumn() {
if m.horizontalScrollFreezeColumnsCount >= len(m.columns) {
m.maxHorizontalColumnIndex = 0
return
}
if m.totalWidth <= m.maxTotalWidth {
m.maxHorizontalColumnIndex = 0
return
}
const (
leftOverflowWidth = 2
borderAdjustment = 1
)
// Always have left border
visibleWidth := borderAdjustment + leftOverflowWidth
for i := 0; i < m.horizontalScrollFreezeColumnsCount; i++ {
visibleWidth += m.columns[i].width + borderAdjustment
}
m.maxHorizontalColumnIndex = len(m.columns) - 1
// Work backwards from the right
for i := len(m.columns) - 1; i >= m.horizontalScrollFreezeColumnsCount && visibleWidth <= m.maxTotalWidth; i-- {
visibleWidth += m.columns[i].width + borderAdjustment
if visibleWidth <= m.maxTotalWidth {
m.maxHorizontalColumnIndex = i - m.horizontalScrollFreezeColumnsCount
}
}
}

View File

@ -1,178 +0,0 @@
package table
import (
"fmt"
"sort"
)
// SortDirection indicates whether a column should sort by ascending or descending.
type SortDirection int
const (
// SortDirectionAsc indicates the column should be in ascending order.
SortDirectionAsc SortDirection = iota
// SortDirectionDesc indicates the column should be in descending order.
SortDirectionDesc
)
// SortColumn describes which column should be sorted and how.
type SortColumn struct {
ColumnKey string
Direction SortDirection
}
// SortByAsc sets the main sorting column to the given key, in ascending order.
// If a previous sort was used, it is replaced by the given column each time
// this function is called. Values are sorted as numbers if possible, or just
// as simple string comparisons if not numbers.
func (m Model) SortByAsc(columnKey string) Model {
m.sortOrder = []SortColumn{
{
ColumnKey: columnKey,
Direction: SortDirectionAsc,
},
}
m.visibleRowCacheUpdated = false
return m
}
// SortByDesc sets the main sorting column to the given key, in descending order.
// If a previous sort was used, it is replaced by the given column each time
// this function is called. Values are sorted as numbers if possible, or just
// as simple string comparisons if not numbers.
func (m Model) SortByDesc(columnKey string) Model {
m.sortOrder = []SortColumn{
{
ColumnKey: columnKey,
Direction: SortDirectionDesc,
},
}
m.visibleRowCacheUpdated = false
return m
}
// ThenSortByAsc provides a secondary sort after the first, in ascending order.
// Can be chained multiple times, applying to smaller subgroups each time.
func (m Model) ThenSortByAsc(columnKey string) Model {
m.sortOrder = append([]SortColumn{
{
ColumnKey: columnKey,
Direction: SortDirectionAsc,
},
}, m.sortOrder...)
m.visibleRowCacheUpdated = false
return m
}
// ThenSortByDesc provides a secondary sort after the first, in descending order.
// Can be chained multiple times, applying to smaller subgroups each time.
func (m Model) ThenSortByDesc(columnKey string) Model {
m.sortOrder = append([]SortColumn{
{
ColumnKey: columnKey,
Direction: SortDirectionDesc,
},
}, m.sortOrder...)
m.visibleRowCacheUpdated = false
return m
}
type sortableTable struct {
rows []Row
byColumn SortColumn
}
func (s *sortableTable) Len() int {
return len(s.rows)
}
func (s *sortableTable) Swap(i, j int) {
old := s.rows[i]
s.rows[i] = s.rows[j]
s.rows[j] = old
}
func (s *sortableTable) extractString(i int, column string) string {
iData, exists := s.rows[i].Data[column]
if !exists {
return ""
}
switch iData := iData.(type) {
case StyledCell:
return fmt.Sprintf("%v", iData.Data)
case string:
return iData
default:
return fmt.Sprintf("%v", iData)
}
}
func (s *sortableTable) extractNumber(i int, column string) (float64, bool) {
iData, exists := s.rows[i].Data[column]
if !exists {
return 0, false
}
return asNumber(iData)
}
func (s *sortableTable) Less(first, second int) bool {
firstNum, firstNumIsValid := s.extractNumber(first, s.byColumn.ColumnKey)
secondNum, secondNumIsValid := s.extractNumber(second, s.byColumn.ColumnKey)
if firstNumIsValid && secondNumIsValid {
if s.byColumn.Direction == SortDirectionAsc {
return firstNum < secondNum
}
return firstNum > secondNum
}
firstVal := s.extractString(first, s.byColumn.ColumnKey)
secondVal := s.extractString(second, s.byColumn.ColumnKey)
if s.byColumn.Direction == SortDirectionAsc {
return firstVal < secondVal
}
return firstVal > secondVal
}
func getSortedRows(sortOrder []SortColumn, rows []Row) []Row {
var sortedRows []Row
if len(sortOrder) == 0 {
sortedRows = rows
return sortedRows
}
sortedRows = make([]Row, len(rows))
copy(sortedRows, rows)
for _, byColumn := range sortOrder {
sorted := &sortableTable{
rows: sortedRows,
byColumn: byColumn,
}
sort.Stable(sorted)
sortedRows = sorted.rows
}
return sortedRows
}

View File

@ -1,26 +0,0 @@
package table
import (
"strings"
"github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
)
func limitStr(str string, maxLen int) string {
if maxLen == 0 {
return ""
}
newLineIndex := strings.Index(str, "\n")
if newLineIndex > -1 {
str = str[:newLineIndex] + "…"
}
if ansi.PrintableRuneWidth(str) > maxLen {
// #nosec: G115
return truncate.StringWithTail(str, uint(maxLen), "…")
}
return str
}

View File

@ -1,154 +0,0 @@
package table
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
func (m *Model) moveHighlightUp() {
m.rowCursorIndex--
if m.rowCursorIndex < 0 {
m.rowCursorIndex = len(m.GetVisibleRows()) - 1
}
m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex)
}
func (m *Model) moveHighlightDown() {
m.rowCursorIndex++
if m.rowCursorIndex >= len(m.GetVisibleRows()) {
m.rowCursorIndex = 0
}
m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex)
}
func (m *Model) toggleSelect() {
if !m.selectableRows || len(m.GetVisibleRows()) == 0 {
return
}
rows := m.GetVisibleRows()
rowID := rows[m.rowCursorIndex].id
currentSelectedState := false
for i := range m.rows {
if m.rows[i].id == rowID {
currentSelectedState = m.rows[i].selected
m.rows[i].selected = !m.rows[i].selected
}
}
m.visibleRowCacheUpdated = false
m.appendUserEvent(UserEventRowSelectToggled{
RowIndex: m.rowCursorIndex,
IsSelected: !currentSelectedState,
})
}
func (m Model) updateFilterTextInput(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if key.Matches(msg, m.keyMap.FilterBlur) {
m.filterTextInput.Blur()
}
}
m.filterTextInput, cmd = m.filterTextInput.Update(msg)
m.pageFirst()
m.visibleRowCacheUpdated = false
return m, cmd
}
// This is a series of Matches tests with minimal logic
//
//nolint:cyclop
func (m *Model) handleKeypress(msg tea.KeyMsg) {
previousRowIndex := m.rowCursorIndex
if key.Matches(msg, m.keyMap.RowDown) {
m.moveHighlightDown()
}
if key.Matches(msg, m.keyMap.RowUp) {
m.moveHighlightUp()
}
if key.Matches(msg, m.keyMap.RowSelectToggle) {
m.toggleSelect()
}
if key.Matches(msg, m.keyMap.PageDown) {
m.pageDown()
}
if key.Matches(msg, m.keyMap.PageUp) {
m.pageUp()
}
if key.Matches(msg, m.keyMap.PageFirst) {
m.pageFirst()
}
if key.Matches(msg, m.keyMap.PageLast) {
m.pageLast()
}
if key.Matches(msg, m.keyMap.Filter) {
m.filterTextInput.Focus()
m.appendUserEvent(UserEventFilterInputFocused{})
}
if key.Matches(msg, m.keyMap.FilterClear) {
m.visibleRowCacheUpdated = false
m.filterTextInput.Reset()
}
if key.Matches(msg, m.keyMap.ScrollRight) {
m.scrollRight()
}
if key.Matches(msg, m.keyMap.ScrollLeft) {
m.scrollLeft()
}
if m.rowCursorIndex != previousRowIndex {
m.appendUserEvent(UserEventHighlightedIndexChanged{
PreviousRowIndex: previousRowIndex,
SelectedRowIndex: m.rowCursorIndex,
})
}
}
// Update responds to input from the user or other messages from Bubble Tea.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.clearUserEvents()
if !m.focused {
return m, nil
}
if m.filterTextInput.Focused() {
var cmd tea.Cmd
m, cmd = m.updateFilterTextInput(msg)
if !m.filterTextInput.Focused() {
m.appendUserEvent(UserEventFilterInputUnfocused{})
}
return m, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
m.handleKeypress(msg)
}
return m, nil
}

View File

@ -1,65 +0,0 @@
package table
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// View renders the table. It does not end in a newline, so that it can be
// composed with other elements more consistently.
//
//nolint:cyclop
func (m Model) View() string {
// Safety valve for empty tables
if len(m.columns) == 0 {
return ""
}
body := strings.Builder{}
rowStrs := make([]string, 0, 1)
headers := m.renderHeaders()
startRowIndex, endRowIndex := m.VisibleIndices()
numRows := endRowIndex - startRowIndex + 1
padding := m.calculatePadding(numRows)
if m.headerVisible {
rowStrs = append(rowStrs, headers)
} else if numRows > 0 || padding > 0 {
//nolint: mnd // This is just getting the first newlined substring
split := strings.SplitN(headers, "\n", 2)
rowStrs = append(rowStrs, split[0])
}
for i := startRowIndex; i <= endRowIndex; i++ {
rowStrs = append(rowStrs, m.renderRow(i, padding == 0 && i == endRowIndex))
}
for i := 1; i <= padding; i++ {
rowStrs = append(rowStrs, m.renderBlankRow(i == padding))
}
var footer string
if len(rowStrs) > 0 {
footer = m.renderFooter(lipgloss.Width(rowStrs[0]), false)
} else {
footer = m.renderFooter(lipgloss.Width(headers), true)
}
if footer != "" {
rowStrs = append(rowStrs, footer)
}
if len(rowStrs) == 0 {
return ""
}
body.WriteString(lipgloss.JoinVertical(lipgloss.Left, rowStrs...))
return body.String()
}

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2019 Christian Muehlhaeuser
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,7 +0,0 @@
package ansi
const Marker = '\x1B'
func IsTerminator(c rune) bool {
return (c >= 0x40 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a)
}

View File

@ -1,40 +0,0 @@
package ansi
import (
"bytes"
"github.com/mattn/go-runewidth"
)
// Buffer is a buffer aware of ANSI escape sequences.
type Buffer struct {
bytes.Buffer
}
// PrintableRuneWidth returns the cell width of all printable runes in the
// buffer.
func (w Buffer) PrintableRuneWidth() int {
return PrintableRuneWidth(w.String())
}
// PrintableRuneWidth returns the cell width of the given string.
func PrintableRuneWidth(s string) int {
var n int
var ansi bool
for _, c := range s {
if c == Marker {
// ANSI escape sequence
ansi = true
} else if ansi {
if IsTerminator(c) {
// ANSI sequence terminated
ansi = false
}
} else {
n += runewidth.RuneWidth(c)
}
}
return n
}

View File

@ -1,76 +0,0 @@
package ansi
import (
"bytes"
"io"
"unicode/utf8"
)
type Writer struct {
Forward io.Writer
ansi bool
ansiseq bytes.Buffer
lastseq bytes.Buffer
seqchanged bool
runeBuf []byte
}
// Write is used to write content to the ANSI buffer.
func (w *Writer) Write(b []byte) (int, error) {
for _, c := range string(b) {
if c == Marker {
// ANSI escape sequence
w.ansi = true
w.seqchanged = true
_, _ = w.ansiseq.WriteRune(c)
} else if w.ansi {
_, _ = w.ansiseq.WriteRune(c)
if IsTerminator(c) {
// ANSI sequence terminated
w.ansi = false
if bytes.HasSuffix(w.ansiseq.Bytes(), []byte("[0m")) {
// reset sequence
w.lastseq.Reset()
w.seqchanged = false
} else if c == 'm' {
// color code
_, _ = w.lastseq.Write(w.ansiseq.Bytes())
}
_, _ = w.ansiseq.WriteTo(w.Forward)
}
} else {
_, err := w.writeRune(c)
if err != nil {
return 0, err
}
}
}
return len(b), nil
}
func (w *Writer) writeRune(r rune) (int, error) {
if w.runeBuf == nil {
w.runeBuf = make([]byte, utf8.UTFMax)
}
n := utf8.EncodeRune(w.runeBuf, r)
return w.Forward.Write(w.runeBuf[:n])
}
func (w *Writer) LastSequence() string {
return w.lastseq.String()
}
func (w *Writer) ResetAnsi() {
if !w.seqchanged {
return
}
_, _ = w.Forward.Write([]byte("\x1b[0m"))
}
func (w *Writer) RestoreAnsi() {
_, _ = w.Forward.Write(w.lastseq.Bytes())
}

View File

@ -1,120 +0,0 @@
package truncate
import (
"bytes"
"io"
"github.com/mattn/go-runewidth"
"github.com/muesli/reflow/ansi"
)
type Writer struct {
width uint
tail string
ansiWriter *ansi.Writer
buf bytes.Buffer
ansi bool
}
func NewWriter(width uint, tail string) *Writer {
w := &Writer{
width: width,
tail: tail,
}
w.ansiWriter = &ansi.Writer{
Forward: &w.buf,
}
return w
}
func NewWriterPipe(forward io.Writer, width uint, tail string) *Writer {
return &Writer{
width: width,
tail: tail,
ansiWriter: &ansi.Writer{
Forward: forward,
},
}
}
// Bytes is shorthand for declaring a new default truncate-writer instance,
// used to immediately truncate a byte slice.
func Bytes(b []byte, width uint) []byte {
return BytesWithTail(b, width, []byte(""))
}
// Bytes is shorthand for declaring a new default truncate-writer instance,
// used to immediately truncate a byte slice. A tail is then added to the
// end of the byte slice.
func BytesWithTail(b []byte, width uint, tail []byte) []byte {
f := NewWriter(width, string(tail))
_, _ = f.Write(b)
return f.Bytes()
}
// String is shorthand for declaring a new default truncate-writer instance,
// used to immediately truncate a string.
func String(s string, width uint) string {
return StringWithTail(s, width, "")
}
// StringWithTail is shorthand for declaring a new default truncate-writer instance,
// used to immediately truncate a string. A tail is then added to the end of the
// string.
func StringWithTail(s string, width uint, tail string) string {
return string(BytesWithTail([]byte(s), width, []byte(tail)))
}
// Write truncates content at the given printable cell width, leaving any
// ansi sequences intact.
func (w *Writer) Write(b []byte) (int, error) {
tw := ansi.PrintableRuneWidth(w.tail)
if w.width < uint(tw) {
return w.buf.WriteString(w.tail)
}
w.width -= uint(tw)
var curWidth uint
for _, c := range string(b) {
if c == ansi.Marker {
// ANSI escape sequence
w.ansi = true
} else if w.ansi {
if ansi.IsTerminator(c) {
// ANSI sequence terminated
w.ansi = false
}
} else {
curWidth += uint(runewidth.RuneWidth(c))
}
if curWidth > w.width {
n, err := w.buf.WriteString(w.tail)
if w.ansiWriter.LastSequence() != "" {
w.ansiWriter.ResetAnsi()
}
return n, err
}
_, err := w.ansiWriter.Write([]byte(string(c)))
if err != nil {
return 0, err
}
}
return len(b), nil
}
// Bytes returns the truncated result as a byte slice.
func (w *Writer) Bytes() []byte {
return w.buf.Bytes()
}
// String returns the truncated result as a string.
func (w *Writer) String() string {
return w.buf.String()
}

View File

@ -1,167 +0,0 @@
package wordwrap
import (
"bytes"
"strings"
"unicode"
"github.com/muesli/reflow/ansi"
)
var (
defaultBreakpoints = []rune{'-'}
defaultNewline = []rune{'\n'}
)
// WordWrap contains settings and state for customisable text reflowing with
// support for ANSI escape sequences. This means you can style your terminal
// output without affecting the word wrapping algorithm.
type WordWrap struct {
Limit int
Breakpoints []rune
Newline []rune
KeepNewlines bool
buf bytes.Buffer
space bytes.Buffer
word ansi.Buffer
lineLen int
ansi bool
}
// NewWriter returns a new instance of a word-wrapping writer, initialized with
// default settings.
func NewWriter(limit int) *WordWrap {
return &WordWrap{
Limit: limit,
Breakpoints: defaultBreakpoints,
Newline: defaultNewline,
KeepNewlines: true,
}
}
// Bytes is shorthand for declaring a new default WordWrap instance,
// used to immediately word-wrap a byte slice.
func Bytes(b []byte, limit int) []byte {
f := NewWriter(limit)
_, _ = f.Write(b)
_ = f.Close()
return f.Bytes()
}
// String is shorthand for declaring a new default WordWrap instance,
// used to immediately word-wrap a string.
func String(s string, limit int) string {
return string(Bytes([]byte(s), limit))
}
func (w *WordWrap) addSpace() {
w.lineLen += w.space.Len()
_, _ = w.buf.Write(w.space.Bytes())
w.space.Reset()
}
func (w *WordWrap) addWord() {
if w.word.Len() > 0 {
w.addSpace()
w.lineLen += w.word.PrintableRuneWidth()
_, _ = w.buf.Write(w.word.Bytes())
w.word.Reset()
}
}
func (w *WordWrap) addNewLine() {
_, _ = w.buf.WriteRune('\n')
w.lineLen = 0
w.space.Reset()
}
func inGroup(a []rune, c rune) bool {
for _, v := range a {
if v == c {
return true
}
}
return false
}
// Write is used to write more content to the word-wrap buffer.
func (w *WordWrap) Write(b []byte) (int, error) {
if w.Limit == 0 {
return w.buf.Write(b)
}
s := string(b)
if !w.KeepNewlines {
s = strings.Replace(strings.TrimSpace(s), "\n", " ", -1)
}
for _, c := range s {
if c == '\x1B' {
// ANSI escape sequence
_, _ = w.word.WriteRune(c)
w.ansi = true
} else if w.ansi {
_, _ = w.word.WriteRune(c)
if (c >= 0x40 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) {
// ANSI sequence terminated
w.ansi = false
}
} else if inGroup(w.Newline, c) {
// end of current line
// see if we can add the content of the space buffer to the current line
if w.word.Len() == 0 {
if w.lineLen+w.space.Len() > w.Limit {
w.lineLen = 0
} else {
// preserve whitespace
_, _ = w.buf.Write(w.space.Bytes())
}
w.space.Reset()
}
w.addWord()
w.addNewLine()
} else if unicode.IsSpace(c) {
// end of current word
w.addWord()
_, _ = w.space.WriteRune(c)
} else if inGroup(w.Breakpoints, c) {
// valid breakpoint
w.addSpace()
w.addWord()
_, _ = w.buf.WriteRune(c)
} else {
// any other character
_, _ = w.word.WriteRune(c)
// add a line break if the current word would exceed the line's
// character limit
if w.lineLen+w.space.Len()+w.word.PrintableRuneWidth() > w.Limit &&
w.word.PrintableRuneWidth() < w.Limit {
w.addNewLine()
}
}
}
return len(b), nil
}
// Close will finish the word-wrap operation. Always call it before trying to
// retrieve the final result.
func (w *WordWrap) Close() error {
w.addWord()
return nil
}
// Bytes returns the word-wrapped result as a byte slice.
func (w *WordWrap) Bytes() []byte {
return w.buf.Bytes()
}
// String returns the word-wrapped result as a string.
func (w *WordWrap) String() string {
return w.buf.String()
}

16
vendor/modules.txt vendored
View File

@ -51,9 +51,6 @@ github.com/ProtonMail/go-crypto/openpgp/packet
github.com/ProtonMail/go-crypto/openpgp/s2k
github.com/ProtonMail/go-crypto/openpgp/x25519
github.com/ProtonMail/go-crypto/openpgp/x448
# github.com/atotto/clipboard v0.1.4
## explicit
github.com/atotto/clipboard
# github.com/aymanbagabas/go-osc52/v2 v2.0.1
## explicit; go 1.16
github.com/aymanbagabas/go-osc52/v2
@ -70,11 +67,8 @@ github.com/cenkalti/backoff/v5
github.com/cespare/xxhash/v2
# github.com/charmbracelet/bubbles v0.21.0
## explicit; go 1.23.0
github.com/charmbracelet/bubbles/cursor
github.com/charmbracelet/bubbles/key
github.com/charmbracelet/bubbles/runeutil
github.com/charmbracelet/bubbles/spinner
github.com/charmbracelet/bubbles/textinput
github.com/charmbracelet/bubbles/viewport
# github.com/charmbracelet/bubbletea v1.3.10
## explicit; go 1.24.0
github.com/charmbracelet/bubbletea
@ -283,9 +277,6 @@ github.com/emirpasic/gods/utils
# github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f
## explicit; go 1.16
github.com/erikgeiser/coninput
# github.com/evertras/bubble-table v0.19.2
## explicit; go 1.18
github.com/evertras/bubble-table/table
# github.com/felixge/httpsnoop v1.0.4
## explicit; go 1.13
github.com/felixge/httpsnoop
@ -493,11 +484,6 @@ github.com/muesli/ansi/compressor
# github.com/muesli/cancelreader v0.2.2
## explicit; go 1.17
github.com/muesli/cancelreader
# github.com/muesli/reflow v0.3.0
## explicit; go 1.13
github.com/muesli/reflow/ansi
github.com/muesli/reflow/truncate
github.com/muesli/reflow/wordwrap
# github.com/muesli/termenv v0.16.0
## explicit; go 1.17
github.com/muesli/termenv