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 <maas@lalani.dev>
This commit is contained in:
Mikael Fangel 2023-05-15 05:19:07 +02:00 committed by GitHub
parent 23c56854d3
commit c8710071ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 300 additions and 56 deletions

23
internal/utils/utils.go Normal file
View file

@ -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
}

View file

@ -35,9 +35,12 @@ func (o Options) Run() error {
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 {

View file

@ -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
}

View file

@ -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
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,18 +34,27 @@ 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.ProcessText(msg)
case tea.KeyMsg:
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
maxLineWidth := m.viewport.Width
// Determine max width of a line.
m.maxWidth = m.viewport.Width
if m.softWrap {
vpStyle := m.viewport.Style
maxLineWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding()
m.maxWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding()
if m.showLineNumbers {
maxLineWidth -= len(" │ ")
m.maxWidth -= lipgloss.Width(" │ ")
}
}
@ -49,8 +63,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.showLineNumbers {
text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1)))
}
for m.softWrap && len(line) > maxLineWidth {
truncatedLine := runewidth.Truncate(line, maxLineWidth, "")
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 {
@ -58,7 +72,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
line = strings.Replace(line, truncatedLine, "", 1)
}
text.WriteString(textStyle.Render(runewidth.Truncate(line, maxLineWidth, "")))
text.WriteString(textStyle.Render(utils.LipglossTruncate(line, m.maxWidth)))
text.WriteString("\n")
}
@ -68,21 +82,60 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
text.WriteString(m.lineNumberStyle.Render(remainingLines))
}
m.viewport.SetContent(text.String())
case tea.KeyMsg:
switch msg.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)
}

163
pager/search.go Normal file
View file

@ -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()
}