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 / nbview / hover.go
hover.go 9.0K
1package nbview23import (4 "context"5 "slices"6 "strings"78 tea "charm.land/bubbletea/v2"9 "github.com/charmbracelet/x/ansi"1011 "src.rest/cat/012/internal/notebook"12 "src.rest/cat/012/internal/nuon"13 "src.rest/cat/012/internal/nushell"14 "src.rest/cat/012/internal/ui/lineedit"15 "src.rest/cat/012/internal/ui/theme"16)1718// The word at the caret, on the context line: once the caret rests on19// a command or a flag, its signature and what it does, as nu's hover20// says; on a $name the notebook binds, the cell it comes from and the21// shape of its value, from the notebook's own outputs without asking22// nu. A problem at the caret wins the line. F1 opens the command's23// whole help (HelpMsg).2425// Hover is what the context line says of a word of a cell's source.26type Hover struct {27 From, To int // the word's bytes28 Text string // sort-by <...comparator: cell-path|closure> --reverse, or $files: table, 12 rows × 4 columns from cell 329 Desc string // what it does, or ""30 // Brief is Text where there's no room for it: sort-by <...comparator> ….31 Brief string32 // Help is the command's whole help, for F1; nil for a variable.33 Help *nushell.Help34}3536// Hoverer says what the word at a byte of src is.37type Hoverer interface {38 Hover(ctx context.Context, src string, offset int) (Hover, bool)39}4041// HelpMsg asks the UI to show a command's help, or its keyboard42// shortcuts when Help is nil: F1 on a word nu has no help for.43type HelpMsg struct{ Help *nushell.Help }4445type (46 // restedMsg is the caret having rested since move moves.47 restedMsg struct {48 view *View49 moves int50 }51 // hoveredMsg is the answer about the word at the caret after moves52 // moves; open says F1 asked, and waits to show its help.53 hoveredMsg struct {54 view *View55 moves int56 src string57 hover Hover58 ok bool59 open bool60 }61)6263// rest notes that the caret moved or the text changed: what was asked64// about the word it was at is stale, and the word it's at is asked65// about once it rests.66func (v *View) rest() tea.Cmd {67 e := &v.edit68 e.moves++69 e.stopHover()70 return v.afterPause(restedMsg{view: v, moves: e.moves})71}7273func (e *editor) stopHover() {74 if e.hoverStop != nil {75 e.hoverStop()76 e.hoverStop = nil77 }78}7980// updateHover takes the hover's messages.81func (v *View) updateHover(msg tea.Msg) (tea.Cmd, bool) {82 switch msg := msg.(type) {83 case restedMsg:84 if msg.view != v {85 return nil, false86 }87 if v.edit.on && msg.moves == v.edit.moves {88 return v.askHover(false), true89 }90 case hoveredMsg:91 if msg.view != v {92 return nil, false93 }94 if !v.edit.on || msg.moves != v.edit.moves {95 return nil, true96 }97 v.edit.hover, v.edit.hoverFor = msg.hover, msg.src98 if !msg.ok {99 v.edit.hover = Hover{}100 }101 if msg.open {102 return helpCmd(v.edit.hover.Help), true103 }104 default:105 return nil, false106 }107 return nil, true108}109110func helpCmd(h *nushell.Help) tea.Cmd { return func() tea.Msg { return HelpMsg{Help: h} } }111112// askHover asks about the word at the caret: a $name of the notebook's113// at once, anything else in the background.114func (v *View) askHover(open bool) tea.Cmd {115 e := &v.edit116 src, off, moves := e.text(), e.area.Offset(), e.moves117 at, ok := wordAt(src, off)118 if !ok || !v.editingCode() {119 e.hover = Hover{}120 if open {121 return helpCmd(nil)122 }123 return nil124 }125 if h, ok := v.cellHover(src, at); ok {126 e.hover, e.hoverFor = h, src127 if open {128 return helpCmd(nil)129 }130 return nil131 }132 hv := v.Providers.Hoverer133 if hv == nil {134 return helpCmdIf(open)135 }136 e.stopHover()137 ctx, stop := context.WithCancel(context.Background())138 e.hoverStop = stop139 return func() tea.Msg {140 h, ok := hv.Hover(ctx, src, at)141 if ctx.Err() != nil {142 return nil143 }144 return hoveredMsg{view: v, moves: moves, src: src, hover: h, ok: ok, open: open}145 }146}147148func helpCmdIf(open bool) tea.Cmd {149 if open {150 return helpCmd(nil)151 }152 return nil153}154155// editingCode reports whether the cell being edited is code.156func (v *View) editingCode() bool {157 for _, c := range v.h.Cells() {158 if c.ID == v.edit.id {159 return c.Kind == notebook.Code160 }161 }162 return false163}164165// WordHelp is F1 in edit mode: the help of the command at the caret,166// asked for if it hasn't been, or the shortcuts when there's none.167func (v *View) WordHelp() tea.Cmd {168 if h, ok := v.hovered(); ok && h.Help != nil {169 return helpCmd(h.Help)170 }171 if !v.edit.on {172 return helpCmd(nil)173 }174 return v.askHover(true)175}176177// hovered is what's said of the word at the caret, while the text is178// what it was said of.179func (v *View) hovered() (Hover, bool) {180 e := &v.edit181 if !e.on || e.hover.Text == "" || e.hoverFor != e.text() {182 return Hover{}, false183 }184 off := e.area.Offset()185 return e.hover, off >= e.hover.From && off <= e.hover.To186}187188// isWordByte reports whether b is part of a word: not a space, a189// bracket, a pipe or a quote.190func isWordByte(b byte) bool { return !strings.ContainsRune(" \t\n|;(){}[],'\"`", rune(b)) }191192// wordAt is the byte of src to ask about for the caret at off: the193// word it's in, or the one it's just after.194func wordAt(src string, off int) (int, bool) {195 switch {196 case off < len(src) && isWordByte(src[off]):197 return off, true198 case off > 0 && off <= len(src) && isWordByte(src[off-1]):199 return off - 1, true200 }201 return 0, false202}203204// wordBounds are the bytes of the word holding byte at.205func wordBounds(src string, at int) (int, int) {206 from, to := at, at207 for from > 0 && isWordByte(src[from-1]) {208 from--209 }210 for to < len(src) && isWordByte(src[to]) {211 to++212 }213 return from, to214}215216// varName is the name $name at byte from of src reads, and where it217// ends: files of $files.name.218func varName(src string, from int) (string, int) {219 if from >= len(src) || src[from] != '$' {220 return "", from221 }222 to := from + 1223 for to < len(src) && (isNameByte(src[to])) {224 to++225 }226 return src[from+1 : to], to227}228229func isNameByte(b byte) bool {230 return b == '_' || b == '-' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= 0x80231}232233// cellHover is what the notebook knows of the $name at byte at: the234// cell whose output or variable it is, and its value's shape.235func (v *View) cellHover(src string, at int) (Hover, bool) {236 from, _ := wordBounds(src, at)237 name, to := varName(src, from)238 if name == "" || at >= to {239 return Hover{}, false240 }241 for i, c := range v.h.Cells() {242 if c.Kind != notebook.Code || c.ID == v.edit.id {243 continue244 }245 var shape string246 switch {247 case c.Name() == name:248 shape = shapeWords(v.shown(c), v.h.Output(c.ID))249 case slices.Contains(c.Parse().Assigned(), name):250 shape = varShape(v.h.Output(c.ID), name)251 default:252 continue253 }254 text := "$" + name + ": " + shape + " from cell " + itoa(i+1)255 if shape == "" {256 text = "$" + name + ": from cell " + itoa(i+1) + ", not run yet"257 }258 return Hover{From: from, To: to, Text: text}, true259 }260 return Hover{}, false261}262263// varShape is the shape of variable name of a cell's last run.264func varShape(o *notebook.Output, name string) string {265 if o == nil || o.Vars[name] == nil {266 return ""267 }268 val, err := nuon.Parse(o.Vars[name])269 if err != nil {270 return "a value"271 }272 sh := shownOf(val)273 if sh.kind == outValue {274 return val.Kind.String()275 }276 return shapeWords(sh, nil)277}278279// shapeWords is an output's shape, "table, 12 rows × 4 columns", or ""280// before it has run.281func shapeWords(sh *shown, o *notebook.Output) string {282 switch sh.kind {283 case outTable:284 return "table, " + more(sh.total, "row") + " × " + more(sh.cols, "column")285 case outRecord:286 return "record, " + more(sh.total, "field")287 case outList:288 return "list, " + more(sh.total, "item")289 case outText:290 if sh.total > 1 {291 return "string, " + more(sh.total, "line")292 }293 return "string"294 case outError:295 return "an error"296 case outValue:297 if o != nil {298 if val, err := nuon.Parse(o.NUON); err == nil {299 return val.Kind.String()300 }301 }302 return "a value"303 case outNone:304 if o != nil && o.Count > 0 {305 return "nothing"306 }307 }308 return ""309}310311// hoverLine draws h in room columns: the signature in the text's own312// color, then what it does, muted;313// flags are left out, then the types, then the description cut, before314// the rest is.315func hoverLine(th *theme.Theme, h Hover, room int) string {316 text, desc := h.Text, h.Desc317 width := func() int {318 w := ansi.StringWidth(text)319 if desc != "" {320 w += 2 + ansi.StringWidth(desc)321 }322 return w323 }324 for width() > room && desc != "" {325 i := strings.LastIndex(text, " --")326 if i < 0 {327 break328 }329 text = strings.TrimSuffix(text[:i], " …") + " …"330 }331 if width() > room && h.Brief != "" {332 text = h.Brief333 }334 if desc == "" {335 return th.Cell.Render(ansi.Truncate(text, max(room, 1), "…"))336 }337 out := th.Cell.Render(ansi.Truncate(text, max(room, 1), "…"))338 if left := room - ansi.StringWidth(text) - 2; left > 3 {339 out += " " + th.Muted.Render(ansi.Truncate(desc, left, "…"))340 }341 return out342}343344// Highlighted draws a line of nushell in its syntax roles, as 012's345// own highlighter has them: a command's usage and examples in its help.346func Highlighted(th *theme.Theme, src string) string {347 buf := []rune(src)348 roles := rolesOf(src, Tokens{}.Highlight(context.Background(), src))349 return drawRow(th, buf, lineedit.Row{Start: 0, End: len(buf)}, roles, nil)350}