From 85c2806604f95ef0b4acc46d8bb11952a4198255 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Mon, 18 May 2026 21:22:17 +0200 Subject: [PATCH] Add search in ui/docs Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- internal/config/default_config.yaml | 6 + internal/config/keybindings.go | 8 + internal/keys/docs.go | 26 ++++ internal/keys/keys.go | 2 + internal/ui/app/pages.go | 3 +- internal/ui/docs/model.go | 220 +++++++++++++++++++++++++++- internal/ui/docs/update.go | 37 +++++ internal/ui/docs/view.go | 25 +++- 8 files changed, 318 insertions(+), 9 deletions(-) create mode 100644 internal/keys/docs.go diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index d14966d..27a74b1 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -98,3 +98,9 @@ keybindings: toggle: "space" edit_config: "e,enter" filter: "/" + + docs: + search: "/" + search_reset: "r" + search_next: "n" + search_prev: "N" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 41849de..9cc7466 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -66,6 +66,13 @@ type PluginsKeys struct { Filter string `mapstructure:"filter"` } +type DocsKeys struct { + Search string `mapstructure:"search"` + SearchReset string `mapstructure:"search_reset"` + SearchNext string `mapstructure:"search_next"` + SearchPrev string `mapstructure:"search_prev"` +} + type Keybindings struct { Global GlobalKeys `mapstructure:"global"` Intercept InterceptKeys `mapstructure:"intercept"` @@ -75,4 +82,5 @@ type Keybindings struct { Diff DiffKeys `mapstructure:"diff"` Findings FindingsKeys `mapstructure:"findings"` Plugins PluginsKeys `mapstructure:"plugins"` + Docs DocsKeys `mapstructure:"docs"` } diff --git a/internal/keys/docs.go b/internal/keys/docs.go new file mode 100644 index 0000000..a8a6e03 --- /dev/null +++ b/internal/keys/docs.go @@ -0,0 +1,26 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/spilltea/internal/config" +) + +type DocsKeyMap struct { + Search key.Binding + SearchReset key.Binding + SearchNext key.Binding + SearchPrev key.Binding +} + +func newDocsKeyMap(cfg config.DocsKeys) DocsKeyMap { + return DocsKeyMap{ + Search: binding(cfg.Search, "search"), + SearchReset: binding(cfg.SearchReset, "reset search"), + SearchNext: binding(cfg.SearchNext, "next match"), + SearchPrev: binding(cfg.SearchPrev, "prev match"), + } +} + +func (d DocsKeyMap) Bindings() []key.Binding { + return []key.Binding{d.Search, d.SearchReset, d.SearchNext, d.SearchPrev} +} diff --git a/internal/keys/keys.go b/internal/keys/keys.go index 849445c..e2bc857 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -16,6 +16,7 @@ type KeyMap struct { Diff DiffKeyMap Findings FindingsKeyMap Plugins PluginsKeyMap + Docs DocsKeyMap } var Keys *KeyMap @@ -31,6 +32,7 @@ func Init(cfg *config.Config) { Diff: newDiffKeyMap(kb.Diff), Findings: newFindingsKeyMap(kb.Findings), Plugins: newPluginsKeyMap(kb.Plugins), + Docs: newDocsKeyMap(kb.Docs), } } diff --git a/internal/ui/app/pages.go b/internal/ui/app/pages.go index b1b2e36..26eb2f3 100644 --- a/internal/ui/app/pages.go +++ b/internal/ui/app/pages.go @@ -126,6 +126,7 @@ var pageRegistry = []pageEntry{ m.docs = updated.(docsUI.Model) return cmd }, - resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) }, + isEditing: func(m *Model) bool { return m.docs.IsEditing() }, + resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) }, }, } diff --git a/internal/ui/docs/model.go b/internal/ui/docs/model.go index b1d83a0..621f281 100644 --- a/internal/ui/docs/model.go +++ b/internal/ui/docs/model.go @@ -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) } diff --git a/internal/ui/docs/update.go b/internal/ui/docs/update.go index cdbe6cb..8cf351d 100644 --- a/internal/ui/docs/update.go +++ b/internal/ui/docs/update.go @@ -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): diff --git a/internal/ui/docs/view.go b/internal/ui/docs/view.go index 0895b87..6b8871e 100644 --- a/internal/ui/docs/view.go +++ b/internal/ui/docs/view.go @@ -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() }