From ff1f56fd01e5f30a12cb4d26f2ab7446d8f5551d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Mon, 2 Oct 2023 09:24:47 +0300 Subject: [PATCH 01/20] Replace survey logic with bubbletea --- go.mod | 18 +++-- go.sum | 66 +++++++++--------- internal/cli/execute.go | 10 ++- internal/cli/option/colors.go | 30 ++++++++ internal/cli/upgrade.go | 14 ++-- pkg/recipe/execute.go | 2 +- pkg/recipeutil/prompt.go | 126 ---------------------------------- pkg/survey/confirm.go | 35 ++++++++++ pkg/survey/select.go | 35 ++++++++++ pkg/survey/string.go | 86 +++++++++++++++++++++++ pkg/survey/survey.go | 125 +++++++++++++++++++++++++++++++++ pkg/survey/table.go | 35 ++++++++++ 12 files changed, 406 insertions(+), 176 deletions(-) create mode 100644 internal/cli/option/colors.go delete mode 100644 pkg/recipeutil/prompt.go create mode 100644 pkg/survey/confirm.go create mode 100644 pkg/survey/select.go create mode 100644 pkg/survey/string.go create mode 100644 pkg/survey/survey.go create mode 100644 pkg/survey/table.go diff --git a/go.mod b/go.mod index a918ffb3..2e7b3a7b 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/futurice/jalapeno go 1.21 require ( - github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Masterminds/sprig v2.22.0+incompatible github.com/antonmedv/expr v1.15.3 github.com/carlmjohnson/versioninfo v0.22.5 + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.8.0 github.com/cucumber/godog v0.13.0 github.com/docker/cli v24.0.6+incompatible github.com/opencontainers/image-spec v1.1.0-rc5 @@ -18,16 +20,27 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/docker/docker v24.0.6+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/term v0.5.0 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect @@ -53,10 +66,7 @@ require ( github.com/huandu/xstrings v1.4.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/opencontainers/runc v1.1.9 // indirect diff --git a/go.sum b/go.sum index 3bf34d1b..47b76611 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= -github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -10,20 +8,29 @@ github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZC github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/antonmedv/expr v1.15.3 h1:q3hOJZNvLvhqE8OHBs1cFRdbXFNKuA+bHmRaI+AmRmI= github.com/antonmedv/expr v1.15.3/go.mod h1:0E/6TxnOlRNp81GMzX9QfDPAmHo2Phg00y4JUv1ihsE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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= github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= +github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM= github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= @@ -72,16 +79,12 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -91,16 +94,15 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2 h1:hRGSmZu7j271trc9sneMrpOW7GN5ngLm8YUZIPzf394= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -109,6 +111,14 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= @@ -121,6 +131,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -132,7 +145,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -151,53 +163,38 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -205,7 +202,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/cli/execute.go b/internal/cli/execute.go index c889804f..5e010fc1 100644 --- a/internal/cli/execute.go +++ b/internal/cli/execute.go @@ -5,10 +5,12 @@ import ( "os" "strings" + "github.com/charmbracelet/lipgloss" "github.com/futurice/jalapeno/internal/cli/option" "github.com/futurice/jalapeno/pkg/oci" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/recipeutil" + "github.com/futurice/jalapeno/pkg/survey" "github.com/gofrs/uuid" "github.com/spf13/cobra" ) @@ -16,6 +18,7 @@ import ( type executeOptions struct { RecipeURL string option.Values + option.Styles option.OCIRepository option.WorkingDirectory option.Common @@ -84,10 +87,11 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { return } - cmd.Printf("Recipe name: %s\n", re.Metadata.Name) + style := lipgloss.NewStyle().Foreground(opts.Colors.Primary) + cmd.Printf("%s: %s\n", style.Render("Recipe name"), re.Metadata.Name) if re.Metadata.Description != "" { - cmd.Printf("Description: %s\n", re.Metadata.Description) + cmd.Printf("%s: %s\n", style.Render("Description"), re.Metadata.Description) } // Load all existing sauces @@ -123,7 +127,7 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { // Filter out variables which don't have value yet filteredVariables := recipeutil.FilterVariablesWithoutValues(re.Variables, predefinedValues) - promptedValues, err := recipeutil.PromptUserForValues(filteredVariables, predefinedValues) + promptedValues, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), filteredVariables, predefinedValues) if err != nil { cmd.PrintErrf("Error when prompting for values: %v\n", err) return diff --git a/internal/cli/option/colors.go b/internal/cli/option/colors.go new file mode 100644 index 00000000..cf98f3c8 --- /dev/null +++ b/internal/cli/option/colors.go @@ -0,0 +1,30 @@ +package option + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/spf13/pflag" +) + +type Styles struct { + NoColors bool + Colors +} + +type Colors struct { + Primary lipgloss.Color + Secondary lipgloss.Color +} + +func (opts *Styles) ApplyFlags(fs *pflag.FlagSet) { + fs.BoolVar(&opts.NoColors, "no-color", false, "If specified, output won't contain any color") +} + +func (opts *Styles) Parse() error { + if opts.NoColors { + return nil + } + + opts.Colors.Primary = lipgloss.Color("#EF4136") + opts.Colors.Secondary = lipgloss.Color("#26A568") + return nil +} diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 55eb5034..33a9a7da 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -8,10 +8,10 @@ import ( "path/filepath" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/futurice/jalapeno/internal/cli/option" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/recipeutil" + "github.com/futurice/jalapeno/pkg/survey" "github.com/spf13/cobra" "golang.org/x/mod/semver" ) @@ -131,7 +131,7 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { } } - values, err := recipeutil.PromptUserForValues(varsWithoutValues, predefinedValues) + values, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), varsWithoutValues, predefinedValues) if err != nil { cmd.PrintErrf("Error: %s", err) return @@ -189,12 +189,12 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { // TODO: We could do better in terms of merge conflict management. Like show the diff or something var override bool - prompt := &survey.Confirm{ - Message: path, - Default: true, - } + // prompt := &survey.Confirm{ + // Message: path, + // Default: true, + // } - err = survey.AskOne(prompt, &override) + // err = survey.AskOne(prompt, &override) if err != nil { cmd.PrintErrf("Error when prompting for question: %s", err) return diff --git a/pkg/recipe/execute.go b/pkg/recipe/execute.go index c8a25044..33de723b 100644 --- a/pkg/recipe/execute.go +++ b/pkg/recipe/execute.go @@ -9,7 +9,7 @@ import ( "github.com/gofrs/uuid" ) -// Renders recipe templates +// Execute executes the recipe and returns a sauce func (re *Recipe) Execute(values VariableValues, id uuid.UUID) (*Sauce, error) { if re.engine == nil { return nil, errors.New("render engine has not been set") diff --git a/pkg/recipeutil/prompt.go b/pkg/recipeutil/prompt.go deleted file mode 100644 index 49e9bb20..00000000 --- a/pkg/recipeutil/prompt.go +++ /dev/null @@ -1,126 +0,0 @@ -package recipeutil - -import ( - "fmt" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/antonmedv/expr" - "github.com/futurice/jalapeno/pkg/recipe" -) - -func PromptUserForValues(variables []recipe.Variable, existingValues recipe.VariableValues) (recipe.VariableValues, error) { - // TODO: This command does not respect stdio defined by the Cobra cmd, so - // capturing and examining the output of this function does not work at the moment - values := recipe.VariableValues{} - headerAdded := false - - for _, variable := range variables { - if !headerAdded { - fmt.Println("\nProvide the following variables:") - headerAdded = true - } - - var prompt survey.Prompt - var askFunc AskFunc = askString - - if variable.If != "" { - result, err := expr.Eval(variable.If, MergeValues(existingValues, values)) - if err != nil { - return nil, fmt.Errorf("error when evaluating 'if' expression: %w", err) - } - - variableShouldBePrompted, ok := result.(bool) - if !ok { - return nil, fmt.Errorf("result of 'if' expression was not a boolean value, was %T instead", result) - } - - if !variableShouldBePrompted { - continue - } - } - - // Select with predefined options - if len(variable.Options) != 0 { - prompt = &survey.Select{ - Message: variable.Name, - Help: variable.Description, - Options: variable.Options, - } - - // Yes/No question - } else if variable.Confirm { - prompt = &survey.Confirm{ - Message: variable.Name, - Help: variable.Description, - Default: variable.Default == "true", - } - askFunc = askBool - - // NOTE: The multiline prompt works quite poorly to provide values for the table, - // and for some reason the "help" field does not work - } else if len(variable.Columns) > 0 { - prompt = &survey.Multiline{ - Message: fmt.Sprintf("%s [EXPERIMENTAL] (columns: %s)", variable.Name, strings.Join(variable.Columns, ", ")), - Help: variable.Description, - } - - // Free input question - } else { - prompt = &survey.Input{ - Message: variable.Name, - Default: variable.Default, - Help: variable.Description, - } - } - - opts := make([]survey.AskOpt, 0) - - if !(variable.Optional || variable.If != "") { - opts = append(opts, survey.WithValidator(survey.Required)) - } - - if variable.RegExp.Pattern != "" { - validator := variable.RegExp.CreateValidatorFunc() - opts = append(opts, survey.WithValidator(validator)) - } - - answer, err := askFunc(prompt, opts) - if err != nil { - return nil, err - } - - if len(variable.Columns) > 0 { - raw := answer.(string) - answer, err = CSVToTable(variable.Columns, raw) - if err != nil { - return nil, err - } - } - - values[variable.Name] = answer - } - - return values, nil -} - -// NOTE: Since survey.AskOne tries to cast the answer to the type of the response -// value pointer and the type of response value can not be interface{}, -// we need to create different ask functions for each response type and return interface{} -type AskFunc func(prompt survey.Prompt, opts []survey.AskOpt) (interface{}, error) - -func askString(prompt survey.Prompt, opts []survey.AskOpt) (interface{}, error) { - return ask[string](prompt, opts) -} - -func askBool(prompt survey.Prompt, opts []survey.AskOpt) (interface{}, error) { - return ask[bool](prompt, opts) -} - -func ask[T string | bool](prompt survey.Prompt, opts []survey.AskOpt) (T, error) { - var answer T - if err := survey.AskOne(prompt, &answer, opts...); err != nil { - return answer, err - } - return answer, nil -} diff --git a/pkg/survey/confirm.go b/pkg/survey/confirm.go new file mode 100644 index 00000000..112ea753 --- /dev/null +++ b/pkg/survey/confirm.go @@ -0,0 +1,35 @@ +package survey + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" +) + +type ConfirmPromptModel struct { + variable recipe.Variable +} + +var _ PromptModel = ConfirmPromptModel{} + +func NewConfirmPromptModel(v recipe.Variable) ConfirmPromptModel { + return ConfirmPromptModel{ + variable: v, + } +} + +func (m ConfirmPromptModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m ConfirmPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, tea.Quit +} + +func (m ConfirmPromptModel) View() string { + return "" +} + +func (m ConfirmPromptModel) Value() interface{} { + return true +} diff --git a/pkg/survey/select.go b/pkg/survey/select.go new file mode 100644 index 00000000..7a12a120 --- /dev/null +++ b/pkg/survey/select.go @@ -0,0 +1,35 @@ +package survey + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" +) + +type SelectPromptModel struct { + variable recipe.Variable +} + +var _ PromptModel = SelectPromptModel{} + +func NewSelectPromptModel(v recipe.Variable) SelectPromptModel { + return SelectPromptModel{ + variable: v, + } +} + +func (m SelectPromptModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m SelectPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, tea.Quit +} + +func (m SelectPromptModel) View() string { + return "" +} + +func (m SelectPromptModel) Value() interface{} { + return "" +} diff --git a/pkg/survey/string.go b/pkg/survey/string.go new file mode 100644 index 00000000..071deeb7 --- /dev/null +++ b/pkg/survey/string.go @@ -0,0 +1,86 @@ +package survey + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/futurice/jalapeno/pkg/recipe" +) + +type StringPromptModel struct { + variable recipe.Variable + textInput textinput.Model + styles Styles + err error +} + +var _ PromptModel = StringPromptModel{} + +type Styles struct { + VariableName lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + VariableName: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#04B575")), + } +} + +func NewStringPromptModel(v recipe.Variable) StringPromptModel { + ti := textinput.New() + ti.Focus() + ti.CharLimit = 156 + ti.Width = 20 + + if v.Default != "" { + ti.SetValue(v.Default) + } + + return StringPromptModel{ + variable: v, + textInput: ti, + err: nil, + styles: DefaultStyles(), + } +} + +func (m StringPromptModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m StringPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg.(type) { + case FocusMsg: + m.textInput.Focus() + return m, nil + case BlurMsg: + m.textInput.Blur() + return m, nil + } + + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m StringPromptModel) View() (s string) { + s += fmt.Sprintf("%s:\n", m.styles.VariableName.Render(m.variable.Name)) + + if m.textInput.Focused() { + s += m.variable.Description + s += "\n" + } + + s += m.textInput.View() + + return +} + +func (m StringPromptModel) Value() interface{} { + return m.textInput.Value() +} diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go new file mode 100644 index 00000000..9ecf5475 --- /dev/null +++ b/pkg/survey/survey.go @@ -0,0 +1,125 @@ +package survey + +import ( + "io" + + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" +) + +type SurveyModel struct { + cursor int + variables []recipe.Variable + prompts []PromptModel +} + +type PromptModel interface { + tea.Model + Value() interface{} +} + +var _ tea.Model = PromptModel(nil) + +type FocusMsg struct{} +type BlurMsg struct{} + +func NewSurveyModel(variables []recipe.Variable) SurveyModel { + model := SurveyModel{ + prompts: make([]PromptModel, 0, len(variables)), + variables: variables, + } + + for _, variable := range variables { + var prompt PromptModel + switch { + case len(variable.Options) != 0: + // prompt = NewSelectModel() // TODO + prompt = NewStringPromptModel(variable) + case variable.Confirm: + // prompt = NewConfirmModel() // TODO + prompt = NewStringPromptModel(variable) + case len(variable.Columns) > 0: + // prompt = NewTableModel() // TODO + prompt = NewStringPromptModel(variable) + default: + prompt = NewStringPromptModel(variable) + } + model.prompts = append(model.prompts, prompt) + } + + return model +} + +func (m SurveyModel) Init() tea.Cmd { + // Initialize the first prompt + return m.prompts[0].Init() +} + +func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // TODO: if property + // TODO: regex validate property + + var updatedModel tea.Model + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + cmds := make([]tea.Cmd, 2) + // Unfocus the current prompt + updatedModel, cmds[0] = m.prompts[m.cursor].Update(BlurMsg{}) + m.prompts[m.cursor] = updatedModel.(PromptModel) + + // Check if we're on the last prompt + if m.cursor == len(m.prompts)-1 { + cmds[1] = tea.Quit + return m, tea.Batch(cmds...) + } + + // Otherwise, move to the next prompt + m.cursor++ + cmds[1] = m.prompts[m.cursor].Init() + return m, tea.Batch(cmds...) + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + } + + var promptCmd tea.Cmd + updatedModel, promptCmd = m.prompts[m.cursor].Update(msg) + m.prompts[m.cursor] = updatedModel.(PromptModel) + return m, promptCmd +} + +func (m SurveyModel) View() (s string) { + s += "Provide the following variables:\n\n" + + for i := 0; i <= m.cursor; i++ { + s += m.prompts[i].View() + s += "\n\n" + } + + return +} + +func (m SurveyModel) Values() recipe.VariableValues { + values := make(recipe.VariableValues, len(m.prompts)) + for i, prompt := range m.prompts { + switch prompt := prompt.(type) { + case PromptModel: + values[m.variables[i].Name] = prompt.Value() + } + } + + return values +} + +// PromptUserForValues prompts the user for values for the given variables +func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variable, existingValues recipe.VariableValues) (recipe.VariableValues, error) { + p := tea.NewProgram(NewSurveyModel(variables), tea.WithInput(in), tea.WithOutput(out)) + if m, err := p.Run(); err != nil { + return nil, err + } else { + return m.(SurveyModel).Values(), nil + } +} diff --git a/pkg/survey/table.go b/pkg/survey/table.go new file mode 100644 index 00000000..262cbbec --- /dev/null +++ b/pkg/survey/table.go @@ -0,0 +1,35 @@ +package survey + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" +) + +type TablePromptModel struct { + variable recipe.Variable +} + +var _ PromptModel = TablePromptModel{} + +func NewTablePromptModel(v recipe.Variable) TablePromptModel { + return TablePromptModel{ + variable: v, + } +} + +func (m TablePromptModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m TablePromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m TablePromptModel) View() string { + return "" +} + +func (m TablePromptModel) Value() interface{} { + return "" +} From 4c24974884be74588460646404834454ec59f4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Mon, 2 Oct 2023 14:00:12 +0300 Subject: [PATCH 02/20] Support setting the delimiter with a flag --- examples/variable-types/recipe.yml | 2 +- internal/cli/execute.go | 2 +- internal/cli/option/values.go | 22 +++++++++++++++++++++- internal/cli/upgrade.go | 2 +- pkg/recipeutil/values.go | 8 ++++---- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/examples/variable-types/recipe.yml b/examples/variable-types/recipe.yml index 48fc48d1..8e76342f 100644 --- a/examples/variable-types/recipe.yml +++ b/examples/variable-types/recipe.yml @@ -32,7 +32,7 @@ vars: {{ .Variables.TABLE_VAR[0].COLUMN_1 }} You can pre-set the table variable by using CSV with having '\n' between the rows, for example: - `jalapeno execute examples/variables `--set 'TABLE_VAR=a;b;c\nx;y;z'` + `jalapeno execute examples/variables `--set 'TABLE_VAR=a,b,c\nx,y,z'` Defined by: non-empty `columns` property. columns: [COLUMN_1, COLUMN_2, COLUMN_3] diff --git a/internal/cli/execute.go b/internal/cli/execute.go index 5e010fc1..54475ed3 100644 --- a/internal/cli/execute.go +++ b/internal/cli/execute.go @@ -117,7 +117,7 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { } } - providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags) + providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags, opts.Values.CSVDelimiter) if err != nil { cmd.PrintErrf("Error when parsing provided values: %v\n", err) return diff --git a/internal/cli/option/values.go b/internal/cli/option/values.go index b22b0133..a8366f62 100644 --- a/internal/cli/option/values.go +++ b/internal/cli/option/values.go @@ -1,13 +1,33 @@ package option -import "github.com/spf13/pflag" +import ( + "errors" + + "github.com/spf13/pflag" +) type Values struct { ReuseSauceValues bool + CSVDelimiter rune Flags []string + + delimiter string } func (opts *Values) ApplyFlags(fs *pflag.FlagSet) { fs.StringArrayVarP(&opts.Flags, "set", "s", []string{}, "Predefine values to be used in the templates. Example: `--set \"MY_VAR=foo\"`") + fs.StringVar(&opts.delimiter, "delimiter", ",", "Delimiter used when setting table variables") fs.BoolVarP(&opts.ReuseSauceValues, "reuse-sauce-values", "r", false, "By default each sauce has their own set of values even if the variable names are same in both recipes. Setting this to `true` will reuse previous sauce values if the variable name match") } + +func (opts *Values) Parse() error { + if opts.delimiter == "" { + return errors.New("delimiter cannot be empty") + } + if len(opts.delimiter) != 1 { + return errors.New("delimiter can be only one character long") + } + + opts.CSVDelimiter = rune(opts.delimiter[0]) + return nil +} diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 33a9a7da..a492ed66 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -113,7 +113,7 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { } } - providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags) + providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags, opts.CSVDelimiter) if err != nil { cmd.PrintErrf("Error when parsing provided values: %v\n", err) return diff --git a/pkg/recipeutil/values.go b/pkg/recipeutil/values.go index e6684e8e..caad3fda 100644 --- a/pkg/recipeutil/values.go +++ b/pkg/recipeutil/values.go @@ -16,7 +16,7 @@ var ( ErrVarNotDefinedInRecipe = errors.New("following variable does not exist in the recipe") ) -func ParseProvidedValues(variables []recipe.Variable, flags []string) (recipe.VariableValues, error) { +func ParseProvidedValues(variables []recipe.Variable, flags []string, delimiter rune) (recipe.VariableValues, error) { values := make(recipe.VariableValues) for _, env := range os.Environ() { if !strings.HasPrefix(env, ValueEnvVarPrefix) { @@ -67,7 +67,7 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string) (recipe.Va } case len(targetedVariable.Columns) > 0: varValue = strings.ReplaceAll(varValue, "\\n", "\n") - table, err := CSVToTable(targetedVariable.Columns, varValue) + table, err := CSVToTable(targetedVariable.Columns, varValue, delimiter) if err != nil { return nil, fmt.Errorf("failed to parse table from CSV for variable '%s': %w", varName, err) } @@ -103,10 +103,10 @@ func FilterVariablesWithoutValues(variables []recipe.Variable, values recipe.Var return variablesWithoutValues } -func CSVToTable(columns []string, str string) ([]map[string]string, error) { +func CSVToTable(columns []string, str string, delimiter rune) ([]map[string]string, error) { reader := csv.NewReader(strings.NewReader(str)) reader.FieldsPerRecord = len(columns) - reader.Comma = ';' + reader.Comma = delimiter reader.TrimLeadingSpace = true rows, err := reader.ReadAll() From 27d827ade7841f9ce4e5e7b0050590306cf9e5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Mon, 2 Oct 2023 14:00:26 +0300 Subject: [PATCH 03/20] Restructure survey components --- internal/cli/execute.go | 3 + internal/cli/upgrade.go | 4 ++ pkg/recipeutil/values_test.go | 2 +- pkg/survey/confirm.go | 35 ---------- pkg/survey/prompt/confirm.go | 38 +++++++++++ pkg/survey/prompt/prompt.go | 11 ++++ pkg/survey/prompt/select.go | 38 +++++++++++ pkg/survey/prompt/string.go | 116 ++++++++++++++++++++++++++++++++++ pkg/survey/prompt/table.go | 38 +++++++++++ pkg/survey/select.go | 35 ---------- pkg/survey/string.go | 86 ------------------------- pkg/survey/survey.go | 99 ++++++++++++++++------------- pkg/survey/table.go | 35 ---------- pkg/survey/util/util.go | 13 ++++ 14 files changed, 317 insertions(+), 236 deletions(-) delete mode 100644 pkg/survey/confirm.go create mode 100644 pkg/survey/prompt/confirm.go create mode 100644 pkg/survey/prompt/prompt.go create mode 100644 pkg/survey/prompt/select.go create mode 100644 pkg/survey/prompt/string.go create mode 100644 pkg/survey/prompt/table.go delete mode 100644 pkg/survey/select.go delete mode 100644 pkg/survey/string.go delete mode 100644 pkg/survey/table.go create mode 100644 pkg/survey/util/util.go diff --git a/internal/cli/execute.go b/internal/cli/execute.go index 54475ed3..e0d3542e 100644 --- a/internal/cli/execute.go +++ b/internal/cli/execute.go @@ -129,6 +129,9 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { filteredVariables := recipeutil.FilterVariablesWithoutValues(re.Variables, predefinedValues) promptedValues, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), filteredVariables, predefinedValues) if err != nil { + if err == survey.ErrUserAborted { + return + } cmd.PrintErrf("Error when prompting for values: %v\n", err) return } diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index a492ed66..d876dc3d 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -133,6 +133,10 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { values, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), varsWithoutValues, predefinedValues) if err != nil { + if err == survey.ErrUserAborted { + return + } + cmd.PrintErrf("Error: %s", err) return } diff --git a/pkg/recipeutil/values_test.go b/pkg/recipeutil/values_test.go index fd4f0af9..30c2d42c 100644 --- a/pkg/recipeutil/values_test.go +++ b/pkg/recipeutil/values_test.go @@ -79,7 +79,7 @@ func TestParsePredefinedValues(t *testing.T) { defer os.Unsetenv(envName) } - actual, err := recipeutil.ParseProvidedValues(test.vars, test.flags) + actual, err := recipeutil.ParseProvidedValues(test.vars, test.flags, ',') if err != nil { if test.expectedErr == nil { t.Fatalf("parser returned error when not expected, error: %+v", err) diff --git a/pkg/survey/confirm.go b/pkg/survey/confirm.go deleted file mode 100644 index 112ea753..00000000 --- a/pkg/survey/confirm.go +++ /dev/null @@ -1,35 +0,0 @@ -package survey - -import ( - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/futurice/jalapeno/pkg/recipe" -) - -type ConfirmPromptModel struct { - variable recipe.Variable -} - -var _ PromptModel = ConfirmPromptModel{} - -func NewConfirmPromptModel(v recipe.Variable) ConfirmPromptModel { - return ConfirmPromptModel{ - variable: v, - } -} - -func (m ConfirmPromptModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m ConfirmPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, tea.Quit -} - -func (m ConfirmPromptModel) View() string { - return "" -} - -func (m ConfirmPromptModel) Value() interface{} { - return true -} diff --git a/pkg/survey/prompt/confirm.go b/pkg/survey/prompt/confirm.go new file mode 100644 index 00000000..a4c110db --- /dev/null +++ b/pkg/survey/prompt/confirm.go @@ -0,0 +1,38 @@ +package prompt + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" +) + +type ConfirmModel struct { + variable recipe.Variable +} + +var _ Model = ConfirmModel{} + +func NewConfirmModel(v recipe.Variable) ConfirmModel { + return ConfirmModel{ + variable: v, + } +} + +func (m ConfirmModel) Init() tea.Cmd { + return nil +} + +func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, tea.Quit +} + +func (m ConfirmModel) View() string { + return "" +} + +func (m ConfirmModel) Value() interface{} { + return true +} + +func (m ConfirmModel) IsSubmitted() bool { + return false +} diff --git a/pkg/survey/prompt/prompt.go b/pkg/survey/prompt/prompt.go new file mode 100644 index 00000000..d561254c --- /dev/null +++ b/pkg/survey/prompt/prompt.go @@ -0,0 +1,11 @@ +package prompt + +import tea "github.com/charmbracelet/bubbletea" + +type Model interface { + tea.Model + IsSubmitted() bool + Value() interface{} +} + +var _ tea.Model = Model(nil) diff --git a/pkg/survey/prompt/select.go b/pkg/survey/prompt/select.go new file mode 100644 index 00000000..384361d1 --- /dev/null +++ b/pkg/survey/prompt/select.go @@ -0,0 +1,38 @@ +package prompt + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" +) + +type SelectModel struct { + variable recipe.Variable +} + +var _ Model = SelectModel{} + +func NewSelectModel(v recipe.Variable) SelectModel { + return SelectModel{ + variable: v, + } +} + +func (m SelectModel) Init() tea.Cmd { + return nil +} + +func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, tea.Quit +} + +func (m SelectModel) View() string { + return "" +} + +func (m SelectModel) Value() interface{} { + return "" +} + +func (m SelectModel) IsSubmitted() bool { + return false +} diff --git a/pkg/survey/prompt/string.go b/pkg/survey/prompt/string.go new file mode 100644 index 00000000..80dd912b --- /dev/null +++ b/pkg/survey/prompt/string.go @@ -0,0 +1,116 @@ +package prompt + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey/util" +) + +type StringModel struct { + variable recipe.Variable + textInput textinput.Model + styles Styles + submitted bool + showDescription bool + err error +} + +var _ Model = StringModel{} + +type Styles struct { + VariableName lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + VariableName: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#04B575")), + } +} + +func NewStringModel(v recipe.Variable) StringModel { + ti := textinput.New() + ti.Focus() + ti.CharLimit = 156 + ti.Width = 20 + + if v.Default != "" { + ti.SetValue(v.Default) + } + + return StringModel{ + variable: v, + textInput: ti, + err: nil, + styles: DefaultStyles(), + } +} + +func (m StringModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "?": + if m.textInput.Value() == "" && m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } + + switch msg.Type { + case tea.KeyEnter: + m.submitted = true + } + case util.FocusMsg: + m.textInput.Focus() + m.textInput.Prompt = "> " + return m, nil + case util.BlurMsg: + m.textInput.Blur() + m.textInput.Prompt = "" + return m, nil + } + + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m StringModel) View() (s string) { + s += m.styles.VariableName.Render(m.variable.Name) + + if m.textInput.Focused() { + if m.variable.Description != "" && !m.showDescription { + style := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) + s += style.Render(" [type ? for more info]") + } + + s += "\n" + if m.showDescription { + s += m.variable.Description + s += "\n" + } + } else { + s += ": " + } + + s += m.textInput.View() + + return +} + +func (m StringModel) Value() interface{} { + return m.textInput.Value() +} + +func (m StringModel) IsSubmitted() bool { + return m.submitted +} diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go new file mode 100644 index 00000000..55bd8ae3 --- /dev/null +++ b/pkg/survey/prompt/table.go @@ -0,0 +1,38 @@ +package prompt + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/futurice/jalapeno/pkg/recipe" +) + +type TableModel struct { + variable recipe.Variable +} + +var _ Model = TableModel{} + +func NewTableModel(v recipe.Variable) TableModel { + return TableModel{ + variable: v, + } +} + +func (m TableModel) Init() tea.Cmd { + return nil +} + +func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m TableModel) View() string { + return "" +} + +func (m TableModel) Value() interface{} { + return "" +} + +func (m TableModel) IsSubmitted() bool { + return false +} diff --git a/pkg/survey/select.go b/pkg/survey/select.go deleted file mode 100644 index 7a12a120..00000000 --- a/pkg/survey/select.go +++ /dev/null @@ -1,35 +0,0 @@ -package survey - -import ( - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/futurice/jalapeno/pkg/recipe" -) - -type SelectPromptModel struct { - variable recipe.Variable -} - -var _ PromptModel = SelectPromptModel{} - -func NewSelectPromptModel(v recipe.Variable) SelectPromptModel { - return SelectPromptModel{ - variable: v, - } -} - -func (m SelectPromptModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m SelectPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, tea.Quit -} - -func (m SelectPromptModel) View() string { - return "" -} - -func (m SelectPromptModel) Value() interface{} { - return "" -} diff --git a/pkg/survey/string.go b/pkg/survey/string.go deleted file mode 100644 index 071deeb7..00000000 --- a/pkg/survey/string.go +++ /dev/null @@ -1,86 +0,0 @@ -package survey - -import ( - "fmt" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/futurice/jalapeno/pkg/recipe" -) - -type StringPromptModel struct { - variable recipe.Variable - textInput textinput.Model - styles Styles - err error -} - -var _ PromptModel = StringPromptModel{} - -type Styles struct { - VariableName lipgloss.Style -} - -func DefaultStyles() Styles { - return Styles{ - VariableName: lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#04B575")), - } -} - -func NewStringPromptModel(v recipe.Variable) StringPromptModel { - ti := textinput.New() - ti.Focus() - ti.CharLimit = 156 - ti.Width = 20 - - if v.Default != "" { - ti.SetValue(v.Default) - } - - return StringPromptModel{ - variable: v, - textInput: ti, - err: nil, - styles: DefaultStyles(), - } -} - -func (m StringPromptModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m StringPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg.(type) { - case FocusMsg: - m.textInput.Focus() - return m, nil - case BlurMsg: - m.textInput.Blur() - return m, nil - } - - m.textInput, cmd = m.textInput.Update(msg) - return m, cmd -} - -func (m StringPromptModel) View() (s string) { - s += fmt.Sprintf("%s:\n", m.styles.VariableName.Render(m.variable.Name)) - - if m.textInput.Focused() { - s += m.variable.Description - s += "\n" - } - - s += m.textInput.View() - - return -} - -func (m StringPromptModel) Value() interface{} { - return m.textInput.Value() -} diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index 9ecf5475..34103c21 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -1,50 +1,48 @@ package survey import ( + "errors" "io" tea "github.com/charmbracelet/bubbletea" "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey/prompt" + "github.com/futurice/jalapeno/pkg/survey/util" ) type SurveyModel struct { cursor int + submitted bool variables []recipe.Variable - prompts []PromptModel + prompts []prompt.Model } -type PromptModel interface { - tea.Model - Value() interface{} -} - -var _ tea.Model = PromptModel(nil) - -type FocusMsg struct{} -type BlurMsg struct{} +var ( + ErrUserAborted = errors.New("user aborted") +) func NewSurveyModel(variables []recipe.Variable) SurveyModel { model := SurveyModel{ - prompts: make([]PromptModel, 0, len(variables)), + prompts: make([]prompt.Model, 0, len(variables)), variables: variables, } for _, variable := range variables { - var prompt PromptModel + var p prompt.Model switch { case len(variable.Options) != 0: // prompt = NewSelectModel() // TODO - prompt = NewStringPromptModel(variable) + p = prompt.NewStringModel(variable) case variable.Confirm: // prompt = NewConfirmModel() // TODO - prompt = NewStringPromptModel(variable) + p = prompt.NewStringModel(variable) case len(variable.Columns) > 0: // prompt = NewTableModel() // TODO - prompt = NewStringPromptModel(variable) + p = prompt.NewStringModel(variable) default: - prompt = NewStringPromptModel(variable) + p = prompt.NewStringModel(variable) } - model.prompts = append(model.prompts, prompt) + model.prompts = append(model.prompts, p) } return model @@ -59,35 +57,38 @@ func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // TODO: if property // TODO: regex validate property - var updatedModel tea.Model - switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { - case tea.KeyEnter: - cmds := make([]tea.Cmd, 2) - // Unfocus the current prompt - updatedModel, cmds[0] = m.prompts[m.cursor].Update(BlurMsg{}) - m.prompts[m.cursor] = updatedModel.(PromptModel) - - // Check if we're on the last prompt - if m.cursor == len(m.prompts)-1 { - cmds[1] = tea.Quit - return m, tea.Batch(cmds...) - } - - // Otherwise, move to the next prompt - m.cursor++ - cmds[1] = m.prompts[m.cursor].Init() - return m, tea.Batch(cmds...) case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit } } - var promptCmd tea.Cmd - updatedModel, promptCmd = m.prompts[m.cursor].Update(msg) - m.prompts[m.cursor] = updatedModel.(PromptModel) + promptModel, promptCmd := m.prompts[m.cursor].Update(msg) + m.prompts[m.cursor] = promptModel.(prompt.Model) + + if m.prompts[m.cursor].IsSubmitted() { + cmds := make([]tea.Cmd, 0, 3) + cmds = append(cmds, promptCmd) + // Unfocus the current prompt + promptModel, promptCmd = m.prompts[m.cursor].Update(util.Blur()) + m.prompts[m.cursor] = promptModel.(prompt.Model) + cmds = append(cmds, promptCmd) + + // Check if we're on the last prompt + if m.cursor == len(m.prompts)-1 { + m.submitted = true + cmds = append(cmds, tea.Quit) + return m, tea.Batch(cmds...) + } + + // Otherwise, move to the next prompt + m.cursor++ + cmds = append(cmds, m.prompts[m.cursor].Init()) + return m, tea.Batch(cmds...) + } + return m, promptCmd } @@ -95,8 +96,16 @@ func (m SurveyModel) View() (s string) { s += "Provide the following variables:\n\n" for i := 0; i <= m.cursor; i++ { + if i == m.cursor && i != 0 && !m.submitted { + s += "\n" + } + s += m.prompts[i].View() - s += "\n\n" + s += "\n" + + if i == m.cursor && i != 0 && !m.submitted { + s += "\n" + } } return @@ -105,10 +114,7 @@ func (m SurveyModel) View() (s string) { func (m SurveyModel) Values() recipe.VariableValues { values := make(recipe.VariableValues, len(m.prompts)) for i, prompt := range m.prompts { - switch prompt := prompt.(type) { - case PromptModel: - values[m.variables[i].Name] = prompt.Value() - } + values[m.variables[i].Name] = prompt.Value() } return values @@ -120,6 +126,11 @@ func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variabl if m, err := p.Run(); err != nil { return nil, err } else { - return m.(SurveyModel).Values(), nil + survey := m.(SurveyModel) + if survey.submitted { + return m.(SurveyModel).Values(), nil + } + + return nil, ErrUserAborted } } diff --git a/pkg/survey/table.go b/pkg/survey/table.go deleted file mode 100644 index 262cbbec..00000000 --- a/pkg/survey/table.go +++ /dev/null @@ -1,35 +0,0 @@ -package survey - -import ( - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/futurice/jalapeno/pkg/recipe" -) - -type TablePromptModel struct { - variable recipe.Variable -} - -var _ PromptModel = TablePromptModel{} - -func NewTablePromptModel(v recipe.Variable) TablePromptModel { - return TablePromptModel{ - variable: v, - } -} - -func (m TablePromptModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m TablePromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, nil -} - -func (m TablePromptModel) View() string { - return "" -} - -func (m TablePromptModel) Value() interface{} { - return "" -} diff --git a/pkg/survey/util/util.go b/pkg/survey/util/util.go new file mode 100644 index 00000000..c48401e2 --- /dev/null +++ b/pkg/survey/util/util.go @@ -0,0 +1,13 @@ +package util + +type FocusMsg struct{} + +func Focus() FocusMsg { + return FocusMsg{} +} + +type BlurMsg struct{} + +func Blur() BlurMsg { + return BlurMsg{} +} From b1f1d5e614bac32becd3c11bffcaf0d16d9fcc08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Wed, 4 Oct 2023 11:07:36 +0300 Subject: [PATCH 04/20] Implement regex validation --- pkg/recipe/variable.go | 2 +- pkg/survey/prompt/string.go | 32 ++++++++++++++++++++++++++++++++ pkg/survey/survey.go | 9 ++++----- pkg/survey/util/util.go | 7 +++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/pkg/recipe/variable.go b/pkg/recipe/variable.go index 18ae06cf..287dc9e2 100644 --- a/pkg/recipe/variable.go +++ b/pkg/recipe/variable.go @@ -67,7 +67,7 @@ func (v *Variable) Validate() error { if v.If != "" { if _, err := expr.Compile(v.If); err != nil { - return fmt.Errorf("invalid variable 'if' expression: %w", err) + return fmt.Errorf("invalid 'if' expression: %w", err) } } diff --git a/pkg/survey/prompt/string.go b/pkg/survey/prompt/string.go index 80dd912b..f06a484c 100644 --- a/pkg/survey/prompt/string.go +++ b/pkg/survey/prompt/string.go @@ -1,6 +1,9 @@ package prompt import ( + "fmt" + "strings" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -21,6 +24,7 @@ var _ Model = StringModel{} type Styles struct { VariableName lipgloss.Style + ErrorText lipgloss.Style } func DefaultStyles() Styles { @@ -28,6 +32,8 @@ func DefaultStyles() Styles { VariableName: lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#04B575")), + ErrorText: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")), } } @@ -68,6 +74,10 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEnter: + if err := m.Validate(); err != nil { + m.err = err + return m, nil + } m.submitted = true } case util.FocusMsg: @@ -104,6 +114,13 @@ func (m StringModel) View() (s string) { s += m.textInput.View() + if m.textInput.Focused() && m.err != nil { + s += "\n" + errMsg := m.err.Error() + errMsg = strings.ToUpper(errMsg[:1]) + errMsg[1:] + s += m.styles.ErrorText.Render(errMsg) + } + return } @@ -114,3 +131,18 @@ func (m StringModel) Value() interface{} { func (m StringModel) IsSubmitted() bool { return m.submitted } + +func (m StringModel) Validate() error { + if !m.variable.Optional && m.textInput.Value() == "" { + return util.ErrRequired + } + + if m.variable.RegExp.Pattern != "" { + validator := m.variable.RegExp.CreateValidatorFunc() + if err := validator(m.textInput.Value()); err != nil { + return fmt.Errorf("%w: %s", util.ErrRegExFailed, err) + } + } + + return nil +} diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index 34103c21..ef43a341 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -55,7 +55,6 @@ func (m SurveyModel) Init() tea.Cmd { func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // TODO: if property - // TODO: regex validate property switch msg := msg.(type) { case tea.KeyMsg: @@ -71,6 +70,7 @@ func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.prompts[m.cursor].IsSubmitted() { cmds := make([]tea.Cmd, 0, 3) cmds = append(cmds, promptCmd) + // Unfocus the current prompt promptModel, promptCmd = m.prompts[m.cursor].Update(util.Blur()) m.prompts[m.cursor] = promptModel.(prompt.Model) @@ -94,16 +94,15 @@ func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m SurveyModel) View() (s string) { s += "Provide the following variables:\n\n" - for i := 0; i <= m.cursor; i++ { - if i == m.cursor && i != 0 && !m.submitted { + cursorIsInLastVisiblePrompt := i == m.cursor && i != 0 && !m.submitted + if cursorIsInLastVisiblePrompt { s += "\n" } s += m.prompts[i].View() - s += "\n" - if i == m.cursor && i != 0 && !m.submitted { + if !cursorIsInLastVisiblePrompt { s += "\n" } } diff --git a/pkg/survey/util/util.go b/pkg/survey/util/util.go index c48401e2..8586463a 100644 --- a/pkg/survey/util/util.go +++ b/pkg/survey/util/util.go @@ -1,5 +1,7 @@ package util +import "errors" + type FocusMsg struct{} func Focus() FocusMsg { @@ -11,3 +13,8 @@ type BlurMsg struct{} func Blur() BlurMsg { return BlurMsg{} } + +var ( + ErrRequired = errors.New("value can not be empty") + ErrRegExFailed = errors.New("validation failed") +) From fb24c3e32bad693e3c3d904b13751d3ab26211d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Wed, 4 Oct 2023 15:43:29 +0300 Subject: [PATCH 05/20] Implement conditional variables --- internal/cli/execute.go | 10 ++- internal/cli/upgrade.go | 6 +- pkg/survey/prompt/confirm.go | 4 + pkg/survey/prompt/prompt.go | 1 + pkg/survey/prompt/select.go | 4 + pkg/survey/prompt/string.go | 4 + pkg/survey/prompt/table.go | 4 + pkg/survey/survey.go | 166 ++++++++++++++++++++++++++--------- 8 files changed, 151 insertions(+), 48 deletions(-) diff --git a/internal/cli/execute.go b/internal/cli/execute.go index e0d3542e..5f8df3a2 100644 --- a/internal/cli/execute.go +++ b/internal/cli/execute.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "os" "strings" @@ -119,7 +120,7 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { providedValues, err := recipeutil.ParseProvidedValues(re.Variables, opts.Values.Flags, opts.Values.CSVDelimiter) if err != nil { - cmd.PrintErrf("Error when parsing provided values: %v\n", err) + cmd.PrintErrf("Error when parsing provided values: %s\n", err) return } @@ -129,11 +130,12 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { filteredVariables := recipeutil.FilterVariablesWithoutValues(re.Variables, predefinedValues) promptedValues, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), filteredVariables, predefinedValues) if err != nil { - if err == survey.ErrUserAborted { + if errors.Is(err, survey.ErrUserAborted) { + return + } else { + cmd.PrintErrf("Error when prompting for values: %s\n", err) return } - cmd.PrintErrf("Error when prompting for values: %v\n", err) - return } sauce, err := re.Execute( diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index d876dc3d..589618ec 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -133,11 +133,9 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { values, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), varsWithoutValues, predefinedValues) if err != nil { - if err == survey.ErrUserAborted { - return + if !errors.Is(err, survey.ErrUserAborted) { + cmd.PrintErrf("Error when prompting for values: %s\n", err) } - - cmd.PrintErrf("Error: %s", err) return } diff --git a/pkg/survey/prompt/confirm.go b/pkg/survey/prompt/confirm.go index a4c110db..95774159 100644 --- a/pkg/survey/prompt/confirm.go +++ b/pkg/survey/prompt/confirm.go @@ -29,6 +29,10 @@ func (m ConfirmModel) View() string { return "" } +func (m ConfirmModel) Name() string { + return m.variable.Name +} + func (m ConfirmModel) Value() interface{} { return true } diff --git a/pkg/survey/prompt/prompt.go b/pkg/survey/prompt/prompt.go index d561254c..b242e7c6 100644 --- a/pkg/survey/prompt/prompt.go +++ b/pkg/survey/prompt/prompt.go @@ -5,6 +5,7 @@ import tea "github.com/charmbracelet/bubbletea" type Model interface { tea.Model IsSubmitted() bool + Name() string Value() interface{} } diff --git a/pkg/survey/prompt/select.go b/pkg/survey/prompt/select.go index 384361d1..8e54f1cb 100644 --- a/pkg/survey/prompt/select.go +++ b/pkg/survey/prompt/select.go @@ -29,6 +29,10 @@ func (m SelectModel) View() string { return "" } +func (m SelectModel) Name() string { + return m.variable.Name +} + func (m SelectModel) Value() interface{} { return "" } diff --git a/pkg/survey/prompt/string.go b/pkg/survey/prompt/string.go index f06a484c..7505eac8 100644 --- a/pkg/survey/prompt/string.go +++ b/pkg/survey/prompt/string.go @@ -124,6 +124,10 @@ func (m StringModel) View() (s string) { return } +func (m StringModel) Name() string { + return m.variable.Name +} + func (m StringModel) Value() interface{} { return m.textInput.Value() } diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index 55bd8ae3..add6d9e4 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -29,6 +29,10 @@ func (m TableModel) View() string { return "" } +func (m TableModel) Name() string { + return m.variable.Name +} + func (m TableModel) Value() interface{} { return "" } diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index ef43a341..d30b5bac 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -2,8 +2,10 @@ package survey import ( "errors" + "fmt" "io" + "github.com/antonmedv/expr" tea "github.com/charmbracelet/bubbletea" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/survey/prompt" @@ -15,6 +17,7 @@ type SurveyModel struct { submitted bool variables []recipe.Variable prompts []prompt.Model + err error } var ( @@ -27,21 +30,12 @@ func NewSurveyModel(variables []recipe.Variable) SurveyModel { variables: variables, } - for _, variable := range variables { - var p prompt.Model - switch { - case len(variable.Options) != 0: - // prompt = NewSelectModel() // TODO - p = prompt.NewStringModel(variable) - case variable.Confirm: - // prompt = NewConfirmModel() // TODO - p = prompt.NewStringModel(variable) - case len(variable.Columns) > 0: - // prompt = NewTableModel() // TODO - p = prompt.NewStringModel(variable) - default: - p = prompt.NewStringModel(variable) - } + p, err := model.createNextPrompt() + if err != nil { + model.err = err + } + + if p != nil { model.prompts = append(model.prompts, p) } @@ -49,13 +43,19 @@ func NewSurveyModel(variables []recipe.Variable) SurveyModel { } func (m SurveyModel) Init() tea.Cmd { - // Initialize the first prompt - return m.prompts[0].Init() + // Initialize the first prompt (if any) + if m.err != nil { + return tea.Quit + } + + if len(m.prompts) > 0 { + return m.prompts[0].Init() + } + + return nil } func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // TODO: if property - switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { @@ -64,28 +64,50 @@ func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - promptModel, promptCmd := m.prompts[m.cursor].Update(msg) - m.prompts[m.cursor] = promptModel.(prompt.Model) + // Check if we have already submitted the survey + if m.submitted { + return m, nil + } + + cmds := make([]tea.Cmd, 0, 3) + submit := func() (tea.Model, tea.Cmd) { + m.submitted = true + cmds = append(cmds, tea.Quit) + return m, tea.Batch(cmds...) + } + + if len(m.prompts) == 0 { + return submit() + } + + lastPrompt := &m.prompts[len(m.prompts)-1] + promptModel, promptCmd := (*lastPrompt).Update(msg) + *lastPrompt = promptModel.(prompt.Model) - if m.prompts[m.cursor].IsSubmitted() { - cmds := make([]tea.Cmd, 0, 3) + if (*lastPrompt).IsSubmitted() { cmds = append(cmds, promptCmd) // Unfocus the current prompt - promptModel, promptCmd = m.prompts[m.cursor].Update(util.Blur()) - m.prompts[m.cursor] = promptModel.(prompt.Model) + promptModel, promptCmd = (*lastPrompt).Update(util.Blur()) + *lastPrompt = promptModel.(prompt.Model) cmds = append(cmds, promptCmd) // Check if we're on the last prompt - if m.cursor == len(m.prompts)-1 { - m.submitted = true - cmds = append(cmds, tea.Quit) - return m, tea.Batch(cmds...) + if m.cursor == len(m.variables)-1 { + return submit() } // Otherwise, move to the next prompt - m.cursor++ - cmds = append(cmds, m.prompts[m.cursor].Init()) + if p, err := m.createNextPrompt(); err != nil { + m.err = err + cmds = append(cmds, tea.Quit) + } else if p == nil { + return submit() + } else { + m.prompts = append(m.prompts, p) + cmds = append(cmds, p.Init()) + } + return m, tea.Batch(cmds...) } @@ -93,18 +115,22 @@ func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m SurveyModel) View() (s string) { - s += "Provide the following variables:\n\n" - for i := 0; i <= m.cursor; i++ { - cursorIsInLastVisiblePrompt := i == m.cursor && i != 0 && !m.submitted - if cursorIsInLastVisiblePrompt { + if len(m.prompts) > 0 && !m.submitted && m.err == nil { + s += "Provide the following variables:\n\n" + } + + for i := range m.prompts { + isLastPrompt := i == len(m.prompts)-1 && !m.submitted + if isLastPrompt { s += "\n" } s += m.prompts[i].View() + s += "\n" + } - if !cursorIsInLastVisiblePrompt { - s += "\n" - } + if m.submitted || m.err != nil { + s += "\n" } return @@ -112,13 +138,69 @@ func (m SurveyModel) View() (s string) { func (m SurveyModel) Values() recipe.VariableValues { values := make(recipe.VariableValues, len(m.prompts)) - for i, prompt := range m.prompts { - values[m.variables[i].Name] = prompt.Value() + for _, prompt := range m.prompts { + if prompt.IsSubmitted() { + values[prompt.Name()] = prompt.Value() + } } return values } +func (m *SurveyModel) createNextPrompt() (prompt.Model, error) { + if len(m.prompts) > 0 { + m.cursor++ + } + + if m.cursor >= len(m.variables) { + return nil, nil + } + + if p, err := m.createPrompt(m.variables[m.cursor]); err != nil { + return nil, err + } else if p == nil { + return m.createNextPrompt() + } else { + return p, nil + } +} + +// createPrompt creates a prompt for the given variable. Returns nil if the variable should be skipped. +func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { + // Check if variable should be skipped + if v.If != "" { + result, err := expr.Eval(v.If, m.Values()) + if err != nil { + return nil, fmt.Errorf("error when evaluating variable \"%s\" 'if' expression: %w", v.Name, err) + } + variableShouldBePrompted, ok := result.(bool) + if !ok { + return nil, fmt.Errorf("result of 'if' expression of variable \"%s\" was not a boolean value, was %T instead", v.Name, result) + } + + if !variableShouldBePrompted { + return nil, nil + } + } + + var p prompt.Model + switch { + case len(v.Options) != 0: + // prompt = NewSelectModel() // TODO + p = prompt.NewStringModel(v) + case v.Confirm: + // prompt = NewConfirmModel() // TODO + p = prompt.NewStringModel(v) + case len(v.Columns) > 0: + // prompt = NewTableModel() // TODO + p = prompt.NewStringModel(v) + default: + p = prompt.NewStringModel(v) + } + + return p, nil +} + // PromptUserForValues prompts the user for values for the given variables func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variable, existingValues recipe.VariableValues) (recipe.VariableValues, error) { p := tea.NewProgram(NewSurveyModel(variables), tea.WithInput(in), tea.WithOutput(out)) @@ -126,6 +208,10 @@ func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variabl return nil, err } else { survey := m.(SurveyModel) + if survey.err != nil { + return nil, survey.err + } + if survey.submitted { return m.(SurveyModel).Values(), nil } From 4b96e2467e49f50f4a2fa609807efab08e6737a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Thu, 5 Oct 2023 14:14:09 +0300 Subject: [PATCH 06/20] Implement confirm prompt --- pkg/survey/prompt/confirm.go | 72 ++++++++++++++++++++++++++++++++---- pkg/survey/prompt/string.go | 56 +++++++++------------------- pkg/survey/survey.go | 22 +++++------ pkg/survey/util/util.go | 36 ++++++++++++------ 4 files changed, 115 insertions(+), 71 deletions(-) diff --git a/pkg/survey/prompt/confirm.go b/pkg/survey/prompt/confirm.go index 95774159..1cf1e284 100644 --- a/pkg/survey/prompt/confirm.go +++ b/pkg/survey/prompt/confirm.go @@ -1,19 +1,28 @@ package prompt import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey/util" ) type ConfirmModel struct { - variable recipe.Variable + variable recipe.Variable + styles util.Styles + value bool + submitted bool + showDescription bool } var _ Model = ConfirmModel{} -func NewConfirmModel(v recipe.Variable) ConfirmModel { +func NewConfirmModel(v recipe.Variable, styles util.Styles) ConfirmModel { return ConfirmModel{ variable: v, + styles: styles, + value: v.Default == "true", } } @@ -22,11 +31,60 @@ func (m ConfirmModel) Init() tea.Cmd { } func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, tea.Quit + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + case "y", "Y": + m.value = true + case "n", "N": + m.value = false + } + switch msg.Type { + case tea.KeyRight: + m.value = true + case tea.KeyLeft: + m.value = false + case tea.KeyEnter: + m.submitted = true + } + } + + return m, nil } -func (m ConfirmModel) View() string { - return "" +func (m ConfirmModel) View() (s string) { + s += m.styles.VariableName.Render(m.variable.Name) + if m.submitted { + if m.value { + s += ": Yes" + } else { + s += ": No" + } + return + } + + if m.variable.Description != "" && !m.showDescription { + s += m.styles.HelpText.Render(" [type ? for more info]") + } + + s += "\n" + if m.showDescription { + s += m.variable.Description + s += "\n" + } + + if m.value { + s += fmt.Sprintf("> No/%s", m.styles.Bold.Render("Yes")) + } else { + s += fmt.Sprintf("> %s/Yes", m.styles.Bold.Render("No")) + } + + return } func (m ConfirmModel) Name() string { @@ -34,9 +92,9 @@ func (m ConfirmModel) Name() string { } func (m ConfirmModel) Value() interface{} { - return true + return m.value } func (m ConfirmModel) IsSubmitted() bool { - return false + return m.submitted } diff --git a/pkg/survey/prompt/string.go b/pkg/survey/prompt/string.go index 7505eac8..abd46735 100644 --- a/pkg/survey/prompt/string.go +++ b/pkg/survey/prompt/string.go @@ -6,7 +6,6 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/survey/util" ) @@ -14,7 +13,7 @@ import ( type StringModel struct { variable recipe.Variable textInput textinput.Model - styles Styles + styles util.Styles submitted bool showDescription bool err error @@ -22,22 +21,7 @@ type StringModel struct { var _ Model = StringModel{} -type Styles struct { - VariableName lipgloss.Style - ErrorText lipgloss.Style -} - -func DefaultStyles() Styles { - return Styles{ - VariableName: lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#04B575")), - ErrorText: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF0000")), - } -} - -func NewStringModel(v recipe.Variable) StringModel { +func NewStringModel(v recipe.Variable, styles util.Styles) StringModel { ti := textinput.New() ti.Focus() ti.CharLimit = 156 @@ -51,7 +35,7 @@ func NewStringModel(v recipe.Variable) StringModel { variable: v, textInput: ti, err: nil, - styles: DefaultStyles(), + styles: styles, } } @@ -78,16 +62,9 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = err return m, nil } + m.textInput.Prompt = "" m.submitted = true } - case util.FocusMsg: - m.textInput.Focus() - m.textInput.Prompt = "> " - return m, nil - case util.BlurMsg: - m.textInput.Blur() - m.textInput.Prompt = "" - return m, nil } m.textInput, cmd = m.textInput.Update(msg) @@ -97,24 +74,25 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m StringModel) View() (s string) { s += m.styles.VariableName.Render(m.variable.Name) - if m.textInput.Focused() { - if m.variable.Description != "" && !m.showDescription { - style := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) - s += style.Render(" [type ? for more info]") - } + if m.submitted { + s += ": " + s += m.textInput.Value() + return + } + + if m.variable.Description != "" && !m.showDescription { + s += m.styles.HelpText.Render(" [type ? for more info]") + } + s += "\n" + if m.showDescription { + s += m.variable.Description s += "\n" - if m.showDescription { - s += m.variable.Description - s += "\n" - } - } else { - s += ": " } s += m.textInput.View() - if m.textInput.Focused() && m.err != nil { + if m.err != nil { s += "\n" errMsg := m.err.Error() errMsg = strings.ToUpper(errMsg[:1]) + errMsg[1:] diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index d30b5bac..579adecc 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -17,6 +17,7 @@ type SurveyModel struct { submitted bool variables []recipe.Variable prompts []prompt.Model + styles util.Styles err error } @@ -28,6 +29,7 @@ func NewSurveyModel(variables []recipe.Variable) SurveyModel { model := SurveyModel{ prompts: make([]prompt.Model, 0, len(variables)), variables: variables, + styles: util.DefaultStyles(), } p, err := model.createNextPrompt() @@ -43,11 +45,11 @@ func NewSurveyModel(variables []recipe.Variable) SurveyModel { } func (m SurveyModel) Init() tea.Cmd { - // Initialize the first prompt (if any) if m.err != nil { return tea.Quit } + // Initialize the first prompt (if any) if len(m.prompts) > 0 { return m.prompts[0].Init() } @@ -87,11 +89,6 @@ func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if (*lastPrompt).IsSubmitted() { cmds = append(cmds, promptCmd) - // Unfocus the current prompt - promptModel, promptCmd = (*lastPrompt).Update(util.Blur()) - *lastPrompt = promptModel.(prompt.Model) - cmds = append(cmds, promptCmd) - // Check if we're on the last prompt if m.cursor == len(m.variables)-1 { return submit() @@ -186,16 +183,15 @@ func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { var p prompt.Model switch { case len(v.Options) != 0: - // prompt = NewSelectModel() // TODO - p = prompt.NewStringModel(v) + // prompt = prompt.NewSelectModel(v, m.styles) // TODO + p = prompt.NewStringModel(v, m.styles) case v.Confirm: - // prompt = NewConfirmModel() // TODO - p = prompt.NewStringModel(v) + p = prompt.NewConfirmModel(v, m.styles) case len(v.Columns) > 0: - // prompt = NewTableModel() // TODO - p = prompt.NewStringModel(v) + // prompt = prompt.NewTableModel(v, m.styles) // TODO + p = prompt.NewStringModel(v, m.styles) default: - p = prompt.NewStringModel(v) + p = prompt.NewStringModel(v, m.styles) } return p, nil diff --git a/pkg/survey/util/util.go b/pkg/survey/util/util.go index 8586463a..4cf33e45 100644 --- a/pkg/survey/util/util.go +++ b/pkg/survey/util/util.go @@ -1,20 +1,32 @@ package util -import "errors" +import ( + "errors" -type FocusMsg struct{} - -func Focus() FocusMsg { - return FocusMsg{} -} - -type BlurMsg struct{} - -func Blur() BlurMsg { - return BlurMsg{} -} + "github.com/charmbracelet/lipgloss" +) var ( ErrRequired = errors.New("value can not be empty") ErrRegExFailed = errors.New("validation failed") ) + +type Styles struct { + VariableName lipgloss.Style + ErrorText lipgloss.Style + HelpText lipgloss.Style + Bold lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + VariableName: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#04B575")), + ErrorText: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")), + HelpText: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#999999")), + Bold: lipgloss.NewStyle().Bold(true), + } +} From 8dfaae6e7c1680cc8f31beab2ab3b79ff224b80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Fri, 6 Oct 2023 13:01:04 +0300 Subject: [PATCH 07/20] Implement select prompt --- go.mod | 1 + go.sum | 2 + pkg/survey/prompt/select.go | 114 +++++++++++++++++++++++++++++++++--- pkg/survey/survey.go | 3 +- 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 2e7b3a7b..3281028e 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/muesli/termenv v0.15.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect diff --git a/go.sum b/go.sum index 47b76611..676b5222 100644 --- a/go.sum +++ b/go.sum @@ -135,6 +135,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= diff --git a/pkg/survey/prompt/select.go b/pkg/survey/prompt/select.go index 8e54f1cb..dfae049a 100644 --- a/pkg/survey/prompt/select.go +++ b/pkg/survey/prompt/select.go @@ -1,19 +1,80 @@ package prompt import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey/util" +) + +const listHeight = 14 + +var ( + itemStyle = lipgloss.NewStyle().PaddingLeft(2) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(0).Foreground(lipgloss.Color("170")) + paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(0) ) type SelectModel struct { - variable recipe.Variable + variable recipe.Variable + list list.Model + styles util.Styles + value string + showDescription bool + submitted bool } var _ Model = SelectModel{} -func NewSelectModel(v recipe.Variable) SelectModel { +type item string + +func (i item) FilterValue() string { return "" } + +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } + + fn := itemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return selectedItemStyle.Render("> " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(string(i))) +} + +func NewSelectModel(v recipe.Variable, styles util.Styles) SelectModel { + items := make([]list.Item, len(v.Options)) + for i := range v.Options { + items[i] = item(v.Options[i]) + } + + const defaultWidth = 20 + + l := list.New(items, itemDelegate{}, defaultWidth, listHeight) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.SetShowTitle(false) + l.Styles.PaginationStyle = paginationStyle + return SelectModel{ variable: v, + list: l, + styles: styles, } } @@ -22,11 +83,50 @@ func (m SelectModel) Init() tea.Cmd { } func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, tea.Quit + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } + switch msg.Type { + case tea.KeyEnter: + m.submitted = true + m.value = string(m.list.SelectedItem().(item)) + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd } -func (m SelectModel) View() string { - return "" +func (m SelectModel) View() (s string) { + s += m.styles.VariableName.Render(m.variable.Name) + if m.submitted { + s += fmt.Sprintf(": %s", m.value) + return + } + + if m.variable.Description != "" && !m.showDescription { + s += m.styles.HelpText.Render(" [type ? for more info]") + } + + s += "\n" + if m.showDescription { + s += m.variable.Description + s += "\n" + } + + s += m.list.View() + return } func (m SelectModel) Name() string { @@ -34,9 +134,9 @@ func (m SelectModel) Name() string { } func (m SelectModel) Value() interface{} { - return "" + return m.value } func (m SelectModel) IsSubmitted() bool { - return false + return m.submitted } diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index 579adecc..826de13a 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -183,8 +183,7 @@ func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { var p prompt.Model switch { case len(v.Options) != 0: - // prompt = prompt.NewSelectModel(v, m.styles) // TODO - p = prompt.NewStringModel(v, m.styles) + p = prompt.NewSelectModel(v, m.styles) case v.Confirm: p = prompt.NewConfirmModel(v, m.styles) case len(v.Columns) > 0: From 4560dce93ba7fe77e2dc8119590b24589993eba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Tue, 10 Oct 2023 12:28:01 +0300 Subject: [PATCH 08/20] Implement first version of editable table --- examples/variable-types/recipe.yml | 30 +- pkg/recipeutil/values.go | 12 + pkg/survey/editable/model.go | 466 +++++++++++++++++++++++++++++ pkg/survey/prompt/table.go | 31 +- pkg/survey/survey.go | 3 +- 5 files changed, 522 insertions(+), 20 deletions(-) create mode 100644 pkg/survey/editable/model.go diff --git a/examples/variable-types/recipe.yml b/examples/variable-types/recipe.yml index 8e76342f..3b6b8581 100644 --- a/examples/variable-types/recipe.yml +++ b/examples/variable-types/recipe.yml @@ -6,25 +6,25 @@ version: v0.0.0 vars: ### Variable types # - - name: STRING_VAR - description: | - Simple string variable + # - name: SELECT_VAR + # description: | + # User chooses one value from the predefined values in `options` property. - - name: BOOLEAN_VAR - description: | - Boolean variable can have value either `true` or `false`. + # Defined by: non-empty `options` property. + # options: + # - option_1 + # - option_2 - Defined by: `confirm: true`. - confirm: true + # - name: STRING_VAR + # description: | + # Simple string variable - - name: SELECT_VAR - description: | - User chooses one value from the predefined values in `options` property. + # - name: BOOLEAN_VAR + # description: | + # Boolean variable can have value either `true` or `false`. - Defined by: non-empty `options` property. - options: - - option_1 - - option_2 + # Defined by: `confirm: true`. + # confirm: true - name: TABLE_VAR description: | diff --git a/pkg/recipeutil/values.go b/pkg/recipeutil/values.go index caad3fda..38879994 100644 --- a/pkg/recipeutil/values.go +++ b/pkg/recipeutil/values.go @@ -124,3 +124,15 @@ func CSVToTable(columns []string, str string, delimiter rune) ([]map[string]stri return table, nil } + +func RowsToTable(columns []string, rows [][]string) ([]map[string]string, error) { + table := make([]map[string]string, len(rows)) + for i, row := range rows { + table[i] = make(map[string]string) + for j, cell := range row { + table[i][columns[j]] = cell + } + } + + return table, nil +} diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go new file mode 100644 index 00000000..06d0b503 --- /dev/null +++ b/pkg/survey/editable/model.go @@ -0,0 +1,466 @@ +package editable + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" +) + +// Model defines a state for the table widget. +type Model struct { + KeyMap KeyMap + + cols []Column + rows []Row + cursorX int + cursorY int + focus bool + styles Styles + + viewport viewport.Model +} + +var _ tea.Model = Model{} + +// Row represents one line in the table. +type Row []textinput.Model + +// Column defines the table structure. +type Column struct { + Title string + Width int +} + +// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which +// is used to render the menu. +type KeyMap struct { + CellUp key.Binding + CellDown key.Binding + CellLeft key.Binding + CellRight key.Binding + NextCell key.Binding + NewRow key.Binding + PageUp key.Binding + PageDown key.Binding + GotoTop key.Binding + GotoBottom key.Binding +} + +// DefaultKeyMap returns a default set of keybindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + CellUp: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "up"), + ), + CellDown: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "down"), + ), + CellLeft: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←", "left"), + ), + CellRight: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "right"), + ), + NextCell: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next cell"), + ), + NewRow: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl + n", "new"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup"), + key.WithHelp("pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown"), + key.WithHelp("pgdn", "page down"), + ), + GotoTop: key.NewBinding( + key.WithKeys("home"), + key.WithHelp("home", "go to start"), + ), + GotoBottom: key.NewBinding( + key.WithKeys("end"), + key.WithHelp("end", "go to end"), + ), + } +} + +// Styles contains style definitions for this list component. By default, these +// values are generated by DefaultStyles. +type Styles struct { + Header lipgloss.Style + Cell lipgloss.Style + Selected lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + Header: lipgloss.NewStyle(). + Bold(true). + Padding(0, 1). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false), + Cell: lipgloss.NewStyle(). + Padding(0, 1). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true), + } +} + +func (m *Model) SetStyles(s Styles) { + m.styles = s + m.UpdateViewport() +} + +// Option is used to set options in New. For example: +// +// table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) +type Option func(*Model) + +// New creates a new model for the table widget. +func New(opts ...Option) Model { + m := Model{ + cursorX: 0, + cursorY: 0, + viewport: viewport.New(0, 3), + + KeyMap: DefaultKeyMap(), + styles: DefaultStyles(), + } + + for _, opt := range opts { + opt(&m) + } + + m.AddRow() + + return m +} + +func WithColumns(cols []Column) Option { + return func(m *Model) { + m.cols = cols + } +} + +func WithRows(rows []Row) Option { + return func(m *Model) { + m.rows = rows + } +} + +func WithHeight(h int) Option { + return func(m *Model) { + m.viewport.Height = h + } +} + +func WithWidth(w int) Option { + return func(m *Model) { + m.viewport.Width = w + } +} + +func WithStyles(s Styles) Option { + return func(m *Model) { + m.styles = s + } +} + +func WithKeyMap(km KeyMap) Option { + return func(m *Model) { + m.KeyMap = km + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.rows[0][0].Focus(), + textinput.Blink, + ) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd = nil + if !m.focus { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.KeyMap.CellUp): + cmd = m.MoveUp(1) + case key.Matches(msg, m.KeyMap.CellDown): + cmd = m.MoveDown(1) + case key.Matches(msg, m.KeyMap.CellLeft): + cmd = m.MoveLeft(1) + case key.Matches(msg, m.KeyMap.CellRight): + cmd = m.MoveRight(1) + case key.Matches(msg, m.KeyMap.NextCell): + cmd = m.MoveToNextCell() + case key.Matches(msg, m.KeyMap.NewRow): + m.AddRow() + case key.Matches(msg, m.KeyMap.PageUp): + cmd = m.MoveUp(m.viewport.Height) + case key.Matches(msg, m.KeyMap.PageDown): + cmd = m.MoveDown(m.viewport.Height) + case key.Matches(msg, m.KeyMap.GotoTop): + cmd = m.GotoTop() + case key.Matches(msg, m.KeyMap.GotoBottom): + cmd = m.GotoBottom() + } + if cmd != nil { + return m, cmd + } + } + + m.UpdateViewport() + m.rows[m.cursorY][m.cursorX], cmd = m.rows[m.cursorY][m.cursorX].Update(msg) + return m, cmd +} + +func (m Model) Focused() bool { + return m.focus +} + +func (m *Model) Focus() { + m.focus = true + m.UpdateViewport() +} + +func (m *Model) Blur() { + m.focus = false + m.UpdateViewport() +} + +func (m Model) View() string { + return m.headersView() + "\n" + m.viewport.View() +} + +func (m *Model) UpdateViewport() { + renderedRows := make([]string, 0, len(m.rows)) + + for i := range m.rows { + renderedRows = append(renderedRows, m.renderRow(i)) + } + + m.viewport.SetContent( + lipgloss.JoinVertical(lipgloss.Left, renderedRows...), + ) +} + +// Rows returns the current rows. +func (m Model) Rows() []Row { + return m.rows +} + +func (m *Model) AddRow() { + row := make(Row, len(m.cols)) + for i := range row { + ti := textinput.New() + ti.Blur() + ti.Width = m.cols[i].Width - 1 + ti.Prompt = "" + row[i] = ti + } + + m.rows = append(m.rows, row) + + m.viewport.Height += 2 + + m.UpdateViewport() +} + +// SetRows sets a new rows state. +func (m *Model) RemoveRow(n int) { + m.rows = append(m.rows[:n], m.rows[n+1:]...) + m.viewport.Height -= 2 + + m.UpdateViewport() +} + +// SetColumns sets a new columns state. +func (m *Model) SetColumns(c []Column) { + m.cols = c + m.UpdateViewport() +} + +// Cursor returns the index of the selected row. +func (m Model) Cursor() (int, int) { + return m.cursorY, m.cursorX +} + +// SetCursor sets the cursor position in the table. +func (m *Model) SetCursor(y, x int) { + m.cursorY = clamp(y, 0, len(m.rows)-1) + m.cursorX = clamp(x, 0, len(m.cols)-1) + m.UpdateViewport() +} + +// MoveUp moves the selection up by any number of rows. +// It can not go above the first row. +func (m *Model) MoveUp(n int) tea.Cmd { + return m.Move(-n, 0) +} + +// MoveDown moves the selection down by any number of rows. +// It can not go below the last row. +func (m *Model) MoveDown(n int) tea.Cmd { + return m.Move(n, 0) +} + +func (m *Model) MoveLeft(n int) tea.Cmd { + return m.Move(0, -n) +} + +func (m *Model) MoveRight(n int) tea.Cmd { + return m.Move(0, n) +} + +func (m *Model) MoveToNextCell() tea.Cmd { + // If we're not on the last column, move right + if m.cursorX < len(m.cols)-1 { + return m.MoveRight(1) + } + + return m.Move(1, -(len(m.cols) - 1)) +} + +func (m *Model) GotoTop() tea.Cmd { + return m.Move(-m.cursorY, 0) +} + +func (m *Model) GotoBottom() tea.Cmd { + return m.Move(len(m.rows)-1, 0) +} + +func (m *Model) Move(y, x int) tea.Cmd { + if y == 0 && x == 0 { + return nil + } + + // Blur existing cell + m.BlurCell() + + if x != 0 { + m.cursorX = clamp(m.cursorX+x, 0, len(m.cols)-1) + } + + if y != 0 { + if m.cursorY+y >= len(m.rows) { + for i := 0; i < m.cursorY+y-len(m.rows)+1; i++ { + m.AddRow() + } + } + + if m.cursorY == len(m.rows)-1 && y < 0 && len(m.rows) > 1 { + for n := 0; n > y; n-- { + isEmpty := true + for _, cell := range m.rows[m.cursorY+n] { + if cell.Value() != "" { + isEmpty = false + break + } + } + if isEmpty { + m.RemoveRow(m.cursorY) + } + } + } + + m.cursorY = clamp(m.cursorY+y, 0, len(m.rows)-1) + } + + m.UpdateViewport() + + // Focus on the new cell + return m.FocusCell() +} + +func (m Model) Columns() []string { + columns := make([]string, len(m.cols)) + for i, col := range m.cols { + columns[i] = col.Title + } + return columns +} + +func (m Model) Values() [][]string { + values := make([][]string, len(m.rows)) + for i, row := range m.rows { + values[i] = make([]string, len(row)) + for j, cell := range row { + values[i][j] = cell.Value() + } + } + + return values +} + +func (m Model) headersView() string { + var s = make([]string, 0, len(m.cols)) + for _, col := range m.cols { + style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) + renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) + s = append(s, m.styles.Header.Render(renderedCell)) + } + return lipgloss.JoinHorizontal(lipgloss.Left, s...) +} + +func (m *Model) renderRow(rowID int) string { + var s = make([]string, 0, len(m.cols)) + for i := range m.rows[rowID] { + cellStyle := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width) + if rowID == m.cursorY && i == m.cursorX { + cellStyle = m.styles.Selected.Inherit(cellStyle) + } + + renderedCell := m.styles.Cell.Render(cellStyle.Render(m.rows[rowID][i].View())) + s = append(s, renderedCell) + } + + return lipgloss.JoinHorizontal(lipgloss.Left, s...) +} + +func (m *Model) FocusCell() tea.Cmd { + return m.rows[m.cursorY][m.cursorX].Focus() +} + +func (m *Model) BlurCell() { + m.rows[m.cursorY][m.cursorX].Blur() +} + +func max(a, b int) int { + if a > b { + return a + } + + return b +} + +func min(a, b int) int { + if a < b { + return a + } + + return b +} + +func clamp(v, low, high int) int { + return min(max(v, low), high) +} diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index add6d9e4..3231f8bd 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -1,32 +1,57 @@ package prompt import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/recipeutil" + "github.com/futurice/jalapeno/pkg/survey/editable" ) type TableModel struct { variable recipe.Variable + table editable.Model } var _ Model = TableModel{} func NewTableModel(v recipe.Variable) TableModel { + cols := make([]editable.Column, len(v.Columns)) + for i, c := range v.Columns { + cols[i] = editable.Column{ + Title: c, + Width: len(c), + } + } + table := editable.New(editable.WithColumns(cols)) + table.Focus() + return TableModel{ variable: v, + table: table, } } func (m TableModel) Init() tea.Cmd { - return nil + return m.table.Init() } func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, nil + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + fmt.Println(recipeutil.RowsToTable(m.variable.Columns, m.table.Values())) + } + } + tm, cmd := m.table.Update(msg) + m.table = tm.(editable.Model) + return m, cmd } func (m TableModel) View() string { - return "" + return m.table.View() } func (m TableModel) Name() string { diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index 826de13a..0bd0a1c9 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -187,8 +187,7 @@ func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { case v.Confirm: p = prompt.NewConfirmModel(v, m.styles) case len(v.Columns) > 0: - // prompt = prompt.NewTableModel(v, m.styles) // TODO - p = prompt.NewStringModel(v, m.styles) + p = prompt.NewTableModel(v) default: p = prompt.NewStringModel(v, m.styles) } From 140ee41de4e268265a72747823debcbef6b52cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Tue, 10 Oct 2023 18:10:03 +0300 Subject: [PATCH 09/20] Prompt only if needed --- internal/cli/execute.go | 26 +++++++++++++------------- internal/cli/upgrade.go | 17 +++++++++++------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/internal/cli/execute.go b/internal/cli/execute.go index 5f8df3a2..385671cc 100644 --- a/internal/cli/execute.go +++ b/internal/cli/execute.go @@ -124,24 +124,24 @@ func runExecute(cmd *cobra.Command, opts executeOptions) { return } - predefinedValues := recipeutil.MergeValues(reusedValues, providedValues) + values := recipeutil.MergeValues(reusedValues, providedValues) // Filter out variables which don't have value yet - filteredVariables := recipeutil.FilterVariablesWithoutValues(re.Variables, predefinedValues) - promptedValues, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), filteredVariables, predefinedValues) - if err != nil { - if errors.Is(err, survey.ErrUserAborted) { - return - } else { - cmd.PrintErrf("Error when prompting for values: %s\n", err) - return + varsWithoutValues := recipeutil.FilterVariablesWithoutValues(re.Variables, values) + if len(varsWithoutValues) > 0 { + promptedValues, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), varsWithoutValues, values) + if err != nil { + if errors.Is(err, survey.ErrUserAborted) { + return + } else { + cmd.PrintErrf("Error when prompting for values: %s\n", err) + return + } } + values = recipeutil.MergeValues(values, promptedValues) } - sauce, err := re.Execute( - recipeutil.MergeValues(predefinedValues, promptedValues), - uuid.Must(uuid.NewV4()), - ) + sauce, err := re.Execute(values, uuid.Must(uuid.NewV4())) if err != nil { cmd.PrintErrf("Error: %s", err) return diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 589618ec..0563337a 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -120,6 +120,7 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { } predefinedValues := recipeutil.MergeValues(reusedValues, providedValues) + values := recipeutil.MergeValues(oldSauce.Values, predefinedValues) // Don't prompt variables which already has a value in existing sauce or is predefined varsWithoutValues := make([]recipe.Variable, 0, len(re.Variables)) @@ -131,15 +132,19 @@ func runUpgrade(cmd *cobra.Command, opts upgradeOptions) { } } - values, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), varsWithoutValues, predefinedValues) - if err != nil { - if !errors.Is(err, survey.ErrUserAborted) { - cmd.PrintErrf("Error when prompting for values: %s\n", err) + if len(varsWithoutValues) > 0 { + promptedValues, err := survey.PromptUserForValues(cmd.InOrStdin(), cmd.OutOrStdout(), varsWithoutValues, predefinedValues) + if err != nil { + if !errors.Is(err, survey.ErrUserAborted) { + cmd.PrintErrf("Error when prompting for values: %s\n", err) + } + return } - return + + values = recipeutil.MergeValues(values, promptedValues) } - newSauce, err := re.Execute(recipeutil.MergeValues(values, oldSauce.Values, predefinedValues), oldSauce.ID) + newSauce, err := re.Execute(values, oldSauce.ID) if err != nil { cmd.PrintErrf("Error: %s", err) return From 371d5dd6f2e9a837ad2f3723b6a0ef5ddf172ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Tue, 10 Oct 2023 18:11:42 +0300 Subject: [PATCH 10/20] Implement table validators --- examples/variable-types/recipe.yml | 36 ++++----- pkg/recipe/variable.go | 56 ++++++++++--- pkg/recipe/variable_test.go | 8 +- pkg/recipeutil/values.go | 8 +- pkg/survey/editable/model.go | 125 ++++++++++++++++++++--------- pkg/survey/prompt/string.go | 10 ++- pkg/survey/prompt/table.go | 17 +++- 7 files changed, 184 insertions(+), 76 deletions(-) diff --git a/examples/variable-types/recipe.yml b/examples/variable-types/recipe.yml index 3b6b8581..a56958f4 100644 --- a/examples/variable-types/recipe.yml +++ b/examples/variable-types/recipe.yml @@ -6,25 +6,25 @@ version: v0.0.0 vars: ### Variable types # - # - name: SELECT_VAR - # description: | - # User chooses one value from the predefined values in `options` property. + - name: STRING_VAR + description: | + Simple string variable - # Defined by: non-empty `options` property. - # options: - # - option_1 - # - option_2 + - name: BOOLEAN_VAR + description: | + Boolean variable can have value either `true` or `false`. - # - name: STRING_VAR - # description: | - # Simple string variable + Defined by: `confirm: true`. + confirm: true - # - name: BOOLEAN_VAR - # description: | - # Boolean variable can have value either `true` or `false`. + - name: SELECT_VAR + description: | + User chooses one value from the predefined values in `options` property. - # Defined by: `confirm: true`. - # confirm: true + Defined by: non-empty `options` property. + options: + - option_1 + - option_2 - name: TABLE_VAR description: | @@ -64,6 +64,6 @@ vars: - name: VAR_WITH_VALIDATOR description: | Regular expression validators can be set for a variable by defining `regexp` property - regexp: - pattern: ".*" - help: "If the check doesn't pass, this help message will be shown" + validators: + - pattern: ".*" + help: "If the check doesn't pass, this help message will be shown" diff --git a/pkg/recipe/variable.go b/pkg/recipe/variable.go index 287dc9e2..d95b53ca 100644 --- a/pkg/recipe/variable.go +++ b/pkg/recipe/variable.go @@ -25,8 +25,8 @@ type Variable struct { // The user selects the value from a list of options Options []string `yaml:"options,omitempty"` - // Regular expression validator for the variable value - RegExp VariableRegExpValidator `yaml:"regexp,omitempty"` + // Regular expression validators for the variable value + Validators []VariableValidator `yaml:"validators,omitempty"` // Makes the variable conditional based on the result of the expression. The result of the evaluation needs to be a boolean value. Uses https://github.com/antonmedv/expr If string `yaml:"if,omitempty"` @@ -35,12 +35,15 @@ type Variable struct { Columns []string `yaml:"columns,omitempty"` } -type VariableRegExpValidator struct { +type VariableValidator struct { // Regular expression pattern to match the input against Pattern string `yaml:"pattern,omitempty"` // If the regular expression validation fails, this help message will be shown to the user Help string `yaml:"help,omitempty"` + + // Apply the validator to a column if the variable type is table + Column string `yaml:"column,omitempty"` } // VariableValues stores values for each variable @@ -59,9 +62,44 @@ func (v *Variable) Validate() error { } } - if v.RegExp.Pattern != "" { - if _, err := regexp.Compile(v.RegExp.Pattern); err != nil { - return fmt.Errorf("invalid variable regexp pattern: %w", err) + for i, validator := range v.Validators { + baseErr := fmt.Errorf("validator %d", i+1) + if v.Confirm { + return fmt.Errorf("%s: validators for boolean variables are not supported", baseErr) + } + + if len(v.Options) > 0 { + return fmt.Errorf("%s: validators for select variables are not supported", baseErr) + } + + if len(v.Columns) > 0 && validator.Column == "" { + return fmt.Errorf("%s: validator need to have `column` property defined since the variable is table type", baseErr) + } + + if validator.Pattern == "" { + return fmt.Errorf("%s: regexp pattern is empty", baseErr) + } + + if validator.Column != "" { + if len(v.Columns) == 0 { + return fmt.Errorf("%s: validator is defined for column while the variable has not defined any", baseErr) + } + + found := false + for _, c := range v.Columns { + if c == validator.Column { + found = true + break + } + } + + if !found { + return fmt.Errorf("%s: column %s does not exist in the variable", baseErr, validator.Column) + } + } + + if _, err := regexp.Compile(validator.Pattern); err != nil { + return fmt.Errorf("%s: invalid variable regexp pattern: %w", baseErr, err) } } @@ -74,11 +112,11 @@ func (v *Variable) Validate() error { return nil } -func (r *VariableRegExpValidator) CreateValidatorFunc() func(input interface{}) error { +func (r *VariableValidator) CreateValidatorFunc() func(input string) error { reg := regexp.MustCompile(r.Pattern) - return func(input interface{}) error { - if match := reg.MatchString(fmt.Sprint(input)); !match { + return func(input string) error { + if match := reg.MatchString(input); !match { if r.Help != "" { return errors.New(r.Help) } else { diff --git a/pkg/recipe/variable_test.go b/pkg/recipe/variable_test.go index 33949530..37e34a85 100644 --- a/pkg/recipe/variable_test.go +++ b/pkg/recipe/variable_test.go @@ -6,12 +6,14 @@ func TestVariableRegExpValidation(t *testing.T) { variable := &Variable{ Name: "foo", Description: "foo description", - RegExp: VariableRegExpValidator{ - Pattern: "^[a-zA-Z0-9_.()-]{0,89}[a-zA-Z0-9_()-]$", + Validators: []VariableValidator{ + { + Pattern: "^[a-zA-Z0-9_.()-]{0,89}[a-zA-Z0-9_()-]$", + }, }, } - validatorFunc := variable.RegExp.CreateValidatorFunc() + validatorFunc := variable.Validators[0].CreateValidatorFunc() err := validatorFunc("") if err == nil { diff --git a/pkg/recipeutil/values.go b/pkg/recipeutil/values.go index 38879994..2813eedf 100644 --- a/pkg/recipeutil/values.go +++ b/pkg/recipeutil/values.go @@ -49,9 +49,9 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string, delimiter return nil, fmt.Errorf("%w: %s", ErrVarNotDefinedInRecipe, varName) } - if targetedVariable.RegExp.Pattern != "" { - validator := targetedVariable.RegExp.CreateValidatorFunc() - if err := validator(varValue); err != nil { + for i := range targetedVariable.Validators { + validatorFunc := targetedVariable.Validators[i].CreateValidatorFunc() + if err := validatorFunc(varValue); err != nil { return nil, fmt.Errorf("validator failed for value '%s=%s': %w", varName, varValue, err) } } @@ -81,6 +81,8 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string, delimiter return values, nil } +// MergeValues merges multiple VariableValues into one. If a key exists in multiple VariableValues, the value from the +// last VariableValues will be used. func MergeValues(valuesSlice ...recipe.VariableValues) recipe.VariableValues { merged := make(recipe.VariableValues) for _, values := range valuesSlice { diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go index 06d0b503..35139bca 100644 --- a/pkg/survey/editable/model.go +++ b/pkg/survey/editable/model.go @@ -1,6 +1,10 @@ package editable import ( + "errors" + "fmt" + "strings" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" @@ -30,8 +34,9 @@ type Row []textinput.Model // Column defines the table structure. type Column struct { - Title string - Width int + Title string + Width int + Validators []func(string) error } // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which @@ -105,7 +110,9 @@ type Styles struct { func DefaultStyles() Styles { return Styles{ - Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + Selected: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")), Header: lipgloss.NewStyle(). Bold(true). Padding(0, 1). @@ -123,7 +130,7 @@ func DefaultStyles() Styles { func (m *Model) SetStyles(s Styles) { m.styles = s - m.UpdateViewport() + m.updateViewport() } // Option is used to set options in New. For example: @@ -136,7 +143,7 @@ func New(opts ...Option) Model { m := Model{ cursorX: 0, cursorY: 0, - viewport: viewport.New(0, 3), + viewport: viewport.New(0, 4), KeyMap: DefaultKeyMap(), styles: DefaultStyles(), @@ -229,7 +236,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - m.UpdateViewport() + m.updateViewport() m.rows[m.cursorY][m.cursorX], cmd = m.rows[m.cursorY][m.cursorX].Update(msg) return m, cmd } @@ -240,30 +247,18 @@ func (m Model) Focused() bool { func (m *Model) Focus() { m.focus = true - m.UpdateViewport() + m.updateViewport() } func (m *Model) Blur() { m.focus = false - m.UpdateViewport() + m.updateViewport() } func (m Model) View() string { return m.headersView() + "\n" + m.viewport.View() } -func (m *Model) UpdateViewport() { - renderedRows := make([]string, 0, len(m.rows)) - - for i := range m.rows { - renderedRows = append(renderedRows, m.renderRow(i)) - } - - m.viewport.SetContent( - lipgloss.JoinVertical(lipgloss.Left, renderedRows...), - ) -} - // Rows returns the current rows. func (m Model) Rows() []Row { return m.rows @@ -272,18 +267,14 @@ func (m Model) Rows() []Row { func (m *Model) AddRow() { row := make(Row, len(m.cols)) for i := range row { - ti := textinput.New() - ti.Blur() - ti.Width = m.cols[i].Width - 1 - ti.Prompt = "" - row[i] = ti + row[i] = m.newTextInput(m.cols[i]) } m.rows = append(m.rows, row) m.viewport.Height += 2 - m.UpdateViewport() + m.updateViewport() } // SetRows sets a new rows state. @@ -291,13 +282,13 @@ func (m *Model) RemoveRow(n int) { m.rows = append(m.rows[:n], m.rows[n+1:]...) m.viewport.Height -= 2 - m.UpdateViewport() + m.updateViewport() } // SetColumns sets a new columns state. func (m *Model) SetColumns(c []Column) { m.cols = c - m.UpdateViewport() + m.updateViewport() } // Cursor returns the index of the selected row. @@ -309,7 +300,7 @@ func (m Model) Cursor() (int, int) { func (m *Model) SetCursor(y, x int) { m.cursorY = clamp(y, 0, len(m.rows)-1) m.cursorX = clamp(x, 0, len(m.cols)-1) - m.UpdateViewport() + m.updateViewport() } // MoveUp moves the selection up by any number of rows. @@ -355,7 +346,8 @@ func (m *Model) Move(y, x int) tea.Cmd { } // Blur existing cell - m.BlurCell() + m.rows[m.cursorY][m.cursorX].Blur() + m.rows[m.cursorY][m.cursorX].Err = m.validateCell(m.cursorY, m.cursorX) if x != 0 { m.cursorX = clamp(m.cursorX+x, 0, len(m.cols)-1) @@ -386,10 +378,10 @@ func (m *Model) Move(y, x int) tea.Cmd { m.cursorY = clamp(m.cursorY+y, 0, len(m.rows)-1) } - m.UpdateViewport() + m.updateViewport() // Focus on the new cell - return m.FocusCell() + return m.rows[m.cursorY][m.cursorX].Focus() } func (m Model) Columns() []string { @@ -412,6 +404,64 @@ func (m Model) Values() [][]string { return values } +func (m Model) Errors() []error { + errors := make([]error, 0, len(m.rows)*len(m.cols)) + for y := range m.rows { + for x := range m.rows[y] { + if m.rows[y][x].Err != nil { + errors = append(errors, fmt.Errorf("cell (%d, %d): %w", y, x, m.rows[y][x].Err)) + } + } + } + + return errors +} + +func (m Model) validateCell(y, x int) error { + if m.cols[x].Validators == nil { + return nil + } + + errs := make([]error, 0, len(m.cols[x].Validators)) + for i := range m.cols[x].Validators { + err := m.cols[x].Validators[i](m.rows[y][x].Value()) + if err != nil { + errs = append(errs, err) + } + } + + errStr := make([]string, len(errs)) + for i := range errs { + errStr[i] = errs[i].Error() + } + + return errors.New(strings.Join(errStr, ", ")) +} + +func (m *Model) updateViewport() { + renderedRows := make([]string, 0, len(m.rows)) + + for i := range m.rows { + renderedRows = append(renderedRows, m.renderRow(i)) + } + + errStr := "" + errors := m.Errors() + if len(errors) != 0 { + for _, err := range errors { + errStr += fmt.Sprintf("Error on %s\n", err) + } + } + + m.viewport.SetContent( + lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.JoinVertical(lipgloss.Left, renderedRows...), + errStr, + ), + ) +} + func (m Model) headersView() string { var s = make([]string, 0, len(m.cols)) for _, col := range m.cols { @@ -437,12 +487,13 @@ func (m *Model) renderRow(rowID int) string { return lipgloss.JoinHorizontal(lipgloss.Left, s...) } -func (m *Model) FocusCell() tea.Cmd { - return m.rows[m.cursorY][m.cursorX].Focus() -} +func (m Model) newTextInput(c Column) textinput.Model { + ti := textinput.New() + ti.Blur() + ti.Prompt = "" + ti.Width = c.Width - 1 -func (m *Model) BlurCell() { - m.rows[m.cursorY][m.cursorX].Blur() + return ti } func max(a, b int) int { diff --git a/pkg/survey/prompt/string.go b/pkg/survey/prompt/string.go index abd46735..8c4e2ecd 100644 --- a/pkg/survey/prompt/string.go +++ b/pkg/survey/prompt/string.go @@ -119,10 +119,12 @@ func (m StringModel) Validate() error { return util.ErrRequired } - if m.variable.RegExp.Pattern != "" { - validator := m.variable.RegExp.CreateValidatorFunc() - if err := validator(m.textInput.Value()); err != nil { - return fmt.Errorf("%w: %s", util.ErrRegExFailed, err) + for _, v := range m.variable.Validators { + if v.Pattern != "" { + validatorFunc := v.CreateValidatorFunc() + if err := validatorFunc(m.textInput.Value()); err != nil { + return fmt.Errorf("%w: %s", util.ErrRegExFailed, err) + } } } diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index 3231f8bd..7aecd053 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -18,10 +18,23 @@ var _ Model = TableModel{} func NewTableModel(v recipe.Variable) TableModel { cols := make([]editable.Column, len(v.Columns)) + + validators := make(map[string][]func(string) error) + for i, validator := range v.Validators { + if validator.Column != "" { + if validators[validator.Column] == nil { + validators[validator.Column] = make([]func(string) error, 0) + } + + validators[validator.Column] = append(validators[validator.Column], v.Validators[i].CreateValidatorFunc()) + } + } + for i, c := range v.Columns { cols[i] = editable.Column{ - Title: c, - Width: len(c), + Title: c, + Width: len(c), + Validators: validators[c], } } table := editable.New(editable.WithColumns(cols)) From 409328727bc8e503296f8c29f97f9824fd6a5488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Tue, 10 Oct 2023 18:46:19 +0300 Subject: [PATCH 11/20] Finalize table prompt --- examples/variable-types/templates/README.md | 14 ++-- pkg/survey/editable/model.go | 17 +---- pkg/survey/prompt/table.go | 76 ++++++++++++++++++--- pkg/survey/survey.go | 4 +- 4 files changed, 74 insertions(+), 37 deletions(-) diff --git a/examples/variable-types/templates/README.md b/examples/variable-types/templates/README.md index 6afcbece..8b9b2a10 100644 --- a/examples/variable-types/templates/README.md +++ b/examples/variable-types/templates/README.md @@ -1,18 +1,14 @@ -# String variable +# String variable: {{ .Variables.STRING_VAR }} -{{- .Variables.STRING_VAR }} +# Boolean variable: {{ .Variables.BOOLEAN_VAR }} -# Boolean variable - -{{- .Variables.BOOLEAN_VAR }} - -# Select variable - -{{- .Variables.SELECT_VAR }} +# Select variable: {{ .Variables.SELECT_VAR }} # Table variable | COLUMN_1 | COLUMN_2 | COLUMN_3 | +| --- | --- | --- | + {{- range $val := .Variables.TABLE_VAR }} | {{ $val.COLUMN_1 }} | {{ $val.COLUMN_2 }} | {{ $val.COLUMN_3 }} | {{- end}} diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go index 35139bca..8ff9105c 100644 --- a/pkg/survey/editable/model.go +++ b/pkg/survey/editable/model.go @@ -13,7 +13,6 @@ import ( "github.com/mattn/go-runewidth" ) -// Model defines a state for the table widget. type Model struct { KeyMap KeyMap @@ -29,18 +28,14 @@ type Model struct { var _ tea.Model = Model{} -// Row represents one line in the table. type Row []textinput.Model -// Column defines the table structure. type Column struct { Title string Width int Validators []func(string) error } -// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which -// is used to render the menu. type KeyMap struct { CellUp key.Binding CellDown key.Binding @@ -54,7 +49,6 @@ type KeyMap struct { GotoBottom key.Binding } -// DefaultKeyMap returns a default set of keybindings. func DefaultKeyMap() KeyMap { return KeyMap{ CellUp: key.NewBinding( @@ -100,8 +94,6 @@ func DefaultKeyMap() KeyMap { } } -// Styles contains style definitions for this list component. By default, these -// values are generated by DefaultStyles. type Styles struct { Header lipgloss.Style Cell lipgloss.Style @@ -138,8 +130,7 @@ func (m *Model) SetStyles(s Styles) { // table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) type Option func(*Model) -// New creates a new model for the table widget. -func New(opts ...Option) Model { +func NewModel(opts ...Option) Model { m := Model{ cursorX: 0, cursorY: 0, @@ -241,10 +232,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m Model) Focused() bool { - return m.focus -} - func (m *Model) Focus() { m.focus = true m.updateViewport() @@ -252,6 +239,7 @@ func (m *Model) Focus() { func (m *Model) Blur() { m.focus = false + m.rows[m.cursorY][m.cursorX].Blur() m.updateViewport() } @@ -345,7 +333,6 @@ func (m *Model) Move(y, x int) tea.Cmd { return nil } - // Blur existing cell m.rows[m.cursorY][m.cursorX].Blur() m.rows[m.cursorY][m.cursorX].Err = m.validateCell(m.cursorY, m.cursorX) diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index 7aecd053..0f097572 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -1,22 +1,28 @@ package prompt import ( - "fmt" - tea "github.com/charmbracelet/bubbletea" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/recipeutil" "github.com/futurice/jalapeno/pkg/survey/editable" + "github.com/futurice/jalapeno/pkg/survey/util" ) type TableModel struct { - variable recipe.Variable - table editable.Model + variable recipe.Variable + table editable.Model + styles util.Styles + submitted bool + showDescription bool + + // Save the table as CSV for the final output. This speeds up the + // rendering when the user has submitted the form. + tableAsCSV string } var _ Model = TableModel{} -func NewTableModel(v recipe.Variable) TableModel { +func NewTableModel(v recipe.Variable, styles util.Styles) TableModel { cols := make([]editable.Column, len(v.Columns)) validators := make(map[string][]func(string) error) @@ -37,12 +43,13 @@ func NewTableModel(v recipe.Variable) TableModel { Validators: validators[c], } } - table := editable.New(editable.WithColumns(cols)) + table := editable.NewModel(editable.WithColumns(cols)) table.Focus() return TableModel{ variable: v, table: table, + styles: styles, } } @@ -53,9 +60,18 @@ func (m TableModel) Init() tea.Cmd { func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + switch msg.String() { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } switch msg.Type { case tea.KeyEnter: - fmt.Println(recipeutil.RowsToTable(m.variable.Columns, m.table.Values())) + m.submitted = true + m.tableAsCSV = m.ValueAsCSV() + m.table.Blur() } } tm, cmd := m.table.Update(msg) @@ -63,8 +79,27 @@ func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m TableModel) View() string { - return m.table.View() +func (m TableModel) View() (s string) { + s += m.styles.VariableName.Render(m.variable.Name) + + if m.submitted { + s += ": " + s += m.tableAsCSV + return + } + + if m.variable.Description != "" && !m.showDescription { + s += m.styles.HelpText.Render(" [type ? for more info]") + } + + s += "\n" + if m.showDescription { + s += m.variable.Description + } + s += "\n" + + s += m.table.View() + return } func (m TableModel) Name() string { @@ -72,9 +107,28 @@ func (m TableModel) Name() string { } func (m TableModel) Value() interface{} { - return "" + values, _ := recipeutil.RowsToTable(m.variable.Columns, m.table.Values()) + return values } func (m TableModel) IsSubmitted() bool { - return false + return m.submitted +} + +func (m TableModel) ValueAsCSV() string { + rows := m.table.Values() + s := "" + for y := range rows { + for x := range rows[y] { + s += rows[y][x] + if x < len(rows[y])-1 { + s += "," + } + } + if y < len(rows)-1 { + s += "\\n" + } + } + + return s } diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index 0bd0a1c9..994cb2ff 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -117,7 +117,7 @@ func (m SurveyModel) View() (s string) { } for i := range m.prompts { - isLastPrompt := i == len(m.prompts)-1 && !m.submitted + isLastPrompt := i == len(m.prompts)-1 && len(m.prompts) > 1 && !m.submitted if isLastPrompt { s += "\n" } @@ -187,7 +187,7 @@ func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { case v.Confirm: p = prompt.NewConfirmModel(v, m.styles) case len(v.Columns) > 0: - p = prompt.NewTableModel(v) + p = prompt.NewTableModel(v, m.styles) default: p = prompt.NewStringModel(v, m.styles) } From e2c356d872c0eff55952117266bbd699e4706475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Wed, 11 Oct 2023 09:16:34 +0300 Subject: [PATCH 12/20] Fix table validation --- examples/variable-types/recipe.yml | 13 +++++++++++-- pkg/survey/editable/model.go | 24 +++++++++++++++++------- pkg/survey/prompt/table.go | 5 +++++ pkg/survey/survey.go | 4 ++-- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/examples/variable-types/recipe.yml b/examples/variable-types/recipe.yml index a56958f4..a4afd057 100644 --- a/examples/variable-types/recipe.yml +++ b/examples/variable-types/recipe.yml @@ -63,7 +63,16 @@ vars: - name: VAR_WITH_VALIDATOR description: | - Regular expression validators can be set for a variable by defining `regexp` property + Regular expression validators can be set for a variable by defining `validators` property validators: - - pattern: ".*" + - pattern: ".+" + help: "If the check doesn't pass, this help message will be shown" + + - name: TABLE_VAR_WITH_VALIDATOR + description: | + Regular expression validators can be set for a table variable by defining `validators` and `column` property + columns: [NOT_EMPTY_COL, CAN_BE_EMPTY_COL] + validators: + - pattern: ".+" + column: NOT_EMPTY_COL help: "If the check doesn't pass, this help message will be shown" diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go index 8ff9105c..b719226e 100644 --- a/pkg/survey/editable/model.go +++ b/pkg/survey/editable/model.go @@ -22,6 +22,7 @@ type Model struct { cursorY int focus bool styles Styles + errors []error viewport viewport.Model } @@ -334,7 +335,7 @@ func (m *Model) Move(y, x int) tea.Cmd { } m.rows[m.cursorY][m.cursorX].Blur() - m.rows[m.cursorY][m.cursorX].Err = m.validateCell(m.cursorY, m.cursorX) + m.Validate() if x != 0 { m.cursorX = clamp(m.cursorX+x, 0, len(m.cols)-1) @@ -391,16 +392,18 @@ func (m Model) Values() [][]string { return values } -func (m Model) Errors() []error { +func (m *Model) Validate() []error { errors := make([]error, 0, len(m.rows)*len(m.cols)) for y := range m.rows { for x := range m.rows[y] { - if m.rows[y][x].Err != nil { - errors = append(errors, fmt.Errorf("cell (%d, %d): %w", y, x, m.rows[y][x].Err)) + err := m.validateCell(y, x) + if err != nil { + errors = append(errors, fmt.Errorf("cell (%d, %d): %w", y, x, err)) } } } + m.errors = errors return errors } @@ -417,6 +420,14 @@ func (m Model) validateCell(y, x int) error { } } + if len(errs) == 0 { + return nil + } + + if len(errs) == 1 { + return errs[0] + } + errStr := make([]string, len(errs)) for i := range errs { errStr[i] = errs[i].Error() @@ -433,9 +444,8 @@ func (m *Model) updateViewport() { } errStr := "" - errors := m.Errors() - if len(errors) != 0 { - for _, err := range errors { + if len(m.errors) != 0 { + for _, err := range m.errors { errStr += fmt.Sprintf("Error on %s\n", err) } } diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index 0f097572..ee92e135 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -69,6 +69,11 @@ func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg.Type { case tea.KeyEnter: + // Validate the table. If there are errors, don't submit the form. + if errs := m.table.Validate(); len(errs) != 0 { + return m, nil + } + m.submitted = true m.tableAsCSV = m.ValueAsCSV() m.table.Blur() diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index 994cb2ff..e7e32f33 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -25,7 +25,7 @@ var ( ErrUserAborted = errors.New("user aborted") ) -func NewSurveyModel(variables []recipe.Variable) SurveyModel { +func NewModel(variables []recipe.Variable) SurveyModel { model := SurveyModel{ prompts: make([]prompt.Model, 0, len(variables)), variables: variables, @@ -197,7 +197,7 @@ func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { // PromptUserForValues prompts the user for values for the given variables func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variable, existingValues recipe.VariableValues) (recipe.VariableValues, error) { - p := tea.NewProgram(NewSurveyModel(variables), tea.WithInput(in), tea.WithOutput(out)) + p := tea.NewProgram(NewModel(variables), tea.WithInput(in), tea.WithOutput(out)) if m, err := p.Run(); err != nil { return nil, err } else { From d1df9df86cd143b4da4592c0ea875feabc86b7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Thu, 12 Oct 2023 11:03:40 +0300 Subject: [PATCH 13/20] Add style improvements --- examples/variable-types/recipe.yml | 4 +-- examples/variable-types/templates/README.md | 1 - pkg/survey/editable/model.go | 21 +++---------- pkg/survey/prompt/confirm.go | 5 +-- pkg/survey/prompt/table.go | 34 +++++++++++++++------ 5 files changed, 34 insertions(+), 31 deletions(-) diff --git a/examples/variable-types/recipe.yml b/examples/variable-types/recipe.yml index a4afd057..a6860c09 100644 --- a/examples/variable-types/recipe.yml +++ b/examples/variable-types/recipe.yml @@ -66,7 +66,7 @@ vars: Regular expression validators can be set for a variable by defining `validators` property validators: - pattern: ".+" - help: "If the check doesn't pass, this help message will be shown" + help: "If the value is empty, this help message will be shown" - name: TABLE_VAR_WITH_VALIDATOR description: | @@ -75,4 +75,4 @@ vars: validators: - pattern: ".+" column: NOT_EMPTY_COL - help: "If the check doesn't pass, this help message will be shown" + help: "If the cell is empty, this help message will be shown" diff --git a/examples/variable-types/templates/README.md b/examples/variable-types/templates/README.md index 8b9b2a10..c6f87cdb 100644 --- a/examples/variable-types/templates/README.md +++ b/examples/variable-types/templates/README.md @@ -8,7 +8,6 @@ | COLUMN_1 | COLUMN_2 | COLUMN_3 | | --- | --- | --- | - {{- range $val := .Variables.TABLE_VAR }} | {{ $val.COLUMN_1 }} | {{ $val.COLUMN_2 }} | {{ $val.COLUMN_3 }} | {{- end}} diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go index b719226e..14348e3d 100644 --- a/pkg/survey/editable/model.go +++ b/pkg/survey/editable/model.go @@ -105,6 +105,7 @@ func DefaultStyles() Styles { return Styles{ Selected: lipgloss.NewStyle(). Bold(true). + Background(lipgloss.Color("236")). Foreground(lipgloss.Color("212")), Header: lipgloss.NewStyle(). Bold(true). @@ -248,7 +249,6 @@ func (m Model) View() string { return m.headersView() + "\n" + m.viewport.View() } -// Rows returns the current rows. func (m Model) Rows() []Row { return m.rows } @@ -260,13 +260,11 @@ func (m *Model) AddRow() { } m.rows = append(m.rows, row) - m.viewport.Height += 2 m.updateViewport() } -// SetRows sets a new rows state. func (m *Model) RemoveRow(n int) { m.rows = append(m.rows[:n], m.rows[n+1:]...) m.viewport.Height -= 2 @@ -274,32 +272,19 @@ func (m *Model) RemoveRow(n int) { m.updateViewport() } -// SetColumns sets a new columns state. func (m *Model) SetColumns(c []Column) { m.cols = c m.updateViewport() } -// Cursor returns the index of the selected row. func (m Model) Cursor() (int, int) { return m.cursorY, m.cursorX } -// SetCursor sets the cursor position in the table. -func (m *Model) SetCursor(y, x int) { - m.cursorY = clamp(y, 0, len(m.rows)-1) - m.cursorX = clamp(x, 0, len(m.cols)-1) - m.updateViewport() -} - -// MoveUp moves the selection up by any number of rows. -// It can not go above the first row. func (m *Model) MoveUp(n int) tea.Cmd { return m.Move(-n, 0) } -// MoveDown moves the selection down by any number of rows. -// It can not go below the last row. func (m *Model) MoveDown(n int) tea.Cmd { return m.Move(n, 0) } @@ -318,6 +303,7 @@ func (m *Model) MoveToNextCell() tea.Cmd { return m.MoveRight(1) } + // else move to the first cell of the next row return m.Move(1, -(len(m.cols) - 1)) } @@ -484,11 +470,12 @@ func (m *Model) renderRow(rowID int) string { return lipgloss.JoinHorizontal(lipgloss.Left, s...) } +// newTextInput initializes a text input which is used inside a cell. func (m Model) newTextInput(c Column) textinput.Model { ti := textinput.New() - ti.Blur() ti.Prompt = "" ti.Width = c.Width - 1 + ti.Blur() return ti } diff --git a/pkg/survey/prompt/confirm.go b/pkg/survey/prompt/confirm.go index 1cf1e284..4c21bb48 100644 --- a/pkg/survey/prompt/confirm.go +++ b/pkg/survey/prompt/confirm.go @@ -60,10 +60,11 @@ func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m ConfirmModel) View() (s string) { s += m.styles.VariableName.Render(m.variable.Name) if m.submitted { + s += ": " if m.value { - s += ": Yes" + s += "Yes" } else { - s += ": No" + s += "No" } return } diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index ee92e135..6efbfb4e 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -1,7 +1,10 @@ package prompt import ( + "strings" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/recipeutil" "github.com/futurice/jalapeno/pkg/survey/editable" @@ -22,6 +25,15 @@ type TableModel struct { var _ Model = TableModel{} +var ( + csvNewLine = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#999999")). + Render("\\n") + csvSeparator string = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#999999")). + Render(",") +) + func NewTableModel(v recipe.Variable, styles util.Styles) TableModel { cols := make([]editable.Column, len(v.Columns)) @@ -93,13 +105,21 @@ func (m TableModel) View() (s string) { return } - if m.variable.Description != "" && !m.showDescription { + if !m.showDescription { s += m.styles.HelpText.Render(" [type ? for more info]") } s += "\n" if m.showDescription { - s += m.variable.Description + if m.variable.Description != "" { + s += m.variable.Description + s += "\n" + } + s += m.styles.HelpText.Render(`Table controls: +- arrow keys: to move between cells +- tab: to move to the next cells +- ctrl+n or move past last row: create a new row +`) } s += "\n" @@ -121,17 +141,13 @@ func (m TableModel) IsSubmitted() bool { } func (m TableModel) ValueAsCSV() string { + rows := m.table.Values() s := "" for y := range rows { - for x := range rows[y] { - s += rows[y][x] - if x < len(rows[y])-1 { - s += "," - } - } + s += strings.Join(rows[y], csvSeparator) if y < len(rows)-1 { - s += "\\n" + s += csvNewLine } } From cd60dc9a2a5a229e15aa7e29f5abd4aec1de6d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Sun, 15 Oct 2023 11:16:25 +0300 Subject: [PATCH 14/20] Start to utilize lipgloss table --- go.mod | 8 +- go.sum | 17 ++-- pkg/survey/editable/model.go | 172 +++++++++++------------------------ pkg/survey/survey.go | 5 +- 4 files changed, 71 insertions(+), 131 deletions(-) diff --git a/go.mod b/go.mod index 3281028e..2885adc3 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/carlmjohnson/versioninfo v0.22.5 github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.2 - github.com/charmbracelet/lipgloss v0.8.0 + github.com/charmbracelet/lipgloss v0.9.1 github.com/cucumber/godog v0.13.0 github.com/docker/cli v24.0.6+incompatible github.com/opencontainers/image-spec v1.1.0-rc5 @@ -32,7 +32,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect @@ -40,7 +40,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect @@ -76,7 +76,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 golang.org/x/crypto v0.13.0 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/sys v0.13.0 // indirect golang.org/x/term v0.12.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/tools v0.13.0 // indirect diff --git a/go.sum b/go.sum index 676b5222..48a07bbf 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= -github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= -github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM= @@ -92,6 +92,8 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2 h1:hRGSmZu7j271trc9sneMrpOW7GN5ngLm8YUZIPzf394= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -101,8 +103,8 @@ github.com/mattn/go-isatty v0.0.19/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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -132,8 +134,9 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= @@ -191,8 +194,8 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go index 14348e3d..faa34bcc 100644 --- a/pkg/survey/editable/model.go +++ b/pkg/survey/editable/model.go @@ -7,10 +7,9 @@ import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" + "github.com/charmbracelet/lipgloss/table" ) type Model struct { @@ -21,13 +20,14 @@ type Model struct { cursorX int cursorY int focus bool - styles Styles errors []error - viewport viewport.Model + styles Styles + table table.Table } var _ tea.Model = Model{} +var _ table.Data = Model{} type Row []textinput.Model @@ -76,14 +76,6 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+n"), key.WithHelp("ctrl + n", "new"), ), - PageUp: key.NewBinding( - key.WithKeys("pgup"), - key.WithHelp("pgup", "page up"), - ), - PageDown: key.NewBinding( - key.WithKeys("pgdown"), - key.WithHelp("pgdn", "page down"), - ), GotoTop: key.NewBinding( key.WithKeys("home"), key.WithHelp("home", "go to start"), @@ -106,25 +98,19 @@ func DefaultStyles() Styles { Selected: lipgloss.NewStyle(). Bold(true). Background(lipgloss.Color("236")). - Foreground(lipgloss.Color("212")), + Foreground(lipgloss.Color("212")). + Padding(0, 1), Header: lipgloss.NewStyle(). Bold(true). - Padding(0, 1). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false), + Padding(0, 1), Cell: lipgloss.NewStyle(). - Padding(0, 1). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true), + Padding(0, 1), } } func (m *Model) SetStyles(s Styles) { m.styles = s - m.updateViewport() + } // Option is used to set options in New. For example: @@ -134,12 +120,12 @@ type Option func(*Model) func NewModel(opts ...Option) Model { m := Model{ - cursorX: 0, - cursorY: 0, - viewport: viewport.New(0, 4), + cursorX: 0, + cursorY: 0, KeyMap: DefaultKeyMap(), styles: DefaultStyles(), + table: *table.New(), } for _, opt := range opts { @@ -151,27 +137,32 @@ func NewModel(opts ...Option) Model { return m } -func WithColumns(cols []Column) Option { - return func(m *Model) { - m.cols = cols - } +func (m Model) At(row, cell int) string { + return m.rows[row][cell].View() } -func WithRows(rows []Row) Option { - return func(m *Model) { - m.rows = rows - } +func (m Model) Columns() int { + return len(m.cols) } -func WithHeight(h int) Option { +func (m Model) Rows() int { + return len(m.rows) +} + +func WithColumns(columns []Column) Option { return func(m *Model) { - m.viewport.Height = h + m.cols = columns + cols := make([]string, len(m.cols)) + for i := range cols { + cols[i] = m.cols[i].Title + } + m.table.Headers(cols...) } } -func WithWidth(w int) Option { +func WithRows(rows []Row) Option { return func(m *Model) { - m.viewport.Width = w + m.rows = rows } } @@ -215,10 +206,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd = m.MoveToNextCell() case key.Matches(msg, m.KeyMap.NewRow): m.AddRow() - case key.Matches(msg, m.KeyMap.PageUp): - cmd = m.MoveUp(m.viewport.Height) - case key.Matches(msg, m.KeyMap.PageDown): - cmd = m.MoveDown(m.viewport.Height) case key.Matches(msg, m.KeyMap.GotoTop): cmd = m.GotoTop() case key.Matches(msg, m.KeyMap.GotoBottom): @@ -229,28 +216,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - m.updateViewport() m.rows[m.cursorY][m.cursorX], cmd = m.rows[m.cursorY][m.cursorX].Update(msg) return m, cmd } func (m *Model) Focus() { m.focus = true - m.updateViewport() } func (m *Model) Blur() { m.focus = false m.rows[m.cursorY][m.cursorX].Blur() - m.updateViewport() } func (m Model) View() string { - return m.headersView() + "\n" + m.viewport.View() -} - -func (m Model) Rows() []Row { - return m.rows + return m.table. + StyleFunc(func(y, x int) lipgloss.Style { + switch { + case y == 0: + return m.styles.Header + case y == m.cursorY+1 && x == m.cursorX: + return m.styles.Selected + default: + return m.styles.Cell + } + }). + Data(m). + Render() } func (m *Model) AddRow() { @@ -260,21 +252,14 @@ func (m *Model) AddRow() { } m.rows = append(m.rows, row) - m.viewport.Height += 2 - - m.updateViewport() } func (m *Model) RemoveRow(n int) { m.rows = append(m.rows[:n], m.rows[n+1:]...) - m.viewport.Height -= 2 - - m.updateViewport() } func (m *Model) SetColumns(c []Column) { m.cols = c - m.updateViewport() } func (m Model) Cursor() (int, int) { @@ -312,6 +297,10 @@ func (m *Model) GotoTop() tea.Cmd { } func (m *Model) GotoBottom() tea.Cmd { + if m.cursorY == len(m.rows)-1 { + return nil + } + return m.Move(len(m.rows)-1, 0) } @@ -321,6 +310,9 @@ func (m *Model) Move(y, x int) tea.Cmd { } m.rows[m.cursorY][m.cursorX].Blur() + + // TODO: This could be optimized to only validate the cells that are affected by. + // But at the moment this is the only place where we validate the table m.Validate() if x != 0 { @@ -335,16 +327,16 @@ func (m *Model) Move(y, x int) tea.Cmd { } if m.cursorY == len(m.rows)-1 && y < 0 && len(m.rows) > 1 { + isEmpty := true for n := 0; n > y; n-- { - isEmpty := true for _, cell := range m.rows[m.cursorY+n] { if cell.Value() != "" { isEmpty = false break } } - if isEmpty { - m.RemoveRow(m.cursorY) + if isEmpty && len(m.rows) > 1 { + m.RemoveRow(m.cursorY + n) } } } @@ -352,20 +344,10 @@ func (m *Model) Move(y, x int) tea.Cmd { m.cursorY = clamp(m.cursorY+y, 0, len(m.rows)-1) } - m.updateViewport() - // Focus on the new cell return m.rows[m.cursorY][m.cursorX].Focus() } -func (m Model) Columns() []string { - columns := make([]string, len(m.cols)) - for i, col := range m.cols { - columns[i] = col.Title - } - return columns -} - func (m Model) Values() [][]string { values := make([][]string, len(m.rows)) for i, row := range m.rows { @@ -422,59 +404,11 @@ func (m Model) validateCell(y, x int) error { return errors.New(strings.Join(errStr, ", ")) } -func (m *Model) updateViewport() { - renderedRows := make([]string, 0, len(m.rows)) - - for i := range m.rows { - renderedRows = append(renderedRows, m.renderRow(i)) - } - - errStr := "" - if len(m.errors) != 0 { - for _, err := range m.errors { - errStr += fmt.Sprintf("Error on %s\n", err) - } - } - - m.viewport.SetContent( - lipgloss.JoinVertical( - lipgloss.Left, - lipgloss.JoinVertical(lipgloss.Left, renderedRows...), - errStr, - ), - ) -} - -func (m Model) headersView() string { - var s = make([]string, 0, len(m.cols)) - for _, col := range m.cols { - style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) - renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) - s = append(s, m.styles.Header.Render(renderedCell)) - } - return lipgloss.JoinHorizontal(lipgloss.Left, s...) -} - -func (m *Model) renderRow(rowID int) string { - var s = make([]string, 0, len(m.cols)) - for i := range m.rows[rowID] { - cellStyle := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width) - if rowID == m.cursorY && i == m.cursorX { - cellStyle = m.styles.Selected.Inherit(cellStyle) - } - - renderedCell := m.styles.Cell.Render(cellStyle.Render(m.rows[rowID][i].View())) - s = append(s, renderedCell) - } - - return lipgloss.JoinHorizontal(lipgloss.Left, s...) -} - // newTextInput initializes a text input which is used inside a cell. func (m Model) newTextInput(c Column) textinput.Model { ti := textinput.New() ti.Prompt = "" - ti.Width = c.Width - 1 + ti.Blur() return ti diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index e7e32f33..69224f65 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -201,7 +201,10 @@ func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variabl if m, err := p.Run(); err != nil { return nil, err } else { - survey := m.(SurveyModel) + survey, ok := m.(SurveyModel) + if !ok { + return nil, errors.New("unexpected model type") + } if survey.err != nil { return nil, survey.err } From 2489d8b7ee1e5d89764e665ecf7d98a771ed3c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Sun, 15 Oct 2023 12:22:28 +0300 Subject: [PATCH 15/20] Print out errors --- pkg/survey/editable/model.go | 82 ++++++++++++++++++++++-------------- pkg/survey/prompt/table.go | 3 +- 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go index faa34bcc..0093cd4a 100644 --- a/pkg/survey/editable/model.go +++ b/pkg/survey/editable/model.go @@ -20,7 +20,6 @@ type Model struct { cursorX int cursorY int focus bool - errors []error styles Styles table table.Table @@ -29,7 +28,12 @@ type Model struct { var _ tea.Model = Model{} var _ table.Data = Model{} -type Row []textinput.Model +type Row []Cell + +type Cell struct { + input textinput.Model + err error +} type Column struct { Title string @@ -91,6 +95,7 @@ type Styles struct { Header lipgloss.Style Cell lipgloss.Style Selected lipgloss.Style + Error lipgloss.Style } func DefaultStyles() Styles { @@ -105,6 +110,8 @@ func DefaultStyles() Styles { Padding(0, 1), Cell: lipgloss.NewStyle(). Padding(0, 1), + Error: lipgloss.NewStyle(). + Foreground(lipgloss.Color("9")), } } @@ -138,7 +145,7 @@ func NewModel(opts ...Option) Model { } func (m Model) At(row, cell int) string { - return m.rows[row][cell].View() + return m.rows[row][cell].input.View() } func (m Model) Columns() int { @@ -180,7 +187,7 @@ func WithKeyMap(km KeyMap) Option { func (m Model) Init() tea.Cmd { return tea.Batch( - m.rows[0][0].Focus(), + m.rows[0][0].input.Focus(), textinput.Blink, ) } @@ -216,7 +223,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - m.rows[m.cursorY][m.cursorX], cmd = m.rows[m.cursorY][m.cursorX].Update(msg) + m.rows[m.cursorY][m.cursorX].input, cmd = m.rows[m.cursorY][m.cursorX].input.Update(msg) return m, cmd } @@ -226,11 +233,11 @@ func (m *Model) Focus() { func (m *Model) Blur() { m.focus = false - m.rows[m.cursorY][m.cursorX].Blur() + m.rows[m.cursorY][m.cursorX].input.Blur() } -func (m Model) View() string { - return m.table. +func (m Model) View() (s string) { + s += m.table. StyleFunc(func(y, x int) lipgloss.Style { switch { case y == 0: @@ -243,12 +250,21 @@ func (m Model) View() string { }). Data(m). Render() + + s += "\n" + if errs := m.Errors(); len(errs) != 0 { + for _, err := range errs { + s += m.styles.Error.Render(fmt.Sprintf("• %s", err.Error())) + s += "\n" + } + } + return } func (m *Model) AddRow() { row := make(Row, len(m.cols)) for i := range row { - row[i] = m.newTextInput(m.cols[i]) + row[i].input = m.newTextInput(m.cols[i]) } m.rows = append(m.rows, row) @@ -309,11 +325,8 @@ func (m *Model) Move(y, x int) tea.Cmd { return nil } - m.rows[m.cursorY][m.cursorX].Blur() - - // TODO: This could be optimized to only validate the cells that are affected by. - // But at the moment this is the only place where we validate the table - m.Validate() + m.rows[m.cursorY][m.cursorX].input.Blur() + m.validateCell(m.cursorY, m.cursorX) if x != 0 { m.cursorX = clamp(m.cursorX+x, 0, len(m.cols)-1) @@ -330,7 +343,7 @@ func (m *Model) Move(y, x int) tea.Cmd { isEmpty := true for n := 0; n > y; n-- { for _, cell := range m.rows[m.cursorY+n] { - if cell.Value() != "" { + if cell.input.Value() != "" { isEmpty = false break } @@ -345,7 +358,7 @@ func (m *Model) Move(y, x int) tea.Cmd { } // Focus on the new cell - return m.rows[m.cursorY][m.cursorX].Focus() + return m.rows[m.cursorY][m.cursorX].input.Focus() } func (m Model) Values() [][]string { @@ -353,47 +366,54 @@ func (m Model) Values() [][]string { for i, row := range m.rows { values[i] = make([]string, len(row)) for j, cell := range row { - values[i][j] = cell.Value() + values[i][j] = cell.input.Value() } } return values } -func (m *Model) Validate() []error { - errors := make([]error, 0, len(m.rows)*len(m.cols)) +func (m *Model) Validate() { for y := range m.rows { for x := range m.rows[y] { - err := m.validateCell(y, x) - if err != nil { - errors = append(errors, fmt.Errorf("cell (%d, %d): %w", y, x, err)) - } + m.validateCell(y, x) } } +} - m.errors = errors - return errors +func (m Model) Errors() []error { + errs := make([]error, 0, len(m.rows)*len(m.cols)) + for y := range m.rows { + for x := range m.rows[y] { + if m.rows[y][x].err != nil { + errs = append(errs, fmt.Errorf("cell (%d, %d): %w", y, x, m.rows[y][x].err)) + } + } + } + return errs } -func (m Model) validateCell(y, x int) error { +func (m *Model) validateCell(y, x int) { + cell := &m.rows[y][x] if m.cols[x].Validators == nil { - return nil + return } errs := make([]error, 0, len(m.cols[x].Validators)) for i := range m.cols[x].Validators { - err := m.cols[x].Validators[i](m.rows[y][x].Value()) + err := m.cols[x].Validators[i](cell.input.Value()) if err != nil { errs = append(errs, err) } } if len(errs) == 0 { - return nil + cell.err = nil + return } if len(errs) == 1 { - return errs[0] + cell.err = errs[0] } errStr := make([]string, len(errs)) @@ -401,7 +421,7 @@ func (m Model) validateCell(y, x int) error { errStr[i] = errs[i].Error() } - return errors.New(strings.Join(errStr, ", ")) + cell.err = errors.New(strings.Join(errStr, ", ")) } // newTextInput initializes a text input which is used inside a cell. diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index 6efbfb4e..8204f3c5 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -82,7 +82,8 @@ func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEnter: // Validate the table. If there are errors, don't submit the form. - if errs := m.table.Validate(); len(errs) != 0 { + m.table.Validate() + if errs := m.table.Errors(); len(errs) != 0 { return m, nil } From 0c762ce26b8bc5ec091ca2a95fb8804a39c89212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Sun, 15 Oct 2023 14:51:25 +0300 Subject: [PATCH 16/20] Use strings.Builder in View methods --- pkg/survey/editable/model.go | 17 +++++++++-------- pkg/survey/prompt/confirm.go | 28 +++++++++++++++------------- pkg/survey/prompt/select.go | 21 +++++++++++---------- pkg/survey/prompt/string.go | 27 ++++++++++++++------------- pkg/survey/prompt/table.go | 29 +++++++++++++++-------------- pkg/survey/survey.go | 18 ++++++++++-------- 6 files changed, 74 insertions(+), 66 deletions(-) diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go index 0093cd4a..9a57e848 100644 --- a/pkg/survey/editable/model.go +++ b/pkg/survey/editable/model.go @@ -187,7 +187,7 @@ func WithKeyMap(km KeyMap) Option { func (m Model) Init() tea.Cmd { return tea.Batch( - m.rows[0][0].input.Focus(), + m.rows[0][0].input.Focus(), // Focus on the first cell textinput.Blink, ) } @@ -236,8 +236,9 @@ func (m *Model) Blur() { m.rows[m.cursorY][m.cursorX].input.Blur() } -func (m Model) View() (s string) { - s += m.table. +func (m Model) View() string { + var s strings.Builder + s.WriteString(m.table. StyleFunc(func(y, x int) lipgloss.Style { switch { case y == 0: @@ -249,16 +250,16 @@ func (m Model) View() (s string) { } }). Data(m). - Render() + Render()) - s += "\n" + s.WriteRune('\n') if errs := m.Errors(); len(errs) != 0 { for _, err := range errs { - s += m.styles.Error.Render(fmt.Sprintf("• %s", err.Error())) - s += "\n" + s.WriteString(m.styles.Error.Render(fmt.Sprintf("• %s", err.Error()))) + s.WriteRune('\n') } } - return + return s.String() } func (m *Model) AddRow() { diff --git a/pkg/survey/prompt/confirm.go b/pkg/survey/prompt/confirm.go index 4c21bb48..9a156de4 100644 --- a/pkg/survey/prompt/confirm.go +++ b/pkg/survey/prompt/confirm.go @@ -2,6 +2,7 @@ package prompt import ( "fmt" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/futurice/jalapeno/pkg/recipe" @@ -57,35 +58,36 @@ func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m ConfirmModel) View() (s string) { - s += m.styles.VariableName.Render(m.variable.Name) +func (m ConfirmModel) View() string { + var s strings.Builder + s.WriteString(m.styles.VariableName.Render(m.variable.Name)) if m.submitted { - s += ": " + s.WriteString(": ") if m.value { - s += "Yes" + s.WriteString("Yes") } else { - s += "No" + s.WriteString("No") } - return + return s.String() } if m.variable.Description != "" && !m.showDescription { - s += m.styles.HelpText.Render(" [type ? for more info]") + s.WriteString(m.styles.HelpText.Render(" [type ? for more info]")) } - s += "\n" + s.WriteRune('\n') if m.showDescription { - s += m.variable.Description - s += "\n" + s.WriteString(m.variable.Description) + s.WriteRune('\n') } if m.value { - s += fmt.Sprintf("> No/%s", m.styles.Bold.Render("Yes")) + s.WriteString(fmt.Sprintf("> No/%s", m.styles.Bold.Render("Yes"))) } else { - s += fmt.Sprintf("> %s/Yes", m.styles.Bold.Render("No")) + s.WriteString(fmt.Sprintf("> %s/Yes", m.styles.Bold.Render("No"))) } - return + return s.String() } func (m ConfirmModel) Name() string { diff --git a/pkg/survey/prompt/select.go b/pkg/survey/prompt/select.go index dfae049a..abc608fe 100644 --- a/pkg/survey/prompt/select.go +++ b/pkg/survey/prompt/select.go @@ -108,25 +108,26 @@ func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m SelectModel) View() (s string) { - s += m.styles.VariableName.Render(m.variable.Name) +func (m SelectModel) View() string { + var s strings.Builder + s.WriteString(m.styles.VariableName.Render(m.variable.Name)) if m.submitted { - s += fmt.Sprintf(": %s", m.value) - return + s.WriteString(fmt.Sprintf(": %s", m.value)) + return s.String() } if m.variable.Description != "" && !m.showDescription { - s += m.styles.HelpText.Render(" [type ? for more info]") + s.WriteString(m.styles.HelpText.Render(" [type ? for more info]")) } - s += "\n" + s.WriteRune('\n') if m.showDescription { - s += m.variable.Description - s += "\n" + s.WriteString(m.variable.Description) + s.WriteRune('\n') } - s += m.list.View() - return + s.WriteString(m.list.View()) + return s.String() } func (m SelectModel) Name() string { diff --git a/pkg/survey/prompt/string.go b/pkg/survey/prompt/string.go index 8c4e2ecd..28caf706 100644 --- a/pkg/survey/prompt/string.go +++ b/pkg/survey/prompt/string.go @@ -71,35 +71,36 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m StringModel) View() (s string) { - s += m.styles.VariableName.Render(m.variable.Name) +func (m StringModel) View() string { + var s strings.Builder + s.WriteString(m.styles.VariableName.Render(m.variable.Name)) if m.submitted { - s += ": " - s += m.textInput.Value() - return + s.WriteString(": ") + s.WriteString(m.textInput.Value()) + return s.String() } if m.variable.Description != "" && !m.showDescription { - s += m.styles.HelpText.Render(" [type ? for more info]") + s.WriteString(m.styles.HelpText.Render(" [type ? for more info]")) } - s += "\n" + s.WriteRune('\n') if m.showDescription { - s += m.variable.Description - s += "\n" + s.WriteString(m.variable.Description) + s.WriteRune('\n') } - s += m.textInput.View() + s.WriteString(m.textInput.View()) if m.err != nil { - s += "\n" + s.WriteRune('\n') errMsg := m.err.Error() errMsg = strings.ToUpper(errMsg[:1]) + errMsg[1:] - s += m.styles.ErrorText.Render(errMsg) + s.WriteString(m.styles.ErrorText.Render(errMsg)) } - return + return s.String() } func (m StringModel) Name() string { diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index 8204f3c5..239127d8 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -97,35 +97,36 @@ func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m TableModel) View() (s string) { - s += m.styles.VariableName.Render(m.variable.Name) +func (m TableModel) View() string { + var s strings.Builder + s.WriteString(m.styles.VariableName.Render(m.variable.Name)) if m.submitted { - s += ": " - s += m.tableAsCSV - return + s.WriteString(": ") + s.WriteString(m.tableAsCSV) + return s.String() } if !m.showDescription { - s += m.styles.HelpText.Render(" [type ? for more info]") + s.WriteString(m.styles.HelpText.Render(" [type ? for more info]")) } - s += "\n" + s.WriteRune('\n') if m.showDescription { if m.variable.Description != "" { - s += m.variable.Description - s += "\n" + s.WriteString(m.variable.Description) + s.WriteRune('\n') } - s += m.styles.HelpText.Render(`Table controls: + s.WriteString(m.styles.HelpText.Render(`Table controls: - arrow keys: to move between cells - tab: to move to the next cells - ctrl+n or move past last row: create a new row -`) +`)) } - s += "\n" + s.WriteRune('\n') - s += m.table.View() - return + s.WriteString(m.table.View()) + return s.String() } func (m TableModel) Name() string { diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index 69224f65..7e3fae0d 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "strings" "github.com/antonmedv/expr" tea "github.com/charmbracelet/bubbletea" @@ -111,26 +112,27 @@ func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, promptCmd } -func (m SurveyModel) View() (s string) { +func (m SurveyModel) View() string { + var s strings.Builder if len(m.prompts) > 0 && !m.submitted && m.err == nil { - s += "Provide the following variables:\n\n" + s.WriteString("Provide the following variables:\n\n") } for i := range m.prompts { isLastPrompt := i == len(m.prompts)-1 && len(m.prompts) > 1 && !m.submitted if isLastPrompt { - s += "\n" + s.WriteRune('\n') } - s += m.prompts[i].View() - s += "\n" + s.WriteString(m.prompts[i].View()) + s.WriteRune('\n') } if m.submitted || m.err != nil { - s += "\n" + s.WriteRune('\n') } - return + return s.String() } func (m SurveyModel) Values() recipe.VariableValues { @@ -203,7 +205,7 @@ func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variabl } else { survey, ok := m.(SurveyModel) if !ok { - return nil, errors.New("unexpected model type") + return nil, errors.New("internal error: unexpected model type") } if survey.err != nil { return nil, survey.err From 61bdc4ecdad6dc2d1467f3a5b4a8ad05989855de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Mon, 16 Oct 2023 11:48:07 +0300 Subject: [PATCH 17/20] Minor improvements --- pkg/survey/prompt/select.go | 4 ++++ pkg/survey/prompt/table.go | 23 +++++++++-------------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/pkg/survey/prompt/select.go b/pkg/survey/prompt/select.go index abc608fe..e4c69592 100644 --- a/pkg/survey/prompt/select.go +++ b/pkg/survey/prompt/select.go @@ -33,10 +33,14 @@ var _ Model = SelectModel{} type item string +var _ list.Item = item("") + func (i item) FilterValue() string { return "" } type itemDelegate struct{} +var _ list.ItemDelegate = itemDelegate{} + func (d itemDelegate) Height() int { return 1 } func (d itemDelegate) Spacing() int { return 0 } func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index 239127d8..4ae01663 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -19,21 +19,12 @@ type TableModel struct { showDescription bool // Save the table as CSV for the final output. This speeds up the - // rendering when the user has submitted the form. + // rendering after the user has submitted the form. tableAsCSV string } var _ Model = TableModel{} -var ( - csvNewLine = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#999999")). - Render("\\n") - csvSeparator string = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#999999")). - Render(",") -) - func NewTableModel(v recipe.Variable, styles util.Styles) TableModel { cols := make([]editable.Column, len(v.Columns)) @@ -122,8 +113,8 @@ func (m TableModel) View() string { - tab: to move to the next cells - ctrl+n or move past last row: create a new row `)) + s.WriteRune('\n') } - s.WriteRune('\n') s.WriteString(m.table.View()) return s.String() @@ -142,14 +133,18 @@ func (m TableModel) IsSubmitted() bool { return m.submitted } -func (m TableModel) ValueAsCSV() string { +var ( + csvSeparator = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")).SetString(",") + csvNewLine = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")).SetString("\\n") +) +func (m TableModel) ValueAsCSV() string { rows := m.table.Values() s := "" for y := range rows { - s += strings.Join(rows[y], csvSeparator) + s += strings.Join(rows[y], csvSeparator.String()) if y < len(rows)-1 { - s += csvNewLine + s += csvNewLine.String() } } From 281f7f198284a57ee93584d084bc85f60650bb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Mon, 16 Oct 2023 11:51:33 +0300 Subject: [PATCH 18/20] Work around halting issue --- pkg/survey/prompt/select.go | 2 -- pkg/survey/survey.go | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/survey/prompt/select.go b/pkg/survey/prompt/select.go index e4c69592..140dcd00 100644 --- a/pkg/survey/prompt/select.go +++ b/pkg/survey/prompt/select.go @@ -17,7 +17,6 @@ const listHeight = 14 var ( itemStyle = lipgloss.NewStyle().PaddingLeft(2) selectedItemStyle = lipgloss.NewStyle().PaddingLeft(0).Foreground(lipgloss.Color("170")) - paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(0) ) type SelectModel struct { @@ -73,7 +72,6 @@ func NewSelectModel(v recipe.Variable, styles util.Styles) SelectModel { l.SetFilteringEnabled(false) l.SetShowHelp(false) l.SetShowTitle(false) - l.Styles.PaginationStyle = paginationStyle return SelectModel{ variable: v, diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index 7e3fae0d..a4c24ce9 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -8,9 +8,11 @@ import ( "github.com/antonmedv/expr" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/futurice/jalapeno/pkg/recipe" "github.com/futurice/jalapeno/pkg/survey/prompt" "github.com/futurice/jalapeno/pkg/survey/util" + "github.com/muesli/termenv" ) type SurveyModel struct { @@ -199,6 +201,9 @@ func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { // PromptUserForValues prompts the user for values for the given variables func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variable, existingValues recipe.VariableValues) (recipe.VariableValues, error) { + // https://github.com/charmbracelet/lipgloss/issues/73#issuecomment-1144921037 + lipgloss.SetHasDarkBackground(termenv.HasDarkBackground()) + p := tea.NewProgram(NewModel(variables), tea.WithInput(in), tea.WithOutput(out)) if m, err := p.Run(); err != nil { return nil, err From 00e86e91c0d2fbcdfa95b4f8e3167b28dee6b0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Mon, 16 Oct 2023 15:49:35 +0300 Subject: [PATCH 19/20] Use pointer of the table --- pkg/survey/editable/model.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/survey/editable/model.go b/pkg/survey/editable/model.go index 9a57e848..0263ea9d 100644 --- a/pkg/survey/editable/model.go +++ b/pkg/survey/editable/model.go @@ -22,7 +22,7 @@ type Model struct { focus bool styles Styles - table table.Table + table *table.Table } var _ tea.Model = Model{} @@ -132,7 +132,7 @@ func NewModel(opts ...Option) Model { KeyMap: DefaultKeyMap(), styles: DefaultStyles(), - table: *table.New(), + table: table.New(), } for _, opt := range opts { From 510298170c810c8ed103e3613a3f0180513fc39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20Kivim=C3=A4ki?= Date: Thu, 19 Oct 2023 14:36:18 +0300 Subject: [PATCH 20/20] Create smoke tests for survey --- go.mod | 4 +- go.sum | 4 ++ internal/cli/option/colors.go | 2 + pkg/survey/prompt/confirm.go | 27 +++++----- pkg/survey/prompt/select.go | 15 +++--- pkg/survey/prompt/string.go | 17 +++---- pkg/survey/prompt/table.go | 15 +++--- pkg/survey/survey.go | 32 ++++++------ pkg/survey/survey_test.go | 96 +++++++++++++++++++++++++++++++++++ 9 files changed, 158 insertions(+), 54 deletions(-) create mode 100644 pkg/survey/survey_test.go diff --git a/go.mod b/go.mod index 2885adc3..d02cc849 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,10 @@ require ( github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.2 github.com/charmbracelet/lipgloss v0.9.1 + github.com/charmbracelet/x/exp/teatest v0.0.0-20231010190216-1cb11efc897d github.com/cucumber/godog v0.13.0 github.com/docker/cli v24.0.6+incompatible + github.com/muesli/termenv v0.15.2 github.com/opencontainers/image-spec v1.1.0-rc5 github.com/spf13/cobra v1.7.0 github.com/xlab/treeprint v1.2.0 @@ -22,6 +24,7 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.1.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect @@ -38,7 +41,6 @@ require ( github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect diff --git a/go.sum b/go.sum index 48a07bbf..b0631d59 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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= +github.com/aymanbagabas/go-udiff v0.1.0 h1:9Dpklm2oBBhMxIFbMffmPvDaF7vOYfv9B5HXVr42KMU= +github.com/aymanbagabas/go-udiff v0.1.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= @@ -26,6 +28,8 @@ github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06 github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/x/exp/teatest v0.0.0-20231010190216-1cb11efc897d h1:WDZRDaKD2usd2HV2qqLATuVB+khVYzwyFIHrvtaSCi8= +github.com/charmbracelet/x/exp/teatest v0.0.0-20231010190216-1cb11efc897d/go.mod h1:TckAxPtan3aJ5wbTgBkySpc50SZhXJRZ8PtYICnZJEw= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM= diff --git a/internal/cli/option/colors.go b/internal/cli/option/colors.go index cf98f3c8..2820ce19 100644 --- a/internal/cli/option/colors.go +++ b/internal/cli/option/colors.go @@ -2,6 +2,7 @@ package option import ( "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "github.com/spf13/pflag" ) @@ -21,6 +22,7 @@ func (opts *Styles) ApplyFlags(fs *pflag.FlagSet) { func (opts *Styles) Parse() error { if opts.NoColors { + lipgloss.SetColorProfile(termenv.Ascii) return nil } diff --git a/pkg/survey/prompt/confirm.go b/pkg/survey/prompt/confirm.go index 9a156de4..3632a3f3 100644 --- a/pkg/survey/prompt/confirm.go +++ b/pkg/survey/prompt/confirm.go @@ -34,24 +34,25 @@ func (m ConfirmModel) Init() tea.Cmd { func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "?": - if m.variable.Description != "" && !m.showDescription { - m.showDescription = true - return m, nil - } - case "y", "Y": - m.value = true - case "n", "N": - m.value = false - } switch msg.Type { + case tea.KeyEnter: + m.submitted = true case tea.KeyRight: m.value = true case tea.KeyLeft: m.value = false - case tea.KeyEnter: - m.submitted = true + case tea.KeyRunes: + switch string(msg.Runes) { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + case "y", "Y": + m.value = true + case "n", "N": + m.value = false + } } } diff --git a/pkg/survey/prompt/select.go b/pkg/survey/prompt/select.go index 140dcd00..b768cb40 100644 --- a/pkg/survey/prompt/select.go +++ b/pkg/survey/prompt/select.go @@ -91,17 +91,18 @@ func (m SelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - switch msg.String() { - case "?": - if m.variable.Description != "" && !m.showDescription { - m.showDescription = true - return m, nil - } - } switch msg.Type { case tea.KeyEnter: m.submitted = true m.value = string(m.list.SelectedItem().(item)) + case tea.KeyRunes: + switch string(msg.Runes) { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } } } diff --git a/pkg/survey/prompt/string.go b/pkg/survey/prompt/string.go index 28caf706..6be32131 100644 --- a/pkg/survey/prompt/string.go +++ b/pkg/survey/prompt/string.go @@ -48,22 +48,21 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "?": - if m.textInput.Value() == "" && m.variable.Description != "" && !m.showDescription { - m.showDescription = true - return m, nil - } - } - switch msg.Type { case tea.KeyEnter: if err := m.Validate(); err != nil { m.err = err return m, nil } - m.textInput.Prompt = "" m.submitted = true + case tea.KeyRunes: + switch string(msg.Runes) { + case "?": + if m.textInput.Value() == "" && m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } } } diff --git a/pkg/survey/prompt/table.go b/pkg/survey/prompt/table.go index 4ae01663..b94f1d15 100644 --- a/pkg/survey/prompt/table.go +++ b/pkg/survey/prompt/table.go @@ -63,13 +63,6 @@ func (m TableModel) Init() tea.Cmd { func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "?": - if m.variable.Description != "" && !m.showDescription { - m.showDescription = true - return m, nil - } - } switch msg.Type { case tea.KeyEnter: // Validate the table. If there are errors, don't submit the form. @@ -81,6 +74,14 @@ func (m TableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.submitted = true m.tableAsCSV = m.ValueAsCSV() m.table.Blur() + case tea.KeyRunes: + switch string(msg.Runes) { + case "?": + if m.variable.Description != "" && !m.showDescription { + m.showDescription = true + return m, nil + } + } } } tm, cmd := m.table.Update(msg) diff --git a/pkg/survey/survey.go b/pkg/survey/survey.go index a4c24ce9..1685126e 100644 --- a/pkg/survey/survey.go +++ b/pkg/survey/survey.go @@ -10,29 +10,32 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/recipeutil" "github.com/futurice/jalapeno/pkg/survey/prompt" "github.com/futurice/jalapeno/pkg/survey/util" "github.com/muesli/termenv" ) type SurveyModel struct { - cursor int - submitted bool - variables []recipe.Variable - prompts []prompt.Model - styles util.Styles - err error + cursor int + submitted bool + variables []recipe.Variable + existingValues recipe.VariableValues + prompts []prompt.Model + styles util.Styles + err error } var ( ErrUserAborted = errors.New("user aborted") ) -func NewModel(variables []recipe.Variable) SurveyModel { +func NewModel(variables []recipe.Variable, existingValues recipe.VariableValues) SurveyModel { model := SurveyModel{ - prompts: make([]prompt.Model, 0, len(variables)), - variables: variables, - styles: util.DefaultStyles(), + prompts: make([]prompt.Model, 0, len(variables)), + variables: variables, + existingValues: existingValues, + styles: util.DefaultStyles(), } p, err := model.createNextPrompt() @@ -92,11 +95,6 @@ func (m SurveyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if (*lastPrompt).IsSubmitted() { cmds = append(cmds, promptCmd) - // Check if we're on the last prompt - if m.cursor == len(m.variables)-1 { - return submit() - } - // Otherwise, move to the next prompt if p, err := m.createNextPrompt(); err != nil { m.err = err @@ -170,7 +168,7 @@ func (m *SurveyModel) createNextPrompt() (prompt.Model, error) { func (m SurveyModel) createPrompt(v recipe.Variable) (prompt.Model, error) { // Check if variable should be skipped if v.If != "" { - result, err := expr.Eval(v.If, m.Values()) + result, err := expr.Eval(v.If, recipeutil.MergeValues(m.existingValues, m.Values())) if err != nil { return nil, fmt.Errorf("error when evaluating variable \"%s\" 'if' expression: %w", v.Name, err) } @@ -204,7 +202,7 @@ func PromptUserForValues(in io.Reader, out io.Writer, variables []recipe.Variabl // https://github.com/charmbracelet/lipgloss/issues/73#issuecomment-1144921037 lipgloss.SetHasDarkBackground(termenv.HasDarkBackground()) - p := tea.NewProgram(NewModel(variables), tea.WithInput(in), tea.WithOutput(out)) + p := tea.NewProgram(NewModel(variables, existingValues), tea.WithInput(in), tea.WithOutput(out)) if m, err := p.Run(); err != nil { return nil, err } else { diff --git a/pkg/survey/survey_test.go b/pkg/survey/survey_test.go new file mode 100644 index 00000000..78eb1bc1 --- /dev/null +++ b/pkg/survey/survey_test.go @@ -0,0 +1,96 @@ +package survey_test + +import ( + "reflect" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/futurice/jalapeno/pkg/recipe" + "github.com/futurice/jalapeno/pkg/survey" +) + +func TestPromptUserForValues(t *testing.T) { + testCases := []struct { + name string + variables []recipe.Variable + existingValues recipe.VariableValues + expected recipe.VariableValues + input string + }{ + { + name: "string_variable", + variables: []recipe.Variable{ + {Name: "VAR_1"}, + }, + expected: recipe.VariableValues{ + "VAR_1": "foo", + }, + input: "foo\n", + }, + { + name: "select_variable", + variables: []recipe.Variable{ + {Name: "VAR_1", Options: []string{"a", "b", "c"}}, + }, + expected: recipe.VariableValues{ + "VAR_1": "c", + }, + input: "↓↓\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(tt *testing.T) { + tm := teatest.NewTestModel( + tt, + survey.NewModel(tc.variables, tc.existingValues), + teatest.WithInitialTermSize(300, 100), + ) + + for _, r := range tc.input { + tm.Send(RuneToKey(r)) + } + + m := tm.FinalModel(tt, teatest.WithFinalTimeout(time.Second)).(survey.SurveyModel) + m.Values() + + // Assert that the result is correct + result := m.Values() + if !reflect.DeepEqual(result, tc.expected) { + t.Errorf("Unexpected result. Got %v, expected %v", result, tc.expected) + } + }) + } +} + +func RuneToKey(r rune) tea.KeyMsg { + switch r { + case '\n': + return tea.KeyMsg{ + Type: tea.KeyEnter, + } + case '↑': + return tea.KeyMsg{ + Type: tea.KeyUp, + } + case '↓': + return tea.KeyMsg{ + Type: tea.KeyDown, + } + case '←': + return tea.KeyMsg{ + Type: tea.KeyLeft, + } + case '→': + return tea.KeyMsg{ + Type: tea.KeyRight, + } + default: + return tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{r}, + } + } +}