451 lines
11 KiB
Go
451 lines
11 KiB
Go
package docs
|
|
|
|
import (
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"aego/core/mathx"
|
|
"aego/editor/core/context"
|
|
"aego/editor/core/services"
|
|
regdocs "aego/engine/docs"
|
|
"aego/ui/core"
|
|
"aego/ui/richtext"
|
|
"aego/ui/widgets"
|
|
)
|
|
|
|
const (
|
|
ID = "reference"
|
|
Language = "language"
|
|
historyMax = 64
|
|
outputLang = "output"
|
|
)
|
|
|
|
type row struct {
|
|
id string
|
|
depth int
|
|
title string
|
|
kids bool
|
|
group bool
|
|
head bool
|
|
}
|
|
|
|
type Panel struct {
|
|
Run func(code string) []string
|
|
reg *regdocs.Registry
|
|
nav services.Navigation
|
|
notes services.Notices
|
|
query string
|
|
shown string
|
|
rows []row
|
|
open map[string]bool
|
|
current string
|
|
back []string
|
|
fwd []string
|
|
text richtext.Text
|
|
scroll float32
|
|
split float32
|
|
dirty bool
|
|
reveal bool
|
|
groups []regdocs.GroupNav
|
|
}
|
|
|
|
func New(reg *regdocs.Registry, nav services.Navigation, notes services.Notices) *Panel {
|
|
p := &Panel{reg: reg, nav: nav, notes: notes, open: map[string]bool{}, split: 0.3, dirty: true}
|
|
p.text.Highlight = Highlight
|
|
if reg != nil {
|
|
p.groups = reg.Export("").Groups
|
|
}
|
|
return p
|
|
}
|
|
|
|
func groupKey(id string) string {
|
|
return "group:" + id
|
|
}
|
|
|
|
func sectionTitle(s string) string {
|
|
switch s {
|
|
case regdocs.SectionStart:
|
|
return "Getting started"
|
|
case regdocs.SectionGuides:
|
|
return "Guides"
|
|
}
|
|
return "Reference"
|
|
}
|
|
|
|
func (p *Panel) ID() string {
|
|
return ID
|
|
}
|
|
|
|
func (p *Panel) Title() string {
|
|
return "Documentation"
|
|
}
|
|
|
|
func (p *Panel) Scope() string {
|
|
return "docs"
|
|
}
|
|
|
|
func (p *Panel) Current() string {
|
|
return p.current
|
|
}
|
|
|
|
func (p *Panel) Show(ref, query string) {
|
|
if p.reg == nil {
|
|
return
|
|
}
|
|
if ref != "" {
|
|
if e, ok := p.reg.Resolve(ref); ok {
|
|
p.query = ""
|
|
p.navigate(e.ID, true)
|
|
return
|
|
}
|
|
}
|
|
if q := strings.TrimSpace(query); q != "" {
|
|
p.query = q
|
|
p.dirty = true
|
|
if hits := p.reg.Search(q, 1); len(hits) > 0 {
|
|
p.navigate(hits[0].ID, true)
|
|
}
|
|
return
|
|
}
|
|
if p.current == "" {
|
|
p.navigate(Language, true)
|
|
}
|
|
}
|
|
|
|
func (p *Panel) navigate(id string, record bool) {
|
|
e, ok := p.reg.Get(id)
|
|
if !ok {
|
|
return
|
|
}
|
|
if record && p.current != "" && p.current != id {
|
|
p.back = append(p.back, p.current)
|
|
if len(p.back) > historyMax {
|
|
p.back = p.back[1:]
|
|
}
|
|
p.fwd = p.fwd[:0]
|
|
}
|
|
p.current = id
|
|
if e.Group != "" {
|
|
p.open[groupKey(e.Group)] = true
|
|
}
|
|
for a := e.Parent; a != ""; {
|
|
p.open[a] = true
|
|
pe, ok := p.reg.Get(a)
|
|
if !ok {
|
|
break
|
|
}
|
|
a = pe.Parent
|
|
}
|
|
p.text.Set(p.page(e))
|
|
p.dirty, p.reveal = true, true
|
|
}
|
|
|
|
func (p *Panel) step(from, to *[]string) {
|
|
n := len(*from)
|
|
if n == 0 {
|
|
return
|
|
}
|
|
id := (*from)[n-1]
|
|
*from = (*from)[:n-1]
|
|
*to = append(*to, p.current)
|
|
p.navigate(id, false)
|
|
}
|
|
|
|
func stripTitle(md string) string {
|
|
t := strings.TrimLeft(md, "\n")
|
|
if !strings.HasPrefix(t, "# ") {
|
|
return md
|
|
}
|
|
if i := strings.IndexByte(t, '\n'); i >= 0 {
|
|
return t[i+1:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func spans(s string, st richtext.Style) []richtext.Span {
|
|
return []richtext.Span{{Text: s, Style: st}}
|
|
}
|
|
|
|
func (p *Panel) page(e regdocs.Entry) []richtext.Block {
|
|
out := []richtext.Block{{Kind: richtext.Heading, Level: 1, Spans: spans(e.Title, richtext.Plain)}}
|
|
if e.Kind != regdocs.Page {
|
|
meta := e.Kind.String()
|
|
if e.Owner != "" {
|
|
meta += " · " + e.Owner
|
|
}
|
|
out = append(out, richtext.Block{Kind: richtext.Paragraph, Spans: spans(meta, richtext.Emphasis)})
|
|
}
|
|
if e.Signature != "" {
|
|
out = append(out, richtext.Block{Kind: richtext.CodeBlock, Lang: "aes", Lines: strings.Split(e.Signature, "\n")})
|
|
}
|
|
out = append(out, richtext.Parse(stripTitle(e.Body))...)
|
|
if e.Example != "" {
|
|
out = append(out,
|
|
richtext.Block{Kind: richtext.Heading, Level: 3, Spans: spans("Example", richtext.Plain)},
|
|
richtext.Block{Kind: richtext.CodeBlock, Lang: "aes", Lines: strings.Split(e.Example, "\n")})
|
|
}
|
|
var links []richtext.Span
|
|
if e.Location.Stub != "" {
|
|
links = append(links, richtext.Span{Text: "Open stub", Target: e.Location.Stub + "#" + e.Location.Symbol})
|
|
}
|
|
if strings.HasPrefix(e.ID, regdocs.ScriptsPrefix) && e.Location.Path != "" {
|
|
if len(links) > 0 {
|
|
links = append(links, richtext.Span{Text: " · "})
|
|
}
|
|
links = append(links, richtext.Span{Text: "Open source", Target: "open:" + e.Location.Path + ":" + strconv.Itoa(e.Location.Line)})
|
|
}
|
|
if len(links) > 0 {
|
|
out = append(out, richtext.Block{Kind: richtext.Paragraph, Spans: links})
|
|
}
|
|
if kids := p.reg.Children(e.ID); len(kids) > 0 {
|
|
out = append(out, richtext.Block{Kind: richtext.Heading, Level: 2, Spans: spans("Contents", richtext.Plain)})
|
|
for _, k := range kids {
|
|
item := []richtext.Span{{Text: k.Title, Target: "ref:" + k.ID}}
|
|
if f := k.Field; f != nil {
|
|
t := " " + f.Type
|
|
if f.Default != "" {
|
|
t += " = " + f.Default
|
|
}
|
|
item = append(item, richtext.Span{Text: t, Style: richtext.Code})
|
|
}
|
|
if k.Summary != "" {
|
|
item = append(item, richtext.Span{Text: " — " + k.Summary})
|
|
}
|
|
out = append(out, richtext.Block{Kind: richtext.ListItem, Spans: item})
|
|
}
|
|
}
|
|
if len(e.Related) > 0 {
|
|
out = append(out, richtext.Block{Kind: richtext.Heading, Level: 2, Spans: spans("Related guides", richtext.Plain)})
|
|
for _, id := range e.Related {
|
|
if re, ok := p.reg.Get(id); ok {
|
|
out = append(out, richtext.Block{Kind: richtext.ListItem, Spans: []richtext.Span{{Text: re.Title, Target: "ref:" + id}}})
|
|
}
|
|
}
|
|
}
|
|
p.autolink(out)
|
|
return out
|
|
}
|
|
|
|
func (p *Panel) link(list []richtext.Span) {
|
|
for i := range list {
|
|
sp := &list[i]
|
|
if sp.Style == richtext.Code && sp.Target == "" {
|
|
if e, ok := p.reg.Link(sp.Text); ok {
|
|
sp.Target = "ref:" + e.ID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Panel) autolink(blocks []richtext.Block) {
|
|
for i := range blocks {
|
|
p.link(blocks[i].Spans)
|
|
for _, r := range blocks[i].Rows {
|
|
for _, c := range r {
|
|
p.link(c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Panel) rebuild() {
|
|
p.dirty = false
|
|
p.shown = p.query
|
|
p.rows = p.rows[:0]
|
|
if q := strings.TrimSpace(p.query); q != "" {
|
|
for _, e := range p.reg.Search(q, 200) {
|
|
p.rows = append(p.rows, row{id: e.ID, title: e.Title + " · " + e.Kind.String()})
|
|
}
|
|
return
|
|
}
|
|
var walk func(nodes []regdocs.Node, depth int)
|
|
walk = func(nodes []regdocs.Node, depth int) {
|
|
for _, n := range nodes {
|
|
e, ok := p.reg.Get(n.ID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
p.rows = append(p.rows, row{id: n.ID, depth: depth, title: e.Title, kids: len(n.Children) > 0})
|
|
if len(n.Children) > 0 && p.open[n.ID] {
|
|
walk(n.Children, depth+1)
|
|
}
|
|
}
|
|
}
|
|
section := ""
|
|
for _, g := range p.groups {
|
|
if g.Section != section {
|
|
section = g.Section
|
|
p.rows = append(p.rows, row{title: sectionTitle(section), head: true})
|
|
}
|
|
key := groupKey(g.ID)
|
|
p.rows = append(p.rows, row{id: key, title: g.Title, kids: true, group: true})
|
|
if p.open[key] {
|
|
walk(g.Nav, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Panel) follow(target string) {
|
|
switch {
|
|
case strings.HasPrefix(target, context.StubPrefix):
|
|
path, sym, _ := strings.Cut(target, "#")
|
|
l := context.At(path, 1, 1)
|
|
l.Symbol = sym
|
|
p.nav.Open(l)
|
|
case strings.HasPrefix(target, "open:"):
|
|
rest := strings.TrimPrefix(target, "open:")
|
|
path, line := rest, 1
|
|
if i := strings.LastIndexByte(rest, ':'); i > 0 {
|
|
if n, err := strconv.Atoi(rest[i+1:]); err == nil {
|
|
path, line = rest[:i], n
|
|
}
|
|
}
|
|
p.nav.Open(context.At(path, line, 1))
|
|
case strings.HasPrefix(target, "http://"), strings.HasPrefix(target, "https://"):
|
|
p.notes.Notify(context.LevelInfo, target)
|
|
default:
|
|
if e, ok := p.reg.Resolve(target); ok {
|
|
p.navigate(e.ID, true)
|
|
return
|
|
}
|
|
p.notes.Notify(context.LevelWarn, "No documentation entry "+strings.TrimPrefix(target, "ref:"))
|
|
}
|
|
}
|
|
|
|
func (p *Panel) run(i int) {
|
|
blocks := p.text.Blocks()
|
|
if p.Run == nil || i < 0 || i >= len(blocks) {
|
|
return
|
|
}
|
|
out := p.Run(strings.Join(blocks[i].Lines, "\n"))
|
|
if next := i + 1; next < len(blocks) && blocks[next].Lang == outputLang {
|
|
blocks[next].Lines = out
|
|
} else {
|
|
blocks = slices.Insert(blocks, next, richtext.Block{Kind: richtext.CodeBlock, Lang: outputLang, Lines: out})
|
|
}
|
|
p.text.Replace(blocks)
|
|
}
|
|
|
|
func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
|
|
ui := f.UI
|
|
if p.reg == nil {
|
|
widgets.LabelWrapped(ui, "Documentation is not loaded.", true)
|
|
return
|
|
}
|
|
if p.current == "" {
|
|
p.navigate(Language, false)
|
|
}
|
|
left, right := widgets.Splitter(ui, ui.ID("split"), r, false, &p.split)
|
|
p.drawTree(ui, left)
|
|
p.drawPage(ui, right)
|
|
}
|
|
|
|
func (p *Panel) drawTree(ui *core.Context, r mathx.Rect) {
|
|
th := &ui.Theme
|
|
bar := mathx.Rect{X: r.X, Y: r.Y, W: r.W, H: th.RowHeight + 4}
|
|
if widgets.SearchField(ui, ui.ID("search"), bar, &p.query) || p.query != p.shown {
|
|
p.dirty = true
|
|
}
|
|
if p.dirty {
|
|
p.rebuild()
|
|
}
|
|
list := mathx.Rect{X: r.X, Y: bar.Y + bar.H + th.Spacing, W: r.W, H: max(0, r.H-bar.H-th.Spacing)}
|
|
if p.reveal {
|
|
p.reveal = false
|
|
for i, rw := range p.rows {
|
|
if rw.id == p.current {
|
|
widgets.EnsureVisible(&p.scroll, i, th.RowHeight, list.H)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
base := ui.ID("row")
|
|
widgets.VirtualList(ui, ui.ID("tree"), list, len(p.rows), th.RowHeight, &p.scroll, func(i int, rr mathx.Rect) {
|
|
rw := p.rows[i]
|
|
if rw.head {
|
|
ui.Text(rw.title, mathx.Vec2{X: rr.X + th.Padding, Y: ui.TextY(rr)}, th.TextDim)
|
|
return
|
|
}
|
|
ev := widgets.TreeRowRect(ui, core.IDSeq(base, i), rr, widgets.TreeRow{
|
|
Depth: rw.depth, Label: rw.title, HasChildren: rw.kids, Open: p.open[rw.id],
|
|
Selected: rw.id == p.current, Focused: true,
|
|
})
|
|
switch {
|
|
case ev.Toggled, ev.Pressed && rw.group:
|
|
p.open[rw.id] = !p.open[rw.id]
|
|
p.dirty = true
|
|
case ev.Pressed:
|
|
if rw.kids && rw.id == p.current {
|
|
p.open[rw.id] = !p.open[rw.id]
|
|
p.dirty = true
|
|
}
|
|
p.navigate(rw.id, true)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (p *Panel) crumbs(ui *core.Context, r mathx.Rect) {
|
|
th := &ui.Theme
|
|
e, ok := p.reg.Get(p.current)
|
|
if !ok {
|
|
return
|
|
}
|
|
var trail []regdocs.Entry
|
|
for a := e.Parent; a != ""; {
|
|
pe, ok := p.reg.Get(a)
|
|
if !ok {
|
|
break
|
|
}
|
|
trail = append([]regdocs.Entry{pe}, trail...)
|
|
a = pe.Parent
|
|
}
|
|
x := r.X
|
|
jump := ""
|
|
ui.PushClip(r)
|
|
for i, pe := range trail {
|
|
w := ui.TextSize(pe.Title).X
|
|
cr := mathx.Rect{X: x, Y: r.Y, W: w, H: r.H}
|
|
cid := core.IDSeq(ui.ID("crumb"), i)
|
|
if ui.Clicked(cid, cr) {
|
|
jump = pe.ID
|
|
}
|
|
col := th.TextDim
|
|
if ui.Hot() == cid {
|
|
col = th.Accent
|
|
}
|
|
ui.Text(pe.Title, mathx.Vec2{X: x, Y: ui.TextY(r)}, col)
|
|
x += w
|
|
ui.Text(" / ", mathx.Vec2{X: x, Y: ui.TextY(r)}, th.TextDisabled)
|
|
x += ui.TextSize(" / ").X
|
|
}
|
|
ui.Text(e.Title, mathx.Vec2{X: x, Y: ui.TextY(r)}, th.Text)
|
|
ui.PopClip()
|
|
if jump != "" {
|
|
p.navigate(jump, true)
|
|
}
|
|
}
|
|
|
|
func (p *Panel) drawPage(ui *core.Context, r mathx.Rect) {
|
|
th := &ui.Theme
|
|
bar := mathx.Rect{X: r.X, Y: r.Y, W: r.W, H: th.RowHeight + 4}
|
|
bw := bar.H
|
|
if widgets.ButtonRect(ui, ui.ID("back"), mathx.Rect{X: bar.X, Y: bar.Y, W: bw, H: bar.H}, widgets.Glyph(ui, '←', "<")) {
|
|
p.step(&p.back, &p.fwd)
|
|
}
|
|
if widgets.ButtonRect(ui, ui.ID("fwd"), mathx.Rect{X: bar.X + bw + 2, Y: bar.Y, W: bw, H: bar.H}, widgets.Glyph(ui, '→', ">")) {
|
|
p.step(&p.fwd, &p.back)
|
|
}
|
|
cx := bar.X + 2*bw + 10
|
|
p.crumbs(ui, mathx.Rect{X: cx, Y: bar.Y, W: max(0, bar.X+bar.W-cx), H: bar.H})
|
|
body := mathx.Rect{X: r.X, Y: bar.Y + bar.H + th.Spacing, W: r.W, H: max(0, r.H-bar.H-th.Spacing)}
|
|
p.text.RunButtons = p.Run != nil
|
|
act := p.text.Draw(ui, ui.ID("page"), body)
|
|
if act.Target != "" {
|
|
p.follow(act.Target)
|
|
}
|
|
if act.Run >= 0 {
|
|
p.run(act.Run)
|
|
}
|
|
}
|