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" "github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/style" ) func readDoc(name string) string { b, _ := spilltea.DocsFS.ReadFile("docs/" + name) return string(b) } var contentMarkdown = strings.Join([]string{ readDoc("main.md"), readDoc("legal-disclaimer.md"), readDoc("basics.md"), readDoc("proxy.md"), readDoc("certificate.md"), readDoc("history.md"), }, "\n") type matchEntry struct { line int start int end int } type Model struct { 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(), searchInput: ti, } } 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() frameH := windowStyle().GetVerticalFrameSize() m.viewport.SetWidth(w - frameW) m.viewport.SetHeight(h - frameH - statusH) 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})) } type docsKeyMap struct{ width int } func (docsKeyMap) ShortHelp() []key.Binding { g := keys.Keys.Global 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(d.Bindings(), pageGlobals...) all = append(all, g.CommonBindings()...) return keys.ChunkByWidth(all, m.width) }