mirror of
https://github.com/charmbracelet/gum
synced 2024-05-17 21:56:42 +02:00
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:
parent
23c56854d3
commit
c8710071ad
23
internal/utils/utils.go
Normal file
23
internal/utils/utils.go
Normal 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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
153
pager/pager.go
153
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)
|
||||
}
|
||||
|
|
163
pager/search.go
Normal file
163
pager/search.go
Normal 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()
|
||||
}
|
Loading…
Reference in a new issue