From c8710071adf3a9f9c4931adfdc6a5706d627799c Mon Sep 17 00:00:00 2001 From: Mikael Fangel <34864484+MikaelFangel@users.noreply.github.com> Date: Mon, 15 May 2023 05:19:07 +0200 Subject: [PATCH] feature(pager): add search functionality (#321) * Added initial search functionality * Added a handler for the key presses * Added a searchbar at the bottom of the screen * Made search results cycleable by pressing n * correct start pos and ignore keys while searching * fix out of bound error when pressing n * made the matching pattern relative to the current pos * added p for searching for previous match * added highlighting to search matches * dynamically replaced all matches * fixed string highlight issue * fixed truncation issue * small simplifaction in ypos logic * made prev and next behave the same atBottom * simplified logic and fixed linter errors * updated help text * style changes * added comments * fixed truncation issue * fixes infinte loop on very long lines * added simple lipgloss truncate function * updated colors for better contrast * lint fix * initial commit for soft-wrap functionality * linter corrections and added for pager with new model * added generic functions to a utility package * fix soft lint errors * made N match previous as well as p * replaced help text when search is active * ran gofmt -w * reimplemented search and next to enabled support for dynamic highlights * made the highlight move as you progress through the search * simplified highlighter * improvements to the clean up of the highlight function * semi working reverse search * reverse search without highlight * added semi working highlight for reverser search * working version of previous match * fixed issue with single letter matches in next * added support for softwrapping * respond to soft lint warnings * removed unused function * lint * simplified matchers and fixed duplicate highlights * optimisations and change in matching pattern * fixed bug in lipglosspadding and allowed matching 1 etc. * make highlight respect user settings * fixed logic error in slice * made prev match wrap around * fix: show next/prev match help when active * updated how view port line is set * avoid crashes when regex doesn't compile * fix: spelling previous --------- Co-authored-by: Maas Lalani --- internal/utils/utils.go | 23 ++++++ pager/command.go | 15 ++-- pager/options.go | 2 + pager/pager.go | 153 +++++++++++++++++++++++++------------ pager/search.go | 163 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 56 deletions(-) create mode 100644 internal/utils/utils.go create mode 100644 pager/search.go diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..eda9240 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,23 @@ +package utils + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// LipglossTruncate truncates a given line based on its lipgloss width. +func LipglossTruncate(s string, width int) string { + var i int + for i = 0; i < len(s) && lipgloss.Width(s[:i]) < width; i++ { + } //revive:disable-line:empty-block + return s[:i] +} + +// LipglossLengthPadding calculated calculates how much padding a string is given by a style. +func LipglossPadding(style lipgloss.Style) (int, int) { + render := style.Render(" ") + before := strings.Index(render, " ") + after := len(render) - len(" ") - before + return before, after +} diff --git a/pager/command.go b/pager/command.go index 47f6182..b64a41e 100644 --- a/pager/command.go +++ b/pager/command.go @@ -32,12 +32,15 @@ func (o Options) Run() error { } model := model{ - viewport: vp, - helpStyle: o.HelpStyle.ToLipgloss(), - content: o.Content, - showLineNumbers: o.ShowLineNumbers, - lineNumberStyle: o.LineNumberStyle.ToLipgloss(), - softWrap: o.SoftWrap, + viewport: vp, + helpStyle: o.HelpStyle.ToLipgloss(), + content: o.Content, + origContent: o.Content, + showLineNumbers: o.ShowLineNumbers, + lineNumberStyle: o.LineNumberStyle.ToLipgloss(), + softWrap: o.SoftWrap, + matchStyle: o.MatchStyle.ToLipgloss(), + matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(), } _, err := tea.NewProgram(model, tea.WithAltScreen()).Run() if err != nil { diff --git a/pager/options.go b/pager/options.go index 210cadc..0ce875f 100644 --- a/pager/options.go +++ b/pager/options.go @@ -11,4 +11,6 @@ type Options struct { ShowLineNumbers bool `help:"Show line numbers" default:"true"` 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:"false"` + MatchStyle style.Styles `embed:"" prefix:"match." help:"Style the matched text" set:"defaultForeground=212" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_"` //nolint:staticcheck + MatchHighlightStyle style.Styles `embed:"" prefix:"match-highlight." help:"Style the matched highlight text" set:"defaultForeground=235" set:"defaultBackground=225" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_HIGH_"` //nolint:staticcheck } diff --git a/pager/pager.go b/pager/pager.go index 685de4f..e3371da 100644 --- a/pager/pager.go +++ b/pager/pager.go @@ -9,17 +9,22 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/gum/internal/utils" "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" ) type model struct { - content string - viewport viewport.Model - helpStyle lipgloss.Style - showLineNumbers bool - lineNumberStyle lipgloss.Style - softWrap bool + content string + origContent string + viewport viewport.Model + helpStyle lipgloss.Style + showLineNumbers bool + lineNumberStyle lipgloss.Style + softWrap bool + search search + matchStyle lipgloss.Style + matchHighlightStyle lipgloss.Style + maxWidth int } func (m model) Init() tea.Cmd { @@ -29,60 +34,108 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.viewport.Height = msg.Height - lipgloss.Height(m.helpStyle.Render("?")) - 1 - m.viewport.Width = msg.Width - textStyle := lipgloss.NewStyle().Width(m.viewport.Width) - var text strings.Builder - - // Determine max width of a line - maxLineWidth := m.viewport.Width - if m.softWrap { - vpStyle := m.viewport.Style - maxLineWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding() - if m.showLineNumbers { - maxLineWidth -= len(" │ ") - } - } - - 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 && len(line) > maxLineWidth { - truncatedLine := runewidth.Truncate(line, maxLineWidth, "") - 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(runewidth.Truncate(line, maxLineWidth, ""))) - 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()) + m.ProcessText(msg) case tea.KeyMsg: - switch msg.String() { + return m.KeyHandler(msg) + } + + return m, nil +} + +func (m *model) ProcessText(msg tea.WindowSizeMsg) { + m.viewport.Height = msg.Height - lipgloss.Height(m.helpStyle.Render("?")) - 1 + 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 := utils.LipglossTruncate(line, m.maxWidth) + 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(utils.LipglossTruncate(line, m.maxWidth))) + 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()) +} + +func (m model) KeyHandler(key tea.KeyMsg) (model, func() tea.Msg) { + var cmd tea.Cmd + if m.search.active { + switch key.String() { + case "enter": + 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 + 2, Width: m.viewport.Width}) + } else { + m.search.Done() + } + case "ctrl+d", "ctrl+c", "esc": + m.search.Done() + default: + m.search.input, cmd = m.search.input.Update(key) + } + } else { + switch key.String() { case "g": m.viewport.GotoTop() case "G": m.viewport.GotoBottom() + case "/": + m.search.Begin() + case "p", "N": + m.search.PrevMatch(&m) + m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + 2, Width: m.viewport.Width}) + case "n": + m.search.NextMatch(&m) + m.ProcessText(tea.WindowSizeMsg{Height: m.viewport.Height + 2, Width: m.viewport.Width}) case "q", "ctrl+c", "esc": return m, tea.Quit } + m.viewport, cmd = m.viewport.Update(key) } - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) + return m, cmd } func (m model) View() string { - return m.viewport.View() + m.helpStyle.Render("\n ↑/↓: Navigate • q: Quit") + helpMsg := "\n ↑/↓: Navigate • q: Quit • /: Search " + if m.search.query != nil { + helpMsg += "• n: Next Match " + helpMsg += "• N: Prev Match " + } + if m.search.active { + return m.viewport.View() + "\n " + m.search.input.View() + } + + return m.viewport.View() + m.helpStyle.Render(helpMsg) } diff --git a/pager/search.go b/pager/search.go new file mode 100644 index 0000000..a15004b --- /dev/null +++ b/pager/search.go @@ -0,0 +1,163 @@ +package pager + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/gum/internal/utils" + "github.com/charmbracelet/lipgloss" +) + +type search struct { + active bool + input textinput.Model + query *regexp.Regexp + matchIndex int + matchLipglossStr string + matchString string +} + +func (s *search) new() { + input := textinput.New() + input.Placeholder = "search" + input.Prompt = "/" + input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + s.input = input +} + +func (s *search) Begin() { + s.new() + s.active = true + s.input.Focus() +} + +// Execute find all lines in the model with a match. +func (s *search) Execute(m *model) { + defer s.Done() + if s.input.Value() == "" { + s.query = nil + return + } + + var err error + s.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 + } +} + +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 := utils.LipglossTruncate(line, maxWidth) + text.WriteString(truncatedLine) + text.WriteString("\n") + line = strings.Replace(line, truncatedLine, "", 1) + } + text.WriteString(utils.LipglossTruncate(line, maxWidth)) + text.WriteString("\n") + } + + return text.String() +}