tiki/view/markdown/navigable_markdown.go
2026-04-13 12:30:24 -04:00

157 lines
4.7 KiB
Go

package markdown
import (
"strings"
"github.com/boolean-maybe/tiki/config"
nav "github.com/boolean-maybe/navidown/navidown"
navtview "github.com/boolean-maybe/navidown/navidown/tview"
navutil "github.com/boolean-maybe/navidown/util"
)
// NavigableMarkdown wraps navidown TextViewViewer with link/anchor handling.
type NavigableMarkdown struct {
viewer *navtview.TextViewViewer
provider nav.ContentProvider
searchRoots []string
onStateChange func()
}
// NavigableMarkdownConfig configures a NavigableMarkdown component.
type NavigableMarkdownConfig struct {
Provider nav.ContentProvider
SearchRoots []string
OnStateChange func() // called on navigation state changes
ImageManager *navtview.ImageManager
MermaidOptions *nav.MermaidOptions // nil = disabled
}
// NewNavigableMarkdown creates a new navigable markdown viewer.
func NewNavigableMarkdown(cfg NavigableMarkdownConfig) *NavigableMarkdown {
nm := &NavigableMarkdown{
viewer: navtview.NewTextView(),
provider: cfg.Provider,
searchRoots: cfg.SearchRoots,
onStateChange: cfg.OnStateChange,
}
nm.viewer.SetAnsiConverter(navutil.NewAnsiConverter(true))
renderer := nav.NewANSIRendererWithStyle(config.GetNavidownStyle())
if t := config.GetCodeBlockTheme(); t != "" {
renderer = renderer.WithCodeTheme(t)
}
if bg := config.GetCodeBlockBackground(); bg != "" {
renderer = renderer.WithCodeBackground(bg)
}
if b := config.GetCodeBlockBorder(); b != "" {
renderer = renderer.WithCodeBorder(b)
}
nm.viewer.SetRenderer(renderer)
nm.viewer.SetBackgroundColor(config.GetColors().ContentBackgroundColor.TCell())
if cfg.ImageManager != nil && cfg.ImageManager.Supported() {
nm.viewer.SetImageManager(cfg.ImageManager)
}
nm.viewer.SetStateChangedHandler(func(_ *navtview.TextViewViewer) {
if nm.onStateChange != nil {
nm.onStateChange()
}
})
if cfg.MermaidOptions != nil {
nm.viewer.Core().SetMermaidOptions(cfg.MermaidOptions)
}
nm.setupSelectHandler()
return nm
}
// Close releases resources held by the navigable markdown (e.g., mermaid temp files).
func (nm *NavigableMarkdown) Close() {
nm.viewer.Core().Close()
}
func (nm *NavigableMarkdown) setupSelectHandler() {
nm.viewer.SetSelectHandler(func(v *navtview.TextViewViewer, elem nav.NavElement) {
if elem.Type != nav.NavElementURL {
return
}
// Internal anchor (same file)
if elem.IsInternalLink() {
v.ScrollToAnchor(elem.AnchorTarget(), true)
return
}
// Cross-file (possibly with anchor)
path, fragment := splitURLFragment(elem.URL)
content, err := nm.provider.FetchContent(nav.NavElement{
URL: path,
SourceFilePath: elem.SourceFilePath,
Type: elem.Type,
})
if err != nil {
v.SetMarkdown(FormatErrorContent(err))
return
}
if content == "" {
return
}
v.SetMarkdownWithSource(content, nm.resolveSourcePath(path, elem.SourceFilePath), true)
if fragment != "" {
v.ScrollToAnchor(fragment, false)
}
})
}
func (nm *NavigableMarkdown) resolveSourcePath(url, sourceFile string) string {
if sourceFile == "" {
return url
}
resolved, err := nav.ResolveMarkdownPath(url, sourceFile, nm.searchRoots)
if err != nil || resolved == "" {
return url
}
return resolved
}
// Viewer returns the underlying TextViewViewer for layout embedding.
func (nm *NavigableMarkdown) Viewer() *navtview.TextViewViewer {
return nm.viewer
}
// CanGoBack returns true if back navigation is available.
func (nm *NavigableMarkdown) CanGoBack() bool {
return nm.viewer.Core().CanGoBack()
}
// CanGoForward returns true if forward navigation is available.
func (nm *NavigableMarkdown) CanGoForward() bool {
return nm.viewer.Core().CanGoForward()
}
// SourceFilePath returns the current source file path.
func (nm *NavigableMarkdown) SourceFilePath() string {
return nm.viewer.Core().SourceFilePath()
}
// SetMarkdown sets markdown content without source context.
func (nm *NavigableMarkdown) SetMarkdown(content string) {
nm.viewer.SetMarkdown(content)
}
// SetMarkdownWithSource sets markdown content with source context.
func (nm *NavigableMarkdown) SetMarkdownWithSource(content, source string, pushHistory bool) {
nm.viewer.SetMarkdownWithSource(content, source, pushHistory)
}
// SetStateChangedHandler sets the callback for navigation state changes.
// This is useful when the handler needs to reference the NavigableMarkdown instance.
func (nm *NavigableMarkdown) SetStateChangedHandler(handler func()) {
nm.onStateChange = handler
}
func splitURLFragment(url string) (path, fragment string) {
path, fragment, _ = strings.Cut(url, "#")
return path, fragment
}
// FormatErrorContent formats an error as markdown content.
func FormatErrorContent(err error) string {
return "# Error\n\n```\n" + err.Error() + "\n```"
}