Files
2026-03-15 10:31:41 -07:00

1257 lines
31 KiB
Go

package docs
import (
"fmt"
"math"
"regexp"
"sort"
"strings"
"time"
"unicode"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
glamourstyles "charm.land/glamour/v2/styles"
"charm.land/lipgloss/v2"
clienttheme "github.com/bishopfox/sliver/client/theme"
embeddeddocs "github.com/bishopfox/sliver/docs"
"github.com/charmbracelet/x/ansi"
"golang.org/x/term"
)
const (
docsMinWidth = 72
docsMinHeight = 18
docsPaneGap = 1
docsViewerHeaderLines = 1
docsNarrowWidth = 96
docsBrowserMinWidth = 32
docsBrowserMaxWidth = 46
docsWindowPollInterval = 100 * time.Millisecond
docsBrowserItemHeight = 3
docsBrowserHeaderGap = 1
docsBrowserFooterGap = 1
docsBrowserFooterLines = 1
docsBrowserFullHelpRows = 3
)
var (
markdownTrimChars = regexp.MustCompile(`^[#>\-\*\+\d\.\)\s` + "`" + `]+`)
yamlFrontmatterBound = regexp.MustCompile(`(?m)^---\r?\n(\s*\r?\n)?`)
markdownLinkPattern = regexp.MustCompile(`!?\[([^\]]+)\]\([^)]+\)`)
markdownInlineReplacer = strings.NewReplacer(
"**", "",
"__", "",
"~~", "",
"`", "",
"*", "",
"_", "",
)
)
type docsFocus int
const (
docsFocusBrowser docsFocus = iota
docsFocusViewer
)
type docsFilterState int
const (
docsFilterStateUnfiltered docsFilterState = iota
docsFilterStateFiltering
docsFilterStateApplied
)
type docEntry struct {
Name string
Content string
Description string
}
type docsHelpEntry struct {
key string
action string
}
type docsStyles struct {
app lipgloss.Style
pane lipgloss.Style
paneFocused lipgloss.Style
header lipgloss.Style
headerMeta lipgloss.Style
empty lipgloss.Style
error lipgloss.Style
minSize lipgloss.Style
browserCount lipgloss.Style
browserCountActive lipgloss.Style
browserTitle lipgloss.Style
browserTitleMuted lipgloss.Style
browserTitleActive lipgloss.Style
browserMeta lipgloss.Style
browserMetaActive lipgloss.Style
browserHelpKey lipgloss.Style
browserHelpText lipgloss.Style
browserHelpDivider lipgloss.Style
browserHelpOverflow lipgloss.Style
}
type docsWindowPollMsg struct {
width int
height int
}
type docsModel struct {
width int
height int
focus docsFocus
filterState docsFilterState
browserShowFullHelp bool
browserWidth int
browserHeight int
browserCursor int
browserFilter textinput.Model
browserFilterSnapshot string
browserPaginator paginator.Model
filteredEntries []docEntry
viewer viewport.Model
entries []docEntry
entriesByName map[string]docEntry
currentDocName string
renderCache map[string]string
styles docsStyles
}
func loadDocEntries() ([]docEntry, error) {
loaded, err := embeddeddocs.All()
if err != nil {
return nil, err
}
entries := make([]docEntry, 0, len(loaded.Docs))
for _, doc := range loaded.Docs {
entries = append(entries, docEntry{
Name: doc.Name,
Content: doc.Content,
Description: summarizeMarkdown(doc.Content),
})
}
sort.SliceStable(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
return entries, nil
}
func newDocsModel(entries []docEntry) *docsModel {
filter := textinput.New()
filter.Prompt = "Find: "
filter.Placeholder = "Search docs"
filterStyles := textinput.DefaultStyles(true)
filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(clienttheme.Primary()).Bold(true)
filterStyles.Blurred.Prompt = filterStyles.Blurred.Prompt.Foreground(clienttheme.DefaultMod(500))
filterStyles.Focused.Text = filterStyles.Focused.Text.Foreground(clienttheme.DefaultMod(900))
filterStyles.Blurred.Text = filterStyles.Blurred.Text.Foreground(clienttheme.DefaultMod(700))
filterStyles.Focused.Placeholder = filterStyles.Focused.Placeholder.Foreground(clienttheme.DefaultMod(400))
filterStyles.Blurred.Placeholder = filterStyles.Blurred.Placeholder.Foreground(clienttheme.DefaultMod(400))
filter.SetStyles(filterStyles)
filter.Blur()
browserPager := paginator.New()
browserPager.Type = paginator.Dots
browserPager.ActiveDot = lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("•")
browserPager.InactiveDot = lipgloss.NewStyle().Foreground(clienttheme.DefaultMod(300)).Render("•")
viewer := viewport.New()
viewer.MouseWheelEnabled = true
viewer.SoftWrap = false
model := &docsModel{
focus: docsFocusBrowser,
filterState: docsFilterStateUnfiltered,
browserFilter: filter,
browserPaginator: browserPager,
viewer: viewer,
entries: entries,
entriesByName: make(map[string]docEntry, len(entries)),
renderCache: make(map[string]string),
styles: newDocsStyles(),
}
for _, entry := range entries {
model.entriesByName[entry.Name] = entry
}
if idx := preferredDocIndex(entries); idx >= 0 {
model.selectVisibleIndex(idx)
model.setCurrentDoc(entries[idx].Name, false)
}
return model
}
func newDocsStyles() docsStyles {
return docsStyles{
app: lipgloss.NewStyle(),
pane: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(clienttheme.DefaultMod(300)).
Padding(0, 1),
paneFocused: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(clienttheme.Primary()).
Padding(0, 1),
header: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)),
headerMeta: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(500)),
empty: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(500)),
error: lipgloss.NewStyle().
Foreground(clienttheme.Danger()),
minSize: lipgloss.NewStyle().
Foreground(clienttheme.Warning()).
Bold(true),
browserCount: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(500)),
browserCountActive: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(700)),
browserTitle: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(900)),
browserTitleMuted: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(400)),
browserTitleActive: lipgloss.NewStyle().
Foreground(clienttheme.Primary()),
browserMeta: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(500)),
browserMetaActive: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(700)),
browserHelpKey: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(700)),
browserHelpText: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(500)),
browserHelpDivider: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(300)),
browserHelpOverflow: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(400)),
}
}
func preferredDocIndex(entries []docEntry) int {
for i, entry := range entries {
if strings.EqualFold(entry.Name, "Getting Started") {
return i
}
}
if len(entries) == 0 {
return -1
}
return 0
}
func summarizeMarkdown(markdown string) string {
text := string(removeFrontmatter([]byte(markdown)))
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "```") {
continue
}
line = markdownTrimChars.ReplaceAllString(line, "")
line = sanitizeBrowserSummary(line)
if line != "" {
return line
}
}
return "Embedded Sliver documentation"
}
func sanitizeBrowserSummary(value string) string {
value = markdownLinkPattern.ReplaceAllString(value, "$1")
value = markdownInlineReplacer.Replace(value)
var builder strings.Builder
lastWasSpace := true
for _, r := range value {
switch {
case unicode.IsSpace(r):
if !lastWasSpace {
builder.WriteByte(' ')
lastWasSpace = true
}
case r > unicode.MaxASCII:
continue
case !unicode.IsPrint(r):
continue
default:
builder.WriteRune(r)
lastWasSpace = false
}
}
return strings.TrimSpace(builder.String())
}
func renderMarkdownWithGlow(width int, markdown string) (string, error) {
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(glamourstyles.DarkStyle),
glamour.WithWordWrap(maxInt(12, width)),
glamour.WithPreservedNewLines(),
)
if err != nil {
return "", err
}
rendered, err := renderer.Render(string(removeFrontmatter([]byte(markdown))))
if err != nil {
return "", err
}
lines := strings.Split(rendered, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, " \t")
}
return strings.Join(lines, "\n"), nil
}
func removeFrontmatter(content []byte) []byte {
if frontmatterBoundaries := detectFrontmatter(content); frontmatterBoundaries[0] == 0 {
return content[frontmatterBoundaries[1]:]
}
return content
}
func detectFrontmatter(content []byte) []int {
if matches := yamlFrontmatterBound.FindAllIndex(content, 2); len(matches) > 1 {
return []int{matches[0][0], matches[1][1]}
}
return []int{-1, -1}
}
func (m *docsModel) Init() tea.Cmd {
return docsWindowPollCmd()
}
func (m *docsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.applyWindowSize(msg.Width, msg.Height)
return m, nil
case docsWindowPollMsg:
return m, tea.Batch(m.windowSizeCmd(msg.width, msg.height), docsWindowPollCmd())
case tea.KeyPressMsg:
return m, m.handleKey(msg)
}
var cmds []tea.Cmd
var cmd tea.Cmd
if m.filterState == docsFilterStateFiltering {
before := m.browserFilter.Value()
m.browserFilter, cmd = m.browserFilter.Update(msg)
cmds = append(cmds, cmd)
if before != m.browserFilter.Value() {
m.updateFilteredEntries(true)
}
}
m.viewer, cmd = m.viewer.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *docsModel) handleKey(msg tea.KeyPressMsg) tea.Cmd {
if msg.String() == "ctrl+c" {
return tea.Quit
}
if m.filterState == docsFilterStateFiltering {
switch msg.Code {
case tea.KeyEnter:
m.finishFiltering(true)
return nil
case tea.KeyEsc:
m.cancelFiltering()
return nil
case tea.KeyTab:
m.finishFiltering(false)
m.focus = docsFocusViewer
return nil
}
switch msg.String() {
case "k", "ctrl+k", "up", "j", "ctrl+j", "down", "home", "g", "end", "G", "h", "left", "l", "right":
m.handleBrowserKey(msg)
return nil
}
before := m.browserFilter.Value()
updated, cmd := m.browserFilter.Update(msg)
m.browserFilter = updated
if before != m.browserFilter.Value() {
m.updateFilteredEntries(true)
}
return cmd
}
switch msg.Code {
case tea.KeyTab:
if m.focus == docsFocusBrowser {
m.focus = docsFocusViewer
} else {
m.focus = docsFocusBrowser
}
return nil
case tea.KeyEsc:
if m.focus == docsFocusViewer {
m.focus = docsFocusBrowser
return nil
}
if m.filterState == docsFilterStateApplied {
m.clearFilter()
return nil
}
}
switch msg.String() {
case "q":
return tea.Quit
case "?":
m.browserShowFullHelp = !m.browserShowFullHelp
m.updateBrowserPagination()
return nil
case "/":
return m.startFiltering()
case "enter":
if m.focus == docsFocusBrowser && m.selectedEntry() != nil {
m.focus = docsFocusViewer
}
return nil
}
if m.focus == docsFocusBrowser {
m.handleBrowserKey(msg)
return nil
}
updated, cmd := m.viewer.Update(msg)
m.viewer = updated
return cmd
}
func (m *docsModel) handleBrowserKey(msg tea.KeyPressMsg) {
switch msg.String() {
case "k", "ctrl+k", "up":
m.moveBrowserCursorUp()
case "j", "ctrl+j", "down":
m.moveBrowserCursorDown()
case "home", "g":
m.moveBrowserToStart()
case "end", "G":
m.moveBrowserToEnd()
case "h", "left":
m.browserPrevPage()
case "l", "right":
m.browserNextPage()
default:
return
}
m.syncSelectionFromBrowser()
}
func (m *docsModel) startFiltering() tea.Cmd {
m.focus = docsFocusBrowser
m.filterState = docsFilterStateFiltering
m.browserFilterSnapshot = m.browserFilter.Value()
m.browserFilter.CursorEnd()
return m.browserFilter.Focus()
}
func (m *docsModel) cancelFiltering() {
m.browserFilter.SetValue(m.browserFilterSnapshot)
m.browserFilter.Blur()
if strings.TrimSpace(m.browserFilter.Value()) == "" {
m.clearFilter()
return
}
m.filterState = docsFilterStateApplied
m.updateFilteredEntries(true)
}
func (m *docsModel) finishFiltering(openViewer bool) {
m.browserFilter.Blur()
if strings.TrimSpace(m.browserFilter.Value()) == "" {
m.clearFilter()
return
}
m.filterState = docsFilterStateApplied
m.updateFilteredEntries(true)
if openViewer && m.selectedEntry() != nil {
m.focus = docsFocusViewer
}
}
func (m *docsModel) clearFilter() {
m.browserFilter.Reset()
m.browserFilter.Blur()
m.browserFilterSnapshot = ""
m.filterState = docsFilterStateUnfiltered
m.filteredEntries = nil
m.updateBrowserPagination()
if !m.selectDocInVisible(m.currentDocName) {
m.selectVisibleIndex(0)
}
m.syncSelectionFromBrowser()
}
func (m *docsModel) updateFilteredEntries(preferCurrent bool) {
query := strings.TrimSpace(strings.ToLower(m.browserFilter.Value()))
if query == "" {
m.filteredEntries = nil
m.updateBrowserPagination()
if !m.selectDocInVisible(m.currentDocName) {
m.selectVisibleIndex(0)
}
m.syncSelectionFromBrowser()
return
}
filtered := make([]docEntry, 0, len(m.entries))
for _, entry := range m.entries {
if matchesDocQuery(entry, query) {
filtered = append(filtered, entry)
}
}
m.filteredEntries = filtered
m.updateBrowserPagination()
if preferCurrent && m.selectDocInVisible(m.currentDocName) {
m.syncSelectionFromBrowser()
return
}
m.selectVisibleIndex(0)
m.syncSelectionFromBrowser()
}
func matchesDocQuery(entry docEntry, query string) bool {
if query == "" {
return true
}
haystack := strings.ToLower(entry.Name + ".md\n" + entry.Description)
return strings.Contains(haystack, query)
}
func (m *docsModel) moveBrowserCursorUp() {
if len(m.visibleEntries()) == 0 {
return
}
m.browserCursor--
if m.browserCursor >= 0 {
return
}
if !m.browserPaginator.OnFirstPage() {
m.browserPaginator.PrevPage()
m.browserCursor = maxInt(0, m.browserPaginator.ItemsOnPage(len(m.visibleEntries()))-1)
return
}
m.browserCursor = 0
}
func (m *docsModel) moveBrowserCursorDown() {
if len(m.visibleEntries()) == 0 {
return
}
m.browserCursor++
itemsOnPage := m.browserPaginator.ItemsOnPage(len(m.visibleEntries()))
if m.browserCursor < itemsOnPage {
return
}
if !m.browserPaginator.OnLastPage() {
m.browserPaginator.NextPage()
m.browserCursor = 0
return
}
m.browserCursor = maxInt(0, itemsOnPage-1)
}
func (m *docsModel) moveBrowserToStart() {
if len(m.visibleEntries()) == 0 {
return
}
m.browserPaginator.Page = 0
m.browserCursor = 0
}
func (m *docsModel) moveBrowserToEnd() {
visible := m.visibleEntries()
if len(visible) == 0 {
return
}
m.selectVisibleIndex(len(visible) - 1)
}
func (m *docsModel) browserPrevPage() {
if m.browserPaginator.OnFirstPage() {
return
}
m.browserPaginator.PrevPage()
m.clampBrowserSelection()
}
func (m *docsModel) browserNextPage() {
if m.browserPaginator.OnLastPage() {
return
}
m.browserPaginator.NextPage()
m.clampBrowserSelection()
}
func (m *docsModel) visibleEntries() []docEntry {
if strings.TrimSpace(m.browserFilter.Value()) == "" {
return m.entries
}
return m.filteredEntries
}
func (m *docsModel) currentPageEntries() []docEntry {
visible := m.visibleEntries()
if len(visible) == 0 {
return nil
}
start, end := m.browserPaginator.GetSliceBounds(len(visible))
if start < 0 || start >= len(visible) || start >= end {
return nil
}
return visible[start:end]
}
func (m *docsModel) selectedEntry() *docEntry {
visible := m.visibleEntries()
if len(visible) == 0 {
return nil
}
index := m.browserPaginator.Page*maxInt(1, m.browserPaginator.PerPage) + m.browserCursor
if index < 0 || index >= len(visible) {
return nil
}
return &visible[index]
}
func (m *docsModel) selectVisibleIndex(index int) {
visible := m.visibleEntries()
if len(visible) == 0 {
m.browserPaginator.Page = 0
m.browserCursor = 0
return
}
index = clampInt(index, 0, len(visible)-1)
perPage := maxInt(1, m.browserPaginator.PerPage)
m.browserPaginator.Page = index / perPage
m.browserCursor = index % perPage
m.clampBrowserSelection()
}
func (m *docsModel) selectDocInVisible(name string) bool {
if name == "" {
return false
}
for i, entry := range m.visibleEntries() {
if strings.EqualFold(entry.Name, name) {
m.selectVisibleIndex(i)
return true
}
}
return false
}
func (m *docsModel) clampBrowserSelection() {
visible := m.visibleEntries()
total := len(visible)
if total == 0 {
m.browserPaginator.Page = 0
m.browserCursor = 0
return
}
if m.browserPaginator.TotalPages <= 0 {
m.browserPaginator.SetTotalPages(total)
}
if m.browserPaginator.Page > maxInt(0, m.browserPaginator.TotalPages-1) {
m.browserPaginator.Page = maxInt(0, m.browserPaginator.TotalPages-1)
}
if m.browserPaginator.Page < 0 {
m.browserPaginator.Page = 0
}
itemsOnPage := m.browserPaginator.ItemsOnPage(total)
if itemsOnPage <= 0 {
m.browserCursor = 0
return
}
if m.browserCursor > itemsOnPage-1 {
m.browserCursor = itemsOnPage - 1
}
if m.browserCursor < 0 {
m.browserCursor = 0
}
}
func (m *docsModel) syncSelectionFromBrowser() {
selected := m.selectedEntry()
if selected == nil || selected.Name == m.currentDocName {
return
}
m.setCurrentDoc(selected.Name, false)
}
func (m *docsModel) View() tea.View {
if m.width < docsMinWidth || m.height < docsMinHeight {
view := tea.NewView(m.styles.minSize.Render(
fmt.Sprintf("Docs viewer needs at least %dx%d. Current size: %dx%d", docsMinWidth, docsMinHeight, m.width, m.height),
))
view.AltScreen = true
return view
}
browserPane, viewerPane := m.renderPanes(m.height)
layout := lipgloss.JoinHorizontal(lipgloss.Top, browserPane, strings.Repeat(" ", docsPaneGap), viewerPane)
if m.isNarrow() {
layout = lipgloss.JoinVertical(lipgloss.Left, browserPane, viewerPane)
}
view := tea.NewView(m.styles.app.Render(layout))
view.AltScreen = true
return view
}
func (m *docsModel) renderPanes(bodyHeight int) (string, string) {
if m.isNarrow() {
browserHeight := clampInt(bodyHeight/3, 8, 13)
viewerHeight := maxInt(5, bodyHeight-browserHeight-docsPaneGap)
browserPane := m.renderBrowserPane(m.width, browserHeight)
viewerPane := m.renderViewerPane(m.width, viewerHeight)
return browserPane, viewerPane
}
browserWidth := clampInt(m.width/3, docsBrowserMinWidth, docsBrowserMaxWidth)
viewerWidth := maxInt(docsMinWidth-browserWidth, m.width-browserWidth-docsPaneGap)
return m.renderBrowserPane(browserWidth, bodyHeight), m.renderViewerPane(viewerWidth, bodyHeight)
}
func (m *docsModel) renderBrowserPane(width, height int) string {
style := m.styles.pane
if m.focus == docsFocusBrowser {
style = m.styles.paneFocused
}
innerWidth := maxInt(1, width-style.GetHorizontalFrameSize())
innerHeight := maxInt(1, height-style.GetVerticalFrameSize())
body := m.renderBrowserBody(innerWidth, innerHeight)
return style.Width(width).Height(height).Render(body)
}
func (m *docsModel) renderBrowserBody(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
headerLines := m.browserHeaderLines(width)
helpLines := m.browserHelpLines(width)
footerLines := []string{
"",
m.browserPaginationLine(width),
}
footerLines = append(footerLines, helpLines...)
itemHeight := maxInt(0, height-len(headerLines)-len(footerLines))
itemLines := m.renderBrowserItems(width, itemHeight)
lines := append([]string{}, headerLines...)
lines = append(lines, itemLines...)
lines = append(lines, footerLines...)
for len(lines) < height {
lines = append(lines, "")
}
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
func (m *docsModel) browserHeaderLines(width int) []string {
countStyle := m.styles.browserCount
if strings.TrimSpace(m.browserFilter.Value()) != "" {
countStyle = m.styles.browserCountActive
}
lines := []string{
truncateText(countStyle.Render(m.browserCountText()), width),
}
if m.filterState == docsFilterStateFiltering || strings.TrimSpace(m.browserFilter.Value()) != "" {
lines = append(lines, truncateText(m.browserFilter.View(), width))
}
lines = append(lines, "")
return lines
}
func (m *docsModel) browserCountText() string {
total := len(m.entries)
visible := len(m.visibleEntries())
if strings.TrimSpace(m.browserFilter.Value()) == "" {
if total == 1 {
return "1 document"
}
return fmt.Sprintf("%d documents", total)
}
if visible == 1 {
return fmt.Sprintf("1 of %d documents", total)
}
return fmt.Sprintf("%d of %d documents", visible, total)
}
func (m *docsModel) browserPaginationLine(width int) string {
if m.browserPaginator.TotalPages <= 1 {
return ""
}
return truncateText(m.browserPaginator.View(), width)
}
func (m *docsModel) browserHelpLines(width int) []string {
groups := m.browserHelpGroups()
if !m.browserShowFullHelp {
return []string{m.renderHelpLine(width, m.browserMiniHelpEntries())}
}
lines := make([]string, 0, len(groups))
for _, group := range groups {
if len(group) == 0 {
continue
}
lines = append(lines, m.renderHelpLine(width, group))
}
if len(lines) == 0 {
return []string{""}
}
return lines
}
func (m *docsModel) browserMiniHelpEntries() []docsHelpEntry {
if m.filterState == docsFilterStateFiltering {
action := "apply"
if len(m.visibleEntries()) > 0 {
action = "open"
}
return []docsHelpEntry{
{key: "enter", action: action},
{key: "esc", action: "cancel"},
}
}
entries := make([]docsHelpEntry, 0, 5)
if m.filterState != docsFilterStateApplied && m.browserPaginator.TotalPages > 1 {
entries = append(entries, docsHelpEntry{key: "h/l", action: "page"})
}
if m.filterState == docsFilterStateApplied {
entries = append(entries,
docsHelpEntry{key: "/", action: "edit"},
docsHelpEntry{key: "esc", action: "clear"},
)
} else {
entries = append(entries, docsHelpEntry{key: "/", action: "find"})
}
entries = append(entries,
docsHelpEntry{key: "q", action: "quit"},
docsHelpEntry{key: "?", action: ternaryHelpLabel(m.browserShowFullHelp)},
)
return entries
}
func (m *docsModel) browserHelpGroups() [][]docsHelpEntry {
switchTarget := "viewer"
if m.focus == docsFocusViewer {
switchTarget = "browser"
}
if m.filterState == docsFilterStateFiltering {
var nav []docsHelpEntry
if len(m.visibleEntries()) > 1 {
nav = append(nav, docsHelpEntry{key: "j/k", action: "move"})
}
if m.browserPaginator.TotalPages > 1 {
nav = append(nav, docsHelpEntry{key: "h/l", action: "page"})
}
action := "apply"
if len(m.visibleEntries()) > 0 {
action = "open"
}
return [][]docsHelpEntry{
nav,
{
{key: "enter", action: action},
{key: "esc", action: "cancel"},
},
{
{key: "tab", action: switchTarget},
{key: "ctrl+c", action: "quit"},
},
}
}
var nav []docsHelpEntry
if len(m.visibleEntries()) > 0 {
nav = append(nav, docsHelpEntry{key: "j/k", action: "move"})
}
if m.browserPaginator.TotalPages > 1 {
nav = append(nav, docsHelpEntry{key: "h/l", action: "page"})
}
filter := []docsHelpEntry{{key: "/", action: "find"}}
if m.filterState == docsFilterStateApplied {
filter = []docsHelpEntry{
{key: "/", action: "edit"},
{key: "esc", action: "clear"},
}
}
if len(m.visibleEntries()) > 0 {
filter = append(filter, docsHelpEntry{key: "enter", action: "open"})
}
return [][]docsHelpEntry{
nav,
filter,
{
{key: "tab", action: switchTarget},
{key: "q", action: "quit"},
{key: "?", action: ternaryHelpLabel(m.browserShowFullHelp)},
},
}
}
func ternaryHelpLabel(showFull bool) string {
if showFull {
return "less"
}
return "more"
}
func (m *docsModel) renderHelpLine(width int, entries []docsHelpEntry) string {
if width <= 0 || len(entries) == 0 {
return ""
}
var builder strings.Builder
overflow := m.styles.browserHelpOverflow.Render("…")
for i, entry := range entries {
segment := m.styles.browserHelpKey.Render(entry.key) + " " + m.styles.browserHelpText.Render(entry.action)
if i < len(entries)-1 {
segment += m.styles.browserHelpDivider.Render(" • ")
}
if lipgloss.Width(builder.String())+lipgloss.Width(segment) > width {
if builder.Len() == 0 {
return truncateText(segment, width)
}
builder.WriteString(overflow)
break
}
builder.WriteString(segment)
}
return builder.String()
}
func (m *docsModel) renderBrowserItems(width, height int) []string {
if height <= 0 {
return nil
}
pageEntries := m.currentPageEntries()
lines := make([]string, 0, height)
if len(pageEntries) == 0 {
lines = append(lines, truncateText(m.styles.empty.Render("No matching documents."), width))
for len(lines) < height {
lines = append(lines, "")
}
return lines
}
for i, entry := range pageEntries {
lines = append(lines, m.browserItemLines(entry, width, i == m.browserCursor)...)
}
for len(lines) < height {
lines = append(lines, "")
}
if len(lines) > height {
lines = lines[:height]
}
return lines
}
func (m *docsModel) browserItemLines(entry docEntry, width int, selected bool) []string {
contentWidth := maxInt(1, width-2)
title := truncatePlain(entry.Name+".md", contentWidth)
meta := truncatePlain(entry.Description, contentWidth)
gutter := " "
titleStyle := m.styles.browserTitle
metaStyle := m.styles.browserMeta
if selected {
gutter = lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("│")
titleStyle = m.styles.browserTitleActive
metaStyle = m.styles.browserMetaActive
} else if m.filterState == docsFilterStateFiltering && strings.TrimSpace(m.browserFilter.Value()) == "" {
titleStyle = m.styles.browserTitleMuted
}
return []string{
truncateText(gutter+" "+titleStyle.Render(title), width),
truncateText(gutter+" "+metaStyle.Render(meta), width),
"",
}
}
func (m *docsModel) renderViewerPane(width, height int) string {
style := m.styles.pane
if m.focus == docsFocusViewer {
style = m.styles.paneFocused
}
innerWidth := maxInt(1, width-style.GetHorizontalFrameSize())
header := truncateText(
m.styles.header.Render(m.viewerTitle())+" "+m.styles.headerMeta.Render(m.viewerMeta()),
innerWidth,
)
body := m.viewer.View()
if strings.TrimSpace(body) == "" {
body = m.styles.empty.Render("Select a document from the browser to start reading.")
}
content := lipgloss.JoinVertical(lipgloss.Left, header, body)
return style.Width(width).Height(height).Render(content)
}
func (m *docsModel) applyWindowSize(width, height int) {
m.width = maxInt(0, width)
m.height = maxInt(0, height)
if m.width < docsMinWidth || m.height < docsMinHeight {
return
}
if m.isNarrow() {
browserHeight := clampInt(m.height/3, 8, 13)
viewerHeight := maxInt(5, m.height-browserHeight-docsPaneGap)
m.setBrowserSize(m.width, browserHeight)
m.setViewerSize(m.width, viewerHeight)
} else {
browserWidth := clampInt(m.width/3, docsBrowserMinWidth, docsBrowserMaxWidth)
viewerWidth := maxInt(docsMinWidth-browserWidth, m.width-browserWidth-docsPaneGap)
m.setBrowserSize(browserWidth, m.height)
m.setViewerSize(viewerWidth, m.height)
}
if m.currentDocName != "" {
m.selectDocInVisible(m.currentDocName)
}
m.renderCurrentDoc(true)
}
func (m *docsModel) windowSizeCmd(width, height int) tea.Cmd {
if width <= 0 || height <= 0 {
return nil
}
if m.width == width && m.height == height {
return nil
}
return func() tea.Msg {
return tea.WindowSizeMsg{Width: width, Height: height}
}
}
func (m *docsModel) setBrowserSize(width, height int) {
style := m.styles.pane
innerWidth := maxInt(1, width-style.GetHorizontalFrameSize())
innerHeight := maxInt(6, height-style.GetVerticalFrameSize())
m.browserWidth = innerWidth
m.browserHeight = innerHeight
m.browserFilter.SetWidth(maxInt(8, innerWidth-lipgloss.Width(m.browserFilter.Prompt)-1))
m.updateBrowserPagination()
}
func (m *docsModel) browserHeaderHeight() int {
height := 1 + docsBrowserHeaderGap
if m.filterState == docsFilterStateFiltering || strings.TrimSpace(m.browserFilter.Value()) != "" {
height++
}
return height
}
func (m *docsModel) browserHelpHeight() int {
if !m.browserShowFullHelp {
return 1
}
rows := 0
for _, group := range m.browserHelpGroups() {
if len(group) > 0 {
rows++
}
}
if rows == 0 {
return 1
}
return minInt(rows, docsBrowserFullHelpRows)
}
func (m *docsModel) updateBrowserPagination() {
availableHeight := m.browserHeight -
m.browserHeaderHeight() -
docsBrowserFooterGap -
docsBrowserFooterLines -
m.browserHelpHeight()
m.browserPaginator.PerPage = maxInt(1, availableHeight/docsBrowserItemHeight)
m.browserPaginator.SetTotalPages(len(m.visibleEntries()))
m.clampBrowserSelection()
}
func (m *docsModel) setViewerSize(width, height int) {
style := m.styles.pane
innerWidth := maxInt(12, width-style.GetHorizontalFrameSize())
innerHeight := maxInt(3, height-style.GetVerticalFrameSize()-docsViewerHeaderLines)
m.viewer.SetWidth(innerWidth)
m.viewer.SetHeight(innerHeight)
}
func (m *docsModel) setCurrentDoc(name string, preserveScroll bool) {
if _, ok := m.entriesByName[name]; !ok {
return
}
m.currentDocName = name
m.renderCurrentDoc(preserveScroll)
}
func (m *docsModel) renderCurrentDoc(preserveScroll bool) {
if m.currentDocName == "" || m.viewer.Width() <= 0 {
return
}
entry, ok := m.entriesByName[m.currentDocName]
if !ok {
return
}
cacheKey := fmt.Sprintf("%s:%d", entry.Name, m.viewer.Width())
rendered, ok := m.renderCache[cacheKey]
if !ok {
var err error
rendered, err = renderMarkdownWithGlow(m.viewer.Width(), entry.Content)
if err != nil {
rendered = m.styles.error.Render(fmt.Sprintf("Failed to render %s\n\n%s", entry.Name, err))
}
m.renderCache[cacheKey] = rendered
}
scrollPercent := m.viewer.ScrollPercent()
m.viewer.SetContent(rendered)
if preserveScroll {
m.restoreViewerScroll(scrollPercent)
return
}
m.viewer.GotoTop()
}
func (m *docsModel) restoreViewerScroll(scrollPercent float64) {
maxOffset := maxInt(0, m.viewer.TotalLineCount()-m.viewer.VisibleLineCount())
m.viewer.SetYOffset(int(math.Round(scrollPercent * float64(maxOffset))))
}
func (m *docsModel) isNarrow() bool {
return m.width < docsNarrowWidth
}
func (m *docsModel) viewerTitle() string {
if m.currentDocName == "" {
return "Document"
}
return m.currentDocName
}
func (m *docsModel) viewerMeta() string {
total := m.viewer.TotalLineCount()
if total == 0 {
return "embedded markdown via Glow"
}
line := minInt(total, m.viewer.YOffset()+1)
return fmt.Sprintf("line %d/%d", line, total)
}
func truncateText(value string, width int) string {
if width <= 0 || lipgloss.Width(value) <= width {
return value
}
return lipgloss.NewStyle().MaxWidth(width).Render(value)
}
func truncatePlain(value string, width int) string {
if width <= 0 {
return ""
}
value = strings.TrimSpace(value)
if ansi.StringWidth(value) <= width {
return value
}
return ansi.Truncate(value, width, "…")
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func clampInt(value, low, high int) int {
return minInt(high, maxInt(low, value))
}
func docsWindowPollCmd() tea.Cmd {
return tea.Tick(docsWindowPollInterval, func(time.Time) tea.Msg {
width, height, ok := currentTerminalSize()
if !ok {
return docsWindowPollMsg{}
}
return docsWindowPollMsg{width: width, height: height}
})
}
func currentTerminalSize() (int, int, bool) {
for _, fd := range []int{1, 0, 2} {
width, height, err := term.GetSize(fd)
if err == nil && width > 0 && height > 0 {
return width, height, true
}
}
return 0, 0, false
}