diff --git a/cmd/humanlog/main.go b/cmd/humanlog/main.go index 919213bf..7b42ecfe 100644 --- a/cmd/humanlog/main.go +++ b/cmd/humanlog/main.go @@ -78,6 +78,7 @@ var ( base.Blurred.BlurredButton = base.Focused.BlurredButton.Bold(false).Underline(false).Strikethrough(true) return base }() + accessibleTUI = os.Getenv("HUMANLOG_ACCESSIBILITY") == "true" ) func fatalf(c *cli.Context, format string, args ...interface{}) { diff --git a/cmd/humanlog/onboarding.go b/cmd/humanlog/onboarding.go index a14d481e..7ae3e8f2 100644 --- a/cmd/humanlog/onboarding.go +++ b/cmd/humanlog/onboarding.go @@ -31,12 +31,12 @@ func onboardingCmd( Hidden: true, Action: func(cctx *cli.Context) error { wantsSignup := true - err := huh.NewConfirm(). - Title("Welcome to humanlog. New features are coming up soon!"). + err := huh.NewConfirm().Title("Welcome to humanlog. New features are coming up soon!"). Description("Would you like to sign-up to learn more?"). Affirmative("Yes!"). Negative("No."). Value(&wantsSignup). + WithAccessible(accessibleTUI). WithTheme(huhTheme).Run() if err != nil { return err diff --git a/go.mod b/go.mod index 0e0698fe..bf8b2a8c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/humanlogio/humanlog -go 1.23 +go 1.22.0 + +toolchain go1.23.2 require ( connectrpc.com/connect v1.16.2 @@ -9,9 +11,9 @@ require ( github.com/NimbleMarkets/ntcharts v0.1.2 github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 github.com/blang/semver v3.5.1+incompatible - github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.1.1 - github.com/charmbracelet/huh v0.4.2 + github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v0.13.0 github.com/charmbracelet/x/term v0.2.0 github.com/cli/safeexec v1.0.1 @@ -42,8 +44,8 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.3.1 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect + github.com/charmbracelet/x/ansi v0.3.2 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -57,10 +59,11 @@ require ( 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.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index 69bf2ee6..d8adc02d 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/NimbleMarkets/ntcharts v0.1.2 h1:iW1aiOif/Dm74sQd18opi10RMED5589cVhy9SGp98Tw= github.com/NimbleMarkets/ntcharts v0.1.2/go.mod h1:WcHS7kc8oQctN1543DeV9a+gOrS4DDVfKp1N9RZFUqc= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -21,22 +23,20 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY= github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= -github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM= -github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0= +github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= +github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.3.1 h1:CRO6lc/6HCx2/D6S/GZ87jDvRvk6GtPyFP+IljkNtqI= -github.com/charmbracelet/x/ansi v0.3.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I= -github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/exp/term v0.0.0-20240524151031-ff83003bf67a h1:k/s6UoOSVynWiw7PlclyGO2VdVs5ZLbMIHiGp4shFZE= -github.com/charmbracelet/x/exp/term v0.0.0-20240524151031-ff83003bf67a/go.mod h1:YBotIGhfoWhHDlnUpJMkjebGV2pdGRCn1Y4/Nk/vVcU= +github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= +github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= @@ -94,14 +94,16 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= diff --git a/vendor/github.com/charmbracelet/bubbles/cursor/cursor.go b/vendor/github.com/charmbracelet/bubbles/cursor/cursor.go index 5abda654..1297422d 100644 --- a/vendor/github.com/charmbracelet/bubbles/cursor/cursor.go +++ b/vendor/github.com/charmbracelet/bubbles/cursor/cursor.go @@ -101,6 +101,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmd := m.BlinkCmd() return m, cmd + case tea.FocusMsg: + return m, m.Focus() + + case tea.BlurMsg: + m.Blur() + return m, nil + case BlinkMsg: // We're choosy about whether to accept blinkMsgs so that our cursor // only exactly when it should. @@ -138,6 +145,10 @@ func (m Model) Mode() Mode { // // For available cursor modes, see type CursorMode. func (m *Model) SetMode(mode Mode) tea.Cmd { + // Adjust the mode value if it's value is out of range + if mode < CursorBlink || mode > CursorHide { + return nil + } m.mode = mode m.Blink = m.mode == CursorHide || !m.focus if mode == CursorBlink { diff --git a/vendor/github.com/charmbracelet/bubbles/help/help.go b/vendor/github.com/charmbracelet/bubbles/help/help.go index 8e5f77f1..f4e1c971 100644 --- a/vendor/github.com/charmbracelet/bubbles/help/help.go +++ b/vendor/github.com/charmbracelet/bubbles/help/help.go @@ -15,7 +15,6 @@ import ( // Note that if a key is disabled (via key.Binding.SetEnabled) it will not be // rendered in the help view, so in theory generated help should self-manage. type KeyMap interface { - // ShortHelp returns a slice of bindings to be displayed in the short // version of the help. The help bubble will render help in the order in // which the help items are returned here. @@ -82,10 +81,10 @@ func New() Model { ShortKey: keyStyle, ShortDesc: descStyle, ShortSeparator: sepStyle, - Ellipsis: sepStyle.Copy(), - FullKey: keyStyle.Copy(), - FullDesc: descStyle.Copy(), - FullSeparator: sepStyle.Copy(), + Ellipsis: sepStyle, + FullKey: keyStyle, + FullDesc: descStyle, + FullSeparator: sepStyle, }, } } @@ -118,7 +117,7 @@ func (m Model) ShortHelpView(bindings []key.Binding) string { var b strings.Builder var totalWidth int - var separator = m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator) + separator := m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator) for i, kb := range bindings { if !kb.Enabled() { @@ -215,9 +214,8 @@ func (m Model) FullHelpView(groups [][]key.Binding) string { if m.Width > 0 && totalWidth > m.Width { break } + out = append(out, sep) } - - out = append(out, sep) } return lipgloss.JoinHorizontal(lipgloss.Top, out...) diff --git a/vendor/github.com/charmbracelet/bubbles/key/key.go b/vendor/github.com/charmbracelet/bubbles/key/key.go index c7888fa7..0682665d 100644 --- a/vendor/github.com/charmbracelet/bubbles/key/key.go +++ b/vendor/github.com/charmbracelet/bubbles/key/key.go @@ -36,9 +36,7 @@ // to render help text for keystrokes in your views. package key -import ( - tea "github.com/charmbracelet/bubbletea" -) +import "fmt" // Binding describes a set of keybindings and, optionally, their associated // help text. @@ -128,8 +126,8 @@ type Help struct { Desc string } -// Matches checks if the given KeyMsg matches the given bindings. -func Matches(k tea.KeyMsg, b ...Binding) bool { +// Matches checks if the given key matches the given bindings. +func Matches[Key fmt.Stringer](k Key, b ...Binding) bool { keys := k.String() for _, binding := range b { for _, v := range binding.keys { diff --git a/vendor/github.com/charmbracelet/bubbles/paginator/paginator.go b/vendor/github.com/charmbracelet/bubbles/paginator/paginator.go deleted file mode 100644 index 82dc3ed3..00000000 --- a/vendor/github.com/charmbracelet/bubbles/paginator/paginator.go +++ /dev/null @@ -1,198 +0,0 @@ -// Package paginator provides a Bubble Tea package for calculating pagination -// and rendering pagination info. Note that this package does not render actual -// pages: it's purely for handling keystrokes related to pagination, and -// rendering pagination status. -package paginator - -import ( - "fmt" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" -) - -// Type specifies the way we render pagination. -type Type int - -// Pagination rendering options. -const ( - Arabic Type = iota - Dots -) - -// KeyMap is the key bindings for different actions within the paginator. -type KeyMap struct { - PrevPage key.Binding - NextPage key.Binding -} - -// DefaultKeyMap is the default set of key bindings for navigating and acting -// upon the paginator. -var DefaultKeyMap = KeyMap{ - PrevPage: key.NewBinding(key.WithKeys("pgup", "left", "h")), - NextPage: key.NewBinding(key.WithKeys("pgdown", "right", "l")), -} - -// Model is the Bubble Tea model for this user interface. -type Model struct { - // Type configures how the pagination is rendered (Arabic, Dots). - Type Type - // Page is the current page number. - Page int - // PerPage is the number of items per page. - PerPage int - // TotalPages is the total number of pages. - TotalPages int - // ActiveDot is used to mark the current page under the Dots display type. - ActiveDot string - // InactiveDot is used to mark inactive pages under the Dots display type. - InactiveDot string - // ArabicFormat is the printf-style format to use for the Arabic display type. - ArabicFormat string - - // KeyMap encodes the keybindings recognized by the widget. - KeyMap KeyMap - - // Deprecated: customize [KeyMap] instead. - UsePgUpPgDownKeys bool - // Deprecated: customize [KeyMap] instead. - UseLeftRightKeys bool - // Deprecated: customize [KeyMap] instead. - UseUpDownKeys bool - // Deprecated: customize [KeyMap] instead. - UseHLKeys bool - // Deprecated: customize [KeyMap] instead. - UseJKKeys bool -} - -// SetTotalPages is a helper function for calculating the total number of pages -// from a given number of items. Its use is optional since this pager can be -// used for other things beyond navigating sets. Note that it both returns the -// number of total pages and alters the model. -func (m *Model) SetTotalPages(items int) int { - if items < 1 { - return m.TotalPages - } - n := items / m.PerPage - if items%m.PerPage > 0 { - n++ - } - m.TotalPages = n - return n -} - -// ItemsOnPage is a helper function for returning the number of items on the -// current page given the total number of items passed as an argument. -func (m Model) ItemsOnPage(totalItems int) int { - if totalItems < 1 { - return 0 - } - start, end := m.GetSliceBounds(totalItems) - return end - start -} - -// GetSliceBounds is a helper function for paginating slices. Pass the length -// of the slice you're rendering and you'll receive the start and end bounds -// corresponding to the pagination. For example: -// -// bunchOfStuff := []stuff{...} -// start, end := model.GetSliceBounds(len(bunchOfStuff)) -// sliceToRender := bunchOfStuff[start:end] -func (m *Model) GetSliceBounds(length int) (start int, end int) { - start = m.Page * m.PerPage - end = min(m.Page*m.PerPage+m.PerPage, length) - return start, end -} - -// PrevPage is a helper function for navigating one page backward. It will not -// page beyond the first page (i.e. page 0). -func (m *Model) PrevPage() { - if m.Page > 0 { - m.Page-- - } -} - -// NextPage is a helper function for navigating one page forward. It will not -// page beyond the last page (i.e. totalPages - 1). -func (m *Model) NextPage() { - if !m.OnLastPage() { - m.Page++ - } -} - -// OnLastPage returns whether or not we're on the last page. -func (m Model) OnLastPage() bool { - return m.Page == m.TotalPages-1 -} - -// OnFirstPage returns whether or not we're on the first page. -func (m Model) OnFirstPage() bool { - return m.Page == 0 -} - -// New creates a new model with defaults. -func New() Model { - return Model{ - Type: Arabic, - Page: 0, - PerPage: 1, - TotalPages: 1, - KeyMap: DefaultKeyMap, - ActiveDot: "•", - InactiveDot: "○", - ArabicFormat: "%d/%d", - } -} - -// NewModel creates a new model with defaults. -// -// Deprecated: use [New] instead. -var NewModel = New - -// Update is the Tea update function which binds keystrokes to pagination. -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.KeyMap.NextPage): - m.NextPage() - case key.Matches(msg, m.KeyMap.PrevPage): - m.PrevPage() - } - } - - return m, nil -} - -// View renders the pagination to a string. -func (m Model) View() string { - switch m.Type { - case Dots: - return m.dotsView() - default: - return m.arabicView() - } -} - -func (m Model) dotsView() string { - var s string - for i := 0; i < m.TotalPages; i++ { - if i == m.Page { - s += m.ActiveDot - continue - } - s += m.InactiveDot - } - return s -} - -func (m Model) arabicView() string { - return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages) -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/vendor/github.com/charmbracelet/bubbles/spinner/spinner.go b/vendor/github.com/charmbracelet/bubbles/spinner/spinner.go new file mode 100644 index 00000000..bb53597f --- /dev/null +++ b/vendor/github.com/charmbracelet/bubbles/spinner/spinner.go @@ -0,0 +1,230 @@ +package spinner + +import ( + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Internal ID management. Used during animating to ensure that frame messages +// are received only by spinner components that sent them. +var ( + lastID int + idMtx sync.Mutex +) + +// Return the next ID we should use on the Model. +func nextID() int { + idMtx.Lock() + defer idMtx.Unlock() + lastID++ + return lastID +} + +// Spinner is a set of frames used in animating the spinner. +type Spinner struct { + Frames []string + FPS time.Duration +} + +// Some spinners to choose from. You could also make your own. +var ( + Line = Spinner{ + Frames: []string{"|", "/", "-", "\\"}, + FPS: time.Second / 10, //nolint:gomnd + } + Dot = Spinner{ + Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "}, + FPS: time.Second / 10, //nolint:gomnd + } + MiniDot = Spinner{ + Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + FPS: time.Second / 12, //nolint:gomnd + } + Jump = Spinner{ + Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"}, + FPS: time.Second / 10, //nolint:gomnd + } + Pulse = Spinner{ + Frames: []string{"█", "▓", "▒", "░"}, + FPS: time.Second / 8, //nolint:gomnd + } + Points = Spinner{ + Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"}, + FPS: time.Second / 7, //nolint:gomnd + } + Globe = Spinner{ + Frames: []string{"🌍", "🌎", "🌏"}, + FPS: time.Second / 4, //nolint:gomnd + } + Moon = Spinner{ + Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"}, + FPS: time.Second / 8, //nolint:gomnd + } + Monkey = Spinner{ + Frames: []string{"🙈", "🙉", "🙊"}, + FPS: time.Second / 3, //nolint:gomnd + } + Meter = Spinner{ + Frames: []string{ + "▱▱▱", + "▰▱▱", + "▰▰▱", + "▰▰▰", + "▰▰▱", + "▰▱▱", + "▱▱▱", + }, + FPS: time.Second / 7, //nolint:gomnd + } + Hamburger = Spinner{ + Frames: []string{"☱", "☲", "☴", "☲"}, + FPS: time.Second / 3, //nolint:gomnd + } + Ellipsis = Spinner{ + Frames: []string{"", ".", "..", "..."}, + FPS: time.Second / 3, //nolint:gomnd + } +) + +// Model contains the state for the spinner. Use New to create new models +// rather than using Model as a struct literal. +type Model struct { + // Spinner settings to use. See type Spinner. + Spinner Spinner + + // Style sets the styling for the spinner. Most of the time you'll just + // want foreground and background coloring, and potentially some padding. + // + // For an introduction to styling with Lip Gloss see: + // https://github.com/charmbracelet/lipgloss + Style lipgloss.Style + + frame int + id int + tag int +} + +// ID returns the spinner's unique ID. +func (m Model) ID() int { + return m.id +} + +// New returns a model with default values. +func New(opts ...Option) Model { + m := Model{ + Spinner: Line, + id: nextID(), + } + + for _, opt := range opts { + opt(&m) + } + + return m +} + +// NewModel returns a model with default values. +// +// Deprecated: use [New] instead. +var NewModel = New + +// TickMsg indicates that the timer has ticked and we should render a frame. +type TickMsg struct { + Time time.Time + tag int + ID int +} + +// Update is the Tea update function. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case TickMsg: + // If an ID is set, and the ID doesn't belong to this spinner, reject + // the message. + if msg.ID > 0 && msg.ID != m.id { + return m, nil + } + + // If a tag is set, and it's not the one we expect, reject the message. + // This prevents the spinner from receiving too many messages and + // thus spinning too fast. + if msg.tag > 0 && msg.tag != m.tag { + return m, nil + } + + m.frame++ + if m.frame >= len(m.Spinner.Frames) { + m.frame = 0 + } + + m.tag++ + return m, m.tick(m.id, m.tag) + default: + return m, nil + } +} + +// View renders the model's view. +func (m Model) View() string { + if m.frame >= len(m.Spinner.Frames) { + return "(error)" + } + + return m.Style.Render(m.Spinner.Frames[m.frame]) +} + +// Tick is the command used to advance the spinner one frame. Use this command +// to effectively start the spinner. +func (m Model) Tick() tea.Msg { + return TickMsg{ + // The time at which the tick occurred. + Time: time.Now(), + + // The ID of the spinner that this message belongs to. This can be + // helpful when routing messages, however bear in mind that spinners + // will ignore messages that don't contain ID by default. + ID: m.id, + + tag: m.tag, + } +} + +func (m Model) tick(id, tag int) tea.Cmd { + return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg { + return TickMsg{ + Time: t, + ID: id, + tag: tag, + } + }) +} + +// Tick is the command used to advance the spinner one frame. Use this command +// to effectively start the spinner. +// +// Deprecated: Use [Model.Tick] instead. +func Tick() tea.Msg { + return TickMsg{Time: time.Now()} +} + +// Option is used to set options in New. For example: +// +// spinner := New(WithSpinner(Dot)) +type Option func(*Model) + +// WithSpinner is an option to set the spinner. +func WithSpinner(spinner Spinner) Option { + return func(m *Model) { + m.Spinner = spinner + } +} + +// WithStyle is an option to set the spinner style. +func WithStyle(style lipgloss.Style) Option { + return func(m *Model) { + m.Style = style + } +} diff --git a/vendor/github.com/charmbracelet/bubbles/table/table.go b/vendor/github.com/charmbracelet/bubbles/table/table.go index 36549e3b..6103c836 100644 --- a/vendor/github.com/charmbracelet/bubbles/table/table.go +++ b/vendor/github.com/charmbracelet/bubbles/table/table.go @@ -3,6 +3,7 @@ package table import ( "strings" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -13,6 +14,7 @@ import ( // Model defines a state for the table widget. type Model struct { KeyMap KeyMap + Help help.Model cols []Column rows []Row @@ -35,7 +37,7 @@ type Column struct { } // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which -// is used to render the menu. +// is used to render the help menu. type KeyMap struct { LineUp key.Binding LineDown key.Binding @@ -47,6 +49,19 @@ type KeyMap struct { GotoBottom key.Binding } +// ShortHelp implements the KeyMap interface. +func (km KeyMap) ShortHelp() []key.Binding { + return []key.Binding{km.LineUp, km.LineDown} +} + +// FullHelp implements the KeyMap interface. +func (km KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom}, + {km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown}, + } +} + // DefaultKeyMap returns a default set of keybindings. func DefaultKeyMap() KeyMap { const spacebar = " " @@ -121,6 +136,7 @@ func New(opts ...Option) Model { viewport: viewport.New(0, 20), KeyMap: DefaultKeyMap(), + Help: help.New(), styles: DefaultStyles(), } @@ -150,7 +166,7 @@ func WithRows(rows []Row) Option { // WithHeight sets the height of the table. func WithHeight(h int) Option { return func(m *Model) { - m.viewport.Height = h + m.viewport.Height = h - lipgloss.Height(m.headersView()) } } @@ -238,6 +254,13 @@ func (m Model) View() string { return m.headersView() + "\n" + m.viewport.View() } +// HelpView is a helper method for rendering the help menu from the keymap. +// Note that this view is not rendered by default and you must call it +// manually in your application, where applicable. +func (m Model) HelpView() string { + return m.Help.View(m.KeyMap) +} + // UpdateViewport updates the list content based on the previously defined // columns and rows. func (m *Model) UpdateViewport() { @@ -276,6 +299,11 @@ func (m Model) Rows() []Row { return m.rows } +// Columns returns the current columns. +func (m Model) Columns() []Column { + return m.cols +} + // SetRows sets a new rows state. func (m *Model) SetRows(r []Row) { m.rows = r @@ -296,7 +324,7 @@ func (m *Model) SetWidth(w int) { // SetHeight sets the height of the viewport of the table. func (m *Model) SetHeight(h int) { - m.viewport.Height = h + m.viewport.Height = h - lipgloss.Height(m.headersView()) m.UpdateViewport() } @@ -329,7 +357,7 @@ func (m *Model) MoveUp(n int) { case m.start == 0: m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor)) case m.start < m.viewport.Height: - m.viewport.SetYOffset(clamp(m.viewport.YOffset+n, 0, m.cursor)) + m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height)) case m.viewport.YOffset >= 1: m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height) } @@ -343,9 +371,9 @@ func (m *Model) MoveDown(n int) { m.UpdateViewport() switch { - case m.end == len(m.rows): + case m.end == len(m.rows) && m.viewport.YOffset > 0: m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height)) - case m.cursor > (m.end-m.start)/2: + case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0: m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor)) case m.viewport.YOffset > 1: case m.cursor > m.viewport.YOffset+m.viewport.Height-1: @@ -380,26 +408,32 @@ func (m *Model) FromValues(value, separator string) { } func (m Model) headersView() string { - var s = make([]string, 0, len(m.cols)) + s := make([]string, 0, len(m.cols)) for _, col := range m.cols { + if col.Width <= 0 { + continue + } 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...) + return lipgloss.JoinHorizontal(lipgloss.Top, s...) } -func (m *Model) renderRow(rowID int) string { - var s = make([]string, 0, len(m.cols)) - for i, value := range m.rows[rowID] { +func (m *Model) renderRow(r int) string { + s := make([]string, 0, len(m.cols)) + for i, value := range m.rows[r] { + if m.cols[i].Width <= 0 { + continue + } style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) renderedCell := m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.cols[i].Width, "…"))) s = append(s, renderedCell) } - row := lipgloss.JoinHorizontal(lipgloss.Left, s...) + row := lipgloss.JoinHorizontal(lipgloss.Top, s...) - if rowID == m.cursor { + if r == m.cursor { return m.styles.Selected.Render(row) } diff --git a/vendor/github.com/charmbracelet/bubbles/textarea/textarea.go b/vendor/github.com/charmbracelet/bubbles/textarea/textarea.go index ce77c4f5..153f39ef 100644 --- a/vendor/github.com/charmbracelet/bubbles/textarea/textarea.go +++ b/vendor/github.com/charmbracelet/bubbles/textarea/textarea.go @@ -3,6 +3,7 @@ package textarea import ( "crypto/sha256" "fmt" + "strconv" "strings" "unicode" @@ -14,13 +15,13 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" rw "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" ) const ( minHeight = 1 - minWidth = 2 defaultHeight = 6 defaultWidth = 40 defaultCharLimit = 400 @@ -29,8 +30,10 @@ const ( ) // Internal messages for clipboard operations. -type pasteMsg string -type pasteErrMsg struct{ error } +type ( + pasteMsg string + pasteErrMsg struct{ error } +) // KeyMap is the key bindings for different actions within the textarea. type KeyMap struct { @@ -63,30 +66,30 @@ type KeyMap struct { // DefaultKeyMap is the default set of key bindings for navigating and acting // upon the textarea. var DefaultKeyMap = KeyMap{ - CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f")), - CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b")), - WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f")), - WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b")), - LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n")), - LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p")), - DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")), - DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d")), - DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")), - DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u")), - InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m")), - DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h")), - DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d")), - LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")), - LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")), - Paste: key.NewBinding(key.WithKeys("ctrl+v")), - InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home")), - InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end")), - - CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c")), - LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l")), - UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u")), - - TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t")), + CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")), + CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")), + WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")), + WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")), + LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")), + LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")), + DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")), + DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")), + DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")), + DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")), + InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")), + DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")), + DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")), + LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")), + LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")), + Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")), + InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")), + InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")), + + CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")), + LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")), + UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), + + TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), } // LineInfo is a helper for keeping track of line information regarding @@ -132,6 +135,37 @@ type Style struct { Text lipgloss.Style } +func (s Style) computedCursorLine() lipgloss.Style { + return s.CursorLine.Inherit(s.Base).Inline(true) +} + +func (s Style) computedCursorLineNumber() lipgloss.Style { + return s.CursorLineNumber. + Inherit(s.CursorLine). + Inherit(s.Base). + Inline(true) +} + +func (s Style) computedEndOfBuffer() lipgloss.Style { + return s.EndOfBuffer.Inherit(s.Base).Inline(true) +} + +func (s Style) computedLineNumber() lipgloss.Style { + return s.LineNumber.Inherit(s.Base).Inline(true) +} + +func (s Style) computedPlaceholder() lipgloss.Style { + return s.Placeholder.Inherit(s.Base).Inline(true) +} + +func (s Style) computedPrompt() lipgloss.Style { + return s.Prompt.Inherit(s.Base).Inline(true) +} + +func (s Style) computedText() lipgloss.Style { + return s.Text.Inherit(s.Base).Inline(true) +} + // line is the input to the text wrapping function. This is stored in a struct // so that it can be hashed and memoized. type line struct { @@ -232,9 +266,6 @@ type Model struct { // vertically such that we can maintain the same navigating position. lastCharOffset int - // lineNumberFormat is the format string used to display line numbers. - lineNumberFormat string - // viewport is the vertically-scrollable viewport of the multi-line text // input. viewport *viewport.Model @@ -260,16 +291,15 @@ func New() Model { FocusedStyle: focusedStyle, BlurredStyle: blurredStyle, cache: memoization.NewMemoCache[line, [][]rune](defaultMaxHeight), - EndOfBufferCharacter: '~', + EndOfBufferCharacter: ' ', ShowLineNumbers: true, Cursor: cur, KeyMap: DefaultKeyMap, - value: make([][]rune, minHeight, defaultMaxHeight), - focus: false, - col: 0, - row: 0, - lineNumberFormat: "%3v ", + value: make([][]rune, minHeight, defaultMaxHeight), + focus: false, + col: 0, + row: 0, viewport: &vp, } @@ -605,8 +635,7 @@ func (m *Model) transposeLeft() { if m.col >= len(m.value[m.row]) { m.SetCursor(m.col - 1) } - m.value[m.row][m.col-1], m.value[m.row][m.col] = - m.value[m.row][m.col], m.value[m.row][m.col-1] + m.value[m.row][m.col-1], m.value[m.row][m.col] = m.value[m.row][m.col], m.value[m.row][m.col-1] if m.col < len(m.value[m.row]) { m.SetCursor(m.col + 1) } @@ -738,10 +767,7 @@ func (m *Model) wordRight() { func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // Skip spaces forward. - for { - if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) { - break - } + for m.col >= len(m.value[m.row]) || unicode.IsSpace(m.value[m.row][m.col]) { if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) { // End of text. break @@ -862,32 +888,40 @@ func (m *Model) moveToEnd() { // It is important that the width of the textarea be exactly the given width // and no more. func (m *Model) SetWidth(w int) { - if m.MaxWidth > 0 { - m.viewport.Width = clamp(w, minWidth, m.MaxWidth) - } else { - m.viewport.Width = max(w, minWidth) + // Update prompt width only if there is no prompt function as SetPromptFunc + // updates the prompt width when it is called. + if m.promptFunc == nil { + m.promptWidth = uniseg.StringWidth(m.Prompt) } - // Since the width of the textarea input is dependent on the width of the - // prompt and line numbers, we need to calculate it by subtracting. - inputWidth := w - if m.ShowLineNumbers { - inputWidth -= uniseg.StringWidth(fmt.Sprintf(m.lineNumberFormat, 0)) - } + // Add base style borders and padding to reserved outer width. + reservedOuter := m.style.Base.GetHorizontalFrameSize() - // Account for base style borders and padding. - inputWidth -= m.style.Base.GetHorizontalFrameSize() + // Add prompt width to reserved inner width. + reservedInner := m.promptWidth - if m.promptFunc == nil { - m.promptWidth = uniseg.StringWidth(m.Prompt) + // Add line number width to reserved inner width. + if m.ShowLineNumbers { + const lnWidth = 4 // Up to 3 digits for line number plus 1 margin. + reservedInner += lnWidth } - inputWidth -= m.promptWidth + // Input width must be at least one more than the reserved inner and outer + // width. This gives us a minimum input width of 1. + minWidth := reservedInner + reservedOuter + 1 + inputWidth := max(w, minWidth) + + // Input width must be no more than maximum width. if m.MaxWidth > 0 { - m.width = clamp(inputWidth, minWidth, m.MaxWidth) - } else { - m.width = max(inputWidth, minWidth) + inputWidth = min(inputWidth, m.MaxWidth) } + + // Since the width of the viewport and input area is dependent on the width of + // borders, prompt and line numbers, we need to calculate it by subtracting + // the reserved width from them. + + m.viewport.Width = inputWidth - reservedOuter + m.width = inputWidth - reservedOuter - reservedInner } // SetPromptFunc supersedes the Prompt field and sets a dynamic prompt @@ -1058,46 +1092,59 @@ func (m Model) View() string { if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" { return m.placeholderView() } - m.Cursor.TextStyle = m.style.CursorLine + m.Cursor.TextStyle = m.style.computedCursorLine() - var s strings.Builder - var style lipgloss.Style - lineInfo := m.LineInfo() - - var newLines int + var ( + s strings.Builder + style lipgloss.Style + newLines int + widestLineNumber int + lineInfo = m.LineInfo() + ) displayLine := 0 for l, line := range m.value { wrappedLines := m.memoizedWrap(line, m.width) if m.row == l { - style = m.style.CursorLine + style = m.style.computedCursorLine() } else { - style = m.style.Text + style = m.style.computedText() } for wl, wrappedLine := range wrappedLines { prompt := m.getPromptString(displayLine) - prompt = m.style.Prompt.Render(prompt) + prompt = m.style.computedPrompt().Render(prompt) s.WriteString(style.Render(prompt)) displayLine++ + var ln string if m.ShowLineNumbers { if wl == 0 { if m.row == l { - s.WriteString(style.Render(m.style.CursorLineNumber.Render(fmt.Sprintf(m.lineNumberFormat, l+1)))) + ln = style.Render(m.style.computedCursorLineNumber().Render(m.formatLineNumber(l + 1))) + s.WriteString(ln) } else { - s.WriteString(style.Render(m.style.LineNumber.Render(fmt.Sprintf(m.lineNumberFormat, l+1)))) + ln = style.Render(m.style.computedLineNumber().Render(m.formatLineNumber(l + 1))) + s.WriteString(ln) } } else { if m.row == l { - s.WriteString(style.Render(m.style.CursorLineNumber.Render(fmt.Sprintf(m.lineNumberFormat, " ")))) + ln = style.Render(m.style.computedCursorLineNumber().Render(m.formatLineNumber(" "))) + s.WriteString(ln) } else { - s.WriteString(style.Render(m.style.LineNumber.Render(fmt.Sprintf(m.lineNumberFormat, " ")))) + ln = style.Render(m.style.computedLineNumber().Render(m.formatLineNumber(" "))) + s.WriteString(ln) } } } + // Note the widest line number for padding purposes later. + lnw := lipgloss.Width(ln) + if lnw > widestLineNumber { + widestLineNumber = lnw + } + strwidth := uniseg.StringWidth(string(wrappedLine)) padding := m.width - strwidth // If the trailing space causes the line to be wider than the @@ -1134,14 +1181,15 @@ func (m Model) View() string { // To do this we can simply pad out a few extra new lines in the view. for i := 0; i < m.height; i++ { prompt := m.getPromptString(displayLine) - prompt = m.style.Prompt.Render(prompt) + prompt = m.style.computedPrompt().Render(prompt) s.WriteString(prompt) displayLine++ - if m.ShowLineNumbers { - lineNumber := m.style.EndOfBuffer.Render((fmt.Sprintf(m.lineNumberFormat, string(m.EndOfBufferCharacter)))) - s.WriteString(lineNumber) - } + // Write end of buffer content + leftGutter := string(m.EndOfBufferCharacter) + rightGapWidth := m.Width() - lipgloss.Width(leftGutter) + widestLineNumber + rightGap := strings.Repeat(" ", max(0, rightGapWidth)) + s.WriteString(m.style.computedEndOfBuffer().Render(leftGutter + rightGap)) s.WriteRune('\n') } @@ -1149,6 +1197,15 @@ func (m Model) View() string { return m.style.Base.Render(m.viewport.View()) } +// formatLineNumber formats the line number for display dynamically based on +// the maximum number of lines +func (m Model) formatLineNumber(x any) string { + // XXX: ultimately we should use a max buffer height, which has yet to be + // implemented. + digits := len(strconv.Itoa(m.MaxHeight)) + return fmt.Sprintf(" %*v ", digits, x) +} + func (m Model) getPromptString(displayLine int) (prompt string) { prompt = m.Prompt if m.promptFunc == nil { @@ -1166,36 +1223,71 @@ func (m Model) getPromptString(displayLine int) (prompt string) { func (m Model) placeholderView() string { var ( s strings.Builder - p = rw.Truncate(m.Placeholder, m.width, "...") - style = m.style.Placeholder.Inline(true) + p = m.Placeholder + style = m.style.computedPlaceholder() ) - prompt := m.getPromptString(0) - prompt = m.style.Prompt.Render(prompt) - s.WriteString(m.style.CursorLine.Render(prompt)) + // word wrap lines + pwordwrap := ansi.Wordwrap(p, m.width, "") + // wrap lines (handles lines that could not be word wrapped) + pwrap := ansi.Hardwrap(pwordwrap, m.width, true) + // split string by new lines + plines := strings.Split(strings.TrimSpace(pwrap), "\n") - if m.ShowLineNumbers { - s.WriteString(m.style.CursorLine.Render(m.style.CursorLineNumber.Render((fmt.Sprintf(m.lineNumberFormat, 1))))) - } - - m.Cursor.TextStyle = m.style.Placeholder - m.Cursor.SetChar(string(p[0])) - s.WriteString(m.style.CursorLine.Render(m.Cursor.View())) - - // The rest of the placeholder text - s.WriteString(m.style.CursorLine.Render(style.Render(p[1:] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(p)))))) + for i := 0; i < m.height; i++ { + lineStyle := m.style.computedPlaceholder() + lineNumberStyle := m.style.computedLineNumber() + if len(plines) > i { + lineStyle = m.style.computedCursorLine() + lineNumberStyle = m.style.computedCursorLineNumber() + } - // The rest of the new lines - for i := 1; i < m.height; i++ { - s.WriteRune('\n') + // render prompt prompt := m.getPromptString(i) - prompt = m.style.Prompt.Render(prompt) - s.WriteString(prompt) + prompt = m.style.computedPrompt().Render(prompt) + s.WriteString(lineStyle.Render(prompt)) + // when show line numbers enabled: + // - render line number for only the cursor line + // - indent other placeholder lines + // this is consistent with vim with line numbers enabled if m.ShowLineNumbers { - eob := m.style.EndOfBuffer.Render((fmt.Sprintf(m.lineNumberFormat, string(m.EndOfBufferCharacter)))) + var ln string + + switch { + case i == 0: + ln = strconv.Itoa(i + 1) + fallthrough + case len(plines) > i: + s.WriteString(lineStyle.Render(lineNumberStyle.Render(m.formatLineNumber(ln)))) + default: + } + } + + switch { + // first line + case i == 0: + // first character of first line as cursor with character + m.Cursor.TextStyle = m.style.computedPlaceholder() + m.Cursor.SetChar(string(plines[0][0])) + s.WriteString(lineStyle.Render(m.Cursor.View())) + + // the rest of the first line + s.WriteString(lineStyle.Render(style.Render(plines[0][1:] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0])))))) + // remaining lines + case len(plines) > i: + // current line placeholder text + if len(plines) > i { + s.WriteString(lineStyle.Render(style.Render(plines[i] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i])))))) + } + default: + // end of line buffer character + eob := m.style.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter)) s.WriteString(eob) } + + // terminate with new line + s.WriteRune('\n') } m.viewport.SetContent(s.String()) diff --git a/vendor/github.com/charmbracelet/bubbles/textinput/textinput.go b/vendor/github.com/charmbracelet/bubbles/textinput/textinput.go index 501f9a79..93bc150b 100644 --- a/vendor/github.com/charmbracelet/bubbles/textinput/textinput.go +++ b/vendor/github.com/charmbracelet/bubbles/textinput/textinput.go @@ -17,8 +17,10 @@ import ( ) // Internal messages for clipboard operations. -type pasteMsg string -type pasteErrMsg struct{ error } +type ( + pasteMsg string + pasteErrMsg struct{ error } +) // EchoMode sets the input behavior of the text input field. type EchoMode int @@ -64,8 +66,8 @@ type KeyMap struct { var DefaultKeyMap = KeyMap{ CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f")), CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b")), - WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f")), - WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b")), + WordForward: key.NewBinding(key.WithKeys("alt+right", "ctrl+right", "alt+f")), + WordBackward: key.NewBinding(key.WithKeys("alt+left", "ctrl+left", "alt+b")), DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")), DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d")), DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")), @@ -181,19 +183,14 @@ func (m *Model) SetValue(s string) { // Clean up any special characters in the input provided by the // caller. This avoids bugs due to e.g. tab characters and whatnot. runes := m.san().Sanitize([]rune(s)) - m.setValueInternal(runes) + err := m.validate(runes) + m.setValueInternal(runes, err) } -func (m *Model) setValueInternal(runes []rune) { - if m.Validate != nil { - if err := m.Validate(string(runes)); err != nil { - m.Err = err - return - } - } +func (m *Model) setValueInternal(runes []rune, err error) { + m.Err = err empty := len(m.value) == 0 - m.Err = nil if m.CharLimit > 0 && len(runes) > m.CharLimit { m.value = runes[:m.CharLimit] @@ -307,8 +304,6 @@ func (m *Model) insertRunesFromUserInput(v []rune) { tail := make([]rune, len(tailSrc)) copy(tail, tailSrc) - oldPos := m.pos - // Insert pasted runes for _, r := range paste { head = append(head, r) @@ -323,11 +318,8 @@ func (m *Model) insertRunesFromUserInput(v []rune) { // Put it all back together value := append(head, tail...) - m.setValueInternal(value) - - if m.Err != nil { - m.pos = oldPos - } + inputErr := m.validate(value) + m.setValueInternal(value, inputErr) } // If a max width is defined, perform some logic to treat the visible area @@ -378,6 +370,7 @@ func (m *Model) handleOverflow() { // deleteBeforeCursor deletes all text before the cursor. func (m *Model) deleteBeforeCursor() { m.value = m.value[m.pos:] + m.Err = m.validate(m.value) m.offset = 0 m.SetCursor(0) } @@ -387,6 +380,7 @@ func (m *Model) deleteBeforeCursor() { // masked input. func (m *Model) deleteAfterCursor() { m.value = m.value[:m.pos] + m.Err = m.validate(m.value) m.SetCursor(len(m.value)) } @@ -432,6 +426,7 @@ func (m *Model) deleteWordBackward() { } else { m.value = append(m.value[:m.pos], m.value[oldPos:]...) } + m.Err = m.validate(m.value) } // deleteWordForward deletes the word right to the cursor. If input is masked @@ -471,6 +466,7 @@ func (m *Model) deleteWordForward() { } else { m.value = append(m.value[:oldPos], m.value[m.pos:]...) } + m.Err = m.validate(m.value) m.SetCursor(oldPos) } @@ -575,12 +571,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case tea.KeyMsg: switch { case key.Matches(msg, m.KeyMap.DeleteWordBackward): - m.Err = nil m.deleteWordBackward() case key.Matches(msg, m.KeyMap.DeleteCharacterBackward): m.Err = nil if len(m.value) > 0 { m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) + m.Err = m.validate(m.value) if m.pos > 0 { m.SetCursor(m.pos - 1) } @@ -597,13 +593,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if m.pos < len(m.value) { m.SetCursor(m.pos + 1) } - case key.Matches(msg, m.KeyMap.DeleteWordBackward): - m.deleteWordBackward() case key.Matches(msg, m.KeyMap.LineStart): m.CursorStart() case key.Matches(msg, m.KeyMap.DeleteCharacterForward): if len(m.value) > 0 && m.pos < len(m.value) { m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) + m.Err = m.validate(m.value) } case key.Matches(msg, m.KeyMap.LineEnd): m.CursorEnd() @@ -830,6 +825,10 @@ func (m *Model) AvailableSuggestions() []string { // CurrentSuggestion returns the currently selected suggestion. func (m *Model) CurrentSuggestion() string { + if m.currentSuggestionIndex >= len(m.matchedSuggestions) { + return "" + } + return string(m.matchedSuggestions[m.currentSuggestionIndex]) } @@ -880,3 +879,10 @@ func (m *Model) previousSuggestion() { m.currentSuggestionIndex = len(m.matchedSuggestions) - 1 } } + +func (m Model) validate(v []rune) error { + if m.Validate != nil { + return m.Validate(string(v)) + } + return nil +} diff --git a/vendor/github.com/charmbracelet/bubbles/viewport/viewport.go b/vendor/github.com/charmbracelet/bubbles/viewport/viewport.go index 0a5a73a8..e0a4cc33 100644 --- a/vendor/github.com/charmbracelet/bubbles/viewport/viewport.go +++ b/vendor/github.com/charmbracelet/bubbles/viewport/viewport.go @@ -92,7 +92,7 @@ func (m Model) ScrollPercent() float64 { } y := float64(m.YOffset) h := float64(m.Height) - t := float64(len(m.lines) - 1) + t := float64(len(m.lines)) v := y / (t - h) return math.Max(0.0, math.Min(1.0, v)) } @@ -378,7 +378,7 @@ func (m Model) View() string { MaxHeight(contentHeight). // truncate height if taller. MaxWidth(contentWidth). // truncate width if wider. Render(strings.Join(m.visibleLines(), "\n")) - return m.Style.Copy(). + return m.Style. UnsetWidth().UnsetHeight(). // Style size already applied in contents. Render(contents) } diff --git a/vendor/github.com/charmbracelet/huh/.gitignore b/vendor/github.com/charmbracelet/huh/.gitignore index 3b735ec4..ba65d3b0 100644 --- a/vendor/github.com/charmbracelet/huh/.gitignore +++ b/vendor/github.com/charmbracelet/huh/.gitignore @@ -19,3 +19,6 @@ # Go workspace file go.work + +# Debugging +debug.log diff --git a/vendor/github.com/charmbracelet/huh/.golangci-soft.yml b/vendor/github.com/charmbracelet/huh/.golangci-soft.yml index 55255e59..93a1caf0 100644 --- a/vendor/github.com/charmbracelet/huh/.golangci-soft.yml +++ b/vendor/github.com/charmbracelet/huh/.golangci-soft.yml @@ -14,17 +14,12 @@ issues: linters: enable: - # - dupl - exhaustive - # - exhaustivestruct - goconst - godot - - godox - - gomnd + - mnd - gomoddirectives - goprintffuncname - # - ifshort - # - lll - misspell - nakedret - nestif @@ -35,13 +30,10 @@ linters: # disable default linters, they are already enabled in .golangci.yml disable: - - deadcode - errcheck - gosimple - govet - ineffassign - staticcheck - - structcheck - typecheck - unused - - varcheck diff --git a/vendor/github.com/charmbracelet/huh/.golangci.yml b/vendor/github.com/charmbracelet/huh/.golangci.yml index a5a91d0d..684d54bf 100644 --- a/vendor/github.com/charmbracelet/huh/.golangci.yml +++ b/vendor/github.com/charmbracelet/huh/.golangci.yml @@ -15,7 +15,6 @@ issues: linters: enable: - bodyclose - - exportloopref - goimports - gosec - nilerr diff --git a/vendor/github.com/charmbracelet/huh/README.md b/vendor/github.com/charmbracelet/huh/README.md index cff542f4..e361fb51 100644 --- a/vendor/github.com/charmbracelet/huh/README.md +++ b/vendor/github.com/charmbracelet/huh/README.md @@ -1,7 +1,7 @@ # Huh?
-
+
@@ -86,7 +86,7 @@ form := huh.NewForm(
// Gather some final details about the order.
huh.NewGroup(
huh.NewInput().
- Title("What's your name?").
+ Title("What’s your name?").
Value(&name).
// Validating fields is easy. The form will mark erroneous fields
// and display error messages accordingly.
@@ -125,6 +125,9 @@ if !discount {
And that’s it! For more info see [the full source][burgersource] for this
example as well as [the docs][docs].
+If you need more dynamic forms that change based on input from previous fields,
+check out the [dynamic forms](#dynamic-forms) example.
+
[burgersource]: ./examples/burger/main.go
[docs]: https://pkg.go.dev/github.com/charmbracelet/huh?tab=doc
@@ -144,7 +147,7 @@ example as well as [the docs][docs].
var name string
huh.NewInput().
- Title("What's your name?").
+ Title("What’s your name?").
Value(&name).
Run() // this is blocking...
@@ -159,7 +162,7 @@ Prompt the user for a single line of text.
```go
huh.NewInput().
- Title("What's for lunch?").
+ Title("What’s for lunch?").
Prompt("?").
Validate(isFood).
Value(&lunch)
@@ -277,6 +280,78 @@ Themes can take advantage of the full range of
[lipgloss]: https://github.com/charmbracelet/lipgloss
+## Dynamic Forms
+
+`huh?` forms can be as dynamic as your heart desires. Simply replace properties
+with their equivalent `Func` to recompute the properties value every time a
+different part of your form changes.
+
+Here’s how you would build a simple country + state / province picker.
+
+First, define some variables that we’ll use to store the user selection.
+
+```go
+var country string
+var state string
+```
+
+Define your country select as you normally would:
+
+```go
+huh.NewSelect[string]().
+ Options(huh.NewOptions("United States", "Canada", "Mexico")...).
+ Value(&country).
+ Title("Country").
+```
+
+Define your state select with `TitleFunc` and `OptionsFunc` instead of `Title`
+and `Options`. This will allow you to change the title and options based on the
+selection of the previous field, i.e. `country`.
+
+To do this, we provide a `func() string` and a `binding any` to `TitleFunc`. The
+function defines what to show for the title and the binding specifies what value
+needs to change for the function to recompute. So if `country` changes (e.g. the
+user changes the selection) we will recompute the function.
+
+For `OptionsFunc`, we provide a `func() []Option[string]` and a `binding any`.
+We’ll fetch the country’s states, provinces, or territories from an API. `huh`
+will automatically handle caching for you.
+
+> [!IMPORTANT]
+> We have to pass `&country` as the binding to recompute the function only when
+> `country` changes, otherwise we will hit the API too often.
+
+```go
+huh.NewSelect[string]().
+ Value(&state).
+ Height(8).
+ TitleFunc(func() string {
+ switch country {
+ case "United States":
+ return "State"
+ case "Canada":
+ return "Province"
+ default:
+ return "Territory"
+ }
+ }, &country).
+ OptionsFunc(func() []huh.Option[string] {
+ opts := fetchStatesForCountry(country)
+ return huh.NewOptions(opts...)
+ }, &country),
+```
+
+Lastly, run the `form` with these inputs.
+
+```go
+err := form.Run()
+if err != nil {
+ log.Fatal(err)
+}
+```
+
+
+
## Bonus: Spinner
`huh?` ships with a standalone spinner package. It’s useful for indicating
@@ -406,7 +481,7 @@ For some `Huh?` programs in production, see:
## Feedback
-We'd love to hear your thoughts on this project. Feel free to drop us a note!
+We’d love to hear your thoughts on this project. Feel free to drop us a note!
- [Twitter](https://twitter.com/charmcli)
- [The Fediverse](https://mastodon.social/@charmcli)
diff --git a/vendor/github.com/charmbracelet/huh/accessibility/accessibility.go b/vendor/github.com/charmbracelet/huh/accessibility/accessibility.go
index 8f957e2a..60accc1f 100644
--- a/vendor/github.com/charmbracelet/huh/accessibility/accessibility.go
+++ b/vendor/github.com/charmbracelet/huh/accessibility/accessibility.go
@@ -14,7 +14,7 @@ import (
// Given invalid input (non-integers, integers outside of the range), the user
// will continue to be reprompted until a valid input is given, ensuring that
// the return value is always valid.
-func PromptInt(prompt string, min, max int) int {
+func PromptInt(prompt string, low, high int) int {
var (
input string
choice int
@@ -22,7 +22,7 @@ func PromptInt(prompt string, min, max int) int {
validInt := func(s string) error {
i, err := strconv.Atoi(s)
- if err != nil || i < min || i > max {
+ if err != nil || i < low || i > high {
return errors.New("invalid input. please try again")
}
return nil
diff --git a/vendor/github.com/charmbracelet/huh/accessor.go b/vendor/github.com/charmbracelet/huh/accessor.go
new file mode 100644
index 00000000..95b4ba9e
--- /dev/null
+++ b/vendor/github.com/charmbracelet/huh/accessor.go
@@ -0,0 +1,44 @@
+package huh
+
+// Accessor give read/write access to field values.
+type Accessor[T any] interface {
+ Get() T
+ Set(value T)
+}
+
+// EmbeddedAccessor is a basic accessor, acting as the default one for fields.
+type EmbeddedAccessor[T any] struct {
+ value T
+}
+
+// Get gets the value.
+func (a *EmbeddedAccessor[T]) Get() T {
+ return a.value
+}
+
+// Set sets the value.
+func (a *EmbeddedAccessor[T]) Set(value T) {
+ a.value = value
+}
+
+// PointerAccessor allows field value to be exposed as a pointed variable.
+type PointerAccessor[T any] struct {
+ value *T
+}
+
+// NewPointerAccessor returns a new pointer accessor.
+func NewPointerAccessor[T any](value *T) *PointerAccessor[T] {
+ return &PointerAccessor[T]{
+ value: value,
+ }
+}
+
+// Get gets the value.
+func (a *PointerAccessor[T]) Get() T {
+ return *a.value
+}
+
+// Set sets the value.
+func (a *PointerAccessor[T]) Set(value T) {
+ *a.value = value
+}
diff --git a/vendor/github.com/charmbracelet/huh/clamp.go b/vendor/github.com/charmbracelet/huh/clamp.go
index e210621b..185e56e5 100644
--- a/vendor/github.com/charmbracelet/huh/clamp.go
+++ b/vendor/github.com/charmbracelet/huh/clamp.go
@@ -1,19 +1,5 @@
package huh
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
-
-func max(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
-
func clamp(n, low, high int) int {
if low > high {
low, high = high, low
diff --git a/vendor/github.com/charmbracelet/huh/eval.go b/vendor/github.com/charmbracelet/huh/eval.go
new file mode 100644
index 00000000..b9e808dd
--- /dev/null
+++ b/vendor/github.com/charmbracelet/huh/eval.go
@@ -0,0 +1,84 @@
+package huh
+
+import (
+ "time"
+
+ "github.com/mitchellh/hashstructure/v2"
+)
+
+// Eval is an evaluatable value, it stores a cached value and a function to
+// recompute it. It's bindings are what we check to see if we need to recompute
+// the value.
+//
+// By default it is also cached.
+type Eval[T any] struct {
+ val T
+ fn func() T
+
+ bindings any
+ bindingsHash uint64
+ cache map[uint64]T
+
+ loading bool
+ loadingStart time.Time
+}
+
+const spinnerShowThreshold = 25 * time.Millisecond
+
+func hash(val any) uint64 {
+ hash, _ := hashstructure.Hash(val, hashstructure.FormatV2, nil)
+ return hash
+}
+
+func (e *Eval[T]) shouldUpdate() (bool, uint64) {
+ if e.fn == nil {
+ return false, 0
+ }
+ newHash := hash(e.bindings)
+ return e.bindingsHash != newHash, newHash
+}
+
+func (e *Eval[T]) loadFromCache() bool {
+ val, ok := e.cache[e.bindingsHash]
+ if ok {
+ e.loading = false
+ e.val = val
+ }
+ return ok
+}
+
+func (e *Eval[T]) update(val T) {
+ e.val = val
+ e.cache[e.bindingsHash] = val
+ e.loading = false
+}
+
+type updateTitleMsg struct {
+ id int
+ hash uint64
+ title string
+}
+
+type updateDescriptionMsg struct {
+ id int
+ hash uint64
+ description string
+}
+
+type updatePlaceholderMsg struct {
+ id int
+ hash uint64
+ placeholder string
+}
+
+type updateSuggestionsMsg struct {
+ id int
+ hash uint64
+ suggestions []string
+}
+
+type updateOptionsMsg[T comparable] struct {
+ id int
+ hash uint64
+ options []Option[T]
+}
diff --git a/vendor/github.com/charmbracelet/huh/field_confirm.go b/vendor/github.com/charmbracelet/huh/field_confirm.go
index debed0f4..708033fc 100644
--- a/vendor/github.com/charmbracelet/huh/field_confirm.go
+++ b/vendor/github.com/charmbracelet/huh/field_confirm.go
@@ -12,12 +12,13 @@ import (
// Confirm is a form confirm field.
type Confirm struct {
- value *bool
- key string
+ accessor Accessor[bool]
+ key string
+ id int
// customization
- title string
- description string
+ title Eval[string]
+ description Eval[string]
affirmative string
negative string
@@ -40,7 +41,10 @@ type Confirm struct {
// NewConfirm returns a new confirm field.
func NewConfirm() *Confirm {
return &Confirm{
- value: new(bool),
+ accessor: &EmbeddedAccessor[bool]{},
+ id: nextID(),
+ title: Eval[string]{cache: make(map[uint64]string)},
+ description: Eval[string]{cache: make(map[uint64]string)},
affirmative: "Yes",
negative: "No",
validate: func(bool) error { return nil },
@@ -82,7 +86,12 @@ func (c *Confirm) Negative(negative string) *Confirm {
// Value sets the value of the confirm field.
func (c *Confirm) Value(value *bool) *Confirm {
- c.value = value
+ return c.Accessor(NewPointerAccessor(value))
+}
+
+// Accessor sets the accessor of the confirm field.
+func (c *Confirm) Accessor(accessor Accessor[bool]) *Confirm {
+ c.accessor = accessor
return c
}
@@ -94,13 +103,29 @@ func (c *Confirm) Key(key string) *Confirm {
// Title sets the title of the confirm field.
func (c *Confirm) Title(title string) *Confirm {
- c.title = title
+ c.title.val = title
+ c.title.fn = nil
+ return c
+}
+
+// TitleFunc sets the title func of the confirm field.
+func (c *Confirm) TitleFunc(f func() string, bindings any) *Confirm {
+ c.title.fn = f
+ c.title.bindings = bindings
return c
}
// Description sets the description of the confirm field.
func (c *Confirm) Description(description string) *Confirm {
- c.description = description
+ c.description.val = description
+ c.description.fn = nil
+ return c
+}
+
+// DescriptionFunc sets the description function of the confirm field.
+func (c *Confirm) DescriptionFunc(f func() string, bindings any) *Confirm {
+ c.description.fn = f
+ c.description.bindings = bindings
return c
}
@@ -119,13 +144,13 @@ func (c *Confirm) Focus() tea.Cmd {
// Blur blurs the confirm field.
func (c *Confirm) Blur() tea.Cmd {
c.focused = false
- c.err = c.validate(*c.value)
+ c.err = c.validate(c.accessor.Get())
return nil
}
// KeyBinds returns the help message for the confirm field.
func (c *Confirm) KeyBinds() []key.Binding {
- return []key.Binding{c.keymap.Toggle, c.keymap.Prev, c.keymap.Submit, c.keymap.Next}
+ return []key.Binding{c.keymap.Toggle, c.keymap.Prev, c.keymap.Submit, c.keymap.Next, c.keymap.Accept, c.keymap.Reject}
}
// Init initializes the confirm field.
@@ -138,6 +163,36 @@ func (c *Confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
+ case updateFieldMsg:
+ if ok, hash := c.title.shouldUpdate(); ok {
+ c.title.bindingsHash = hash
+ if !c.title.loadFromCache() {
+ c.title.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateTitleMsg{id: c.id, title: c.title.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := c.description.shouldUpdate(); ok {
+ c.description.bindingsHash = hash
+ if !c.description.loadFromCache() {
+ c.description.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateDescriptionMsg{id: c.id, description: c.description.fn(), hash: hash}
+ })
+ }
+ }
+
+ case updateTitleMsg:
+ if msg.id == c.id && msg.hash == c.title.bindingsHash {
+ c.title.val = msg.title
+ c.title.loading = false
+ }
+ case updateDescriptionMsg:
+ if msg.id == c.id && msg.hash == c.description.bindingsHash {
+ c.description.val = msg.description
+ c.description.loading = false
+ }
case tea.KeyMsg:
c.err = nil
switch {
@@ -145,12 +200,17 @@ func (c *Confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c.negative == "" {
break
}
- v := !*c.value
- *c.value = v
+ c.accessor.Set(!c.accessor.Get())
case key.Matches(msg, c.keymap.Prev):
cmds = append(cmds, PrevField)
case key.Matches(msg, c.keymap.Next, c.keymap.Submit):
cmds = append(cmds, NextField)
+ case key.Matches(msg, c.keymap.Accept):
+ c.accessor.Set(true)
+ cmds = append(cmds, NextField)
+ case key.Matches(msg, c.keymap.Reject):
+ c.accessor.Set(false)
+ cmds = append(cmds, NextField)
}
}
@@ -173,14 +233,14 @@ func (c *Confirm) View() string {
styles := c.activeStyles()
var sb strings.Builder
- sb.WriteString(styles.Title.Render(c.title))
+ sb.WriteString(styles.Title.Render(c.title.val))
if c.err != nil {
sb.WriteString(styles.ErrorIndicator.String())
}
- description := styles.Description.Render(c.description)
+ description := styles.Description.Render(c.description.val)
- if !c.inline && c.description != "" {
+ if !c.inline && (c.description.val != "" || c.description.fn != nil) {
sb.WriteString("\n")
}
sb.WriteString(description)
@@ -193,18 +253,34 @@ func (c *Confirm) View() string {
var negative string
var affirmative string
if c.negative != "" {
- if *c.value {
+ if c.accessor.Get() {
affirmative = styles.FocusedButton.Render(c.affirmative)
negative = styles.BlurredButton.Render(c.negative)
} else {
affirmative = styles.BlurredButton.Render(c.affirmative)
negative = styles.FocusedButton.Render(c.negative)
}
+ c.keymap.Reject.SetHelp("n", c.negative)
} else {
affirmative = styles.FocusedButton.Render(c.affirmative)
+ c.keymap.Reject.SetEnabled(false)
}
- sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, affirmative, negative))
+ c.keymap.Accept.SetHelp("y", c.affirmative)
+
+ buttonsRow := lipgloss.JoinHorizontal(lipgloss.Center, affirmative, negative)
+
+ promptWidth := lipgloss.Width(sb.String())
+ buttonsWidth := lipgloss.Width(buttonsRow)
+
+ renderWidth := promptWidth
+ if buttonsWidth > renderWidth {
+ renderWidth = buttonsWidth
+ }
+
+ style := lipgloss.NewStyle().Width(renderWidth).Align(lipgloss.Center)
+
+ sb.WriteString(style.Render(buttonsRow))
return styles.Base.Render(sb.String())
}
@@ -219,15 +295,15 @@ func (c *Confirm) Run() error {
// runAccessible runs the confirm field in accessible mode.
func (c *Confirm) runAccessible() error {
styles := c.activeStyles()
- fmt.Println(styles.Title.Render(c.title))
+ fmt.Println(styles.Title.Render(c.title.val))
fmt.Println()
- *c.value = accessibility.PromptBool()
+ c.accessor.Set(accessibility.PromptBool())
fmt.Println(styles.SelectedOption.Render("Chose: "+c.String()) + "\n")
return nil
}
func (c *Confirm) String() string {
- if *c.value {
+ if c.accessor.Get() {
return c.affirmative
}
return c.negative
@@ -281,5 +357,5 @@ func (c *Confirm) GetKey() string {
// GetValue returns the value of the field.
func (c *Confirm) GetValue() any {
- return *c.value
+ return c.accessor.Get()
}
diff --git a/vendor/github.com/charmbracelet/huh/field_filepicker.go b/vendor/github.com/charmbracelet/huh/field_filepicker.go
index 340407b4..5f1bb5ce 100644
--- a/vendor/github.com/charmbracelet/huh/field_filepicker.go
+++ b/vendor/github.com/charmbracelet/huh/field_filepicker.go
@@ -17,9 +17,9 @@ import (
// FilePicker is a form file file field.
type FilePicker struct {
- value *string
- key string
- picker filepicker.Model
+ accessor Accessor[string]
+ key string
+ picker filepicker.Model
// state
focused bool
@@ -52,7 +52,7 @@ func NewFilePicker() *FilePicker {
}
return &FilePicker{
- value: new(string),
+ accessor: &EmbeddedAccessor[string]{},
validate: func(string) error { return nil },
picker: fp,
}
@@ -61,6 +61,9 @@ func NewFilePicker() *FilePicker {
// CurrentDirectory sets the directory of the file field.
func (f *FilePicker) CurrentDirectory(directory string) *FilePicker {
f.picker.CurrentDirectory = directory
+ if cmd := f.picker.Init(); cmd != nil {
+ f.picker, _ = f.picker.Update(cmd())
+ }
return f
}
@@ -102,7 +105,12 @@ func (f *FilePicker) DirAllowed(v bool) *FilePicker {
// Value sets the value of the file field.
func (f *FilePicker) Value(value *string) *FilePicker {
- f.value = value
+ return f.Accessor(NewPointerAccessor(value))
+}
+
+// Accessor sets the accessor of the file field.
+func (f *FilePicker) Accessor(accessor Accessor[string]) *FilePicker {
+ f.accessor = accessor
return f
}
@@ -178,7 +186,7 @@ func (f *FilePicker) Focus() tea.Cmd {
func (f *FilePicker) Blur() tea.Cmd {
f.focused = false
f.setPicking(false)
- f.err = f.validate(*f.value)
+ f.err = f.validate(f.accessor.Get())
return nil
}
@@ -207,7 +215,7 @@ func (f *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return f, f.picker.Init()
case key.Matches(msg, f.keymap.Close):
f.setPicking(false)
- return f, nil
+ return f, NextField
case key.Matches(msg, f.keymap.Next):
f.setPicking(false)
return f, NextField
@@ -221,7 +229,7 @@ func (f *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.picker, cmd = f.picker.Update(msg)
didSelect, file := f.picker.DidSelectFile(msg)
if didSelect {
- *f.value = file
+ f.accessor.Set(file)
f.setPicking(false)
return f, NextField
}
@@ -259,8 +267,8 @@ func (f *FilePicker) View() string {
if f.picking {
sb.WriteString(strings.TrimSuffix(f.picker.View(), "\n"))
} else {
- if *f.value != "" {
- sb.WriteString(styles.SelectedOption.Render(*f.value))
+ if f.accessor.Get() != "" {
+ sb.WriteString(styles.SelectedOption.Render(f.accessor.Get()))
} else {
sb.WriteString(styles.TextInput.Placeholder.Render("No file selected."))
}
@@ -320,8 +328,8 @@ func (f *FilePicker) runAccessible() error {
return f.validate(s)
}
- *f.value = accessibility.PromptString("File: ", validateFile)
- fmt.Println(styles.SelectedOption.Render(*f.value + "\n"))
+ f.accessor.Set(accessibility.PromptString("File: ", validateFile))
+ fmt.Println(styles.SelectedOption.Render(f.accessor.Get() + "\n"))
return nil
}
@@ -403,5 +411,5 @@ func (f *FilePicker) GetKey() string {
// GetValue returns the value of the field.
func (f *FilePicker) GetValue() any {
- return *f.value
+ return f.accessor.Get()
}
diff --git a/vendor/github.com/charmbracelet/huh/field_input.go b/vendor/github.com/charmbracelet/huh/field_input.go
index ad0b06b1..3510d527 100644
--- a/vendor/github.com/charmbracelet/huh/field_input.go
+++ b/vendor/github.com/charmbracelet/huh/field_input.go
@@ -11,42 +11,55 @@ import (
"github.com/charmbracelet/lipgloss"
)
-// Input is a form input field.
+// Input is a input field.
+//
+// The input field is a field that allows the user to enter text. Use it to user
+// input. It can be used for collecting text, passwords, or other short input.
+//
+// The input field supports Suggestions, Placeholder, and Validation.
type Input struct {
- value *string
- key string
+ accessor Accessor[string]
+ key string
+ id int
- // customization
- title string
- description string
- inline bool
-
- // error handling
- validate func(string) error
- err error
+ title Eval[string]
+ description Eval[string]
+ placeholder Eval[string]
+ suggestions Eval[[]string]
- // model
textinput textinput.Model
- // state
- focused bool
+ inline bool
+ validate func(string) error
+ err error
+ focused bool
- // options
- width int
- height int
accessible bool
- theme *Theme
- keymap InputKeyMap
+ width int
+ height int // not really used anywhere
+
+ theme *Theme
+ keymap InputKeyMap
}
-// NewInput returns a new input field.
+// NewInput creates a new input field.
+//
+// The input field is a field that allows the user to enter text. Use it to user
+// input. It can be used for collecting text, passwords, or other short input.
+//
+// The input field supports Suggestions, Placeholder, and Validation.
func NewInput() *Input {
input := textinput.New()
i := &Input{
- value: new(string),
- textinput: input,
- validate: func(string) error { return nil },
+ accessor: &EmbeddedAccessor[string]{},
+ textinput: input,
+ validate: func(string) error { return nil },
+ id: nextID(),
+ title: Eval[string]{cache: make(map[uint64]string)},
+ description: Eval[string]{cache: make(map[uint64]string)},
+ placeholder: Eval[string]{cache: make(map[uint64]string)},
+ suggestions: Eval[[]string]{cache: make(map[uint64][]string)},
}
return i
@@ -54,8 +67,13 @@ func NewInput() *Input {
// Value sets the value of the input field.
func (i *Input) Value(value *string) *Input {
- i.value = value
- i.textinput.SetValue(*value)
+ return i.Accessor(NewPointerAccessor(value))
+}
+
+// Accessor sets the accessor of the input field.
+func (i *Input) Accessor(accessor Accessor[string]) *Input {
+ i.accessor = accessor
+ i.textinput.SetValue(i.accessor.Get())
return i
}
@@ -66,14 +84,46 @@ func (i *Input) Key(key string) *Input {
}
// Title sets the title of the input field.
+//
+// The Title is static for dynamic Title use `TitleFunc`.
func (i *Input) Title(title string) *Input {
- i.title = title
+ i.title.val = title
+ i.title.fn = nil
return i
}
// Description sets the description of the input field.
+//
+// The Description is static for dynamic Description use `DescriptionFunc`.
func (i *Input) Description(description string) *Input {
- i.description = description
+ i.description.val = description
+ i.description.fn = nil
+ return i
+}
+
+// TitleFunc sets the title func of the input field.
+//
+// The TitleFunc will be re-evaluated when the binding of the TitleFunc changes.
+// This is useful when you want to display dynamic content and update the title
+// when another part of your form changes.
+//
+// See README#Dynamic for more usage information.
+func (i *Input) TitleFunc(f func() string, bindings any) *Input {
+ i.title.fn = f
+ i.title.bindings = bindings
+ return i
+}
+
+// DescriptionFunc sets the description func of the input field.
+//
+// The DescriptionFunc will be re-evaluated when the binding of the
+// DescriptionFunc changes. This is useful when you want to display dynamic
+// content and update the description when another part of your form changes.
+//
+// See README#Dynamic for more usage information.
+func (i *Input) DescriptionFunc(f func() string, bindings any) *Input {
+ i.description.fn = f
+ i.description.bindings = bindings
return i
}
@@ -91,13 +141,35 @@ func (i *Input) CharLimit(charlimit int) *Input {
// Suggestions sets the suggestions to display for autocomplete in the input
// field.
+//
+// The suggestions are static for dynamic suggestions use `SuggestionsFunc`.
func (i *Input) Suggestions(suggestions []string) *Input {
+ i.suggestions.fn = nil
+
i.textinput.ShowSuggestions = len(suggestions) > 0
i.textinput.KeyMap.AcceptSuggestion.SetEnabled(len(suggestions) > 0)
i.textinput.SetSuggestions(suggestions)
return i
}
+// SuggestionsFunc sets the suggestions func to display for autocomplete in the
+// input field.
+//
+// The SuggestionsFunc will be re-evaluated when the binding of the
+// SuggestionsFunc changes. This is useful when you want to display dynamic
+// suggestions when another part of your form changes.
+//
+// See README#Dynamic for more usage information.
+func (i *Input) SuggestionsFunc(f func() []string, bindings any) *Input {
+ i.suggestions.fn = f
+ i.suggestions.bindings = bindings
+ i.suggestions.loading = true
+
+ i.textinput.KeyMap.AcceptSuggestion.SetEnabled(f != nil)
+ i.textinput.ShowSuggestions = f != nil
+ return i
+}
+
// EchoMode sets the input behavior of the text Input field.
type EchoMode textinput.EchoMode
@@ -139,6 +211,13 @@ func (i *Input) Placeholder(str string) *Input {
return i
}
+// PlaceholderFunc sets the placeholder func of the text input.
+func (i *Input) PlaceholderFunc(f func() string, bindings any) *Input {
+ i.placeholder.fn = f
+ i.placeholder.bindings = bindings
+ return i
+}
+
// Inline sets whether the title and input should be on the same line.
func (i *Input) Inline(inline bool) *Input {
i.inline = inline
@@ -152,19 +231,13 @@ func (i *Input) Validate(validate func(string) error) *Input {
}
// Error returns the error of the input field.
-func (i *Input) Error() error {
- return i.err
-}
+func (i *Input) Error() error { return i.err }
// Skip returns whether the input should be skipped or should be blocking.
-func (*Input) Skip() bool {
- return false
-}
+func (*Input) Skip() bool { return false }
// Zoom returns whether the input should be zoomed.
-func (*Input) Zoom() bool {
- return false
-}
+func (*Input) Zoom() bool { return false }
// Focus focuses the input field.
func (i *Input) Focus() tea.Cmd {
@@ -175,9 +248,9 @@ func (i *Input) Focus() tea.Cmd {
// Blur blurs the input field.
func (i *Input) Blur() tea.Cmd {
i.focused = false
- *i.value = i.textinput.Value()
+ i.accessor.Set(i.textinput.Value())
i.textinput.Blur()
- i.err = i.validate(*i.value)
+ i.err = i.validate(i.accessor.Get())
return nil
}
@@ -200,11 +273,70 @@ func (i *Input) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
- i.textinput, cmd = i.textinput.Update(msg)
- cmds = append(cmds, cmd)
- *i.value = i.textinput.Value()
-
switch msg := msg.(type) {
+ case updateFieldMsg:
+ var cmds []tea.Cmd
+ if ok, hash := i.title.shouldUpdate(); ok {
+ i.title.bindingsHash = hash
+ if !i.title.loadFromCache() {
+ i.title.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateTitleMsg{id: i.id, title: i.title.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := i.description.shouldUpdate(); ok {
+ i.description.bindingsHash = hash
+ if !i.description.loadFromCache() {
+ i.description.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateDescriptionMsg{id: i.id, description: i.description.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := i.placeholder.shouldUpdate(); ok {
+ i.placeholder.bindingsHash = hash
+ if i.placeholder.loadFromCache() {
+ i.textinput.Placeholder = i.placeholder.val
+ } else {
+ i.placeholder.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updatePlaceholderMsg{id: i.id, placeholder: i.placeholder.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := i.suggestions.shouldUpdate(); ok {
+ i.suggestions.bindingsHash = hash
+ if i.suggestions.loadFromCache() {
+ i.textinput.ShowSuggestions = len(i.suggestions.val) > 0
+ i.textinput.SetSuggestions(i.suggestions.val)
+ } else {
+ i.suggestions.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateSuggestionsMsg{id: i.id, suggestions: i.suggestions.fn(), hash: hash}
+ })
+ }
+ }
+ return i, tea.Batch(cmds...)
+ case updateTitleMsg:
+ if i.id == msg.id && i.title.bindingsHash == msg.hash {
+ i.title.update(msg.title)
+ }
+ case updateDescriptionMsg:
+ if i.id == msg.id && i.description.bindingsHash == msg.hash {
+ i.description.update(msg.description)
+ }
+ case updatePlaceholderMsg:
+ if i.id == msg.id && i.placeholder.bindingsHash == msg.hash {
+ i.placeholder.update(msg.placeholder)
+ i.textinput.Placeholder = msg.placeholder
+ }
+ case updateSuggestionsMsg:
+ if i.id == msg.id && i.suggestions.bindingsHash == msg.hash {
+ i.suggestions.update(msg.suggestions)
+ i.textinput.ShowSuggestions = len(msg.suggestions) > 0
+ i.textinput.SetSuggestions(msg.suggestions)
+ }
case tea.KeyMsg:
i.err = nil
@@ -226,6 +358,10 @@ func (i *Input) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+ i.textinput, cmd = i.textinput.Update(msg)
+ cmds = append(cmds, cmd)
+ i.accessor.Set(i.textinput.Value())
+
return i, tea.Batch(cmds...)
}
@@ -250,17 +386,23 @@ func (i *Input) View() string {
i.textinput.PlaceholderStyle = styles.TextInput.Placeholder
i.textinput.PromptStyle = styles.TextInput.Prompt
i.textinput.Cursor.Style = styles.TextInput.Cursor
+ i.textinput.Cursor.TextStyle = styles.TextInput.CursorText
i.textinput.TextStyle = styles.TextInput.Text
+ // Adjust text input size to its char limit if it fit in its width
+ if i.textinput.CharLimit > 0 {
+ i.textinput.Width = min(i.textinput.CharLimit, i.textinput.Width)
+ }
+
var sb strings.Builder
- if i.title != "" {
- sb.WriteString(styles.Title.Render(i.title))
+ if i.title.val != "" || i.title.fn != nil {
+ sb.WriteString(styles.Title.Render(i.title.val))
if !i.inline {
sb.WriteString("\n")
}
}
- if i.description != "" {
- sb.WriteString(styles.Description.Render(i.description))
+ if i.description.val != "" || i.description.fn != nil {
+ sb.WriteString(styles.Description.Render(i.description.val))
if !i.inline {
sb.WriteString("\n")
}
@@ -286,10 +428,10 @@ func (i *Input) run() error {
// runAccessible runs the input field in accessible mode.
func (i *Input) runAccessible() error {
styles := i.activeStyles()
- fmt.Println(styles.Title.Render(i.title))
+ fmt.Println(styles.Title.Render(i.title.val))
fmt.Println()
- *i.value = accessibility.PromptString("Input: ", i.validate)
- fmt.Println(styles.SelectedOption.Render("Input: " + *i.value + "\n"))
+ i.accessor.Set(accessibility.PromptString("Input: ", i.validate))
+ fmt.Println(styles.SelectedOption.Render("Input: " + i.accessor.Get() + "\n"))
return nil
}
@@ -321,8 +463,8 @@ func (i *Input) WithWidth(width int) Field {
i.width = width
frameSize := styles.Base.GetHorizontalFrameSize()
promptWidth := lipgloss.Width(i.textinput.PromptStyle.Render(i.textinput.Prompt))
- titleWidth := lipgloss.Width(styles.Title.Render(i.title))
- descriptionWidth := lipgloss.Width(styles.Description.Render(i.description))
+ titleWidth := lipgloss.Width(styles.Title.Render(i.title.val))
+ descriptionWidth := lipgloss.Width(styles.Description.Render(i.description.val))
i.textinput.Width = width - frameSize - promptWidth - 1
if i.inline {
i.textinput.Width -= titleWidth
@@ -346,11 +488,9 @@ func (i *Input) WithPosition(p FieldPosition) Field {
}
// GetKey returns the key of the field.
-func (i *Input) GetKey() string {
- return i.key
-}
+func (i *Input) GetKey() string { return i.key }
// GetValue returns the value of the field.
func (i *Input) GetValue() any {
- return *i.value
+ return i.accessor.Get()
}
diff --git a/vendor/github.com/charmbracelet/huh/field_multiselect.go b/vendor/github.com/charmbracelet/huh/field_multiselect.go
index a9fb70a5..faa33ee5 100644
--- a/vendor/github.com/charmbracelet/huh/field_multiselect.go
+++ b/vendor/github.com/charmbracelet/huh/field_multiselect.go
@@ -3,8 +3,10 @@ package huh
import (
"fmt"
"strings"
+ "time"
"github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -14,13 +16,14 @@ import (
// MultiSelect is a form multi-select field.
type MultiSelect[T comparable] struct {
- value *[]T
- key string
+ accessor Accessor[[]T]
+ key string
+ id int
// customization
- title string
- description string
- options []Option[T]
+ title Eval[string]
+ description Eval[string]
+ options Eval[[]Option[T]]
filterable bool
filteredOptions []Option[T]
limit int
@@ -36,6 +39,7 @@ type MultiSelect[T comparable] struct {
filtering bool
filter textinput.Model
viewport viewport.Model
+ spinner spinner.Model
// options
width int
@@ -49,22 +53,34 @@ func NewMultiSelect[T comparable]() *MultiSelect[T] {
filter := textinput.New()
filter.Prompt = "/"
+ s := spinner.New(spinner.WithSpinner(spinner.Line))
+
return &MultiSelect[T]{
- options: []Option[T]{},
- value: new([]T),
- validate: func([]T) error { return nil },
- filtering: false,
- filter: filter,
+ accessor: &EmbeddedAccessor[[]T]{},
+ validate: func([]T) error { return nil },
+ filtering: false,
+ filter: filter,
+ id: nextID(),
+ options: Eval[[]Option[T]]{cache: make(map[uint64][]Option[T])},
+ title: Eval[string]{cache: make(map[uint64]string)},
+ description: Eval[string]{cache: make(map[uint64]string)},
+ spinner: s,
+ filterable: true,
}
}
// Value sets the value of the multi-select field.
func (m *MultiSelect[T]) Value(value *[]T) *MultiSelect[T] {
- m.value = value
- for i, o := range m.options {
- for _, v := range *value {
+ return m.Accessor(NewPointerAccessor(value))
+}
+
+// Accessor sets the accessor of the input field.
+func (m *MultiSelect[T]) Accessor(accessor Accessor[[]T]) *MultiSelect[T] {
+ m.accessor = accessor
+ for i, o := range m.options.val {
+ for _, v := range m.accessor.Get() {
if o.Value == v {
- m.options[i].selected = true
+ m.options.val[i].selected = true
break
}
}
@@ -81,13 +97,28 @@ func (m *MultiSelect[T]) Key(key string) *MultiSelect[T] {
// Title sets the title of the multi-select field.
func (m *MultiSelect[T]) Title(title string) *MultiSelect[T] {
- m.title = title
+ m.title.val = title
+ m.title.fn = nil
+ return m
+}
+
+// TitleFunc sets the title func of the multi-select field.
+func (m *MultiSelect[T]) TitleFunc(f func() string, bindings any) *MultiSelect[T] {
+ m.title.fn = f
+ m.title.bindings = bindings
return m
}
// Description sets the description of the multi-select field.
func (m *MultiSelect[T]) Description(description string) *MultiSelect[T] {
- m.description = description
+ m.description.val = description
+ return m
+}
+
+// DescriptionFunc sets the description func of the multi-select field.
+func (m *MultiSelect[T]) DescriptionFunc(f func() string, bindings any) *MultiSelect[T] {
+ m.description.fn = f
+ m.description.bindings = bindings
return m
}
@@ -98,28 +129,50 @@ func (m *MultiSelect[T]) Options(options ...Option[T]) *MultiSelect[T] {
}
for i, o := range options {
- for _, v := range *m.value {
+ for _, v := range m.accessor.Get() {
if o.Value == v {
options[i].selected = true
break
}
}
}
- m.options = options
+ m.options.val = options
m.filteredOptions = options
m.updateViewportHeight()
return m
}
+// OptionsFunc sets the options func of the multi-select field.
+func (m *MultiSelect[T]) OptionsFunc(f func() []Option[T], bindings any) *MultiSelect[T] {
+ m.options.fn = f
+ m.options.bindings = bindings
+ m.filteredOptions = make([]Option[T], 0)
+ // If there is no height set, we should attach a static height since these
+ // options are possibly dynamic.
+ if m.height <= 0 {
+ m.height = defaultHeight
+ m.updateViewportHeight()
+ }
+ return m
+}
+
// Filterable sets the multi-select field as filterable.
func (m *MultiSelect[T]) Filterable(filterable bool) *MultiSelect[T] {
m.filterable = filterable
return m
}
+// Filtering sets the filtering state of the multi-select field.
+func (m *MultiSelect[T]) Filtering(filtering bool) *MultiSelect[T] {
+ m.filtering = filtering
+ m.filter.Focus()
+ return m
+}
+
// Limit sets the limit of the multi-select field.
func (m *MultiSelect[T]) Limit(limit int) *MultiSelect[T] {
m.limit = limit
+ m.setSelectAllHelp()
return m
}
@@ -155,29 +208,42 @@ func (*MultiSelect[T]) Zoom() bool {
// Focus focuses the multi-select field.
func (m *MultiSelect[T]) Focus() tea.Cmd {
+ m.updateValue()
m.focused = true
return nil
}
// Blur blurs the multi-select field.
func (m *MultiSelect[T]) Blur() tea.Cmd {
+ m.updateValue()
m.focused = false
return nil
}
// KeyBinds returns the help message for the multi-select field.
func (m *MultiSelect[T]) KeyBinds() []key.Binding {
- return []key.Binding{
+ binds := []key.Binding{
m.keymap.Toggle,
m.keymap.Up,
m.keymap.Down,
- m.keymap.Filter,
- m.keymap.SetFilter,
- m.keymap.ClearFilter,
+ }
+ if m.filterable {
+ binds = append(
+ binds,
+ m.keymap.Filter,
+ m.keymap.SetFilter,
+ m.keymap.ClearFilter,
+ )
+ }
+ binds = append(
+ binds,
m.keymap.Prev,
m.keymap.Submit,
m.keymap.Next,
- }
+ m.keymap.SelectAll,
+ m.keymap.SelectNone,
+ )
+ return binds
}
// Init initializes the multi-select field.
@@ -187,6 +253,8 @@ func (m *MultiSelect[T]) Init() tea.Cmd {
// Update updates the multi-select field.
func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
// Enforce height on the viewport during update as we need themes to
// be applied before we can calculate the height.
m.updateViewportHeight()
@@ -194,13 +262,73 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if m.filtering {
m.filter, cmd = m.filter.Update(msg)
+ m.setSelectAllHelp()
+ cmds = append(cmds, cmd)
}
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case updateFieldMsg:
+ var fieldCmds []tea.Cmd
+ if ok, hash := m.title.shouldUpdate(); ok {
+ m.title.bindingsHash = hash
+ if !m.title.loadFromCache() {
+ m.title.loading = true
+ fieldCmds = append(fieldCmds, func() tea.Msg {
+ return updateTitleMsg{id: m.id, title: m.title.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := m.description.shouldUpdate(); ok {
+ m.description.bindingsHash = hash
+ if !m.description.loadFromCache() {
+ m.description.loading = true
+ fieldCmds = append(fieldCmds, func() tea.Msg {
+ return updateDescriptionMsg{id: m.id, description: m.description.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := m.options.shouldUpdate(); ok {
+ m.options.bindingsHash = hash
+ if m.options.loadFromCache() {
+ m.filteredOptions = m.options.val
+ m.updateValue()
+ m.cursor = clamp(m.cursor, 0, len(m.filteredOptions)-1)
+ } else {
+ m.options.loading = true
+ m.options.loadingStart = time.Now()
+ fieldCmds = append(fieldCmds, func() tea.Msg {
+ return updateOptionsMsg[T]{id: m.id, options: m.options.fn(), hash: hash}
+ }, m.spinner.Tick)
+ }
+ }
- m.err = nil
+ return m, tea.Batch(fieldCmds...)
+ case spinner.TickMsg:
+ if !m.options.loading {
+ break
+ }
+ m.spinner, cmd = m.spinner.Update(msg)
+ return m, cmd
+
+ case updateTitleMsg:
+ if msg.id == m.id && msg.hash == m.title.bindingsHash {
+ m.title.update(msg.title)
+ }
+ case updateDescriptionMsg:
+ if msg.id == m.id && msg.hash == m.description.bindingsHash {
+ m.description.update(msg.description)
+ }
+ case updateOptionsMsg[T]:
+ if msg.id == m.id && msg.hash == m.options.bindingsHash {
+ m.options.update(msg.options)
+ // since we're updating the options, we need to reset the cursor.
+ m.filteredOptions = m.options.val
+ m.updateValue()
+ m.cursor = clamp(m.cursor, 0, len(m.filteredOptions)-1)
+ }
+ case tea.KeyMsg:
+ m.err = nil
switch {
case key.Matches(msg, m.keymap.Filter):
m.setFilter(true)
@@ -208,14 +336,15 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keymap.SetFilter):
if len(m.filteredOptions) <= 0 {
m.filter.SetValue("")
- m.filteredOptions = m.options
+ m.filteredOptions = m.options.val
}
m.setFilter(false)
case key.Matches(msg, m.keymap.ClearFilter):
m.filter.SetValue("")
- m.filteredOptions = m.options
+ m.filteredOptions = m.options.val
m.setFilter(false)
case key.Matches(msg, m.keymap.Up):
+ // FIXME: should use keys in keymap
if m.filtering && msg.String() == "k" {
break
}
@@ -225,6 +354,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetYOffset(m.cursor)
}
case key.Matches(msg, m.keymap.Down):
+ // FIXME: should use keys in keymap
if m.filtering && msg.String() == "j" {
break
}
@@ -251,25 +381,50 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keymap.HalfPageDown):
m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1)
m.viewport.HalfViewDown()
- case key.Matches(msg, m.keymap.Toggle):
- for i, option := range m.options {
+ case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
+ for i, option := range m.options.val {
if option.Key == m.filteredOptions[m.cursor].Key {
- if !m.options[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit {
+ if !m.options.val[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit {
break
}
- selected := m.options[i].selected
- m.options[i].selected = !selected
+ selected := m.options.val[i].selected
+ m.options.val[i].selected = !selected
m.filteredOptions[m.cursor].selected = !selected
}
}
+ m.setSelectAllHelp()
+ m.updateValue()
+ case key.Matches(msg, m.keymap.SelectAll, m.keymap.SelectNone) && m.limit <= 0:
+ selected := false
+
+ for _, option := range m.filteredOptions {
+ if !option.selected {
+ selected = true
+ break
+ }
+ }
+
+ for i, option := range m.options.val {
+ for j := range m.filteredOptions {
+ if option.Key == m.filteredOptions[j].Key {
+ m.options.val[i].selected = selected
+ m.filteredOptions[j].selected = selected
+ break
+ }
+ }
+ }
+ m.setSelectAllHelp()
+ m.updateValue()
case key.Matches(msg, m.keymap.Prev):
- m.finalize()
+ m.updateValue()
+ m.err = m.validate(m.accessor.Get())
if m.err != nil {
return m, nil
}
return m, PrevField
case key.Matches(msg, m.keymap.Next, m.keymap.Submit):
- m.finalize()
+ m.updateValue()
+ m.err = m.validate(m.accessor.Get())
if m.err != nil {
return m, nil
}
@@ -277,10 +432,10 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.filtering {
- m.filteredOptions = m.options
+ m.filteredOptions = m.options.val
if m.filter.Value() != "" {
m.filteredOptions = nil
- for _, option := range m.options {
+ for _, option := range m.options.val {
if m.filterFunc(option.Key) {
m.filteredOptions = append(m.filteredOptions, option)
}
@@ -293,7 +448,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
- return m, cmd
+ return m, tea.Batch(cmds...)
}
// updateViewportHeight updates the viewport size according to the Height setting
@@ -301,7 +456,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *MultiSelect[T]) updateViewportHeight() {
// If no height is set size the viewport to the number of options.
if m.height <= 0 {
- m.viewport.Height = len(m.options)
+ m.viewport.Height = len(m.options.val)
return
}
@@ -311,9 +466,10 @@ func (m *MultiSelect[T]) updateViewportHeight() {
lipgloss.Height(m.descriptionView()))
}
+// numSelected returns the total number of selected options.
func (m *MultiSelect[T]) numSelected() int {
var count int
- for _, o := range m.options {
+ for _, o := range m.options.val {
if o.selected {
count++
}
@@ -321,14 +477,27 @@ func (m *MultiSelect[T]) numSelected() int {
return count
}
-func (m *MultiSelect[T]) finalize() {
- *m.value = make([]T, 0)
- for _, option := range m.options {
+// numFilteredOptionsSelected returns the number of selected options with the
+// current filter applied.
+func (m *MultiSelect[T]) numFilteredSelected() int {
+ var count int
+ for _, o := range m.filteredOptions {
+ if o.selected {
+ count++
+ }
+ }
+ return count
+}
+
+func (m *MultiSelect[T]) updateValue() {
+ value := make([]T, 0)
+ for _, option := range m.options.val {
if option.selected {
- *m.value = append(*m.value, option.Value)
+ value = append(value, option.Value)
}
}
- m.err = m.validate(*m.value)
+ m.accessor.Set(value)
+ m.err = m.validate(m.accessor.Get())
}
func (m *MultiSelect[T]) activeStyles() *FieldStyles {
@@ -343,7 +512,7 @@ func (m *MultiSelect[T]) activeStyles() *FieldStyles {
}
func (m *MultiSelect[T]) titleView() string {
- if m.title == "" {
+ if m.title.val == "" {
return ""
}
var (
@@ -353,9 +522,9 @@ func (m *MultiSelect[T]) titleView() string {
if m.filtering {
sb.WriteString(m.filter.View())
} else if m.filter.Value() != "" {
- sb.WriteString(styles.Title.Render(m.title) + styles.Description.Render("/"+m.filter.Value()))
+ sb.WriteString(styles.Title.Render(m.title.val) + styles.Description.Render("/"+m.filter.Value()))
} else {
- sb.WriteString(styles.Title.Render(m.title))
+ sb.WriteString(styles.Title.Render(m.title.val))
}
if m.err != nil {
sb.WriteString(styles.ErrorIndicator.String())
@@ -364,15 +533,22 @@ func (m *MultiSelect[T]) titleView() string {
}
func (m *MultiSelect[T]) descriptionView() string {
- return m.activeStyles().Description.Render(m.description)
+ return m.activeStyles().Description.Render(m.description.val)
}
-func (m *MultiSelect[T]) choicesView() string {
+func (m *MultiSelect[T]) optionsView() string {
var (
styles = m.activeStyles()
c = styles.MultiSelectSelector.String()
sb strings.Builder
)
+
+ if m.options.loading && time.Since(m.options.loadingStart) > spinnerShowThreshold {
+ m.spinner.Style = m.activeStyles().MultiSelectSelector.UnsetString()
+ sb.WriteString(m.spinner.View() + " Loading...")
+ return sb.String()
+ }
+
for i, option := range m.filteredOptions {
if m.cursor == i {
sb.WriteString(c)
@@ -387,12 +563,12 @@ func (m *MultiSelect[T]) choicesView() string {
sb.WriteString(styles.UnselectedPrefix.String())
sb.WriteString(styles.UnselectedOption.Render(option.Key))
}
- if i < len(m.options)-1 {
+ if i < len(m.options.val)-1 {
sb.WriteString("\n")
}
}
- for i := len(m.filteredOptions); i < len(m.options)-1; i++ {
+ for i := len(m.filteredOptions); i < len(m.options.val)-1; i++ {
sb.WriteString("\n")
}
@@ -402,14 +578,15 @@ func (m *MultiSelect[T]) choicesView() string {
// View renders the multi-select field.
func (m *MultiSelect[T]) View() string {
styles := m.activeStyles()
- m.viewport.SetContent(m.choicesView())
+
+ m.viewport.SetContent(m.optionsView())
var sb strings.Builder
- if m.title != "" {
+ if m.title.val != "" || m.title.fn != nil {
sb.WriteString(m.titleView())
sb.WriteString("\n")
}
- if m.description != "" {
+ if m.description.val != "" || m.description.fn != nil {
sb.WriteString(m.descriptionView() + "\n")
}
sb.WriteString(m.viewport.View())
@@ -419,11 +596,10 @@ func (m *MultiSelect[T]) View() string {
func (m *MultiSelect[T]) printOptions() {
styles := m.activeStyles()
var sb strings.Builder
-
- sb.WriteString(styles.Title.Render(m.title))
+ sb.WriteString(styles.Title.Render(m.title.val))
sb.WriteString("\n")
- for i, option := range m.options {
+ for i, option := range m.options.val {
if option.selected {
sb.WriteString(styles.SelectedOption.Render(fmt.Sprintf("%d. %s %s", i+1, "✓", option.Key)))
} else {
@@ -452,6 +628,17 @@ func (m *MultiSelect[T]) filterFunc(option string) bool {
return strings.Contains(strings.ToLower(option), strings.ToLower(m.filter.Value()))
}
+// setSelectAllHelp enables the appropriate select all or select none keybinding.
+func (m *MultiSelect[T]) setSelectAllHelp() {
+ if m.limit <= 0 {
+ noneSelected := m.numFilteredSelected() <= 0
+ allSelected := m.numFilteredSelected() > 0 && m.numFilteredSelected() < len(m.filteredOptions)
+ selectAll := noneSelected || allSelected
+ m.keymap.SelectAll.SetEnabled(selectAll)
+ m.keymap.SelectNone.SetEnabled(!selectAll)
+ }
+}
+
// Run runs the multi-select field.
func (m *MultiSelect[T]) Run() error {
if m.accessible {
@@ -469,10 +656,10 @@ func (m *MultiSelect[T]) runAccessible() error {
for {
fmt.Printf("Select up to %d options. 0 to continue.\n", m.limit)
- choice = accessibility.PromptInt("Select: ", 0, len(m.options))
+ choice = accessibility.PromptInt("Select: ", 0, len(m.options.val))
if choice == 0 {
- m.finalize()
- err := m.validate(*m.value)
+ m.updateValue()
+ err := m.validate(m.accessor.Get())
if err != nil {
fmt.Println(err)
continue
@@ -480,15 +667,15 @@ func (m *MultiSelect[T]) runAccessible() error {
break
}
- if !m.options[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit {
+ if !m.options.val[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit {
fmt.Printf("You can't select more than %d options.\n", m.limit)
continue
}
- m.options[choice-1].selected = !m.options[choice-1].selected
- if m.options[choice-1].selected {
- fmt.Printf("Selected: %s\n\n", m.options[choice-1].Key)
+ m.options.val[choice-1].selected = !m.options.val[choice-1].selected
+ if m.options.val[choice-1].selected {
+ fmt.Printf("Selected: %s\n\n", m.options.val[choice-1].Key)
} else {
- fmt.Printf("Deselected: %s\n\n", m.options[choice-1].Key)
+ fmt.Printf("Deselected: %s\n\n", m.options.val[choice-1].Key)
}
m.printOptions()
@@ -496,12 +683,14 @@ func (m *MultiSelect[T]) runAccessible() error {
var values []string
- for _, option := range m.options {
+ value := m.accessor.Get()
+ for _, option := range m.options.val {
if option.selected {
- *m.value = append(*m.value, option.Value)
+ value = append(value, option.Value)
values = append(values, option.Key)
}
}
+ m.accessor.Set(value)
fmt.Println(styles.SelectedOption.Render("Selected:", strings.Join(values, ", ")+"\n"))
return nil
@@ -513,8 +702,11 @@ func (m *MultiSelect[T]) WithTheme(theme *Theme) Field {
return m
}
m.theme = theme
- m.filter.Cursor.Style = m.theme.Focused.TextInput.Cursor
- m.filter.PromptStyle = m.theme.Focused.TextInput.Prompt
+ m.filter.Cursor.Style = theme.Focused.TextInput.Cursor
+ m.filter.Cursor.TextStyle = theme.Focused.TextInput.CursorText
+ m.filter.PromptStyle = theme.Focused.TextInput.Prompt
+ m.filter.TextStyle = theme.Focused.TextInput.Text
+ m.filter.PlaceholderStyle = theme.Focused.TextInput.Placeholder
m.updateViewportHeight()
return m
}
@@ -522,6 +714,11 @@ func (m *MultiSelect[T]) WithTheme(theme *Theme) Field {
// WithKeyMap sets the keymap of the multi-select field.
func (m *MultiSelect[T]) WithKeyMap(k *KeyMap) Field {
m.keymap = k.MultiSelect
+ if !m.filterable {
+ m.keymap.Filter.SetEnabled(false)
+ m.keymap.ClearFilter.SetEnabled(false)
+ m.keymap.SetFilter.SetEnabled(false)
+ }
return m
}
@@ -537,9 +734,10 @@ func (m *MultiSelect[T]) WithWidth(width int) Field {
return m
}
-// WithHeight sets the height of the multi-select field.
+// WithHeight sets the total height of the multi-select field. Including padding
+// and help menu heights.
func (m *MultiSelect[T]) WithHeight(height int) Field {
- m.height = height
+ m.Height(height)
return m
}
@@ -561,5 +759,5 @@ func (m *MultiSelect[T]) GetKey() string {
// GetValue returns the multi-select's value.
func (m *MultiSelect[T]) GetValue() any {
- return *m.value
+ return m.accessor.Get()
}
diff --git a/vendor/github.com/charmbracelet/huh/field_note.go b/vendor/github.com/charmbracelet/huh/field_note.go
index d8ad5885..c93125f0 100644
--- a/vendor/github.com/charmbracelet/huh/field_note.go
+++ b/vendor/github.com/charmbracelet/huh/field_note.go
@@ -8,48 +8,113 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
-// Note is a form note field.
+// Note is a note field.
+//
+// A note is responsible for displaying information to the user. Use it to
+// provide context around a different field. Generally, the notes are not
+// interacted with unless the note has a next button `Next(true)`.
type Note struct {
- // customization
- title string
- description string
+ id int
+
+ title Eval[string]
+ description Eval[string]
nextLabel string
- // state
- showNextButton bool
focused bool
+ showNextButton bool
+ skip bool
- // options
- skip bool
- width int
- height int
accessible bool
- theme *Theme
- keymap NoteKeyMap
+ height int
+ width int
+
+ theme *Theme
+ keymap NoteKeyMap
}
// NewNote creates a new note field.
+//
+// A note is responsible for displaying information to the user. Use it to
+// provide context around a different field. Generally, the notes are not
+// interacted with unless the note has a next button `Next(true)`.
func NewNote() *Note {
return &Note{
+ id: nextID(),
showNextButton: false,
skip: true,
nextLabel: "Next",
+ title: Eval[string]{cache: make(map[uint64]string)},
+ description: Eval[string]{cache: make(map[uint64]string)},
}
}
-// Title sets the title of the note field.
+// Title sets the note field's title.
+//
+// This title will be static, for dynamic titles use `TitleFunc`.
func (n *Note) Title(title string) *Note {
- n.title = title
+ n.title.val = title
+ n.title.fn = nil
+ return n
+}
+
+// TitleFunc sets the title func of the note field.
+//
+// The TitleFunc will be re-evaluated when the binding of the TitleFunc changes.
+// This is useful when you want to display dynamic content and update the title
+// of a note when another part of your form changes.
+//
+// See README.md#Dynamic for more usage information.
+func (n *Note) TitleFunc(f func() string, bindings any) *Note {
+ n.title.fn = f
+ n.title.bindings = bindings
return n
}
-// Description sets the description of the note field.
+// Description sets the note field's description.
+//
+// This description will be static, for dynamic descriptions use `DescriptionFunc`.
func (n *Note) Description(description string) *Note {
- n.description = description
+ n.description.val = description
+ n.description.fn = nil
+ return n
+}
+
+// DescriptionFunc sets the description func of the note field.
+//
+// The DescriptionFunc will be re-evaluated when the binding of the
+// DescriptionFunc changes. This is useful when you want to display dynamic
+// content and update the description of a note when another part of your form
+// changes.
+//
+// For example, you can make a dynamic markdown preview with the following Form & Group.
+//
+// huh.NewText().Title("Markdown").Value(&md),
+// huh.NewNote().Height(20).Title("Preview").
+// DescriptionFunc(func() string {
+// return md
+// }, &md),
+//
+// Notice the `binding` of the Note is the same as the `Value` of the Text field.
+// This binds the two values together, so that when the `Value` of the Text
+// field changes so does the Note description.
+func (n *Note) DescriptionFunc(f func() string, bindings any) *Note {
+ n.description.fn = f
+ n.description.bindings = bindings
+ return n
+}
+
+// Height sets the note field's height.
+func (n *Note) Height(height int) *Note {
+ n.height = height
return n
}
-// Next sets whether to show the next button.
+// Next sets whether or not to show the next button.
+//
+// Title
+// Description
+//
+// [ Next ]
func (n *Note) Next(show bool) *Note {
n.showNextButton = show
return n
@@ -74,33 +139,58 @@ func (n *Note) Blur() tea.Cmd {
}
// Error returns the error of the note field.
-func (n *Note) Error() error {
- return nil
-}
+func (n *Note) Error() error { return nil }
// Skip returns whether the note should be skipped or should be blocking.
-func (n *Note) Skip() bool {
- return n.skip
-}
+func (n *Note) Skip() bool { return n.skip }
// Zoom returns whether the note should be zoomed.
-func (n *Note) Zoom() bool {
- return false
-}
+func (n *Note) Zoom() bool { return false }
// KeyBinds returns the help message for the note field.
func (n *Note) KeyBinds() []key.Binding {
- return []key.Binding{n.keymap.Prev, n.keymap.Submit, n.keymap.Next}
+ return []key.Binding{
+ n.keymap.Prev,
+ n.keymap.Submit,
+ n.keymap.Next,
+ }
}
// Init initializes the note field.
-func (n *Note) Init() tea.Cmd {
- return nil
-}
+func (n *Note) Init() tea.Cmd { return nil }
// Update updates the note field.
func (n *Note) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case updateFieldMsg:
+ var cmds []tea.Cmd
+ if ok, hash := n.title.shouldUpdate(); ok {
+ n.title.bindingsHash = hash
+ if !n.title.loadFromCache() {
+ n.title.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateTitleMsg{id: n.id, title: n.title.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := n.description.shouldUpdate(); ok {
+ n.description.bindingsHash = hash
+ if !n.description.loadFromCache() {
+ n.description.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateDescriptionMsg{id: n.id, description: n.description.fn(), hash: hash}
+ })
+ }
+ }
+ return n, tea.Batch(cmds...)
+ case updateTitleMsg:
+ if msg.id == n.id && msg.hash == n.title.bindingsHash {
+ n.title.update(msg.title)
+ }
+ case updateDescriptionMsg:
+ if msg.id == n.id && msg.hash == n.description.bindingsHash {
+ n.description.update(msg.description)
+ }
case tea.KeyMsg:
switch {
case key.Matches(msg, n.keymap.Prev):
@@ -121,27 +211,25 @@ func (n *Note) activeStyles() *FieldStyles {
if n.focused {
return &theme.Focused
}
- return &theme.Focused
+ return &theme.Blurred
}
// View renders the note field.
func (n *Note) View() string {
- var (
- styles = n.activeStyles()
- sb strings.Builder
- )
+ styles := n.activeStyles()
+ sb := strings.Builder{}
- if n.title != "" {
- sb.WriteString(styles.NoteTitle.Render(n.title))
+ if n.title.val != "" || n.title.fn != nil {
+ sb.WriteString(styles.NoteTitle.Render(n.title.val))
}
- if n.description != "" {
+ if n.description.val != "" || n.description.fn != nil {
sb.WriteString("\n")
- sb.WriteString(render(n.description))
+ sb.WriteString(render(n.description.val))
}
if n.showNextButton {
sb.WriteString(styles.Next.Render(n.nextLabel))
}
- return styles.Card.Render(sb.String())
+ return styles.Card.Height(n.height).Render(sb.String())
}
// Run runs the note field.
@@ -154,15 +242,12 @@ func (n *Note) Run() error {
// runAccessible runs an accessible note field.
func (n *Note) runAccessible() error {
- var body string
-
- if n.title != "" {
- body = n.title + "\n\n"
+ if n.title.val != "" {
+ fmt.Println(n.title.val)
+ fmt.Println()
}
- body += n.description
-
- fmt.Println(body)
+ fmt.Println(n.description.val)
fmt.Println()
return nil
}
@@ -196,7 +281,7 @@ func (n *Note) WithWidth(width int) Field {
// WithHeight sets the height of the note field.
func (n *Note) WithHeight(height int) Field {
- n.height = height
+ n.Height(height)
return n
}
@@ -214,21 +299,25 @@ func (n *Note) WithPosition(p FieldPosition) Field {
}
// GetValue satisfies the Field interface, notes do not have values.
-func (n *Note) GetValue() any {
- return nil
-}
+func (n *Note) GetValue() any { return nil }
// GetKey satisfies the Field interface, notes do not have keys.
-func (n *Note) GetKey() string {
- return ""
-}
+func (n *Note) GetKey() string { return "" }
func render(input string) string {
var result strings.Builder
var italic, bold, codeblock bool
+ var escape bool
for _, char := range input {
+ if escape || codeblock {
+ result.WriteRune(char)
+ escape = false
+ continue
+ }
switch char {
+ case '\\':
+ escape = true
case '_':
if !italic {
result.WriteString("\033[3m")
diff --git a/vendor/github.com/charmbracelet/huh/field_select.go b/vendor/github.com/charmbracelet/huh/field_select.go
index 8e503b05..81e57d9e 100644
--- a/vendor/github.com/charmbracelet/huh/field_select.go
+++ b/vendor/github.com/charmbracelet/huh/field_select.go
@@ -3,8 +3,10 @@ package huh
import (
"fmt"
"strings"
+ "time"
"github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
@@ -12,60 +14,85 @@ import (
"github.com/charmbracelet/lipgloss"
)
-// Select is a form select field.
+const (
+ minHeight = 1
+ defaultHeight = 10
+)
+
+// Select is a select field.
+//
+// A select field is a field that allows the user to select from a list of
+// options. The options can be provided statically or dynamically using Options
+// or OptionsFunc. The options can be filtered using "/" and navigation is done
+// using j/k, up/down, or ctrl+n/ctrl+p keys.
type Select[T comparable] struct {
- value *T
+ id int
+ accessor Accessor[T]
key string
+
viewport viewport.Model
- // customization
- title string
- description string
- options []Option[T]
+ title Eval[string]
+ description Eval[string]
+ options Eval[[]Option[T]]
filteredOptions []Option[T]
- height int
- // error handling
validate func(T) error
err error
- // state
selected int
focused bool
filtering bool
filter textinput.Model
+ spinner spinner.Model
- // options
inline bool
width int
+ height int
accessible bool
theme *Theme
keymap SelectKeyMap
}
-// NewSelect returns a new select field.
+// NewSelect creates a new select field.
+//
+// A select field is a field that allows the user to select from a list of
+// options. The options can be provided statically or dynamically using Options
+// or OptionsFunc. The options can be filtered using "/" and navigation is done
+// using j/k, up/down, or ctrl+n/ctrl+p keys.
func NewSelect[T comparable]() *Select[T] {
filter := textinput.New()
filter.Prompt = "/"
+ s := spinner.New(spinner.WithSpinner(spinner.Line))
+
return &Select[T]{
- options: []Option[T]{},
- value: new(T),
- validate: func(T) error { return nil },
- filtering: false,
- filter: filter,
+ accessor: &EmbeddedAccessor[T]{},
+ validate: func(T) error { return nil },
+ filtering: false,
+ filter: filter,
+ options: Eval[[]Option[T]]{cache: make(map[uint64][]Option[T])},
+ title: Eval[string]{cache: make(map[uint64]string)},
+ description: Eval[string]{cache: make(map[uint64]string)},
+ spinner: s,
}
}
// Value sets the value of the select field.
func (s *Select[T]) Value(value *T) *Select[T] {
- s.value = value
- s.selectValue(*value)
+ return s.Accessor(NewPointerAccessor(value))
+}
+
+// Accessor sets the accessor of the select field.
+func (s *Select[T]) Accessor(accessor Accessor[T]) *Select[T] {
+ s.accessor = accessor
+ s.selectValue(s.accessor.Get())
+ s.updateValue()
return s
}
func (s *Select[T]) selectValue(value T) {
- for i, o := range s.options {
+ for i, o := range s.options.val {
if o.Value == value {
s.selected = i
break
@@ -81,28 +108,77 @@ func (s *Select[T]) Key(key string) *Select[T] {
}
// Title sets the title of the select field.
+//
+// This title will be static, for dynamic titles use `TitleFunc`.
func (s *Select[T]) Title(title string) *Select[T] {
- s.title = title
+ s.title.val = title
+ s.title.fn = nil
+ return s
+}
+
+// TitleFunc sets the title func of the select field.
+//
+// This TitleFunc will be re-evaluated when the binding of the TitleFunc
+// changes. This when you want to display dynamic content and update the title
+// when another part of your form changes.
+//
+// See README#Dynamic for more usage information.
+func (s *Select[T]) TitleFunc(f func() string, bindings any) *Select[T] {
+ s.title.fn = f
+ s.title.bindings = bindings
+ return s
+}
+
+// Filtering sets the filtering state of the select field.
+func (s *Select[T]) Filtering(filtering bool) *Select[T] {
+ s.filtering = filtering
+ s.filter.Focus()
return s
}
// Description sets the description of the select field.
+//
+// This description will be static, for dynamic descriptions use `DescriptionFunc`.
func (s *Select[T]) Description(description string) *Select[T] {
- s.description = description
+ s.description.val = description
+ return s
+}
+
+// DescriptionFunc sets the description func of the select field.
+//
+// This DescriptionFunc will be re-evaluated when the binding of the
+// DescriptionFunc changes. This is useful when you want to display dynamic
+// content and update the description when another part of your form changes.
+//
+// See README#Dynamic for more usage information.
+func (s *Select[T]) DescriptionFunc(f func() string, bindings any) *Select[T] {
+ s.description.fn = f
+ s.description.bindings = bindings
return s
}
// Options sets the options of the select field.
+//
+// This is what your user will select from.
+//
+// Title
+// Description
+//
+// -> Option 1
+// Option 2
+// Option 3
+//
+// These options will be static, for dynamic options use `OptionsFunc`.
func (s *Select[T]) Options(options ...Option[T]) *Select[T] {
if len(options) <= 0 {
return s
}
- s.options = options
+ s.options.val = options
s.filteredOptions = options
// Set the cursor to the existing value or the last selected option.
for i, option := range options {
- if option.Value == *s.value {
+ if option.Value == s.accessor.Get() {
s.selected = i
break
} else if option.selected {
@@ -111,7 +187,43 @@ func (s *Select[T]) Options(options ...Option[T]) *Select[T] {
}
s.updateViewportHeight()
+ s.updateValue()
+
+ return s
+}
+// OptionsFunc sets the options func of the select field.
+//
+// This OptionsFunc will be re-evaluated when the binding of the OptionsFunc
+// changes. This is useful when you want to display dynamic content and update
+// the options when another part of your form changes.
+//
+// For example, changing the state / provinces, based on the selected country.
+//
+// huh.NewSelect[string]().
+// Options(huh.NewOptions("United States", "Canada", "Mexico")...).
+// Value(&country).
+// Title("Country").
+// Height(5),
+//
+// huh.NewSelect[string]().
+// Title("State / Province"). // This can also be made dynamic with `TitleFunc`.
+// OptionsFunc(func() []huh.Option[string] {
+// s := states[country]
+// time.Sleep(1000 * time.Millisecond)
+// return huh.NewOptions(s...)
+// }, &country),
+//
+// See examples/dynamic/dynamic-country/main.go for the full example.
+func (s *Select[T]) OptionsFunc(f func() []Option[T], bindings any) *Select[T] {
+ s.options.fn = f
+ s.options.bindings = bindings
+ // If there is no height set, we should attach a static height since these
+ // options are possibly dynamic.
+ if s.height <= 0 {
+ s.height = defaultHeight
+ s.updateViewportHeight()
+ }
return s
}
@@ -128,8 +240,8 @@ func (s *Select[T]) Inline(v bool) *Select[T] {
return s
}
-// Height sets the height of the select field. If the number of options
-// exceeds the height, the select field will become scrollable.
+// Height sets the height of the select field. If the number of options exceeds
+// the height, the select field will become scrollable.
func (s *Select[T]) Height(height int) *Select[T] {
s.height = height
s.updateViewportHeight()
@@ -143,19 +255,13 @@ func (s *Select[T]) Validate(validate func(T) error) *Select[T] {
}
// Error returns the error of the select field.
-func (s *Select[T]) Error() error {
- return s.err
-}
+func (s *Select[T]) Error() error { return s.err }
// Skip returns whether the select should be skipped or should be blocking.
-func (*Select[T]) Skip() bool {
- return false
-}
+func (*Select[T]) Skip() bool { return false }
// Zoom returns whether the input should be zoomed.
-func (*Select[T]) Zoom() bool {
- return false
-}
+func (*Select[T]) Zoom() bool { return false }
// Focus focuses the select field.
func (s *Select[T]) Focus() tea.Cmd {
@@ -165,7 +271,7 @@ func (s *Select[T]) Focus() tea.Cmd {
// Blur blurs the select field.
func (s *Select[T]) Blur() tea.Cmd {
- value := *s.value
+ value := s.accessor.Get()
if s.inline {
s.clearFilter()
s.selectValue(value)
@@ -211,6 +317,67 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch msg := msg.(type) {
+ case updateFieldMsg:
+ var cmds []tea.Cmd
+ if ok, hash := s.title.shouldUpdate(); ok {
+ s.title.bindingsHash = hash
+ if !s.title.loadFromCache() {
+ s.title.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateTitleMsg{id: s.id, title: s.title.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := s.description.shouldUpdate(); ok {
+ s.description.bindingsHash = hash
+ if !s.description.loadFromCache() {
+ s.description.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateDescriptionMsg{id: s.id, description: s.description.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := s.options.shouldUpdate(); ok {
+ s.clearFilter()
+ s.options.bindingsHash = hash
+ if s.options.loadFromCache() {
+ s.filteredOptions = s.options.val
+ s.selected = clamp(s.selected, 0, len(s.options.val)-1)
+ } else {
+ s.options.loading = true
+ s.options.loadingStart = time.Now()
+ cmds = append(cmds, func() tea.Msg {
+ return updateOptionsMsg[T]{id: s.id, hash: hash, options: s.options.fn()}
+ }, s.spinner.Tick)
+ }
+ }
+ return s, tea.Batch(cmds...)
+
+ case spinner.TickMsg:
+ if !s.options.loading {
+ break
+ }
+ s.spinner, cmd = s.spinner.Update(msg)
+ return s, cmd
+
+ case updateTitleMsg:
+ if msg.id == s.id && msg.hash == s.title.bindingsHash {
+ s.title.update(msg.title)
+ }
+ case updateDescriptionMsg:
+ if msg.id == s.id && msg.hash == s.description.bindingsHash {
+ s.description.update(msg.description)
+ }
+ case updateOptionsMsg[T]:
+ if msg.id == s.id && msg.hash == s.options.bindingsHash {
+ s.options.update(msg.options)
+
+ // since we're updating the options, we need to update the selected cursor
+ // position and filteredOptions.
+ s.selected = clamp(s.selected, 0, len(msg.options)-1)
+ s.filteredOptions = msg.options
+ s.updateValue()
+ }
case tea.KeyMsg:
s.err = nil
switch {
@@ -220,7 +387,7 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, s.keymap.SetFilter):
if len(s.filteredOptions) <= 0 {
s.filter.SetValue("")
- s.filteredOptions = s.options
+ s.filteredOptions = s.options.val
}
s.setFiltering(false)
case key.Matches(msg, s.keymap.ClearFilter):
@@ -235,16 +402,22 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.filtering && (msg.String() == "k" || msg.String() == "h") {
break
}
- s.selected = max(s.selected-1, 0)
+ s.selected = s.selected - 1
+ if s.selected < 0 {
+ s.selected = len(s.filteredOptions) - 1
+ s.viewport.GotoBottom()
+ }
if s.selected < s.viewport.YOffset {
s.viewport.SetYOffset(s.selected)
}
+ s.updateValue()
case key.Matches(msg, s.keymap.GotoTop):
if s.filtering {
break
}
s.selected = 0
s.viewport.GotoTop()
+ s.updateValue()
case key.Matches(msg, s.keymap.GotoBottom):
if s.filtering {
break
@@ -254,9 +427,11 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, s.keymap.HalfPageUp):
s.selected = max(s.selected-s.viewport.Height/2, 0)
s.viewport.HalfViewUp()
+ s.updateValue()
case key.Matches(msg, s.keymap.HalfPageDown):
s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1)
s.viewport.HalfViewDown()
+ s.updateValue()
case key.Matches(msg, s.keymap.Down, s.keymap.Right):
// When filtering we should ignore j/k keybindings
//
@@ -264,40 +439,45 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.filtering && (msg.String() == "j" || msg.String() == "l") {
break
}
- s.selected = min(s.selected+1, len(s.filteredOptions)-1)
+ s.selected = s.selected + 1
+ if s.selected > len(s.filteredOptions)-1 {
+ s.selected = 0
+ s.viewport.GotoTop()
+ }
if s.selected >= s.viewport.YOffset+s.viewport.Height {
s.viewport.LineDown(1)
}
+ s.updateValue()
case key.Matches(msg, s.keymap.Prev):
if s.selected >= len(s.filteredOptions) {
break
}
- value := s.filteredOptions[s.selected].Value
- s.err = s.validate(value)
+ s.updateValue()
+ s.err = s.validate(s.accessor.Get())
if s.err != nil {
return s, nil
}
- *s.value = value
+ s.updateValue()
return s, PrevField
case key.Matches(msg, s.keymap.Next, s.keymap.Submit):
if s.selected >= len(s.filteredOptions) {
break
}
- value := s.filteredOptions[s.selected].Value
s.setFiltering(false)
- s.err = s.validate(value)
+ s.updateValue()
+ s.err = s.validate(s.accessor.Get())
if s.err != nil {
return s, nil
}
- *s.value = value
+ s.updateValue()
return s, NextField
}
if s.filtering {
- s.filteredOptions = s.options
+ s.filteredOptions = s.options.val
if s.filter.Value() != "" {
s.filteredOptions = nil
- for _, option := range s.options {
+ for _, option := range s.options.val {
if s.filterFunc(option.Key) {
s.filteredOptions = append(s.filteredOptions, option)
}
@@ -313,16 +493,21 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, cmd
}
+func (s *Select[T]) updateValue() {
+ if s.selected < len(s.filteredOptions) && s.selected >= 0 {
+ s.accessor.Set(s.filteredOptions[s.selected].Value)
+ }
+}
+
// updateViewportHeight updates the viewport size according to the Height setting
// on this select field.
func (s *Select[T]) updateViewportHeight() {
// If no height is set size the viewport to the number of options.
if s.height <= 0 {
- s.viewport.Height = len(s.options)
+ s.viewport.Height = len(s.options.val)
return
}
- const minHeight = 1
s.viewport.Height = max(minHeight, s.height-
lipgloss.Height(s.titleView())-
lipgloss.Height(s.descriptionView()))
@@ -340,19 +525,16 @@ func (s *Select[T]) activeStyles() *FieldStyles {
}
func (s *Select[T]) titleView() string {
- if s.title == "" {
- return ""
- }
var (
styles = s.activeStyles()
sb = strings.Builder{}
)
if s.filtering {
- sb.WriteString(styles.Title.Render(s.filter.View()))
+ sb.WriteString(s.filter.View())
} else if s.filter.Value() != "" && !s.inline {
- sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value()))
+ sb.WriteString(styles.Title.Render(s.title.val) + styles.Description.Render("/"+s.filter.Value()))
} else {
- sb.WriteString(styles.Title.Render(s.title))
+ sb.WriteString(styles.Title.Render(s.title.val))
}
if s.err != nil {
sb.WriteString(styles.ErrorIndicator.String())
@@ -361,16 +543,22 @@ func (s *Select[T]) titleView() string {
}
func (s *Select[T]) descriptionView() string {
- return s.activeStyles().Description.Render(s.description)
+ return s.activeStyles().Description.Render(s.description.val)
}
-func (s *Select[T]) choicesView() string {
+func (s *Select[T]) optionsView() string {
var (
styles = s.activeStyles()
c = styles.SelectSelector.String()
sb strings.Builder
)
+ if s.options.loading && time.Since(s.options.loadingStart) > spinnerShowThreshold {
+ s.spinner.Style = s.activeStyles().MultiSelectSelector.UnsetString()
+ sb.WriteString(s.spinner.View() + " Loading...")
+ return sb.String()
+ }
+
if s.inline {
sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String())
if len(s.filteredOptions) > 0 {
@@ -386,14 +574,14 @@ func (s *Select[T]) choicesView() string {
if s.selected == i {
sb.WriteString(c + styles.SelectedOption.Render(option.Key))
} else {
- sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key))
+ sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.UnselectedOption.Render(option.Key))
}
- if i < len(s.options)-1 {
+ if i < len(s.options.val)-1 {
sb.WriteString("\n")
}
}
- for i := len(s.filteredOptions); i < len(s.options)-1; i++ {
+ for i := len(s.filteredOptions); i < len(s.options.val)-1; i++ {
sb.WriteString("\n")
}
@@ -403,16 +591,16 @@ func (s *Select[T]) choicesView() string {
// View renders the select field.
func (s *Select[T]) View() string {
styles := s.activeStyles()
- s.viewport.SetContent(s.choicesView())
+ s.viewport.SetContent(s.optionsView())
var sb strings.Builder
- if s.title != "" {
+ if s.title.val != "" || s.title.fn != nil {
sb.WriteString(s.titleView())
if !s.inline {
sb.WriteString("\n")
}
}
- if s.description != "" {
+ if s.description.val != "" || s.description.fn != nil {
sb.WriteString(s.descriptionView())
if !s.inline {
sb.WriteString("\n")
@@ -425,7 +613,7 @@ func (s *Select[T]) View() string {
// clearFilter clears the value of the filter.
func (s *Select[T]) clearFilter() {
s.filter.SetValue("")
- s.filteredOptions = s.options
+ s.filteredOptions = s.options.val
s.setFiltering(false)
}
@@ -458,10 +646,9 @@ func (s *Select[T]) Run() error {
func (s *Select[T]) runAccessible() error {
var sb strings.Builder
styles := s.activeStyles()
+ sb.WriteString(styles.Title.Render(s.title.val) + "\n")
- sb.WriteString(styles.Title.Render(s.title) + "\n")
-
- for i, option := range s.options {
+ for i, option := range s.options.val {
sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key))
sb.WriteString("\n")
}
@@ -469,14 +656,14 @@ func (s *Select[T]) runAccessible() error {
fmt.Println(sb.String())
for {
- choice := accessibility.PromptInt("Choose: ", 1, len(s.options))
- option := s.options[choice-1]
+ choice := accessibility.PromptInt("Choose: ", 1, len(s.options.val))
+ option := s.options.val[choice-1]
if err := s.validate(option.Value); err != nil {
fmt.Println(err.Error())
continue
}
fmt.Println(styles.SelectedOption.Render("Chose: " + option.Key + "\n"))
- *s.value = option.Value
+ s.accessor.Set(option.Value)
break
}
@@ -489,8 +676,11 @@ func (s *Select[T]) WithTheme(theme *Theme) Field {
return s
}
s.theme = theme
- s.filter.Cursor.Style = s.theme.Focused.TextInput.Cursor
- s.filter.PromptStyle = s.theme.Focused.TextInput.Prompt
+ s.filter.Cursor.Style = theme.Focused.TextInput.Cursor
+ s.filter.Cursor.TextStyle = theme.Focused.TextInput.CursorText
+ s.filter.PromptStyle = theme.Focused.TextInput.Prompt
+ s.filter.TextStyle = theme.Focused.TextInput.Text
+ s.filter.PlaceholderStyle = theme.Focused.TextInput.Placeholder
s.updateViewportHeight()
return s
}
@@ -534,11 +724,9 @@ func (s *Select[T]) WithPosition(p FieldPosition) Field {
}
// GetKey returns the key of the field.
-func (s *Select[T]) GetKey() string {
- return s.key
-}
+func (s *Select[T]) GetKey() string { return s.key }
// GetValue returns the value of the field.
func (s *Select[T]) GetValue() any {
- return *s.value
+ return s.accessor.Get()
}
diff --git a/vendor/github.com/charmbracelet/huh/field_text.go b/vendor/github.com/charmbracelet/huh/field_text.go
index 51e22b2b..a5c3a40c 100644
--- a/vendor/github.com/charmbracelet/huh/field_text.go
+++ b/vendor/github.com/charmbracelet/huh/field_text.go
@@ -13,36 +13,42 @@ import (
"github.com/charmbracelet/lipgloss"
)
-// Text is a form text field. It allows for a multi-line string input.
+// Text is a text field.
+//
+// A text box is responsible for getting multi-line input from the user. Use
+// it to gather longer-form user input. The Text field can be filled with an
+// EDITOR.
type Text struct {
- value *string
- key string
-
- // error handling
- validate func(string) error
- err error
+ accessor Accessor[string]
+ key string
+ id int
- // model
- textarea textarea.Model
+ title Eval[string]
+ description Eval[string]
+ placeholder Eval[string]
- // customization
- title string
- description string
editorCmd string
editorArgs []string
editorExtension string
- // state
- focused bool
+ textarea textarea.Model
+
+ focused bool
+ validate func(string) error
+ err error
- // form options
- width int
accessible bool
- theme *Theme
- keymap TextKeyMap
+ width int
+
+ theme *Theme
+ keymap TextKeyMap
}
-// NewText returns a new text field.
+// NewText creates a new text field.
+//
+// A text box is responsible for getting multi-line input from the user. Use
+// it to gather longer-form user input. The Text field can be filled with an
+// EDITOR.
func NewText() *Text {
text := textarea.New()
text.ShowLineNumbers = false
@@ -52,12 +58,16 @@ func NewText() *Text {
editorCmd, editorArgs := getEditor()
t := &Text{
- value: new(string),
+ accessor: &EmbeddedAccessor[string]{},
+ id: nextID(),
textarea: text,
validate: func(string) error { return nil },
editorCmd: editorCmd,
editorArgs: editorArgs,
editorExtension: "md",
+ title: Eval[string]{cache: make(map[uint64]string)},
+ description: Eval[string]{cache: make(map[uint64]string)},
+ placeholder: Eval[string]{cache: make(map[uint64]string)},
}
return t
@@ -65,8 +75,13 @@ func NewText() *Text {
// Value sets the value of the text field.
func (t *Text) Value(value *string) *Text {
- t.value = value
- t.textarea.SetValue(*value)
+ return t.Accessor(NewPointerAccessor(value))
+}
+
+// Accessor sets the accessor of the text field.
+func (t *Text) Accessor(accessor Accessor[string]) *Text {
+ t.accessor = accessor
+ t.textarea.SetValue(t.accessor.Get())
return t
}
@@ -76,21 +91,53 @@ func (t *Text) Key(key string) *Text {
return t
}
-// Title sets the title of the text field.
+// Title sets the text field's title.
+//
+// This title will be static, for dynamic titles use `TitleFunc`.
func (t *Text) Title(title string) *Text {
- t.title = title
+ t.title.val = title
+ t.title.fn = nil
return t
}
-// Lines sets the number of lines to show of the text field.
-func (t *Text) Lines(lines int) *Text {
- t.textarea.SetHeight(lines)
+// TitleFunc sets the text field's title func.
+//
+// The TitleFunc will be re-evaluated when the binding of the TitleFunc changes.
+// This is useful when you want to display dynamic content and update the title
+// when another part of your form changes.
+//
+// See README#Dynamic for more usage information.
+func (t *Text) TitleFunc(f func() string, bindings any) *Text {
+ t.title.fn = f
+ t.title.bindings = bindings
return t
}
// Description sets the description of the text field.
+//
+// This description will be static, for dynamic description use `DescriptionFunc`.
func (t *Text) Description(description string) *Text {
- t.description = description
+ t.description.val = description
+ t.description.fn = nil
+ return t
+}
+
+// DescriptionFunc sets the description func of the text field.
+//
+// The DescriptionFunc will be re-evaluated when the binding of the
+// DescriptionFunc changes. This is useful when you want to display dynamic
+// content and update the description when another part of your form changes.
+//
+// See README#Dynamic for more usage information.
+func (t *Text) DescriptionFunc(f func() string, bindings any) *Text {
+ t.description.fn = f
+ t.description.bindings = bindings
+ return t
+}
+
+// Lines sets the number of lines to show of the text field.
+func (t *Text) Lines(lines int) *Text {
+ t.textarea.SetHeight(lines)
return t
}
@@ -107,11 +154,26 @@ func (t *Text) ShowLineNumbers(show bool) *Text {
}
// Placeholder sets the placeholder of the text field.
+//
+// This placeholder will be static, for dynamic placeholders use `PlaceholderFunc`.
func (t *Text) Placeholder(str string) *Text {
t.textarea.Placeholder = str
return t
}
+// PlaceholderFunc sets the placeholder func of the text field.
+//
+// The PlaceholderFunc will be re-evaluated when the binding of the
+// PlaceholderFunc changes. This is useful when you want to display dynamic
+// content and update the placeholder when another part of your form changes.
+//
+// See README#Dynamic for more usage information.
+func (t *Text) PlaceholderFunc(f func() string, bindings any) *Text {
+ t.placeholder.fn = f
+ t.placeholder.bindings = bindings
+ return t
+}
+
// Validate sets the validation function of the text field.
func (t *Text) Validate(validate func(string) error) *Text {
t.validate = validate
@@ -150,19 +212,13 @@ func (t *Text) EditorExtension(extension string) *Text {
}
// Error returns the error of the text field.
-func (t *Text) Error() error {
- return t.err
-}
+func (t *Text) Error() error { return t.err }
// Skip returns whether the textarea should be skipped or should be blocking.
-func (*Text) Skip() bool {
- return false
-}
+func (*Text) Skip() bool { return false }
// Zoom returns whether the note should be zoomed.
-func (*Text) Zoom() bool {
- return false
-}
+func (*Text) Zoom() bool { return false }
// Focus focuses the text field.
func (t *Text) Focus() tea.Cmd {
@@ -173,9 +229,9 @@ func (t *Text) Focus() tea.Cmd {
// Blur blurs the text field.
func (t *Text) Blur() tea.Cmd {
t.focused = false
- *t.value = t.textarea.Value()
+ t.accessor.Set(t.textarea.Value())
t.textarea.Blur()
- t.err = t.validate(*t.value)
+ t.err = t.validate(t.accessor.Get())
return nil
}
@@ -197,17 +253,56 @@ func (t *Text) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
- t.textarea, cmd = t.textarea.Update(msg)
- cmds = append(cmds, cmd)
- *t.value = t.textarea.Value()
-
switch msg := msg.(type) {
case updateValueMsg:
t.textarea.SetValue(string(msg))
t.textarea, cmd = t.textarea.Update(msg)
cmds = append(cmds, cmd)
- *t.value = t.textarea.Value()
-
+ t.accessor.Set(t.textarea.Value())
+ case updateFieldMsg:
+ var cmds []tea.Cmd
+ if ok, hash := t.placeholder.shouldUpdate(); ok {
+ t.placeholder.bindingsHash = hash
+ if t.placeholder.loadFromCache() {
+ t.textarea.Placeholder = t.placeholder.val
+ } else {
+ t.placeholder.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updatePlaceholderMsg{id: t.id, placeholder: t.placeholder.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := t.title.shouldUpdate(); ok {
+ t.title.bindingsHash = hash
+ if !t.title.loadFromCache() {
+ cmds = append(cmds, func() tea.Msg {
+ return updateTitleMsg{id: t.id, title: t.title.fn(), hash: hash}
+ })
+ }
+ }
+ if ok, hash := t.description.shouldUpdate(); ok {
+ t.description.bindingsHash = hash
+ if !t.description.loadFromCache() {
+ t.description.loading = true
+ cmds = append(cmds, func() tea.Msg {
+ return updateDescriptionMsg{id: t.id, description: t.description.fn(), hash: hash}
+ })
+ }
+ }
+ return t, tea.Batch(cmds...)
+ case updatePlaceholderMsg:
+ if t.id == msg.id && t.placeholder.bindingsHash == msg.hash {
+ t.placeholder.update(msg.placeholder)
+ t.textarea.Placeholder = msg.placeholder
+ }
+ case updateTitleMsg:
+ if t.id == msg.id && t.title.bindingsHash == msg.hash {
+ t.title.update(msg.title)
+ }
+ case updateDescriptionMsg:
+ if t.id == msg.id && t.description.bindingsHash == msg.hash {
+ t.description.update(msg.description)
+ }
case tea.KeyMsg:
t.err = nil
@@ -215,8 +310,8 @@ func (t *Text) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, t.keymap.Editor):
ext := strings.TrimPrefix(t.editorExtension, ".")
tmpFile, _ := os.CreateTemp(os.TempDir(), "*."+ext)
- cmd := exec.Command(t.editorCmd, append(t.editorArgs, tmpFile.Name())...)
- _ = os.WriteFile(tmpFile.Name(), []byte(t.textarea.Value()), 0600)
+ cmd := exec.Command(t.editorCmd, append(t.editorArgs, tmpFile.Name())...) //nolint:gosec
+ _ = os.WriteFile(tmpFile.Name(), []byte(t.textarea.Value()), 0o644) //nolint:mnd,gosec
cmds = append(cmds, tea.ExecProcess(cmd, func(error) tea.Msg {
content, _ := os.ReadFile(tmpFile.Name())
_ = os.Remove(tmpFile.Name())
@@ -239,6 +334,10 @@ func (t *Text) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+ t.textarea, cmd = t.textarea.Update(msg)
+ cmds = append(cmds, cmd)
+ t.accessor.Set(t.textarea.Value())
+
return t, tea.Batch(cmds...)
}
@@ -265,8 +364,8 @@ func (t *Text) activeTextAreaStyles() *textarea.Style {
// View renders the text field.
func (t *Text) View() string {
- var styles = t.activeStyles()
- var textareaStyles = t.activeTextAreaStyles()
+ styles := t.activeStyles()
+ textareaStyles := t.activeTextAreaStyles()
// NB: since the method is on a pointer receiver these are being mutated.
// Because this runs on every render this shouldn't matter in practice,
@@ -276,17 +375,18 @@ func (t *Text) View() string {
textareaStyles.Prompt = styles.TextInput.Prompt
textareaStyles.CursorLine = styles.TextInput.Text
t.textarea.Cursor.Style = styles.TextInput.Cursor
+ t.textarea.Cursor.TextStyle = styles.TextInput.CursorText
var sb strings.Builder
- if t.title != "" {
- sb.WriteString(styles.Title.Render(t.title))
+ if t.title.val != "" || t.title.fn != nil {
+ sb.WriteString(styles.Title.Render(t.title.val))
if t.err != nil {
sb.WriteString(styles.ErrorIndicator.String())
}
sb.WriteString("\n")
}
- if t.description != "" {
- sb.WriteString(styles.Description.Render(t.description))
+ if t.description.val != "" || t.description.fn != nil {
+ sb.WriteString(styles.Description.Render(t.description.val))
sb.WriteString("\n")
}
sb.WriteString(t.textarea.View())
@@ -305,9 +405,9 @@ func (t *Text) Run() error {
// runAccessible runs an accessible text field.
func (t *Text) runAccessible() error {
styles := t.activeStyles()
- fmt.Println(styles.Title.Render(t.title))
+ fmt.Println(styles.Title.Render(t.title.val))
fmt.Println()
- *t.value = accessibility.PromptString("Input: ", func(input string) error {
+ t.accessor.Set(accessibility.PromptString("Input: ", func(input string) error {
if err := t.validate(input); err != nil {
// Handle the error from t.validate, return it
return err
@@ -317,7 +417,7 @@ func (t *Text) runAccessible() error {
return fmt.Errorf("Input cannot exceed %d characters", t.textarea.CharLimit)
}
return nil
- })
+ }))
fmt.Println()
return nil
}
@@ -354,10 +454,10 @@ func (t *Text) WithWidth(width int) Field {
// WithHeight sets the height of the text field.
func (t *Text) WithHeight(height int) Field {
adjust := 0
- if t.title != "" {
+ if t.title.val != "" {
adjust++
}
- if t.description != "" {
+ if t.description.val != "" {
adjust++
}
t.textarea.SetHeight(height - t.activeStyles().Base.GetVerticalFrameSize() - adjust)
@@ -373,11 +473,9 @@ func (t *Text) WithPosition(p FieldPosition) Field {
}
// GetKey returns the key of the field.
-func (t *Text) GetKey() string {
- return t.key
-}
+func (t *Text) GetKey() string { return t.key }
// GetValue returns the value of the field.
func (t *Text) GetValue() any {
- return *t.value
+ return t.accessor.Get()
}
diff --git a/vendor/github.com/charmbracelet/huh/form.go b/vendor/github.com/charmbracelet/huh/form.go
index 322afb35..e1afb0d2 100644
--- a/vendor/github.com/charmbracelet/huh/form.go
+++ b/vendor/github.com/charmbracelet/huh/form.go
@@ -1,18 +1,37 @@
package huh
import (
+ "context"
"errors"
+ "fmt"
"io"
"os"
+ "sync"
+ "time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/paginator"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/huh/internal/selector"
)
const defaultWidth = 80
+// Internal ID management. Used during animating to ensure that frame messages
+// are received only by spinner components that sent them.
+var (
+ lastID int
+ idMtx sync.Mutex
+)
+
+// Return the next ID we should use on the Model.
+func nextID() int {
+ idMtx.Lock()
+ defer idMtx.Unlock()
+ lastID++
+ return lastID
+}
+
// FormState represents the current state of the form.
type FormState int
@@ -30,22 +49,25 @@ const (
// ErrUserAborted is the error returned when a user exits the form before submitting.
var ErrUserAborted = errors.New("user aborted")
+// ErrTimeout is the error returned when the timeout is reached.
+var ErrTimeout = errors.New("timeout")
+
+// ErrTimeoutUnsupported is the error returned when timeout is used while in accessible mode.
+var ErrTimeoutUnsupported = errors.New("timeout is not supported in accessible mode")
+
// Form is a collection of groups that are displayed one at a time on a "page".
//
// The form can navigate between groups and is complete once all the groups are
// complete.
type Form struct {
// collection of groups
- groups []*Group
+ selector *selector.Selector[*Group]
results map[string]any
- // navigation
- paginator paginator.Model
-
// callbacks
- submitCmd tea.Cmd
- cancelCmd tea.Cmd
+ SubmitCmd tea.Cmd
+ CancelCmd tea.Cmd
State FormState
@@ -61,8 +83,10 @@ type Form struct {
width int
height int
keymap *KeyMap
+ timeout time.Duration
teaOptions []tea.ProgramOption
- output io.Writer
+
+ layout Layout
}
// NewForm returns a form with the given groups and default themes and
@@ -71,14 +95,13 @@ type Form struct {
// Use With* methods to customize the form with options, such as setting
// different themes and keybindings.
func NewForm(groups ...*Group) *Form {
- p := paginator.New()
- p.SetTotalPages(len(groups))
+ selector := selector.NewSelector(groups)
f := &Form{
- groups: groups,
- paginator: p,
- keymap: NewDefaultKeyMap(),
- results: make(map[string]any),
+ selector: selector,
+ keymap: NewDefaultKeyMap(),
+ results: make(map[string]any),
+ layout: LayoutDefault,
teaOptions: []tea.ProgramOption{
tea.WithOutput(os.Stderr),
},
@@ -209,9 +232,10 @@ func (f *Form) WithAccessible(accessible bool) *Form {
// This allows the form groups and field to show what keybindings are available
// to the user.
func (f *Form) WithShowHelp(v bool) *Form {
- for _, group := range f.groups {
+ f.selector.Range(func(_ int, group *Group) bool {
group.WithShowHelp(v)
- }
+ return true
+ })
return f
}
@@ -220,9 +244,10 @@ func (f *Form) WithShowHelp(v bool) *Form {
// This allows the form groups and fields to show errors when the Validate
// function returns an error.
func (f *Form) WithShowErrors(v bool) *Form {
- for _, group := range f.groups {
+ f.selector.Range(func(_ int, group *Group) bool {
group.WithShowErrors(v)
- }
+ return true
+ })
return f
}
@@ -235,9 +260,10 @@ func (f *Form) WithTheme(theme *Theme) *Form {
if theme == nil {
return f
}
- for _, group := range f.groups {
+ f.selector.Range(func(_ int, group *Group) bool {
group.WithTheme(theme)
- }
+ return true
+ })
return f
}
@@ -249,9 +275,10 @@ func (f *Form) WithKeyMap(keymap *KeyMap) *Form {
return f
}
f.keymap = keymap
- for _, group := range f.groups {
+ f.selector.Range(func(_ int, group *Group) bool {
group.WithKeyMap(keymap)
- }
+ return true
+ })
f.UpdateFieldPositions()
return f
}
@@ -266,9 +293,11 @@ func (f *Form) WithWidth(width int) *Form {
return f
}
f.width = width
- for _, group := range f.groups {
+ f.selector.Range(func(_ int, group *Group) bool {
+ width := f.layout.GroupWidth(f, group, width)
group.WithWidth(width)
- }
+ return true
+ })
return f
}
@@ -278,66 +307,90 @@ func (f *Form) WithHeight(height int) *Form {
return f
}
f.height = height
- for _, group := range f.groups {
+ f.selector.Range(func(_ int, group *Group) bool {
group.WithHeight(height)
- }
+ return true
+ })
return f
}
// WithOutput sets the io.Writer to output the form.
func (f *Form) WithOutput(w io.Writer) *Form {
- f.output = w
f.teaOptions = append(f.teaOptions, tea.WithOutput(w))
return f
}
+// WithInput sets the io.Reader to the input form.
+func (f *Form) WithInput(r io.Reader) *Form {
+ f.teaOptions = append(f.teaOptions, tea.WithInput(r))
+ return f
+}
+
+// WithTimeout sets the duration for the form to be killed.
+func (f *Form) WithTimeout(t time.Duration) *Form {
+ f.timeout = t
+ return f
+}
+
// WithProgramOptions sets the tea options of the form.
func (f *Form) WithProgramOptions(opts ...tea.ProgramOption) *Form {
f.teaOptions = opts
return f
}
+// WithLayout sets the layout on a form.
+//
+// This allows customization of the form group layout.
+func (f *Form) WithLayout(layout Layout) *Form {
+ f.layout = layout
+ return f
+}
+
// UpdateFieldPositions sets the position on all the fields.
func (f *Form) UpdateFieldPositions() *Form {
firstGroup := 0
- lastGroup := len(f.groups) - 1
+ lastGroup := f.selector.Total() - 1
// determine the first non-hidden group.
- for g := range f.groups {
+ f.selector.Range(func(_ int, g *Group) bool {
if !f.isGroupHidden(g) {
- break
+ return false
}
firstGroup++
- }
+ return true
+ })
// determine the last non-hidden group.
- for g := len(f.groups) - 1; g > 0; g-- {
+ f.selector.ReverseRange(func(_ int, g *Group) bool {
if !f.isGroupHidden(g) {
- break
+ return false
}
lastGroup--
- }
+ return true
+ })
- for g, group := range f.groups {
+ f.selector.Range(func(g int, group *Group) bool {
// determine the first non-skippable field.
var firstField int
- for _, field := range group.fields {
- if !field.Skip() || len(group.fields) == 1 {
- break
+ group.selector.Range(func(_ int, field Field) bool {
+ if !field.Skip() || group.selector.Total() == 1 {
+ return false
}
firstField++
- }
+ return true
+ })
// determine the last non-skippable field.
var lastField int
- for i := len(group.fields) - 1; i > 0; i-- {
+ group.selector.ReverseRange(func(i int, field Field) bool {
lastField = i
- if !group.fields[i].Skip() || len(group.fields) == 1 {
- break
+ if !field.Skip() || group.selector.Total() == 1 {
+ return false
}
- }
+ return true
+ })
- for i, field := range group.fields {
+ group.selector.Range(func(i int, field Field) bool {
field.WithPosition(FieldPosition{
Group: g,
Field: i,
@@ -346,25 +399,28 @@ func (f *Form) UpdateFieldPositions() *Form {
FirstGroup: firstGroup,
LastGroup: lastGroup,
})
- }
- }
+ return true
+ })
+
+ return true
+ })
return f
}
// Errors returns the current groups' errors.
func (f *Form) Errors() []error {
- return f.groups[f.paginator.Page].Errors()
+ return f.selector.Selected().Errors()
}
// Help returns the current groups' help.
func (f *Form) Help() help.Model {
- return f.groups[f.paginator.Page].help
+ return f.selector.Selected().help
}
// KeyBinds returns the current fields' keybinds.
func (f *Form) KeyBinds() []key.Binding {
- group := f.groups[f.paginator.Page]
- return group.fields[group.paginator.Page].KeyBinds()
+ group := f.selector.Selected()
+ return group.selector.Selected().KeyBinds()
}
// Get returns a result from the form.
@@ -381,7 +437,7 @@ func (f *Form) GetString(key string) string {
return v
}
-// GetInt returns a result as a string from the form.
+// GetInt returns a result as a int from the form.
func (f *Form) GetInt(key string) int {
v, ok := f.results[key].(int)
if !ok {
@@ -425,12 +481,16 @@ func (f *Form) PrevField() tea.Cmd {
// Init initializes the form.
func (f *Form) Init() tea.Cmd {
- cmds := make([]tea.Cmd, len(f.groups))
- for i, group := range f.groups {
+ cmds := make([]tea.Cmd, f.selector.Total())
+ f.selector.Range(func(i int, group *Group) bool {
+ if i == 0 {
+ group.active = true
+ }
cmds[i] = group.Init()
- }
+ return true
+ })
- if f.isGroupHidden(f.paginator.Page) {
+ if f.isGroupHidden(f.selector.Selected()) {
cmds = append(cmds, nextGroup)
}
@@ -444,37 +504,39 @@ func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return f, nil
}
- page := f.paginator.Page
- group := f.groups[page]
+ group := f.selector.Selected()
switch msg := msg.(type) {
case tea.WindowSizeMsg:
if f.width > 0 {
break
}
- for _, group := range f.groups {
- group.WithWidth(msg.Width)
- }
+ f.selector.Range(func(_ int, group *Group) bool {
+ width := f.layout.GroupWidth(f, group, msg.Width)
+ group.WithWidth(width)
+ return true
+ })
if f.height > 0 {
break
}
- for _, group := range f.groups {
+ f.selector.Range(func(_ int, group *Group) bool {
if group.fullHeight() > msg.Height {
group.WithHeight(msg.Height)
}
- }
+ return true
+ })
case tea.KeyMsg:
switch {
case key.Matches(msg, f.keymap.Quit):
f.aborted = true
f.quitting = true
f.State = StateAborted
- return f, f.cancelCmd
+ return f, f.CancelCmd
}
case nextFieldMsg:
// Form is progressing to the next field, let's save the value of the current field.
- field := group.fields[group.paginator.Page]
+ field := group.selector.Selected()
f.results[field.GetKey()] = field.GetValue()
case nextGroupMsg:
@@ -485,43 +547,45 @@ func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
submit := func() (tea.Model, tea.Cmd) {
f.quitting = true
f.State = StateCompleted
- return f, f.submitCmd
+ return f, f.SubmitCmd
}
- if f.paginator.OnLastPage() {
+ if f.selector.OnLast() {
return submit()
}
- for i := f.paginator.Page + 1; i < f.paginator.TotalPages; i++ {
- if !f.isGroupHidden(i) {
- f.paginator.Page = i
+ for i := f.selector.Index() + 1; i < f.selector.Total(); i++ {
+ if !f.isGroupHidden(f.selector.Get(i)) {
+ f.selector.SetIndex(i)
break
}
// all subsequent groups are hidden, so we must act as
// if we were in the last one.
- if i == f.paginator.TotalPages-1 {
+ if i == f.selector.Total()-1 {
return submit()
}
}
- return f, f.groups[f.paginator.Page].Init()
+ f.selector.Selected().active = true
+ return f, f.selector.Selected().Init()
case prevGroupMsg:
if len(group.Errors()) > 0 {
return f, nil
}
- for i := f.paginator.Page - 1; i >= 0; i-- {
- if !f.isGroupHidden(i) {
- f.paginator.Page = i
+ for i := f.selector.Index() - 1; i >= 0; i-- {
+ if !f.isGroupHidden(f.selector.Get(i)) {
+ f.selector.SetIndex(i)
break
}
}
- return f, f.groups[f.paginator.Page].Init()
+ f.selector.Selected().active = true
+ return f, f.selector.Selected().Init()
}
m, cmd := group.Update(msg)
- f.groups[page] = m.(*Group)
+ f.selector.Set(f.selector.Index(), m.(*Group))
// A user input a key, this could hide or show other groups,
// let's update all of their positions.
@@ -533,8 +597,8 @@ func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return f, cmd
}
-func (f *Form) isGroupHidden(page int) bool {
- hide := f.groups[page].hide
+func (f *Form) isGroupHidden(group *Group) bool {
+ hide := group.hide
if hide == nil {
return false
}
@@ -547,15 +611,20 @@ func (f *Form) View() string {
return ""
}
- return f.groups[f.paginator.Page].View()
+ return f.layout.View(f)
}
// Run runs the form.
func (f *Form) Run() error {
- f.submitCmd = tea.Quit
- f.cancelCmd = tea.Quit
+ return f.RunWithContext(context.Background())
+}
+
+// RunWithContext runs the form with the given context.
+func (f *Form) RunWithContext(ctx context.Context) error {
+ f.SubmitCmd = tea.Quit
+ f.CancelCmd = tea.Quit
- if len(f.groups) == 0 {
+ if f.selector.Total() == 0 {
return nil
}
@@ -563,27 +632,48 @@ func (f *Form) Run() error {
return f.runAccessible()
}
- return f.run()
+ return f.run(ctx)
}
// run runs the form in normal mode.
-func (f *Form) run() error {
+func (f *Form) run(ctx context.Context) error {
+ if f.timeout > 0 {
+ ctx, cancel := context.WithTimeout(ctx, f.timeout)
+ defer cancel()
+ f.teaOptions = append(f.teaOptions, tea.WithContext(ctx), tea.WithReportFocus())
+ } else {
+ f.teaOptions = append(f.teaOptions, tea.WithContext(ctx), tea.WithReportFocus())
+ }
+
m, err := tea.NewProgram(f, f.teaOptions...).Run()
if m.(*Form).aborted {
- err = ErrUserAborted
+ return ErrUserAborted
+ }
+ if errors.Is(err, tea.ErrProgramKilled) {
+ return ErrTimeout
}
- return err
+ if err != nil {
+ return fmt.Errorf("huh: %w", err)
+ }
+ return nil
}
// runAccessible runs the form in accessible mode.
func (f *Form) runAccessible() error {
- for _, group := range f.groups {
- for _, field := range group.fields {
+ // Timeouts are not supported in this mode.
+ if f.timeout > 0 {
+ return ErrTimeoutUnsupported
+ }
+
+ f.selector.Range(func(_ int, group *Group) bool {
+ group.selector.Range(func(_ int, field Field) bool {
field.Init()
field.Focus()
_ = field.WithAccessible(true).Run()
- }
- }
+ return true
+ })
+ return true
+ })
return nil
}
diff --git a/vendor/github.com/charmbracelet/huh/group.go b/vendor/github.com/charmbracelet/huh/group.go
index ee97fa36..d890751b 100644
--- a/vendor/github.com/charmbracelet/huh/group.go
+++ b/vendor/github.com/charmbracelet/huh/group.go
@@ -4,9 +4,9 @@ import (
"strings"
"github.com/charmbracelet/bubbles/help"
- "github.com/charmbracelet/bubbles/paginator"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/huh/internal/selector"
"github.com/charmbracelet/lipgloss"
)
@@ -18,15 +18,14 @@ import (
// progress to the next group.
type Group struct {
// collection of fields
- fields []Field
+ selector *selector.Selector[Field]
// information
title string
description string
// navigation
- paginator paginator.Model
- viewport viewport.Model
+ viewport viewport.Model
// help
showHelp bool
@@ -40,24 +39,22 @@ type Group struct {
height int
keymap *KeyMap
hide func() bool
+ active bool
}
// NewGroup returns a new group with the given fields.
func NewGroup(fields ...Field) *Group {
- p := paginator.New()
- p.SetTotalPages(len(fields))
-
+ selector := selector.NewSelector(fields)
group := &Group{
- fields: fields,
- paginator: p,
+ selector: selector,
help: help.New(),
showHelp: true,
showErrors: true,
+ active: false,
}
height := group.fullHeight()
- //nolint:gomnd
- v := viewport.New(80, height)
+ v := viewport.New(80, height) //nolint:mnd
group.viewport = v
group.height = height
@@ -91,9 +88,10 @@ func (g *Group) WithShowErrors(show bool) *Group {
// WithTheme sets the theme on a group.
func (g *Group) WithTheme(t *Theme) *Group {
g.help.Styles = t.Help
- for _, field := range g.fields {
+ g.selector.Range(func(_ int, field Field) bool {
field.WithTheme(t)
- }
+ return true
+ })
if g.height <= 0 {
g.WithHeight(g.fullHeight())
}
@@ -103,9 +101,10 @@ func (g *Group) WithTheme(t *Theme) *Group {
// WithKeyMap sets the keymap on a group.
func (g *Group) WithKeyMap(k *KeyMap) *Group {
g.keymap = k
- for _, field := range g.fields {
+ g.selector.Range(func(_ int, field Field) bool {
field.WithKeyMap(k)
- }
+ return true
+ })
return g
}
@@ -113,9 +112,10 @@ func (g *Group) WithKeyMap(k *KeyMap) *Group {
func (g *Group) WithWidth(width int) *Group {
g.width = width
g.viewport.Width = width
- for _, field := range g.fields {
+ g.selector.Range(func(_ int, field Field) bool {
field.WithWidth(width)
- }
+ return true
+ })
return g
}
@@ -123,12 +123,13 @@ func (g *Group) WithWidth(width int) *Group {
func (g *Group) WithHeight(height int) *Group {
g.height = height
g.viewport.Height = height
- for _, field := range g.fields {
+ g.selector.Range(func(_ int, field Field) bool {
// A field height must not exceed the form height.
if height-1 <= lipgloss.Height(field.View()) {
field.WithHeight(height)
}
- }
+ return true
+ })
return g
}
@@ -147,14 +148,22 @@ func (g *Group) WithHideFunc(hideFunc func() bool) *Group {
// Errors returns the groups' fields' errors.
func (g *Group) Errors() []error {
var errs []error
- for _, field := range g.fields {
+ g.selector.Range(func(_ int, field Field) bool {
if err := field.Error(); err != nil {
errs = append(errs, err)
}
- }
+ return true
+ })
return errs
}
+// updateFieldMsg is a message to update the fields of a group that is currently
+// displayed.
+//
+// This is used to update all TitleFunc, DescriptionFunc, and ...Func update
+// methods to make all fields dynamically update based on user input.
+type updateFieldMsg struct{}
+
// nextFieldMsg is a message to move to the next field,
//
// each field controls when to send this message such that it is able to use
@@ -181,52 +190,54 @@ func PrevField() tea.Msg {
func (g *Group) Init() tea.Cmd {
var cmds []tea.Cmd
- if g.fields[g.paginator.Page].Skip() {
- if g.paginator.OnLastPage() {
+ if g.selector.Selected().Skip() {
+ if g.selector.OnLast() {
cmds = append(cmds, g.prevField()...)
- } else if g.paginator.Page == 0 {
+ } else if g.selector.OnFirst() {
cmds = append(cmds, g.nextField()...)
}
return tea.Batch(cmds...)
}
- cmd := g.fields[g.paginator.Page].Focus()
- cmds = append(cmds, cmd)
+ if g.active {
+ cmd := g.selector.Selected().Focus()
+ cmds = append(cmds, cmd)
+ }
g.buildView()
return tea.Batch(cmds...)
}
// nextField moves to the next field.
func (g *Group) nextField() []tea.Cmd {
- blurCmd := g.fields[g.paginator.Page].Blur()
- if g.paginator.OnLastPage() {
+ blurCmd := g.selector.Selected().Blur()
+ if g.selector.OnLast() {
return []tea.Cmd{blurCmd, nextGroup}
}
- g.paginator.NextPage()
- for g.fields[g.paginator.Page].Skip() {
- if g.paginator.OnLastPage() {
+ g.selector.Next()
+ for g.selector.Selected().Skip() {
+ if g.selector.OnLast() {
return []tea.Cmd{blurCmd, nextGroup}
}
- g.paginator.NextPage()
+ g.selector.Next()
}
- focusCmd := g.fields[g.paginator.Page].Focus()
+ focusCmd := g.selector.Selected().Focus()
return []tea.Cmd{blurCmd, focusCmd}
}
// prevField moves to the previous field.
func (g *Group) prevField() []tea.Cmd {
- blurCmd := g.fields[g.paginator.Page].Blur()
- if g.paginator.Page <= 0 {
+ blurCmd := g.selector.Selected().Blur()
+ if g.selector.OnFirst() {
return []tea.Cmd{blurCmd, prevGroup}
}
- g.paginator.PrevPage()
- for g.fields[g.paginator.Page].Skip() {
- if g.paginator.Page <= 0 {
+ g.selector.Prev()
+ for g.selector.Selected().Skip() {
+ if g.selector.OnFirst() {
return []tea.Cmd{blurCmd, prevGroup}
}
- g.paginator.PrevPage()
+ g.selector.Prev()
}
- focusCmd := g.fields[g.paginator.Page].Focus()
+ focusCmd := g.selector.Selected().Focus()
return []tea.Cmd{blurCmd, focusCmd}
}
@@ -234,13 +245,30 @@ func (g *Group) prevField() []tea.Cmd {
func (g *Group) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
- m, cmd := g.fields[g.paginator.Page].Update(msg)
- g.fields[g.paginator.Page] = m.(Field)
- cmds = append(cmds, cmd)
+ // Update all the fields in the group.
+ g.selector.Range(func(i int, field Field) bool {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ break
+ default:
+ m, cmd := field.Update(msg)
+ g.selector.Set(i, m.(Field))
+ cmds = append(cmds, cmd)
+ }
+ if g.selector.Index() == i {
+ m, cmd := field.Update(msg)
+ g.selector.Set(i, m.(Field))
+ cmds = append(cmds, cmd)
+ }
+ m, cmd := field.Update(updateFieldMsg{})
+ g.selector.Set(i, m.(Field))
+ cmds = append(cmds, cmd)
+ return true
+ })
switch msg := msg.(type) {
case tea.WindowSizeMsg:
- g.WithHeight(min(g.height, min(g.fullHeight(), msg.Height-1)))
+ g.WithHeight(max(g.height, min(g.fullHeight(), msg.Height-1)))
case nextFieldMsg:
cmds = append(cmds, g.nextField()...)
case prevFieldMsg:
@@ -254,35 +282,43 @@ func (g *Group) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// height returns the full height of the group.
func (g *Group) fullHeight() int {
- height := len(g.fields)
- for _, f := range g.fields {
- height += lipgloss.Height(f.View())
- }
+ height := g.selector.Total()
+ g.selector.Range(func(_ int, field Field) bool {
+ height += lipgloss.Height(field.View())
+ return true
+ })
return height
}
-func (g *Group) buildView() {
+func (g *Group) getContent() (int, string) {
var fields strings.Builder
offset := 0
gap := "\n\n"
// if the focused field is requesting it be zoomed, only show that field.
- if g.fields[g.paginator.Page].Zoom() {
- g.fields[g.paginator.Page].WithHeight(g.height - 1)
- fields.WriteString(g.fields[g.paginator.Page].View())
+ if g.selector.Selected().Zoom() {
+ g.selector.Selected().WithHeight(g.height - 1)
+ fields.WriteString(g.selector.Selected().View())
} else {
- for i, field := range g.fields {
+ g.selector.Range(func(i int, field Field) bool {
fields.WriteString(field.View())
- if i == g.paginator.Page {
+ if i == g.selector.Index() {
offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View())
}
- if i < len(g.fields)-1 {
+ if i < g.selector.Total()-1 {
fields.WriteString(gap)
}
- }
+ return true
+ })
}
- g.viewport.SetContent(fields.String() + "\n")
+ return offset, fields.String() + "\n"
+}
+
+func (g *Group) buildView() {
+ offset, content := g.getContent()
+
+ g.viewport.SetContent(content)
g.viewport.SetYOffset(offset)
}
@@ -290,10 +326,23 @@ func (g *Group) buildView() {
func (g *Group) View() string {
var view strings.Builder
view.WriteString(g.viewport.View())
+ view.WriteString(g.Footer())
+ return view.String()
+}
+
+// Content renders the group's content only (no footer).
+func (g *Group) Content() string {
+ _, content := g.getContent()
+ return content
+}
+
+// Footer renders the group's footer only (no content).
+func (g *Group) Footer() string {
+ var view strings.Builder
view.WriteRune('\n')
errors := g.Errors()
if g.showHelp && len(errors) <= 0 {
- view.WriteString(g.help.ShortHelpView(g.fields[g.paginator.Page].KeyBinds()))
+ view.WriteString(g.help.ShortHelpView(g.selector.Selected().KeyBinds()))
}
if g.showErrors {
for _, err := range errors {
diff --git a/vendor/github.com/charmbracelet/huh/internal/selector/selector.go b/vendor/github.com/charmbracelet/huh/internal/selector/selector.go
new file mode 100644
index 00000000..d0d41284
--- /dev/null
+++ b/vendor/github.com/charmbracelet/huh/internal/selector/selector.go
@@ -0,0 +1,96 @@
+package selector
+
+// Selector is a helper type for selecting items.
+type Selector[T any] struct {
+ items []T
+ index int
+}
+
+// NewSelector creates a new item selector.
+func NewSelector[T any](items []T) *Selector[T] {
+ return &Selector[T]{
+ items: items,
+ }
+}
+
+// Append adds an item to the selector.
+func (s *Selector[T]) Append(item T) {
+ s.items = append(s.items, item)
+}
+
+// Next moves the selector to the next item.
+func (s *Selector[T]) Next() {
+ if s.index < len(s.items)-1 {
+ s.index++
+ }
+}
+
+// Prev moves the selector to the previous item.
+func (s *Selector[T]) Prev() {
+ if s.index > 0 {
+ s.index--
+ }
+}
+
+// OnFirst returns true if the selector is on the first item.
+func (s *Selector[T]) OnFirst() bool {
+ return s.index == 0
+}
+
+// OnLast returns true if the selector is on the last item.
+func (s *Selector[T]) OnLast() bool {
+ return s.index == len(s.items)-1
+}
+
+// Selected returns the index of the current selected item.
+func (s *Selector[T]) Selected() T {
+ return s.items[s.index]
+}
+
+// Index returns the index of the current selected item.
+func (s *Selector[T]) Index() int {
+ return s.index
+}
+
+// Totoal returns the total number of items.
+func (s *Selector[T]) Total() int {
+ return len(s.items)
+}
+
+// SetIndex sets the selected item.
+func (s *Selector[T]) SetIndex(i int) {
+ if i < 0 || i >= len(s.items) {
+ return
+ }
+ s.index = i
+}
+
+// Get returns the item at the given index.
+func (s *Selector[T]) Get(i int) T {
+ return s.items[i]
+}
+
+// Set sets the item at the given index.
+func (s *Selector[T]) Set(i int, item T) {
+ s.items[i] = item
+}
+
+// Range iterates over the items.
+// The callback function should return true to continue the iteration.
+func (s *Selector[T]) Range(f func(i int, item T) bool) {
+ for i, item := range s.items {
+ if !f(i, item) {
+ break
+ }
+ }
+}
+
+// ReverseRange iterates over the items in reverse.
+// The callback function should return true to continue the iteration.
+func (s *Selector[T]) ReverseRange(f func(i int, item T) bool) {
+ for i := len(s.items) - 1; i >= 0; i-- {
+ if !f(i, s.items[i]) {
+ break
+ }
+ }
+}
diff --git a/vendor/github.com/charmbracelet/huh/keymap.go b/vendor/github.com/charmbracelet/huh/keymap.go
index 31f74099..24a3bc34 100644
--- a/vendor/github.com/charmbracelet/huh/keymap.go
+++ b/vendor/github.com/charmbracelet/huh/keymap.go
@@ -65,6 +65,8 @@ type MultiSelectKeyMap struct {
SetFilter key.Binding
ClearFilter key.Binding
Submit key.Binding
+ SelectAll key.Binding
+ SelectNone key.Binding
}
// FilePickerKey is the keybindings for filepicker fields.
@@ -97,6 +99,8 @@ type ConfirmKeyMap struct {
Prev key.Binding
Toggle key.Binding
Submit key.Binding
+ Accept key.Binding
+ Reject key.Binding
}
// NewDefaultKeyMap returns a new default keymap.
@@ -162,6 +166,8 @@ func NewDefaultKeyMap() *KeyMap {
HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")),
GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")),
GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")),
+ SelectAll: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "select all")),
+ SelectNone: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "select none"), key.WithDisabled()),
},
Note: NoteKeyMap{
Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")),
@@ -173,6 +179,8 @@ func NewDefaultKeyMap() *KeyMap {
Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")),
Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")),
Toggle: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("←/→", "toggle")),
+ Accept: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "Yes")),
+ Reject: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "No")),
},
}
}
diff --git a/vendor/github.com/charmbracelet/huh/layout.go b/vendor/github.com/charmbracelet/huh/layout.go
new file mode 100644
index 00000000..513d22cf
--- /dev/null
+++ b/vendor/github.com/charmbracelet/huh/layout.go
@@ -0,0 +1,167 @@
+package huh
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// A Layout is responsible for laying out groups in a form.
+type Layout interface {
+ View(f *Form) string
+ GroupWidth(f *Form, g *Group, w int) int
+}
+
+// Default layout shows a single group at a time.
+var LayoutDefault Layout = &layoutDefault{}
+
+// Stack layout stacks all groups on top of each other.
+var LayoutStack Layout = &layoutStack{}
+
+// Column layout distributes groups in even columns.
+func LayoutColumns(columns int) Layout {
+ return &layoutColumns{columns: columns}
+}
+
+// Grid layout distributes groups in a grid.
+func LayoutGrid(rows int, columns int) Layout {
+ return &layoutGrid{rows: rows, columns: columns}
+}
+
+type layoutDefault struct{}
+
+func (l *layoutDefault) View(f *Form) string {
+ return f.selector.Selected().View()
+}
+
+func (l *layoutDefault) GroupWidth(_ *Form, _ *Group, w int) int {
+ return w
+}
+
+type layoutColumns struct {
+ columns int
+}
+
+func (l *layoutColumns) visibleGroups(f *Form) []*Group {
+ segmentIndex := f.selector.Index() / l.columns
+ start := segmentIndex * l.columns
+ end := start + l.columns
+
+ total := f.selector.Total()
+ if end > total {
+ end = total
+ }
+
+ var groups []*Group
+ f.selector.Range(func(i int, group *Group) bool {
+ if i >= start && i < end {
+ groups = append(groups, group)
+ return true
+ }
+ return false
+ })
+
+ return groups
+}
+
+func (l *layoutColumns) View(f *Form) string {
+ groups := l.visibleGroups(f)
+ if len(groups) == 0 {
+ return ""
+ }
+
+ columns := make([]string, 0, len(groups))
+ for _, group := range groups {
+ columns = append(columns, group.Content())
+ }
+ footer := f.selector.Selected().Footer()
+
+ return lipgloss.JoinVertical(lipgloss.Left,
+ lipgloss.JoinHorizontal(lipgloss.Top, columns...),
+ footer,
+ )
+}
+
+func (l *layoutColumns) GroupWidth(_ *Form, _ *Group, w int) int {
+ return w / l.columns
+}
+
+type layoutStack struct{}
+
+func (l *layoutStack) View(f *Form) string {
+ var columns []string
+ f.selector.Range(func(_ int, group *Group) bool {
+ columns = append(columns, group.Content())
+ return true
+ })
+ footer := f.selector.Selected().Footer()
+
+ var view strings.Builder
+ view.WriteString(strings.Join(columns, "\n"))
+ view.WriteString(footer)
+ return view.String()
+}
+
+func (l *layoutStack) GroupWidth(_ *Form, _ *Group, w int) int {
+ return w
+}
+
+type layoutGrid struct {
+ rows, columns int
+}
+
+func (l *layoutGrid) visibleGroups(f *Form) [][]*Group {
+ total := l.rows * l.columns
+ segmentIndex := f.selector.Index() / total
+ start := segmentIndex * total
+ end := start + total
+
+ if glen := f.selector.Total(); end > glen {
+ end = glen
+ }
+
+ var visible []*Group
+ f.selector.Range(func(i int, group *Group) bool {
+ if i >= start && i < end {
+ visible = append(visible, group)
+ return true
+ }
+ return false
+ })
+ grid := make([][]*Group, l.rows)
+ for i := 0; i < l.rows; i++ {
+ startRow := i * l.columns
+ endRow := startRow + l.columns
+ if startRow >= len(visible) {
+ break
+ }
+ if endRow > len(visible) {
+ endRow = len(visible)
+ }
+ grid[i] = visible[startRow:endRow]
+ }
+ return grid
+}
+
+func (l *layoutGrid) View(f *Form) string {
+ grid := l.visibleGroups(f)
+ if len(grid) == 0 {
+ return ""
+ }
+
+ rows := make([]string, 0, len(grid))
+ for _, row := range grid {
+ var columns []string
+ for _, group := range row {
+ columns = append(columns, group.Content())
+ }
+ rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, columns...))
+ }
+ footer := f.selector.Selected().Footer()
+
+ return lipgloss.JoinVertical(lipgloss.Left, strings.Join(rows, "\n"), footer)
+}
+
+func (l *layoutGrid) GroupWidth(_ *Form, _ *Group, w int) int {
+ return w / l.columns
+}
diff --git a/vendor/github.com/charmbracelet/huh/theme.go b/vendor/github.com/charmbracelet/huh/theme.go
index 7541a070..32982a1b 100644
--- a/vendor/github.com/charmbracelet/huh/theme.go
+++ b/vendor/github.com/charmbracelet/huh/theme.go
@@ -58,6 +58,7 @@ type FieldStyles struct {
// TextInputStyles are the styles for text inputs.
type TextInputStyles struct {
Cursor lipgloss.Style
+ CursorText lipgloss.Style
Placeholder lipgloss.Style
Prompt lipgloss.Style
Text lipgloss.Style
diff --git a/vendor/github.com/charmbracelet/huh/validate.go b/vendor/github.com/charmbracelet/huh/validate.go
index 829d947f..7110e422 100644
--- a/vendor/github.com/charmbracelet/huh/validate.go
+++ b/vendor/github.com/charmbracelet/huh/validate.go
@@ -16,32 +16,32 @@ func ValidateNotEmpty() func(s string) error {
}
// ValidateMinLength checks if the length of the input is at least min.
-func ValidateMinLength(min int) func(s string) error {
+func ValidateMinLength(v int) func(s string) error {
return func(s string) error {
- if utf8.RuneCountInString(s) < min {
- return fmt.Errorf("input must be at least %d characters long", min)
+ if utf8.RuneCountInString(s) < v {
+ return fmt.Errorf("input must be at least %d characters long", v)
}
return nil
}
}
// ValidateMaxLength checks if the length of the input is at most max.
-func ValidateMaxLength(max int) func(s string) error {
+func ValidateMaxLength(v int) func(s string) error {
return func(s string) error {
- if utf8.RuneCountInString(s) > max {
- return fmt.Errorf("input must be at most %d characters long", max)
+ if utf8.RuneCountInString(s) > v {
+ return fmt.Errorf("input must be at most %d characters long", v)
}
return nil
}
}
// ValidateLength checks if the length of the input is within the specified range.
-func ValidateLength(min, max int) func(s string) error {
+func ValidateLength(minl, maxl int) func(s string) error {
return func(s string) error {
- if err := ValidateMinLength(min)(s); err != nil {
+ if err := ValidateMinLength(minl)(s); err != nil {
return err
}
- return ValidateMaxLength(max)(s)
+ return ValidateMaxLength(maxl)(s)
}
}
diff --git a/vendor/github.com/charmbracelet/x/ansi/background.go b/vendor/github.com/charmbracelet/x/ansi/background.go
index f519af08..6c66e629 100644
--- a/vendor/github.com/charmbracelet/x/ansi/background.go
+++ b/vendor/github.com/charmbracelet/x/ansi/background.go
@@ -23,6 +23,12 @@ func SetForegroundColor(c color.Color) string {
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
const RequestForegroundColor = "\x1b]10;?\x07"
+// ResetForegroundColor is a sequence that resets the default terminal
+// foreground color.
+//
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+const ResetForegroundColor = "\x1b]110\x07"
+
// SetBackgroundColor returns a sequence that sets the default terminal
// background color.
//
@@ -42,6 +48,12 @@ func SetBackgroundColor(c color.Color) string {
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
const RequestBackgroundColor = "\x1b]11;?\x07"
+// ResetBackgroundColor is a sequence that resets the default terminal
+// background color.
+//
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+const ResetBackgroundColor = "\x1b]111\x07"
+
// SetCursorColor returns a sequence that sets the terminal cursor color.
//
// OSC 12 ; color ST
@@ -59,3 +71,8 @@ func SetCursorColor(c color.Color) string {
//
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
const RequestCursorColor = "\x1b]12;?\x07"
+
+// ResetCursorColor is a sequence that resets the terminal cursor color.
+//
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+const ResetCursorColor = "\x1b]112\x07"
diff --git a/vendor/github.com/mitchellh/hashstructure/v2/LICENSE b/vendor/github.com/mitchellh/hashstructure/v2/LICENSE
new file mode 100644
index 00000000..a3866a29
--- /dev/null
+++ b/vendor/github.com/mitchellh/hashstructure/v2/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 Mitchell Hashimoto
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/vendor/github.com/mitchellh/hashstructure/v2/README.md b/vendor/github.com/mitchellh/hashstructure/v2/README.md
new file mode 100644
index 00000000..21f36be1
--- /dev/null
+++ b/vendor/github.com/mitchellh/hashstructure/v2/README.md
@@ -0,0 +1,76 @@
+# hashstructure [![GoDoc](https://godoc.org/github.com/mitchellh/hashstructure?status.svg)](https://godoc.org/github.com/mitchellh/hashstructure)
+
+hashstructure is a Go library for creating a unique hash value
+for arbitrary values in Go.
+
+This can be used to key values in a hash (for use in a map, set, etc.)
+that are complex. The most common use case is comparing two values without
+sending data across the network, caching values locally (de-dup), and so on.
+
+## Features
+
+ * Hash any arbitrary Go value, including complex types.
+
+ * Tag a struct field to ignore it and not affect the hash value.
+
+ * Tag a slice type struct field to treat it as a set where ordering
+ doesn't affect the hash code but the field itself is still taken into
+ account to create the hash value.
+
+ * Optionally, specify a custom hash function to optimize for speed, collision
+ avoidance for your data set, etc.
+
+ * Optionally, hash the output of `.String()` on structs that implement fmt.Stringer,
+ allowing effective hashing of time.Time
+
+ * Optionally, override the hashing process by implementing `Hashable`.
+
+## Installation
+
+Standard `go get`:
+
+```
+$ go get github.com/mitchellh/hashstructure/v2
+```
+
+**Note on v2:** It is highly recommended you use the "v2" release since this
+fixes some significant hash collisions issues from v1. In practice, we used
+v1 for many years in real projects at HashiCorp and never had issues, but it
+is highly dependent on the shape of the data you're hashing and how you use
+those hashes.
+
+When using v2+, you can still generate weaker v1 hashes by using the
+`FormatV1` format when calling `Hash`.
+
+## Usage & Example
+
+For usage and examples see the [Godoc](http://godoc.org/github.com/mitchellh/hashstructure).
+
+A quick code example is shown below:
+
+```go
+type ComplexStruct struct {
+ Name string
+ Age uint
+ Metadata map[string]interface{}
+}
+
+v := ComplexStruct{
+ Name: "mitchellh",
+ Age: 64,
+ Metadata: map[string]interface{}{
+ "car": true,
+ "location": "California",
+ "siblings": []string{"Bob", "John"},
+ },
+}
+
+hash, err := hashstructure.Hash(v, hashstructure.FormatV2, nil)
+if err != nil {
+ panic(err)
+}
+
+fmt.Printf("%d", hash)
+// Output:
+// 2307517237273902113
+```
diff --git a/vendor/github.com/mitchellh/hashstructure/v2/errors.go b/vendor/github.com/mitchellh/hashstructure/v2/errors.go
new file mode 100644
index 00000000..44b89514
--- /dev/null
+++ b/vendor/github.com/mitchellh/hashstructure/v2/errors.go
@@ -0,0 +1,22 @@
+package hashstructure
+
+import (
+ "fmt"
+)
+
+// ErrNotStringer is returned when there's an error with hash:"string"
+type ErrNotStringer struct {
+ Field string
+}
+
+// Error implements error for ErrNotStringer
+func (ens *ErrNotStringer) Error() string {
+ return fmt.Sprintf("hashstructure: %s has hash:\"string\" set, but does not implement fmt.Stringer", ens.Field)
+}
+
+// ErrFormat is returned when an invalid format is given to the Hash function.
+type ErrFormat struct{}
+
+func (*ErrFormat) Error() string {
+ return "format must be one of the defined Format values in the hashstructure library"
+}
diff --git a/vendor/github.com/mitchellh/hashstructure/v2/hashstructure.go b/vendor/github.com/mitchellh/hashstructure/v2/hashstructure.go
new file mode 100644
index 00000000..3dc0eb74
--- /dev/null
+++ b/vendor/github.com/mitchellh/hashstructure/v2/hashstructure.go
@@ -0,0 +1,482 @@
+package hashstructure
+
+import (
+ "encoding/binary"
+ "fmt"
+ "hash"
+ "hash/fnv"
+ "reflect"
+ "time"
+)
+
+// HashOptions are options that are available for hashing.
+type HashOptions struct {
+ // Hasher is the hash function to use. If this isn't set, it will
+ // default to FNV.
+ Hasher hash.Hash64
+
+ // TagName is the struct tag to look at when hashing the structure.
+ // By default this is "hash".
+ TagName string
+
+ // ZeroNil is flag determining if nil pointer should be treated equal
+ // to a zero value of pointed type. By default this is false.
+ ZeroNil bool
+
+ // IgnoreZeroValue is determining if zero value fields should be
+ // ignored for hash calculation.
+ IgnoreZeroValue bool
+
+ // SlicesAsSets assumes that a `set` tag is always present for slices.
+ // Default is false (in which case the tag is used instead)
+ SlicesAsSets bool
+
+ // UseStringer will attempt to use fmt.Stringer always. If the struct
+ // doesn't implement fmt.Stringer, it'll fall back to trying usual tricks.
+ // If this is true, and the "string" tag is also set, the tag takes
+ // precedence (meaning that if the type doesn't implement fmt.Stringer, we
+ // panic)
+ UseStringer bool
+}
+
+// Format specifies the hashing process used. Different formats typically
+// generate different hashes for the same value and have different properties.
+type Format uint
+
+const (
+ // To disallow the zero value
+ formatInvalid Format = iota
+
+ // FormatV1 is the format used in v1.x of this library. This has the
+ // downsides noted in issue #18 but allows simultaneous v1/v2 usage.
+ FormatV1
+
+ // FormatV2 is the current recommended format and fixes the issues
+ // noted in FormatV1.
+ FormatV2
+
+ formatMax // so we can easily find the end
+)
+
+// Hash returns the hash value of an arbitrary value.
+//
+// If opts is nil, then default options will be used. See HashOptions
+// for the default values. The same *HashOptions value cannot be used
+// concurrently. None of the values within a *HashOptions struct are
+// safe to read/write while hashing is being done.
+//
+// The "format" is required and must be one of the format values defined
+// by this library. You should probably just use "FormatV2". This allows
+// generated hashes uses alternate logic to maintain compatibility with
+// older versions.
+//
+// Notes on the value:
+//
+// * Unexported fields on structs are ignored and do not affect the
+// hash value.
+//
+// * Adding an exported field to a struct with the zero value will change
+// the hash value.
+//
+// For structs, the hashing can be controlled using tags. For example:
+//
+// struct {
+// Name string
+// UUID string `hash:"ignore"`
+// }
+//
+// The available tag values are:
+//
+// * "ignore" or "-" - The field will be ignored and not affect the hash code.
+//
+// * "set" - The field will be treated as a set, where ordering doesn't
+// affect the hash code. This only works for slices.
+//
+// * "string" - The field will be hashed as a string, only works when the
+// field implements fmt.Stringer
+//
+func Hash(v interface{}, format Format, opts *HashOptions) (uint64, error) {
+ // Validate our format
+ if format <= formatInvalid || format >= formatMax {
+ return 0, &ErrFormat{}
+ }
+
+ // Create default options
+ if opts == nil {
+ opts = &HashOptions{}
+ }
+ if opts.Hasher == nil {
+ opts.Hasher = fnv.New64()
+ }
+ if opts.TagName == "" {
+ opts.TagName = "hash"
+ }
+
+ // Reset the hash
+ opts.Hasher.Reset()
+
+ // Create our walker and walk the structure
+ w := &walker{
+ format: format,
+ h: opts.Hasher,
+ tag: opts.TagName,
+ zeronil: opts.ZeroNil,
+ ignorezerovalue: opts.IgnoreZeroValue,
+ sets: opts.SlicesAsSets,
+ stringer: opts.UseStringer,
+ }
+ return w.visit(reflect.ValueOf(v), nil)
+}
+
+type walker struct {
+ format Format
+ h hash.Hash64
+ tag string
+ zeronil bool
+ ignorezerovalue bool
+ sets bool
+ stringer bool
+}
+
+type visitOpts struct {
+ // Flags are a bitmask of flags to affect behavior of this visit
+ Flags visitFlag
+
+ // Information about the struct containing this field
+ Struct interface{}
+ StructField string
+}
+
+var timeType = reflect.TypeOf(time.Time{})
+
+func (w *walker) visit(v reflect.Value, opts *visitOpts) (uint64, error) {
+ t := reflect.TypeOf(0)
+
+ // Loop since these can be wrapped in multiple layers of pointers
+ // and interfaces.
+ for {
+ // If we have an interface, dereference it. We have to do this up
+ // here because it might be a nil in there and the check below must
+ // catch that.
+ if v.Kind() == reflect.Interface {
+ v = v.Elem()
+ continue
+ }
+
+ if v.Kind() == reflect.Ptr {
+ if w.zeronil {
+ t = v.Type().Elem()
+ }
+ v = reflect.Indirect(v)
+ continue
+ }
+
+ break
+ }
+
+ // If it is nil, treat it like a zero.
+ if !v.IsValid() {
+ v = reflect.Zero(t)
+ }
+
+ // Binary writing can use raw ints, we have to convert to
+ // a sized-int, we'll choose the largest...
+ switch v.Kind() {
+ case reflect.Int:
+ v = reflect.ValueOf(int64(v.Int()))
+ case reflect.Uint:
+ v = reflect.ValueOf(uint64(v.Uint()))
+ case reflect.Bool:
+ var tmp int8
+ if v.Bool() {
+ tmp = 1
+ }
+ v = reflect.ValueOf(tmp)
+ }
+
+ k := v.Kind()
+
+ // We can shortcut numeric values by directly binary writing them
+ if k >= reflect.Int && k <= reflect.Complex64 {
+ // A direct hash calculation
+ w.h.Reset()
+ err := binary.Write(w.h, binary.LittleEndian, v.Interface())
+ return w.h.Sum64(), err
+ }
+
+ switch v.Type() {
+ case timeType:
+ w.h.Reset()
+ b, err := v.Interface().(time.Time).MarshalBinary()
+ if err != nil {
+ return 0, err
+ }
+
+ err = binary.Write(w.h, binary.LittleEndian, b)
+ return w.h.Sum64(), err
+ }
+
+ switch k {
+ case reflect.Array:
+ var h uint64
+ l := v.Len()
+ for i := 0; i < l; i++ {
+ current, err := w.visit(v.Index(i), nil)
+ if err != nil {
+ return 0, err
+ }
+
+ h = hashUpdateOrdered(w.h, h, current)
+ }
+
+ return h, nil
+
+ case reflect.Map:
+ var includeMap IncludableMap
+ if opts != nil && opts.Struct != nil {
+ if v, ok := opts.Struct.(IncludableMap); ok {
+ includeMap = v
+ }
+ }
+
+ // Build the hash for the map. We do this by XOR-ing all the key
+ // and value hashes. This makes it deterministic despite ordering.
+ var h uint64
+ for _, k := range v.MapKeys() {
+ v := v.MapIndex(k)
+ if includeMap != nil {
+ incl, err := includeMap.HashIncludeMap(
+ opts.StructField, k.Interface(), v.Interface())
+ if err != nil {
+ return 0, err
+ }
+ if !incl {
+ continue
+ }
+ }
+
+ kh, err := w.visit(k, nil)
+ if err != nil {
+ return 0, err
+ }
+ vh, err := w.visit(v, nil)
+ if err != nil {
+ return 0, err
+ }
+
+ fieldHash := hashUpdateOrdered(w.h, kh, vh)
+ h = hashUpdateUnordered(h, fieldHash)
+ }
+
+ if w.format != FormatV1 {
+ // Important: read the docs for hashFinishUnordered
+ h = hashFinishUnordered(w.h, h)
+ }
+
+ return h, nil
+
+ case reflect.Struct:
+ parent := v.Interface()
+ var include Includable
+ if impl, ok := parent.(Includable); ok {
+ include = impl
+ }
+
+ if impl, ok := parent.(Hashable); ok {
+ return impl.Hash()
+ }
+
+ // If we can address this value, check if the pointer value
+ // implements our interfaces and use that if so.
+ if v.CanAddr() {
+ vptr := v.Addr()
+ parentptr := vptr.Interface()
+ if impl, ok := parentptr.(Includable); ok {
+ include = impl
+ }
+
+ if impl, ok := parentptr.(Hashable); ok {
+ return impl.Hash()
+ }
+ }
+
+ t := v.Type()
+ h, err := w.visit(reflect.ValueOf(t.Name()), nil)
+ if err != nil {
+ return 0, err
+ }
+
+ l := v.NumField()
+ for i := 0; i < l; i++ {
+ if innerV := v.Field(i); v.CanSet() || t.Field(i).Name != "_" {
+ var f visitFlag
+ fieldType := t.Field(i)
+ if fieldType.PkgPath != "" {
+ // Unexported
+ continue
+ }
+
+ tag := fieldType.Tag.Get(w.tag)
+ if tag == "ignore" || tag == "-" {
+ // Ignore this field
+ continue
+ }
+
+ if w.ignorezerovalue {
+ if innerV.IsZero() {
+ continue
+ }
+ }
+
+ // if string is set, use the string value
+ if tag == "string" || w.stringer {
+ if impl, ok := innerV.Interface().(fmt.Stringer); ok {
+ innerV = reflect.ValueOf(impl.String())
+ } else if tag == "string" {
+ // We only show this error if the tag explicitly
+ // requests a stringer.
+ return 0, &ErrNotStringer{
+ Field: v.Type().Field(i).Name,
+ }
+ }
+ }
+
+ // Check if we implement includable and check it
+ if include != nil {
+ incl, err := include.HashInclude(fieldType.Name, innerV)
+ if err != nil {
+ return 0, err
+ }
+ if !incl {
+ continue
+ }
+ }
+
+ switch tag {
+ case "set":
+ f |= visitFlagSet
+ }
+
+ kh, err := w.visit(reflect.ValueOf(fieldType.Name), nil)
+ if err != nil {
+ return 0, err
+ }
+
+ vh, err := w.visit(innerV, &visitOpts{
+ Flags: f,
+ Struct: parent,
+ StructField: fieldType.Name,
+ })
+ if err != nil {
+ return 0, err
+ }
+
+ fieldHash := hashUpdateOrdered(w.h, kh, vh)
+ h = hashUpdateUnordered(h, fieldHash)
+ }
+
+ if w.format != FormatV1 {
+ // Important: read the docs for hashFinishUnordered
+ h = hashFinishUnordered(w.h, h)
+ }
+ }
+
+ return h, nil
+
+ case reflect.Slice:
+ // We have two behaviors here. If it isn't a set, then we just
+ // visit all the elements. If it is a set, then we do a deterministic
+ // hash code.
+ var h uint64
+ var set bool
+ if opts != nil {
+ set = (opts.Flags & visitFlagSet) != 0
+ }
+ l := v.Len()
+ for i := 0; i < l; i++ {
+ current, err := w.visit(v.Index(i), nil)
+ if err != nil {
+ return 0, err
+ }
+
+ if set || w.sets {
+ h = hashUpdateUnordered(h, current)
+ } else {
+ h = hashUpdateOrdered(w.h, h, current)
+ }
+ }
+
+ if set && w.format != FormatV1 {
+ // Important: read the docs for hashFinishUnordered
+ h = hashFinishUnordered(w.h, h)
+ }
+
+ return h, nil
+
+ case reflect.String:
+ // Directly hash
+ w.h.Reset()
+ _, err := w.h.Write([]byte(v.String()))
+ return w.h.Sum64(), err
+
+ default:
+ return 0, fmt.Errorf("unknown kind to hash: %s", k)
+ }
+
+}
+
+func hashUpdateOrdered(h hash.Hash64, a, b uint64) uint64 {
+ // For ordered updates, use a real hash function
+ h.Reset()
+
+ // We just panic if the binary writes fail because we are writing
+ // an int64 which should never be fail-able.
+ e1 := binary.Write(h, binary.LittleEndian, a)
+ e2 := binary.Write(h, binary.LittleEndian, b)
+ if e1 != nil {
+ panic(e1)
+ }
+ if e2 != nil {
+ panic(e2)
+ }
+
+ return h.Sum64()
+}
+
+func hashUpdateUnordered(a, b uint64) uint64 {
+ return a ^ b
+}
+
+// After mixing a group of unique hashes with hashUpdateUnordered, it's always
+// necessary to call hashFinishUnordered. Why? Because hashUpdateUnordered
+// is a simple XOR, and calling hashUpdateUnordered on hashes produced by
+// hashUpdateUnordered can effectively cancel out a previous change to the hash
+// result if the same hash value appears later on. For example, consider:
+//
+// hashUpdateUnordered(hashUpdateUnordered("A", "B"), hashUpdateUnordered("A", "C")) =
+// H("A") ^ H("B")) ^ (H("A") ^ H("C")) =
+// (H("A") ^ H("A")) ^ (H("B") ^ H(C)) =
+// H(B) ^ H(C) =
+// hashUpdateUnordered(hashUpdateUnordered("Z", "B"), hashUpdateUnordered("Z", "C"))
+//
+// hashFinishUnordered "hardens" the result, so that encountering partially
+// overlapping input data later on in a different context won't cancel out.
+func hashFinishUnordered(h hash.Hash64, a uint64) uint64 {
+ h.Reset()
+
+ // We just panic if the writes fail
+ e1 := binary.Write(h, binary.LittleEndian, a)
+ if e1 != nil {
+ panic(e1)
+ }
+
+ return h.Sum64()
+}
+
+// visitFlag is used as a bitmask for affecting visit behavior
+type visitFlag uint
+
+const (
+ visitFlagInvalid visitFlag = iota
+ visitFlagSet = iota << 1
+)
diff --git a/vendor/github.com/mitchellh/hashstructure/v2/include.go b/vendor/github.com/mitchellh/hashstructure/v2/include.go
new file mode 100644
index 00000000..702d3541
--- /dev/null
+++ b/vendor/github.com/mitchellh/hashstructure/v2/include.go
@@ -0,0 +1,22 @@
+package hashstructure
+
+// Includable is an interface that can optionally be implemented by
+// a struct. It will be called for each field in the struct to check whether
+// it should be included in the hash.
+type Includable interface {
+ HashInclude(field string, v interface{}) (bool, error)
+}
+
+// IncludableMap is an interface that can optionally be implemented by
+// a struct. It will be called when a map-type field is found to ask the
+// struct if the map item should be included in the hash.
+type IncludableMap interface {
+ HashIncludeMap(field string, k, v interface{}) (bool, error)
+}
+
+// Hashable is an interface that can optionally be implemented by a struct
+// to override the hash value. This value will override the hash value for
+// the entire struct. Entries in the struct will not be hashed.
+type Hashable interface {
+ Hash() (uint64, error)
+}
diff --git a/vendor/github.com/muesli/termenv/README.md b/vendor/github.com/muesli/termenv/README.md
index 29dcf017..fa7929d4 100644
--- a/vendor/github.com/muesli/termenv/README.md
+++ b/vendor/github.com/muesli/termenv/README.md
@@ -307,7 +307,7 @@ termenv.DisableBracketedPaste()
### Color Support
-- 24-bit (RGB): alacritty, foot, iTerm, kitty, Konsole, st, tmux, vte-based, wezterm, Windows Terminal
+- 24-bit (RGB): alacritty, foot, iTerm, kitty, Konsole, st, tmux, vte-based, wezterm, Ghostty, Windows Terminal
- 8-bit (256): rxvt, screen, xterm, Apple Terminal
- 4-bit (16): Linux Console
@@ -350,7 +350,7 @@ You can help improve this list! Check out [how to](ansi_compat.md) and open an i
| Terminal | Copy to Clipboard (OSC52) | Hyperlinks (OSC8) | Notifications (OSC777) |
| ---------------- | :-----------------------: | :---------------: | :--------------------: |
-| alacritty | ✅ | ❌[^alacritty] | ❌ |
+| alacritty | ✅ | ✅[^alacritty] | ❌ |
| foot | ✅ | ✅ | ✅ |
| kitty | ✅ | ✅ | ✅ |
| Konsole | ❌[^konsole] | ✅ | ❌ |
@@ -374,7 +374,7 @@ You can help improve this list! Check out [how to](ansi_compat.md) and open an i
[^apple]: OSC52 works with a [workaround](https://github.com/roy2220/osc52pty).
[^tmux]: OSC8 is not supported, for more info see [issue#911](https://github.com/tmux/tmux/issues/911).
[^screen]: OSC8 is not supported, for more info see [bug#50952](https://savannah.gnu.org/bugs/index.php?50952).
-[^alacritty]: OSC8 is not supported, for more info see [issue#922](https://github.com/alacritty/alacritty/issues/922).
+[^alacritty]: OSC8 is supported since [v0.11.0](https://github.com/alacritty/alacritty/releases/tag/v0.11.0)
diff --git a/vendor/github.com/muesli/termenv/ansicolors.go b/vendor/github.com/muesli/termenv/ansicolors.go
index ee303e22..1a301b0f 100644
--- a/vendor/github.com/muesli/termenv/ansicolors.go
+++ b/vendor/github.com/muesli/termenv/ansicolors.go
@@ -1,6 +1,6 @@
package termenv
-// ANSI color codes
+// ANSI color codes.
const (
ANSIBlack ANSIColor = iota
ANSIRed
diff --git a/vendor/github.com/muesli/termenv/color.go b/vendor/github.com/muesli/termenv/color.go
index 1a216e93..eb4f9886 100644
--- a/vendor/github.com/muesli/termenv/color.go
+++ b/vendor/github.com/muesli/termenv/color.go
@@ -14,7 +14,7 @@ var (
ErrInvalidColor = errors.New("invalid color")
)
-// Foreground and Background sequence codes
+// Foreground and Background sequence codes.
const (
Foreground = "38"
Background = "48"
diff --git a/vendor/github.com/muesli/termenv/constants_zos.go b/vendor/github.com/muesli/termenv/constants_zos.go
new file mode 100644
index 00000000..4262f03b
--- /dev/null
+++ b/vendor/github.com/muesli/termenv/constants_zos.go
@@ -0,0 +1,8 @@
+package termenv
+
+import "golang.org/x/sys/unix"
+
+const (
+ tcgetattr = unix.TCGETS
+ tcsetattr = unix.TCSETS
+)
diff --git a/vendor/github.com/muesli/termenv/output.go b/vendor/github.com/muesli/termenv/output.go
index e22d369c..ebe48fe6 100644
--- a/vendor/github.com/muesli/termenv/output.go
+++ b/vendor/github.com/muesli/termenv/output.go
@@ -12,6 +12,8 @@ var (
)
// File represents a file descriptor.
+//
+// Deprecated: Use *os.File instead.
type File interface {
io.ReadWriter
Fd() uintptr
@@ -23,7 +25,7 @@ type OutputOption = func(*Output)
// Output is a terminal output.
type Output struct {
Profile
- tty io.Writer
+ w io.Writer
environ Environ
assumeTTY bool
@@ -61,10 +63,10 @@ func SetDefaultOutput(o *Output) {
output = o
}
-// NewOutput returns a new Output for the given file descriptor.
-func NewOutput(tty io.Writer, opts ...OutputOption) *Output {
+// NewOutput returns a new Output for the given writer.
+func NewOutput(w io.Writer, opts ...OutputOption) *Output {
o := &Output{
- tty: tty,
+ w: w,
environ: &osEnviron{},
Profile: -1,
fgSync: &sync.Once{},
@@ -73,8 +75,8 @@ func NewOutput(tty io.Writer, opts ...OutputOption) *Output {
bgColor: NoColor{},
}
- if o.tty == nil {
- o.tty = os.Stdout
+ if o.w == nil {
+ o.w = os.Stdout
}
for _, opt := range opts {
opt(o)
@@ -180,15 +182,23 @@ func (o *Output) HasDarkBackground() bool {
// TTY returns the terminal's file descriptor. This may be nil if the output is
// not a terminal.
+//
+// Deprecated: Use Writer() instead.
func (o Output) TTY() File {
- if f, ok := o.tty.(File); ok {
+ if f, ok := o.w.(File); ok {
return f
}
return nil
}
+// Writer returns the underlying writer. This may be of type io.Writer,
+// io.ReadWriter, or *os.File.
+func (o Output) Writer() io.Writer {
+ return o.w
+}
+
func (o Output) Write(p []byte) (int, error) {
- return o.tty.Write(p)
+ return o.w.Write(p)
}
// WriteString writes the given string to the output.
diff --git a/vendor/github.com/muesli/termenv/profile.go b/vendor/github.com/muesli/termenv/profile.go
index fa128e20..d7b43c08 100644
--- a/vendor/github.com/muesli/termenv/profile.go
+++ b/vendor/github.com/muesli/termenv/profile.go
@@ -12,16 +12,31 @@ import (
type Profile int
const (
- // TrueColor, 24-bit color profile
+ // TrueColor, 24-bit color profile.
TrueColor = Profile(iota)
- // ANSI256, 8-bit color profile
+ // ANSI256, 8-bit color profile.
ANSI256
- // ANSI, 4-bit color profile
+ // ANSI, 4-bit color profile.
ANSI
- // Ascii, uncolored profile
+ // Ascii, uncolored profile.
Ascii //nolint:revive
)
+// Name returns the profile name as a string.
+func (p Profile) Name() string {
+ switch p {
+ case Ascii:
+ return "Ascii"
+ case ANSI:
+ return "ANSI"
+ case ANSI256:
+ return "ANSI256"
+ case TrueColor:
+ return "TrueColor"
+ }
+ return "Unknown"
+}
+
// String returns a new Style.
func (p Profile) String(s ...string) Style {
return Style{
diff --git a/vendor/github.com/muesli/termenv/screen.go b/vendor/github.com/muesli/termenv/screen.go
index a71181b6..19f28dcd 100644
--- a/vendor/github.com/muesli/termenv/screen.go
+++ b/vendor/github.com/muesli/termenv/screen.go
@@ -71,234 +71,234 @@ const (
// Reset the terminal to its default style, removing any active styles.
func (o Output) Reset() {
- fmt.Fprint(o.tty, CSI+ResetSeq+"m")
+ fmt.Fprint(o.w, CSI+ResetSeq+"m")
}
// SetForegroundColor sets the default foreground color.
func (o Output) SetForegroundColor(color Color) {
- fmt.Fprintf(o.tty, OSC+SetForegroundColorSeq, color)
+ fmt.Fprintf(o.w, OSC+SetForegroundColorSeq, color)
}
// SetBackgroundColor sets the default background color.
func (o Output) SetBackgroundColor(color Color) {
- fmt.Fprintf(o.tty, OSC+SetBackgroundColorSeq, color)
+ fmt.Fprintf(o.w, OSC+SetBackgroundColorSeq, color)
}
// SetCursorColor sets the cursor color.
func (o Output) SetCursorColor(color Color) {
- fmt.Fprintf(o.tty, OSC+SetCursorColorSeq, color)
+ fmt.Fprintf(o.w, OSC+SetCursorColorSeq, color)
}
// RestoreScreen restores a previously saved screen state.
func (o Output) RestoreScreen() {
- fmt.Fprint(o.tty, CSI+RestoreScreenSeq)
+ fmt.Fprint(o.w, CSI+RestoreScreenSeq)
}
// SaveScreen saves the screen state.
func (o Output) SaveScreen() {
- fmt.Fprint(o.tty, CSI+SaveScreenSeq)
+ fmt.Fprint(o.w, CSI+SaveScreenSeq)
}
// AltScreen switches to the alternate screen buffer. The former view can be
// restored with ExitAltScreen().
func (o Output) AltScreen() {
- fmt.Fprint(o.tty, CSI+AltScreenSeq)
+ fmt.Fprint(o.w, CSI+AltScreenSeq)
}
// ExitAltScreen exits the alternate screen buffer and returns to the former
// terminal view.
func (o Output) ExitAltScreen() {
- fmt.Fprint(o.tty, CSI+ExitAltScreenSeq)
+ fmt.Fprint(o.w, CSI+ExitAltScreenSeq)
}
// ClearScreen clears the visible portion of the terminal.
func (o Output) ClearScreen() {
- fmt.Fprintf(o.tty, CSI+EraseDisplaySeq, 2)
+ fmt.Fprintf(o.w, CSI+EraseDisplaySeq, 2)
o.MoveCursor(1, 1)
}
// MoveCursor moves the cursor to a given position.
func (o Output) MoveCursor(row int, column int) {
- fmt.Fprintf(o.tty, CSI+CursorPositionSeq, row, column)
+ fmt.Fprintf(o.w, CSI+CursorPositionSeq, row, column)
}
// HideCursor hides the cursor.
func (o Output) HideCursor() {
- fmt.Fprint(o.tty, CSI+HideCursorSeq)
+ fmt.Fprint(o.w, CSI+HideCursorSeq)
}
// ShowCursor shows the cursor.
func (o Output) ShowCursor() {
- fmt.Fprint(o.tty, CSI+ShowCursorSeq)
+ fmt.Fprint(o.w, CSI+ShowCursorSeq)
}
// SaveCursorPosition saves the cursor position.
func (o Output) SaveCursorPosition() {
- fmt.Fprint(o.tty, CSI+SaveCursorPositionSeq)
+ fmt.Fprint(o.w, CSI+SaveCursorPositionSeq)
}
// RestoreCursorPosition restores a saved cursor position.
func (o Output) RestoreCursorPosition() {
- fmt.Fprint(o.tty, CSI+RestoreCursorPositionSeq)
+ fmt.Fprint(o.w, CSI+RestoreCursorPositionSeq)
}
// CursorUp moves the cursor up a given number of lines.
func (o Output) CursorUp(n int) {
- fmt.Fprintf(o.tty, CSI+CursorUpSeq, n)
+ fmt.Fprintf(o.w, CSI+CursorUpSeq, n)
}
// CursorDown moves the cursor down a given number of lines.
func (o Output) CursorDown(n int) {
- fmt.Fprintf(o.tty, CSI+CursorDownSeq, n)
+ fmt.Fprintf(o.w, CSI+CursorDownSeq, n)
}
// CursorForward moves the cursor up a given number of lines.
func (o Output) CursorForward(n int) {
- fmt.Fprintf(o.tty, CSI+CursorForwardSeq, n)
+ fmt.Fprintf(o.w, CSI+CursorForwardSeq, n)
}
// CursorBack moves the cursor backwards a given number of cells.
func (o Output) CursorBack(n int) {
- fmt.Fprintf(o.tty, CSI+CursorBackSeq, n)
+ fmt.Fprintf(o.w, CSI+CursorBackSeq, n)
}
// CursorNextLine moves the cursor down a given number of lines and places it at
// the beginning of the line.
func (o Output) CursorNextLine(n int) {
- fmt.Fprintf(o.tty, CSI+CursorNextLineSeq, n)
+ fmt.Fprintf(o.w, CSI+CursorNextLineSeq, n)
}
// CursorPrevLine moves the cursor up a given number of lines and places it at
// the beginning of the line.
func (o Output) CursorPrevLine(n int) {
- fmt.Fprintf(o.tty, CSI+CursorPreviousLineSeq, n)
+ fmt.Fprintf(o.w, CSI+CursorPreviousLineSeq, n)
}
// ClearLine clears the current line.
func (o Output) ClearLine() {
- fmt.Fprint(o.tty, CSI+EraseEntireLineSeq)
+ fmt.Fprint(o.w, CSI+EraseEntireLineSeq)
}
// ClearLineLeft clears the line to the left of the cursor.
func (o Output) ClearLineLeft() {
- fmt.Fprint(o.tty, CSI+EraseLineLeftSeq)
+ fmt.Fprint(o.w, CSI+EraseLineLeftSeq)
}
// ClearLineRight clears the line to the right of the cursor.
func (o Output) ClearLineRight() {
- fmt.Fprint(o.tty, CSI+EraseLineRightSeq)
+ fmt.Fprint(o.w, CSI+EraseLineRightSeq)
}
// ClearLines clears a given number of lines.
func (o Output) ClearLines(n int) {
clearLine := fmt.Sprintf(CSI+EraseLineSeq, 2)
cursorUp := fmt.Sprintf(CSI+CursorUpSeq, 1)
- fmt.Fprint(o.tty, clearLine+strings.Repeat(cursorUp+clearLine, n))
+ fmt.Fprint(o.w, clearLine+strings.Repeat(cursorUp+clearLine, n))
}
// ChangeScrollingRegion sets the scrolling region of the terminal.
func (o Output) ChangeScrollingRegion(top, bottom int) {
- fmt.Fprintf(o.tty, CSI+ChangeScrollingRegionSeq, top, bottom)
+ fmt.Fprintf(o.w, CSI+ChangeScrollingRegionSeq, top, bottom)
}
// InsertLines inserts the given number of lines at the top of the scrollable
// region, pushing lines below down.
func (o Output) InsertLines(n int) {
- fmt.Fprintf(o.tty, CSI+InsertLineSeq, n)
+ fmt.Fprintf(o.w, CSI+InsertLineSeq, n)
}
// DeleteLines deletes the given number of lines, pulling any lines in
// the scrollable region below up.
func (o Output) DeleteLines(n int) {
- fmt.Fprintf(o.tty, CSI+DeleteLineSeq, n)
+ fmt.Fprintf(o.w, CSI+DeleteLineSeq, n)
}
// EnableMousePress enables X10 mouse mode. Button press events are sent only.
func (o Output) EnableMousePress() {
- fmt.Fprint(o.tty, CSI+EnableMousePressSeq)
+ fmt.Fprint(o.w, CSI+EnableMousePressSeq)
}
// DisableMousePress disables X10 mouse mode.
func (o Output) DisableMousePress() {
- fmt.Fprint(o.tty, CSI+DisableMousePressSeq)
+ fmt.Fprint(o.w, CSI+DisableMousePressSeq)
}
// EnableMouse enables Mouse Tracking mode.
func (o Output) EnableMouse() {
- fmt.Fprint(o.tty, CSI+EnableMouseSeq)
+ fmt.Fprint(o.w, CSI+EnableMouseSeq)
}
// DisableMouse disables Mouse Tracking mode.
func (o Output) DisableMouse() {
- fmt.Fprint(o.tty, CSI+DisableMouseSeq)
+ fmt.Fprint(o.w, CSI+DisableMouseSeq)
}
// EnableMouseHilite enables Hilite Mouse Tracking mode.
func (o Output) EnableMouseHilite() {
- fmt.Fprint(o.tty, CSI+EnableMouseHiliteSeq)
+ fmt.Fprint(o.w, CSI+EnableMouseHiliteSeq)
}
// DisableMouseHilite disables Hilite Mouse Tracking mode.
func (o Output) DisableMouseHilite() {
- fmt.Fprint(o.tty, CSI+DisableMouseHiliteSeq)
+ fmt.Fprint(o.w, CSI+DisableMouseHiliteSeq)
}
// EnableMouseCellMotion enables Cell Motion Mouse Tracking mode.
func (o Output) EnableMouseCellMotion() {
- fmt.Fprint(o.tty, CSI+EnableMouseCellMotionSeq)
+ fmt.Fprint(o.w, CSI+EnableMouseCellMotionSeq)
}
// DisableMouseCellMotion disables Cell Motion Mouse Tracking mode.
func (o Output) DisableMouseCellMotion() {
- fmt.Fprint(o.tty, CSI+DisableMouseCellMotionSeq)
+ fmt.Fprint(o.w, CSI+DisableMouseCellMotionSeq)
}
// EnableMouseAllMotion enables All Motion Mouse mode.
func (o Output) EnableMouseAllMotion() {
- fmt.Fprint(o.tty, CSI+EnableMouseAllMotionSeq)
+ fmt.Fprint(o.w, CSI+EnableMouseAllMotionSeq)
}
// DisableMouseAllMotion disables All Motion Mouse mode.
func (o Output) DisableMouseAllMotion() {
- fmt.Fprint(o.tty, CSI+DisableMouseAllMotionSeq)
+ fmt.Fprint(o.w, CSI+DisableMouseAllMotionSeq)
}
// EnableMouseExtendedMotion enables Extended Mouse mode (SGR). This should be
// enabled in conjunction with EnableMouseCellMotion, and EnableMouseAllMotion.
func (o Output) EnableMouseExtendedMode() {
- fmt.Fprint(o.tty, CSI+EnableMouseExtendedModeSeq)
+ fmt.Fprint(o.w, CSI+EnableMouseExtendedModeSeq)
}
// DisableMouseExtendedMotion disables Extended Mouse mode (SGR).
func (o Output) DisableMouseExtendedMode() {
- fmt.Fprint(o.tty, CSI+DisableMouseExtendedModeSeq)
+ fmt.Fprint(o.w, CSI+DisableMouseExtendedModeSeq)
}
// EnableMousePixelsMotion enables Pixel Motion Mouse mode (SGR-Pixels). This
// should be enabled in conjunction with EnableMouseCellMotion, and
// EnableMouseAllMotion.
func (o Output) EnableMousePixelsMode() {
- fmt.Fprint(o.tty, CSI+EnableMousePixelsModeSeq)
+ fmt.Fprint(o.w, CSI+EnableMousePixelsModeSeq)
}
// DisableMousePixelsMotion disables Pixel Motion Mouse mode (SGR-Pixels).
func (o Output) DisableMousePixelsMode() {
- fmt.Fprint(o.tty, CSI+DisableMousePixelsModeSeq)
+ fmt.Fprint(o.w, CSI+DisableMousePixelsModeSeq)
}
// SetWindowTitle sets the terminal window title.
func (o Output) SetWindowTitle(title string) {
- fmt.Fprintf(o.tty, OSC+SetWindowTitleSeq, title)
+ fmt.Fprintf(o.w, OSC+SetWindowTitleSeq, title)
}
// EnableBracketedPaste enables bracketed paste.
func (o Output) EnableBracketedPaste() {
- fmt.Fprintf(o.tty, CSI+EnableBracketedPasteSeq)
+ fmt.Fprintf(o.w, CSI+EnableBracketedPasteSeq)
}
// DisableBracketedPaste disables bracketed paste.
func (o Output) DisableBracketedPaste() {
- fmt.Fprintf(o.tty, CSI+DisableBracketedPasteSeq)
+ fmt.Fprintf(o.w, CSI+DisableBracketedPasteSeq)
}
// Legacy functions.
diff --git a/vendor/github.com/muesli/termenv/style.go b/vendor/github.com/muesli/termenv/style.go
index 83b0b4d7..dedc1f9f 100644
--- a/vendor/github.com/muesli/termenv/style.go
+++ b/vendor/github.com/muesli/termenv/style.go
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
- "github.com/mattn/go-runewidth"
+ "github.com/rivo/uniseg"
)
// Sequence definitions.
@@ -122,5 +122,5 @@ func (t Style) CrossOut() Style {
// Width returns the width required to print all runes in Style.
func (t Style) Width() int {
- return runewidth.StringWidth(t.string)
+ return uniseg.StringWidth(t.string)
}
diff --git a/vendor/github.com/muesli/termenv/termenv.go b/vendor/github.com/muesli/termenv/termenv.go
index 4ceb2717..d702cd55 100644
--- a/vendor/github.com/muesli/termenv/termenv.go
+++ b/vendor/github.com/muesli/termenv/termenv.go
@@ -2,6 +2,7 @@ package termenv
import (
"errors"
+ "os"
"github.com/mattn/go-isatty"
)
@@ -12,15 +13,15 @@ var (
)
const (
- // Escape character
+ // Escape character.
ESC = '\x1b'
- // Bell
+ // Bell.
BEL = '\a'
- // Control Sequence Introducer
+ // Control Sequence Introducer.
CSI = string(ESC) + "["
- // Operating System Command
+ // Operating System Command.
OSC = string(ESC) + "]"
- // String Terminator
+ // String Terminator.
ST = string(ESC) + `\`
)
@@ -31,11 +32,11 @@ func (o *Output) isTTY() bool {
if len(o.environ.Getenv("CI")) > 0 {
return false
}
- if o.TTY() == nil {
- return false
+ if f, ok := o.Writer().(*os.File); ok {
+ return isatty.IsTerminal(f.Fd())
}
- return isatty.IsTerminal(o.TTY().Fd())
+ return false
}
// ColorProfile returns the supported color profile:
diff --git a/vendor/github.com/muesli/termenv/termenv_posix.go b/vendor/github.com/muesli/termenv/termenv_posix.go
index b2109b74..c971dd99 100644
--- a/vendor/github.com/muesli/termenv/termenv_posix.go
+++ b/vendor/github.com/muesli/termenv/termenv_posix.go
@@ -1,5 +1,5 @@
-//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd
-// +build darwin dragonfly freebsd linux netbsd openbsd
+//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || zos
+// +build darwin dragonfly freebsd linux netbsd openbsd zos
package termenv
diff --git a/vendor/github.com/muesli/termenv/termenv_unix.go b/vendor/github.com/muesli/termenv/termenv_unix.go
index 24d519a5..d38cb279 100644
--- a/vendor/github.com/muesli/termenv/termenv_unix.go
+++ b/vendor/github.com/muesli/termenv/termenv_unix.go
@@ -1,5 +1,5 @@
-//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
-// +build darwin dragonfly freebsd linux netbsd openbsd solaris
+//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
+// +build darwin dragonfly freebsd linux netbsd openbsd solaris zos
package termenv
@@ -50,7 +50,7 @@ func (o *Output) ColorProfile() Profile {
}
switch term {
- case "xterm-kitty", "wezterm":
+ case "xterm-kitty", "wezterm", "xterm-ghostty":
return TrueColor
case "linux":
return ANSI
@@ -227,7 +227,7 @@ func (o Output) termStatusReport(sequence int) (string, error) {
// screen/tmux can't support OSC, because they can be connected to multiple
// terminals concurrently.
term := o.environ.Getenv("TERM")
- if strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") {
+ if strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") || strings.HasPrefix(term, "dumb") {
return "", ErrStatusReport
}
diff --git a/vendor/github.com/muesli/termenv/termenv_windows.go b/vendor/github.com/muesli/termenv/termenv_windows.go
index 1d9c6187..f9b1def0 100644
--- a/vendor/github.com/muesli/termenv/termenv_windows.go
+++ b/vendor/github.com/muesli/termenv/termenv_windows.go
@@ -5,6 +5,7 @@ package termenv
import (
"fmt"
+ "os"
"strconv"
"golang.org/x/sys/windows"
@@ -103,8 +104,8 @@ func EnableVirtualTerminalProcessing(o *Output) (restoreFunc func() error, err e
}
// If o is not a tty, then there is nothing to do.
- tty := o.TTY()
- if tty == nil {
+ tty, ok := o.Writer().(*os.File)
+ if tty == nil || !ok {
return
}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 498c905d..6a30d80d 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -34,14 +34,14 @@ github.com/blang/semver
# github.com/catppuccin/go v0.2.0
## explicit; go 1.19
github.com/catppuccin/go
-# github.com/charmbracelet/bubbles v0.18.0
+# github.com/charmbracelet/bubbles v0.20.0
## explicit; go 1.18
github.com/charmbracelet/bubbles/cursor
github.com/charmbracelet/bubbles/filepicker
github.com/charmbracelet/bubbles/help
github.com/charmbracelet/bubbles/key
-github.com/charmbracelet/bubbles/paginator
github.com/charmbracelet/bubbles/runeutil
+github.com/charmbracelet/bubbles/spinner
github.com/charmbracelet/bubbles/table
github.com/charmbracelet/bubbles/textarea
github.com/charmbracelet/bubbles/textarea/memoization
@@ -50,19 +50,20 @@ github.com/charmbracelet/bubbles/viewport
# github.com/charmbracelet/bubbletea v1.1.1
## explicit; go 1.18
github.com/charmbracelet/bubbletea
-# github.com/charmbracelet/huh v0.4.2
-## explicit; go 1.18
+# github.com/charmbracelet/huh v0.6.0
+## explicit; go 1.21
github.com/charmbracelet/huh
github.com/charmbracelet/huh/accessibility
+github.com/charmbracelet/huh/internal/selector
# github.com/charmbracelet/lipgloss v0.13.0
## explicit; go 1.18
github.com/charmbracelet/lipgloss
github.com/charmbracelet/lipgloss/tree
-# github.com/charmbracelet/x/ansi v0.3.1
+# github.com/charmbracelet/x/ansi v0.3.2
## explicit; go 1.18
github.com/charmbracelet/x/ansi
github.com/charmbracelet/x/ansi/parser
-# github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a
+# github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0
## explicit; go 1.19
github.com/charmbracelet/x/exp/strings
# github.com/charmbracelet/x/term v0.2.0
@@ -166,6 +167,9 @@ github.com/mattn/go-localereader
# github.com/mattn/go-runewidth v0.0.16
## explicit; go 1.9
github.com/mattn/go-runewidth
+# github.com/mitchellh/hashstructure/v2 v2.0.2
+## explicit; go 1.14
+github.com/mitchellh/hashstructure/v2
# github.com/mtibben/percent v0.2.1
## explicit; go 1.14
github.com/mtibben/percent
@@ -176,7 +180,7 @@ github.com/muesli/ansi/compressor
# github.com/muesli/cancelreader v0.2.2
## explicit; go 1.17
github.com/muesli/cancelreader
-# github.com/muesli/termenv v0.15.2
+# github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a
## explicit; go 1.17
github.com/muesli/termenv
# github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d