cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / cmdhelp / cmdhelp.go
cmdhelp.go 5.9K
1// Package cmdhelp is a nushell command's help, F1 on a command in a2// notebook's cell: a scrollable box over the notebook with what the3// command does, a link to its page in nushell's docs, its usage, flags,4// parameters, input and output types and examples, as nu's help has5// them. It knows the UI only through Host.6package cmdhelp78import (9 "strconv"10 "strings"1112 tea "charm.land/bubbletea/v2"13 "github.com/charmbracelet/x/ansi"1415 "src.rest/cat/012/internal/nushell"16 "src.rest/cat/012/internal/ui/overlay"17 "src.rest/cat/012/internal/ui/theme"18)1920// Host is what the help needs of the UI.21type Host interface {22 Theme() *theme.Theme23 Size() (width, height int)24 // Close closes the open overlay.25 Close()26 // Code draws a line of nushell highlighted.27 Code(src string) string28}2930// View is the open help.31type View struct {32 h Host33 help nushell.Help34 top int35}3637// ID identifies the view's box in mouse events.38const ID = "cmdhelp"3940// maxWidth is the widest the text runs, for reading.41const maxWidth = 924243// New returns the help of a command, scrolled to the top.44func New(h Host, help nushell.Help) *View { return &View{h: h, help: help} }4546// Top is the first line shown.47func (s *View) Top() int { return s.top }4849func (s *View) Indicator() string { return "HELP" }5051// width is the text's width inside the box.52func (s *View) width() int {53 w, _ := s.h.Size()54 return max(min(w-6, maxWidth), 20)55}5657// Lines are the help's lines at the box's width.58func (s *View) Lines() []string {59 th := s.h.Theme()60 w := s.width()61 hp := s.help62 var out []string63 wrap := func(text, indent string) {64 for line := range strings.SplitSeq(ansi.Wordwrap(text, w-len(indent), ""), "\n") {65 out = append(out, indent+line)66 }67 }68 heading := func(title string) { out = append(out, "", th.Title.Render(title)) }69 for _, para := range strings.Split(hp.Desc, "\n\n") {70 if len(out) > 0 {71 out = append(out, "")72 }73 wrap(strings.Join(strings.Fields(para), " "), "")74 }75 if hp.Desc != "" { // a command of the cell's own has no page76 url := nushell.DocsURL(hp.Name)77 out = append(out, "", th.Muted.Render("Docs: ")+th.Link.Hyperlink(url).Render(url))78 }79 heading("Usage")80 out = append(out, " "+s.h.Code(hp.Usage))81 if len(hp.Flags) > 0 {82 heading("Flags")83 out = append(out, s.table(flagRows(hp.Flags))...)84 }85 if len(hp.Params) > 0 {86 heading("Parameters")87 out = append(out, s.table(paramRows(hp.Params))...)88 }89 if len(hp.Types) > 0 {90 heading("Input and output")91 for _, t := range hp.Types {92 out = append(out, " "+t)93 }94 }95 if len(hp.Examples) > 0 {96 heading("Examples")97 for i, ex := range hp.Examples {98 if i > 0 {99 out = append(out, "")100 }101 wrap(ex.Desc, " ")102 for line := range strings.SplitSeq(ex.Code, "\n") {103 out = append(out, " "+s.h.Code(line))104 }105 }106 }107 return out108}109110// flagRows are the flags as names and what they do: -r, --reverse.111func flagRows(flags []nushell.Flag) [][2]string {112 rows := make([][2]string, len(flags))113 for i, f := range flags {114 name := f.Long115 if f.Short != "" {116 name = strings.TrimSuffix(f.Short+", "+f.Long, ", ")117 }118 if f.Arg != "" {119 name += " <" + f.Arg + ">"120 }121 rows[i] = [2]string{name, f.Desc}122 }123 return rows124}125126func paramRows(params []nushell.Param) [][2]string {127 rows := make([][2]string, len(params))128 for i, p := range params {129 rows[i] = [2]string{strings.TrimSuffix(p.Name+": "+p.Type, ": "), p.Desc}130 }131 return rows132}133134// table draws names and what they are in two columns, the second135// wrapped beside the first.136func (s *View) table(rows [][2]string) []string {137 th := s.h.Theme()138 w := s.width()139 nameW := 0140 for _, r := range rows {141 nameW = max(nameW, ansi.StringWidth(r[0]))142 }143 nameW = min(nameW, w/2)144 var out []string145 for _, r := range rows {146 name := ansi.Truncate(r[0], nameW, "…")147 lead := " " + th.Key.Render(name) + strings.Repeat(" ", nameW-ansi.StringWidth(name)+2)148 pad := strings.Repeat(" ", nameW+4)149 for i, line := range strings.Split(ansi.Wordwrap(r[1], max(w-nameW-4, 10), ""), "\n") {150 if i == 0 {151 out = append(out, lead+line)152 } else {153 out = append(out, pad+line)154 }155 }156 }157 return out158}159160// visible is how many lines fit between the menu bar and the status161// line.162func (s *View) visible(total int) int {163 _, height := s.h.Size()164 return max(min(total, height-2-2-1), 1)165}166167func (s *View) Layout() []overlay.Box {168 th := s.h.Theme()169 width, height := s.h.Size()170 lines := append([]string{""}, s.Lines()...) // breathing room under the title171 inner := s.width() + 2172 n := s.visible(len(lines))173 s.top = min(max(s.top, 0), max(len(lines)-n, 0))174 rows := make([]string, n)175 for i := range rows {176 line := ""177 if j := s.top + i; j < len(lines) {178 line = lines[j]179 }180 rows[i] = theme.Cells(th.MenuBar, " "+line, inner)181 }182 footer := ""183 if n < len(lines) {184 footer = strconv.Itoa(s.top+1) + "-" + strconv.Itoa(s.top+n) + " of " + strconv.Itoa(len(lines))185 }186 b := th.Frame(inner, s.help.Name, footer, rows)187 bw, bh := ansi.StringWidth(b[0]), len(b)188 return []overlay.Box{{ID: ID, X: (width - bw) / 2, Y: max(overlay.MenuLine+1, (height-1-bh)/2), Lines: b}}189}190191func (s *View) Key(k tea.KeyPressMsg) tea.Cmd {192 total := len(s.Lines()) + 1193 page := s.visible(total)194 switch k.String() {195 case "up", "k":196 s.top--197 case "down", "j":198 s.top++199 case "pgup":200 s.top -= page201 case "pgdown", "space":202 s.top += page203 case "home":204 s.top = 0205 case "end":206 s.top = total207 case "esc", "enter", "q", "f1":208 s.h.Close()209 }210 s.top = min(max(s.top, 0), max(total-page, 0))211 return nil212}213214func (s *View) Mouse(e overlay.MouseEvent) tea.Cmd {215 switch {216 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:217 s.top = max(s.top-3, 0)218 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:219 s.top += 3 // layout clamps220 case e.Kind == overlay.MousePress && e.Box != ID:221 s.h.Close()222 }223 return nil224}225226func (s *View) Status() (string, string) {227 return s.help.Summary(), s.h.Theme().KeyHints("Up/Down", "scroll", "Esc", "close")228}