mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
Add search in ui/docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
+214
-6
@@ -1,12 +1,16 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
spilltea "github.com/anotherhadi/spilltea"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
@@ -26,18 +30,39 @@ var contentMarkdown = strings.Join([]string{
|
||||
readDoc("history.md"),
|
||||
}, "\n")
|
||||
|
||||
type matchEntry struct {
|
||||
line int
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
viewport viewport.Model
|
||||
help help.Model
|
||||
viewport viewport.Model
|
||||
help help.Model
|
||||
searchInput textinput.Model
|
||||
searching bool
|
||||
|
||||
matches []matchEntry
|
||||
matchIndex int
|
||||
|
||||
renderedLines []string
|
||||
strippedLines []string
|
||||
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = "/"
|
||||
s := ti.Styles()
|
||||
s.Focused.Prompt = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
ti.SetStyles(s)
|
||||
|
||||
return Model{
|
||||
viewport: viewport.New(),
|
||||
help: style.NewHelp(),
|
||||
viewport: viewport.New(),
|
||||
help: style.NewHelp(),
|
||||
searchInput: ti,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +70,13 @@ func (e Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) IsEditing() bool { return m.searching }
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.help.SetWidth(w - 2)
|
||||
m.searchInput.SetWidth(w - 4)
|
||||
|
||||
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
|
||||
frameW := windowStyle().GetHorizontalFrameSize()
|
||||
@@ -59,7 +87,184 @@ func (m *Model) SetSize(w, h int) {
|
||||
m.renderMarkdown()
|
||||
}
|
||||
|
||||
func (m *Model) applySearch() {
|
||||
query := m.searchInput.Value()
|
||||
m.matches = nil
|
||||
m.matchIndex = 0
|
||||
|
||||
if query != "" {
|
||||
re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(query))
|
||||
if err == nil {
|
||||
for lineIdx, stripped := range m.strippedLines {
|
||||
for _, match := range re.FindAllStringIndex(stripped, -1) {
|
||||
m.matches = append(m.matches, matchEntry{
|
||||
line: lineIdx,
|
||||
start: match[0],
|
||||
end: match[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.rebuildViewportContent()
|
||||
if len(m.matches) > 0 {
|
||||
m.viewport.SetYOffset(m.matches[0].line)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) searchNext() {
|
||||
if len(m.matches) == 0 {
|
||||
return
|
||||
}
|
||||
m.matchIndex = (m.matchIndex + 1) % len(m.matches)
|
||||
m.rebuildViewportContent()
|
||||
m.viewport.SetYOffset(m.matches[m.matchIndex].line)
|
||||
}
|
||||
|
||||
func (m *Model) searchPrev() {
|
||||
if len(m.matches) == 0 {
|
||||
return
|
||||
}
|
||||
m.matchIndex = (m.matchIndex - 1 + len(m.matches)) % len(m.matches)
|
||||
m.rebuildViewportContent()
|
||||
m.viewport.SetYOffset(m.matches[m.matchIndex].line)
|
||||
}
|
||||
|
||||
func (m *Model) rebuildViewportContent() {
|
||||
if len(m.matches) == 0 || m.searchInput.Value() == "" {
|
||||
m.viewport.SetContent(strings.Join(m.renderedLines, "\n"))
|
||||
return
|
||||
}
|
||||
|
||||
type lineInfo struct {
|
||||
intervals [][]int
|
||||
currentIdx int
|
||||
}
|
||||
byLine := make(map[int]*lineInfo)
|
||||
for i, match := range m.matches {
|
||||
li := byLine[match.line]
|
||||
if li == nil {
|
||||
li = &lineInfo{currentIdx: -1}
|
||||
byLine[match.line] = li
|
||||
}
|
||||
li.intervals = append(li.intervals, []int{match.start, match.end})
|
||||
if i == m.matchIndex {
|
||||
li.currentIdx = len(li.intervals) - 1
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([]string, len(m.renderedLines))
|
||||
for i, ansiLine := range m.renderedLines {
|
||||
if li, ok := byLine[i]; ok {
|
||||
lines[i] = injectHighlightsInLine(ansiLine, li.intervals, li.currentIdx)
|
||||
} else {
|
||||
lines[i] = ansiLine
|
||||
}
|
||||
}
|
||||
m.viewport.SetContent(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func lipglossAnsiCodes(s lipgloss.Style) (open, close string) {
|
||||
const sentinel = "X"
|
||||
rendered := s.Render(sentinel)
|
||||
idx := strings.Index(rendered, sentinel)
|
||||
if idx < 0 {
|
||||
return "", ""
|
||||
}
|
||||
return rendered[:idx], rendered[idx+len(sentinel):]
|
||||
}
|
||||
|
||||
func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int) string {
|
||||
if len(intervals) == 0 {
|
||||
return ansiLine
|
||||
}
|
||||
|
||||
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.SubtleBg))
|
||||
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.Primary).Foreground(style.S.Text))
|
||||
|
||||
type injection struct {
|
||||
visPos int
|
||||
code string
|
||||
priority int // 0 = close (emit before opens at same pos), 1 = open
|
||||
}
|
||||
var injections []injection
|
||||
for i, iv := range intervals {
|
||||
open, close := normalOpen, normalClose
|
||||
if i == currentIdx {
|
||||
open, close = currentOpen, currentClose
|
||||
}
|
||||
injections = append(injections, injection{visPos: iv[0], code: open, priority: 1})
|
||||
injections = append(injections, injection{visPos: iv[1], code: close, priority: 0})
|
||||
}
|
||||
sort.SliceStable(injections, func(a, b int) bool {
|
||||
if injections[a].visPos != injections[b].visPos {
|
||||
return injections[a].visPos < injections[b].visPos
|
||||
}
|
||||
return injections[a].priority < injections[b].priority
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
visPos := 0
|
||||
injIdx := 0
|
||||
i := 0
|
||||
for i < len(ansiLine) {
|
||||
for injIdx < len(injections) && injections[injIdx].visPos == visPos {
|
||||
sb.WriteString(injections[injIdx].code)
|
||||
injIdx++
|
||||
}
|
||||
if ansiLine[i] == '\x1b' {
|
||||
j := i + 1
|
||||
if j < len(ansiLine) {
|
||||
switch ansiLine[j] {
|
||||
case '[':
|
||||
j++
|
||||
for j < len(ansiLine) && (ansiLine[j] < '@' || ansiLine[j] > '~') {
|
||||
j++
|
||||
}
|
||||
if j < len(ansiLine) {
|
||||
j++
|
||||
}
|
||||
case ']':
|
||||
j++
|
||||
for j < len(ansiLine) {
|
||||
if ansiLine[j] == '\a' {
|
||||
j++
|
||||
break
|
||||
}
|
||||
if ansiLine[j] == '\x1b' && j+1 < len(ansiLine) && ansiLine[j+1] == '\\' {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
default:
|
||||
j++
|
||||
}
|
||||
}
|
||||
sb.WriteString(ansiLine[i:j])
|
||||
i = j
|
||||
} else {
|
||||
_, size := utf8.DecodeRuneInString(ansiLine[i:])
|
||||
if size == 0 {
|
||||
size = 1
|
||||
}
|
||||
sb.WriteString(ansiLine[i : i+size])
|
||||
i += size
|
||||
visPos += size
|
||||
}
|
||||
}
|
||||
for injIdx < len(injections) {
|
||||
sb.WriteString(injections[injIdx].code)
|
||||
injIdx++
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *Model) renderStatusBar() string {
|
||||
if m.searching {
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.searchInput.View())
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(docsKeyMap{width: m.width}))
|
||||
}
|
||||
|
||||
@@ -67,12 +272,15 @@ type docsKeyMap struct{ width int }
|
||||
|
||||
func (docsKeyMap) ShortHelp() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
return []key.Binding{g.Up, g.Down, g.Help}
|
||||
d := keys.Keys.Docs
|
||||
return []key.Binding{g.Up, g.Down, d.Search, g.Help}
|
||||
}
|
||||
|
||||
func (m docsKeyMap) FullHelp() [][]key.Binding {
|
||||
g := keys.Keys.Global
|
||||
d := keys.Keys.Docs
|
||||
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown}
|
||||
all := append(pageGlobals, g.CommonBindings()...)
|
||||
all := append(d.Bindings(), pageGlobals...)
|
||||
all = append(all, g.CommonBindings()...)
|
||||
return keys.ChunkByWidth(all, m.width)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
g := keys.Keys.Global
|
||||
d := keys.Keys.Docs
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.MouseWheelMsg:
|
||||
switch msg.Button {
|
||||
@@ -18,7 +20,42 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if e.searching {
|
||||
switch {
|
||||
case key.Matches(msg, d.SearchReset):
|
||||
e.searching = false
|
||||
e.searchInput.Blur()
|
||||
e.searchInput.SetValue("")
|
||||
e.matches = nil
|
||||
e.matchIndex = 0
|
||||
e.SetSize(e.width, e.height)
|
||||
case msg.String() == "enter":
|
||||
e.searching = false
|
||||
e.searchInput.Blur()
|
||||
e.SetSize(e.width, e.height)
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
e.searchInput, cmd = e.searchInput.Update(msg)
|
||||
e.applySearch()
|
||||
return e, cmd
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, d.Search):
|
||||
e.searching = true
|
||||
e.searchInput.SetValue("")
|
||||
e.searchInput.Focus()
|
||||
e.SetSize(e.width, e.height)
|
||||
case key.Matches(msg, d.SearchReset):
|
||||
e.matches = nil
|
||||
e.matchIndex = 0
|
||||
e.rebuildViewportContent()
|
||||
case key.Matches(msg, d.SearchNext):
|
||||
e.searchNext()
|
||||
case key.Matches(msg, d.SearchPrev):
|
||||
e.searchPrev()
|
||||
case key.Matches(msg, g.Up):
|
||||
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
|
||||
case key.Matches(msg, g.Down):
|
||||
|
||||
@@ -2,6 +2,8 @@ package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func windowStyle() lipgloss.Style {
|
||||
@@ -19,9 +22,22 @@ func windowStyle() lipgloss.Style {
|
||||
}
|
||||
|
||||
func (e Model) View() tea.View {
|
||||
statusBar := e.renderStatusBar()
|
||||
if len(e.matches) > 0 {
|
||||
var countText string
|
||||
if e.searching {
|
||||
countText = fmt.Sprintf("%d matches", len(e.matches))
|
||||
} else {
|
||||
countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches))
|
||||
}
|
||||
count := lipgloss.NewStyle().Padding(0, 1).
|
||||
Foreground(style.S.MutedFg).
|
||||
Render(countText)
|
||||
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
|
||||
}
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
|
||||
windowStyle().Render(e.viewport.View()),
|
||||
e.renderStatusBar(),
|
||||
statusBar,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -50,5 +66,10 @@ func (m *Model) renderMarkdown() {
|
||||
)
|
||||
|
||||
str, _ := renderer.Render(processed.String())
|
||||
m.viewport.SetContent(str)
|
||||
m.renderedLines = strings.Split(str, "\n")
|
||||
m.strippedLines = make([]string, len(m.renderedLines))
|
||||
for i, l := range m.renderedLines {
|
||||
m.strippedLines[i] = ansi.Strip(l)
|
||||
}
|
||||
m.applySearch()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user