diff --git a/README.md b/README.md index a538089ca..30d19f43c 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,8 @@ The above example is running from a single shell script ([source](./examples/dem ## Tutorial -Gum provides highly configurable, ready-to-use utilities to help you write -useful shell scripts and dotfiles aliases with just a few lines of code. -Let's build a simple script to help you write -[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) -for your dotfiles. +Gum provides highly configurable, ready-to-use utilities to help you write useful shell scripts and dotfiles aliases with just a few lines of code. +Let's build a simple script to help you write [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) for your dotfiles. Ask for the commit type with gum choose: diff --git a/filter/filter.go b/filter/filter.go index 0c7202352..fb605da1b 100644 --- a/filter/filter.go +++ b/filter/filter.go @@ -19,7 +19,6 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" "github.com/sahilm/fuzzy" ) @@ -31,6 +30,18 @@ func defaultKeymap() keymap { Up: key.NewBinding( key.WithKeys("up", "ctrl+k", "ctrl+p"), ), + Left: key.NewBinding( + key.WithKeys("left"), + ), + Right: key.NewBinding( + key.WithKeys("right"), + ), + NLeft: key.NewBinding( + key.WithKeys("h"), + ), + NRight: key.NewBinding( + key.WithKeys("l"), + ), NDown: key.NewBinding( key.WithKeys("j"), ), @@ -93,6 +104,10 @@ type keymap struct { Up, NDown, NUp, + Right, + Left, + NRight, + NLeft, Home, End, ToggleAndNext, @@ -111,8 +126,8 @@ func (k keymap) FullHelp() [][]key.Binding { return nil } func (k keymap) ShortHelp() []key.Binding { return []key.Binding{ key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↓↑", "navigate"), + key.WithKeys("left", "down", "up", "rigth"), + key.WithHelp("←↓↑→", "navigate"), ), k.FocusInSearch, k.FocusOutSearch, @@ -187,22 +202,11 @@ func (m model) View() string { // The line's text style is set depending on whether or not the cursor // points to this line. if i == m.cursor { - s.WriteString(m.indicatorStyle.Render(m.indicator)) lineTextStyle = m.cursorTextStyle } else { - s.WriteString(strings.Repeat(" ", lipgloss.Width(m.indicator))) lineTextStyle = m.textStyle } - // If there are multiple selections mark them, otherwise leave an empty space - if _, ok := m.selected[match.Str]; ok { - s.WriteString(m.selectedPrefixStyle.Render(m.selectedPrefix)) - } else if m.limit > 1 { - s.WriteString(m.unselectedPrefixStyle.Render(m.unselectedPrefix)) - } else { - s.WriteString(" ") - } - styledOption := m.choices[match.Str] if len(match.MatchedIndexes) == 0 { // No matches, just render the text. @@ -211,28 +215,15 @@ func (m model) View() string { continue } - var buf strings.Builder - lastIdx := 0 - // Use ansi.Truncate and ansi.TruncateLeft and ansi.StringWidth to // style match.MatchedIndexes without losing the original option style: + ranges := []lipgloss.Range{} for _, rng := range matchedRanges(match.MatchedIndexes) { - // fmt.Print("here ", lastIdx, rng, " - ", match.Str[rng[0]:rng[1]+1], "\r\n") - // Add the text before this match - if rng[0] > lastIdx { - buf.WriteString(ansi.Cut(styledOption, lastIdx, rng[0])) - } - - // Add the matched character with highlight - buf.WriteString(m.matchStyle.Render(ansi.Cut(match.Str, rng[0], rng[1]+1))) - lastIdx = rng[1] + 1 + ranges = append(ranges, lipgloss.NewRange(rng[0], rng[1]+1, m.matchStyle)) } // Add any remaining text after the last match - buf.WriteString(ansi.TruncateLeft(styledOption, lastIdx, "")) - - // Flush text buffer. - s.WriteString(lineTextStyle.Render(buf.String())) + s.WriteString(lipgloss.StyleRanges(styledOption, ranges...)) // We have finished displaying the match with all of it's matched // characters highlighted and the rest filled in. @@ -240,7 +231,7 @@ func (m model) View() string { s.WriteRune('\n') } - m.viewport.SetContent(s.String()) + m.viewport.SetContent(strings.TrimSpace(s.String())) help := "" if m.showHelp { @@ -276,10 +267,26 @@ func (m model) helpView() string { } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.viewport.LeftGutterFunc = func(gc viewport.GutterContext) string { + selectGutter := "" + if m.limit > 1 { + selectGutter = m.unselectedPrefixStyle.Render(m.unselectedPrefix) + } + if gc.Index < len(m.matches)-1 { + if _, ok := m.selected[m.matches[gc.Index].Str]; ok { + selectGutter = m.selectedPrefixStyle.Render(m.selectedPrefix) + } + } + if gc.Index == m.cursor { + return m.indicatorStyle.Render(m.indicator) + selectGutter + } + return strings.Repeat(" ", lipgloss.Width(m.indicator)) + selectGutter + } var cmd, icmd tea.Cmd m.textinput, icmd = m.textinput.Update(msg) switch msg := msg.(type) { case tea.WindowSizeMsg: + m.textinput.Width = msg.Width if m.height == 0 || m.height > msg.Height { m.viewport.Height = msg.Height - lipgloss.Height(m.textinput.View()) } @@ -316,6 +323,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.CursorDown() case key.Matches(msg, km.Up, km.NUp): m.CursorUp() + case key.Matches(msg, km.Right, km.NRight): + m.viewport.MoveRight(6) + case key.Matches(msg, km.Left, km.NLeft): + m.viewport.MoveLeft(6) case key.Matches(msg, km.Home): m.cursor = 0 m.viewport.GotoTop() diff --git a/filter/options.go b/filter/options.go index 07c50ce72..4d2539ec7 100644 --- a/filter/options.go +++ b/filter/options.go @@ -10,7 +10,7 @@ import ( type Options struct { Options []string `arg:"" optional:"" help:"Options to filter."` - Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"` + Indicator string `help:"Character for selection" default:"• " env:"GUM_FILTER_INDICATOR"` IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_INDICATOR_"` Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"` NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"` diff --git a/go.mod b/go.mod index 489e32a74..66d2aeea9 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 github.com/charmbracelet/x/editor v0.1.0 github.com/charmbracelet/x/term v0.2.1 - github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 github.com/muesli/termenv v0.15.3-0.20241211131612-0d230cb6eb15 github.com/sahilm/fuzzy v0.1.1 @@ -39,6 +38,7 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/yuin/goldmark v1.7.4 // indirect github.com/yuin/goldmark-emoji v1.0.4 // indirect @@ -48,3 +48,7 @@ require ( golang.org/x/sys v0.28.0 // indirect golang.org/x/term v0.22.0 // indirect ) + +replace github.com/charmbracelet/bubbles => ../bubbles + +replace github.com/charmbracelet/lipgloss => ../lipgloss diff --git a/go.sum b/go.sum index 88954fb57..41f23004f 100644 --- a/go.sum +++ b/go.sum @@ -20,22 +20,18 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -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.2.5-0.20241207142916-e0515bc22ad1 h1:osd3dk14DEriOrqJBWzeDE9eN2Yd00BkKzFAiLXxkS8= github.com/charmbracelet/bubbletea v1.2.5-0.20241207142916-e0515bc22ad1/go.mod h1:Hbk5+oE4a7cDyjfdPi4sHZ42aGTMYcmHnVDhsRswn7A= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 h1:TSjbA80sXnABV/Vxhnb67Ho7p8bEYqz6NIdhLAx+1yg= github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= -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/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/pager/command.go b/pager/command.go index 414bee74d..dfbcd5ede 100644 --- a/pager/command.go +++ b/pager/command.go @@ -2,7 +2,6 @@ package pager import ( "fmt" - "regexp" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/viewport" @@ -18,30 +17,33 @@ func (o Options) Run() error { vp.Style = o.Style.ToLipgloss() if o.Content == "" { - stdin, err := stdin.Read() + stdin, err := stdin.Read(stdin.StripANSI(true)) if err != nil { return fmt.Errorf("unable to read stdin") } if stdin != "" { - // Sanitize the input from stdin by removing backspace sequences. - backspace := regexp.MustCompile(".\x08") - o.Content = backspace.ReplaceAllString(stdin, "") + o.Content = stdin } else { return fmt.Errorf("provide some content to display") } } + if o.ShowLineNumbers { + vp.LeftGutterFunc = viewport.LineNumberGutter(o.LineNumberStyle.ToLipgloss()) + } + + vp.SoftWrap = o.SoftWrap + vp.FillHeight = o.ShowLineNumbers + vp.SetContent(o.Content) + vp.HighlightStyle = o.MatchStyle.ToLipgloss() + vp.SelectedHighlightStyle = o.MatchHighlightStyle.ToLipgloss() + m := model{ - viewport: vp, - help: help.New(), - content: o.Content, - origContent: o.Content, - showLineNumbers: o.ShowLineNumbers, - lineNumberStyle: o.LineNumberStyle.ToLipgloss(), - softWrap: o.SoftWrap, - matchStyle: o.MatchStyle.ToLipgloss(), - matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(), - keymap: defaultKeymap(), + viewport: vp, + help: help.New(), + showLineNumbers: o.ShowLineNumbers, + lineNumberStyle: o.LineNumberStyle.ToLipgloss(), + keymap: defaultKeymap(), } ctx, cancel := timeout.Context(o.Timeout) diff --git a/pager/options.go b/pager/options.go index a44fca78f..ab9422c1e 100644 --- a/pager/options.go +++ b/pager/options.go @@ -11,7 +11,7 @@ type Options struct { //nolint:staticcheck Style style.Styles `embed:"" help:"Style the pager" set:"defaultBorder=rounded" set:"defaultPadding=0 1" set:"defaultBorderForeground=212" envprefix:"GUM_PAGER_"` Content string `arg:"" optional:"" help:"Display content to scroll"` - ShowLineNumbers bool `help:"Show line numbers" default:"true"` + ShowLineNumbers bool `help:"Show line numbers" default:"true" negatable:""` LineNumberStyle style.Styles `embed:"" prefix:"line-number." help:"Style the line numbers" set:"defaultForeground=237" envprefix:"GUM_PAGER_LINE_NUMBER_"` SoftWrap bool `help:"Soft wrap lines" default:"true" negatable:""` MatchStyle style.Styles `embed:"" prefix:"match." help:"Style the matched text" set:"defaultForeground=212" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_"` //nolint:staticcheck diff --git a/pager/pager.go b/pager/pager.go index 2ee9b1e57..22a08536e 100644 --- a/pager/pager.go +++ b/pager/pager.go @@ -4,16 +4,12 @@ package pager import ( - "fmt" - "strings" - "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/truncate" ) type keymap struct { @@ -37,8 +33,8 @@ func (k keymap) FullHelp() [][]key.Binding { func (k keymap) ShortHelp() []key.Binding { return []key.Binding{ key.NewBinding( - key.WithKeys("up", "down"), - key.WithHelp("↓↑", "navigate"), + key.WithKeys("left", "down", "up", "rigth"), + key.WithHelp("←↓↑→", "navigate"), ), k.Quit, k.Search, @@ -89,18 +85,13 @@ func defaultKeymap() keymap { } type model struct { - content string - origContent string - viewport viewport.Model - help help.Model - showLineNumbers bool - lineNumberStyle lipgloss.Style - softWrap bool - search search - matchStyle lipgloss.Style - matchHighlightStyle lipgloss.Style - maxWidth int - keymap keymap + viewport viewport.Model + help help.Model + showLineNumbers bool + lineNumberStyle lipgloss.Style + search search + maxWidth int + keymap keymap } func (m model) Init() tea.Cmd { return nil } @@ -109,16 +100,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.processText(msg) + m.search.input.Width = msg.Width case tea.KeyMsg: return m.keyHandler(msg) } - m.keymap.PrevMatch.SetEnabled(m.search.query != nil) - m.keymap.NextMatch.SetEnabled(m.search.query != nil) + m.keymap.PrevMatch.SetEnabled(m.search.navigating) + m.keymap.NextMatch.SetEnabled(m.search.navigating) var cmd tea.Cmd + var cmds []tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) m.search.input, cmd = m.search.input.Update(msg) - return m, cmd + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) } func (m *model) helpView() string { @@ -128,43 +124,9 @@ func (m *model) helpView() string { func (m *model) processText(msg tea.WindowSizeMsg) { m.viewport.Height = msg.Height - lipgloss.Height(m.helpView()) m.viewport.Width = msg.Width - textStyle := lipgloss.NewStyle().Width(m.viewport.Width) - var text strings.Builder // Determine max width of a line. m.maxWidth = m.viewport.Width - if m.softWrap { - vpStyle := m.viewport.Style - m.maxWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding() - if m.showLineNumbers { - m.maxWidth -= lipgloss.Width(" │ ") - } - } - - for i, line := range strings.Split(m.content, "\n") { - line = strings.ReplaceAll(line, "\t", " ") - if m.showLineNumbers { - text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1))) - } - for m.softWrap && lipgloss.Width(line) > m.maxWidth { - truncatedLine := truncate.String(line, uint(m.maxWidth)) //nolint: gosec - text.WriteString(textStyle.Render(truncatedLine)) - text.WriteString("\n") - if m.showLineNumbers { - text.WriteString(m.lineNumberStyle.Render(" │ ")) - } - line = strings.Replace(line, truncatedLine, "", 1) - } - text.WriteString(textStyle.Render(truncate.String(line, uint(m.maxWidth)))) //nolint: gosec - text.WriteString("\n") - } - - diffHeight := m.viewport.Height - lipgloss.Height(text.String()) - if diffHeight > 0 && m.showLineNumbers { - remainingLines := " ~ │ " + strings.Repeat("\n ~ │ ", diffHeight-1) - text.WriteString(m.lineNumberStyle.Render(remainingLines)) - } - m.viewport.SetContent(text.String()) } const heightOffset = 2 @@ -172,21 +134,18 @@ const heightOffset = 2 func (m model) keyHandler(msg tea.KeyMsg) (model, tea.Cmd) { km := m.keymap var cmd tea.Cmd - if m.search.active { + if m.search.visible { switch { case key.Matches(msg, km.ConfirmSearch): if m.search.input.Value() != "" { - m.content = m.origContent - m.search.Execute(&m) - - // Trigger a view update to highlight the found matches. - m.search.NextMatch(&m) - m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) + m.viewport.SetHighligths(m.search.Execute(m.viewport.GetContent())) } else { m.search.Done() + m.viewport.ClearHighlights() } case key.Matches(msg, km.CancelSearch): m.search.Done() + m.viewport.ClearHighlights() default: m.search.input, cmd = m.search.input.Update(msg) } @@ -197,14 +156,12 @@ func (m model) keyHandler(msg tea.KeyMsg) (model, tea.Cmd) { case key.Matches(msg, km.End): m.viewport.GotoBottom() case key.Matches(msg, km.Search): - m.search.Begin() + m.search.Show(m.viewport.Width) return m, textinput.Blink case key.Matches(msg, km.PrevMatch): - m.search.PrevMatch(&m) - m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) + m.viewport.HighlightPrevious() case key.Matches(msg, km.NextMatch): - m.search.NextMatch(&m) - m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) + m.viewport.HightlightNext() case key.Matches(msg, km.Quit): return m, tea.Quit case key.Matches(msg, km.Abort): @@ -217,7 +174,7 @@ func (m model) keyHandler(msg tea.KeyMsg) (model, tea.Cmd) { } func (m model) View() string { - if m.search.active { + if m.search.visible { return m.viewport.View() + "\n " + m.search.input.View() } diff --git a/pager/search.go b/pager/search.go index e32260da5..896d69182 100644 --- a/pager/search.go +++ b/pager/search.go @@ -1,23 +1,16 @@ package pager import ( - "fmt" "regexp" - "strings" "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/gum/internal/utils" "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/truncate" ) type search struct { - active bool - input textinput.Model - query *regexp.Regexp - matchIndex int - matchLipglossStr string - matchString string + visible bool + navigating bool + input textinput.Model } func (s *search) new() { @@ -28,137 +21,31 @@ func (s *search) new() { s.input = input } -func (s *search) Begin() { +func (s *search) Show(w int) { s.new() - s.active = true + s.visible = true + s.input.Width = w s.input.Focus() } // Execute find all lines in the model with a match. -func (s *search) Execute(m *model) { - defer s.Done() +func (s *search) Execute(content string) [][]int { if s.input.Value() == "" { - s.query = nil - return + s.navigating = false + s.visible = false + return nil } - var err error - s.query, err = regexp.Compile(s.input.Value()) + s.navigating = true + s.visible = false + query, err := regexp.Compile(s.input.Value()) if err != nil { - s.query = nil - return - } - query := regexp.MustCompile(fmt.Sprintf("(%s)", s.query.String())) - m.content = query.ReplaceAllString(m.content, m.matchStyle.Render("$1")) - - // Recompile the regex to match the an replace the highlights. - leftPad, _ := utils.LipglossPadding(m.matchStyle) - matchingString := regexp.QuoteMeta(m.matchStyle.Render()[:leftPad]) + s.query.String() + regexp.QuoteMeta(m.matchStyle.Render()[leftPad:]) - s.query, err = regexp.Compile(matchingString) - if err != nil { - s.query = nil + return nil } + return query.FindAllStringIndex(content, -1) } func (s *search) Done() { - s.active = false - - // To account for the first match is always executed. - s.matchIndex = -1 -} - -func (s *search) NextMatch(m *model) { - // Check that we are within bounds. - if s.query == nil { - return - } - - // Remove previous highlight. - m.content = strings.Replace(m.content, s.matchLipglossStr, s.matchString, 1) - - // Highlight the next match. - allMatches := s.query.FindAllStringIndex(m.content, -1) - if len(allMatches) == 0 { - return - } - - leftPad, rightPad := utils.LipglossPadding(m.matchStyle) - s.matchIndex = (s.matchIndex + 1) % len(allMatches) - match := allMatches[s.matchIndex] - lhs := m.content[:match[0]] - rhs := m.content[match[0]:] - s.matchString = m.content[match[0]:match[1]] - s.matchLipglossStr = m.matchHighlightStyle.Render(s.matchString[leftPad : len(s.matchString)-rightPad]) - m.content = lhs + strings.Replace(rhs, m.content[match[0]:match[1]], s.matchLipglossStr, 1) - - // Update the viewport position. - var line int - formatStr := softWrapEm(m.content, m.maxWidth, m.softWrap) - index := strings.Index(formatStr, s.matchLipglossStr) - if index != -1 { - line = strings.Count(formatStr[:index], "\n") - } - - // Only update if the match is not within the viewport. - if index != -1 && (line > m.viewport.YOffset-1+m.viewport.VisibleLineCount()-1 || line < m.viewport.YOffset) { - m.viewport.SetYOffset(line) - } -} - -func (s *search) PrevMatch(m *model) { - // Check that we are within bounds. - if s.query == nil { - return - } - - // Remove previous highlight. - m.content = strings.Replace(m.content, s.matchLipglossStr, s.matchString, 1) - - // Highlight the previous match. - allMatches := s.query.FindAllStringIndex(m.content, -1) - if len(allMatches) == 0 { - return - } - - s.matchIndex = (s.matchIndex - 1) % len(allMatches) - if s.matchIndex < 0 { - s.matchIndex = len(allMatches) - 1 - } - - leftPad, rightPad := utils.LipglossPadding(m.matchStyle) - match := allMatches[s.matchIndex] - lhs := m.content[:match[0]] - rhs := m.content[match[0]:] - s.matchString = m.content[match[0]:match[1]] - s.matchLipglossStr = m.matchHighlightStyle.Render(s.matchString[leftPad : len(s.matchString)-rightPad]) - m.content = lhs + strings.Replace(rhs, m.content[match[0]:match[1]], s.matchLipglossStr, 1) - - // Update the viewport position. - var line int - formatStr := softWrapEm(m.content, m.maxWidth, m.softWrap) - index := strings.Index(formatStr, s.matchLipglossStr) - if index != -1 { - line = strings.Count(formatStr[:index], "\n") - } - - // Only update if the match is not within the viewport. - if index != -1 && (line > m.viewport.YOffset-1+m.viewport.VisibleLineCount()-1 || line < m.viewport.YOffset) { - m.viewport.SetYOffset(line) - } -} - -func softWrapEm(str string, maxWidth int, softWrap bool) string { - var text strings.Builder - for _, line := range strings.Split(str, "\n") { - for softWrap && lipgloss.Width(line) > maxWidth { - truncatedLine := truncate.String(line, uint(maxWidth)) //nolint: gosec - text.WriteString(truncatedLine) - text.WriteString("\n") - line = strings.Replace(line, truncatedLine, "", 1) - } - text.WriteString(truncate.String(line, uint(maxWidth))) //nolint: gosec - text.WriteString("\n") - } - - return text.String() + s.visible = false + s.navigating = false }