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?

- Hey there! I'm Glenn! + Hey there! I’m Glenn!

Latest Release Go Docs @@ -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) +} +``` + +Country / State form with dynamic inputs running. + ## 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