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

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

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

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