Once the caret rests in a code cell, the context line says what the word at it is: a command's signature and summary, a flag in its command's help (nu --ide-hover, asked as the other questions are: after a pause, stopped when stale, falling back silently), or for a $name the notebook binds, the cell it comes from and its value's shape, from the outputs without asking nu. A problem at the caret wins the line. F1 in edit mode opens the command's whole help in a scrollable box (internal/ui/cmdhelp) with its page in nushell's docs, or the shortcuts where there's no command.
- commit
- c07f987983afb430d35977e8a55d7d62163b5f65
- parent
- e6a3731a93c7f99c943ee2f28a592038717add7e
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.5 of 3, Jev's estimate
@@ -239,6 +239,16 @@ func (h Help) Signature() string {
239239 return strings.Join(sig, " ")240240 }241241 242+// Brief is the signature where there's no room for it: the usage243+// without types, and … for its flags: sort-by <...comparator> ….244+func (h Help) Brief() string {245+ brief := strings.Join(strings.Fields(strings.Replace(h.Usage, "{flags}", "", 1)), " ")246+ if len(h.Flags) > 0 {247+ brief += " …"248+ }249+ return brief250+}251+242252 // param is the parameter usage's word w names: <...comparator>,243253 // <rows?>.244254 func (h Help) param(w string) (Param, bool) {@@ -41,6 +41,9 @@ func TestParseHelp(t *testing.T) {
4141 if got := help.Signature(); got != "sort-by <...comparator: cell-path|closure> --reverse --ignore-case --natural --custom" {4242 t.Errorf("signature %q", got)4343 }44+ if got := help.Brief(); got != "sort-by <...comparator> …" {45+ t.Errorf("brief %q", got)46+ }4447 if f, ok := help.Flag("-r"); !ok || f.Long != "--reverse" || f.Desc != "Sort in reverse order." {4548 t.Errorf("-r: %+v", f)4649 }@@ -0,0 +1,228 @@
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.6+package cmdhelp7+8+import (9+ "strconv"10+ "strings"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/nushell"16+ "github.com/FelineStateMachine/012/internal/ui/overlay"17+ "github.com/FelineStateMachine/012/internal/ui/theme"18+)19+20+// Host is what the help needs of the UI.21+type 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+}29+30+// View is the open help.31+type View struct {32+ h Host33+ help nushell.Help34+ top int35+}36+37+// ID identifies the view's box in mouse events.38+const ID = "cmdhelp"39+40+// maxWidth is the widest the text runs, for reading.41+const maxWidth = 9242+43+// New returns the help of a command, scrolled to the top.44+func New(h Host, help nushell.Help) *View { return &View{h: h, help: help} }45+46+// Top is the first line shown.47+func (s *View) Top() int { return s.top }48+49+func (s *View) Indicator() string { return "HELP" }50+51+// width is the text's width inside the box.52+func (s *View) width() int {53+ w, _ := s.h.Size()54+ return max(min(w-6, maxWidth), 20)55+}56+57+// Lines are the help's lines at the box's width.58+func (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+}109+110+// flagRows are the flags as names and what they do: -r, --reverse.111+func 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+}125+126+func 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+}133+134+// table draws names and what they are in two columns, the second135+// wrapped beside the first.136+func (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+}159+160+// visible is how many lines fit between the menu bar and the status161+// line.162+func (s *View) visible(total int) int {163+ _, height := s.h.Size()164+ return max(min(total, height-2-2-1), 1)165+}166+167+func (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+}190+191+func (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+}213+214+func (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+}225+226+func (s *View) Status() (string, string) {227+ return s.help.Summary(), s.h.Theme().KeyHints("Up/Down", "scroll", "Esc", "close")228+}@@ -0,0 +1,73 @@
1+package cmdhelp2+3+import (4+ "strconv"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/nushell"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+type fakeHost struct {16+ th theme.Theme17+ w, h int18+ closed bool19+}20+21+func (f *fakeHost) Theme() *theme.Theme { return &f.th }22+func (f *fakeHost) Size() (int, int) { return f.w, f.h }23+func (f *fakeHost) Close() { f.closed = true }24+func (f *fakeHost) Code(src string) string { return src }25+26+var sortBy = nushell.Help{27+ Name: "sort-by", Desc: "Sort by the given cell path or closure.", Usage: "sort-by {flags} <...comparator>",28+ Flags: []nushell.Flag{{Short: "-r", Long: "--reverse", Desc: "Sort in reverse order."},29+ {Short: "-i", Long: "--ignore-case", Desc: "Sort string-based data case-insensitively."}},30+ Params: []nushell.Param{{Name: "...comparator", Type: "oneof<cell-path, closure>", Desc: "The cell path(s) or closure(s) to compare elements by."}},31+ Types: []string{"table | table"},32+ Examples: []nushell.Example{{Desc: "Sort files by modified date.", Code: "ls | sort-by modified"}, {Desc: "Reversed.", Code: "ls | sort-by name -r"}},33+}34+35+func boxText(v *View) []string {36+ lines := v.Layout()[0].Lines37+ out := make([]string, len(lines))38+ for i, l := range lines {39+ out[i] = ansi.Strip(l)40+ }41+ return out42+}43+44+// The help says what the command does, links its docs, and lists its45+// usage, flags, parameters, types and examples, scrolling in a box46+// that fits the screen.47+func TestHelpShowsTheCommand(t *testing.T) {48+ f := &fakeHost{th: theme.New(true), w: 80, h: 16}49+ v := New(f, sortBy)50+ lines := boxText(v)51+ box := strings.Join(lines, "\n")52+ for _, want := range []string{"┌─ sort-by ", "Sort by the given cell path", "Docs: https://www.nushell.sh/commands/docs/sort-by.html", "Usage", "-r, --reverse"} {53+ if !strings.Contains(box, want) {54+ t.Errorf("no %q in\n%s", want, box)55+ }56+ }57+ for _, l := range lines {58+ if w := ansi.StringWidth(l); w > f.w {59+ t.Errorf("a line %d wide: %q", w, l)60+ }61+ }62+ if total := len(v.Lines()) + 1; !strings.Contains(lines[len(lines)-1], "of "+strconv.Itoa(total)) {63+ t.Errorf("the count: %q", lines[len(lines)-1])64+ }65+ v.Key(tea.KeyPressMsg{Code: tea.KeyEnd})66+ if box := strings.Join(boxText(v), "\n"); !strings.Contains(box, "ls | sort-by name -r") {67+ t.Errorf("scrolled to the end\n%s", box)68+ }69+ v.Key(tea.KeyPressMsg{Code: tea.KeyEscape})70+ if !f.closed {71+ t.Error("Esc didn't close")72+ }73+}@@ -7,6 +7,7 @@ import (
77 "github.com/FelineStateMachine/012/internal/sheet"88 "github.com/FelineStateMachine/012/internal/telemetry"99 "github.com/FelineStateMachine/012/internal/ui/choicebar"10+ "github.com/FelineStateMachine/012/internal/ui/cmdhelp"1011 "github.com/FelineStateMachine/012/internal/ui/cmdline"1112 "github.com/FelineStateMachine/012/internal/ui/filterpick"1213 "github.com/FelineStateMachine/012/internal/ui/findbar"@@ -39,6 +40,7 @@ var (
3940 _ sortbar.Host = host{}4041 _ filterpick.Host = host{}4142 _ shortcuts.Host = host{}43+ _ cmdhelp.Host = host{}4244 _ suggest.Host = host{}4345 _ review.Host = host{}4446 )@@ -4,12 +4,16 @@ import (
44 "slices"55 "strings"66 7+ tea "charm.land/bubbletea/v2"8+79 "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/cmdhelp"811 "github.com/FelineStateMachine/012/internal/ui/nbview"912 )1013 1114 // What the code editor knows of nushell: nu itself, asked about the12-// cell being written (nbview.Nu), with the notebook's own words. nu is15+// cell being written (nbview.Nu), with the notebook's own words, and16+// F1's help on the command at the caret. nu is1317 // asked only where a cell could run without asking: not in 012 serve1418 // unless serve-shell allows it, not with shell = off, and not about a1519 // file from another computer until its cells are trusted. Otherwise,@@ -88,3 +92,23 @@ func (m *Model) nbWords(s *sheet.Sheet) []nbview.Word {
8892 }8993 return out9094 }95+96+func init() {97+ register(&command{id: "nb.word_help", macro: macroNever, title: "Help on the command",98+ desc: "Show the whole help of the nushell command or flag at the caret, with its page in nushell's docs",99+ enabled: func(m *Model) bool { v := m.nbView(); return v != nil && v.Editing() },100+ run: func(m *Model) tea.Cmd { return m.nbView().WordHelp() }})101+}102+103+// showWordHelp opens the help of the command at the caret, or the104+// keyboard shortcuts, as F1 anywhere else, when nu has none for it.105+func (m *Model) showWordHelp(msg nbview.HelpMsg) tea.Cmd {106+ if msg.Help == nil {107+ return m.runCommand("help")108+ }109+ m.openOverlay(cmdhelp.New(m.host(), *msg.Help))110+ return nil111+}112+113+// Code draws a line of nushell as a notebook's cells do.114+func (h host) Code(src string) string { return nbview.Highlighted(&h.m.th, src) }@@ -3,9 +3,11 @@ package ui
33 import (44 "bytes"55 "context"6+ "encoding/json"67 "fmt"78 "io"89 "slices"10+ "strconv"911 "strings"1012 "testing"1113 @@ -13,6 +15,8 @@ import (
1315 1416 "github.com/FelineStateMachine/012/internal/nushell"1517 "github.com/FelineStateMachine/012/internal/sheet"18+ "github.com/FelineStateMachine/012/internal/ui/cmdhelp"19+ "github.com/FelineStateMachine/012/internal/ui/shortcuts"1620 )1721 1822 // ideNu is fakeNu answering the code editor's questions too, as nu@@ -22,6 +26,9 @@ type ideNu struct {
2226 *fakeNu2327 ide []string2428 problem string29+ // help is the hover of every command, a command's help as nu30+ // writes it.31+ help string2532 }2633 2734 func (f *ideNu) Run(ctx context.Context, job nushell.Job, script string, stdout io.Writer) error {@@ -33,6 +40,9 @@ func (f *ideNu) Run(ctx context.Context, job nushell.Job, script string, stdout
3340 if i := strings.Index(script, f.problem); job.IDE[0] == "--ide-check" && f.problem != "" && i >= 0 {3441 answer = fmt.Sprintf(`{"type":"diagnostic","severity":"Error","message":"Not a flag.","span":{"start":%d,"end":%d}}`, i, i+len(f.problem))3542 }43+ if job.IDE[0] == "--ide-hover" && f.help != "" {44+ answer = f.hover(script, job.IDE[1])45+ }3646 _, err := io.WriteString(stdout, answer)3747 return err3848 }@@ -92,3 +102,73 @@ func TestNotebookAsksNuAsTrusted(t *testing.T) {
92102 t.Errorf("served without serve-shell: asked %v", got)93103 }94104 }105+106+// hover answers as nu does about the word at byte at of script: a107+// flag's "flag", a command's help.108+func (f *ideNu) hover(script, at string) string {109+ i, _ := strconv.Atoi(at)110+ from, to := i, i111+ for from > 0 && script[from-1] != ' ' && script[from-1] != '\n' {112+ from--113+ }114+ for to < len(script) && script[to] != ' ' {115+ to++116+ }117+ text := f.help118+ switch {119+ case from == to:120+ return ""121+ case script[from] == '-':122+ text = "flag"123+ case script[from] == '$' || script[from] == '|':124+ return ""125+ }126+ b, _ := json.Marshal(map[string]any{"hover": text, "span": map[string]int{"start": from, "end": to}})127+ return string(b)128+}129+130+// sortByHelp is sort-by's hover, as nu 0.116 writes it.131+const sortByHelp = "Sort by the given cell path or closure.\n### Usage\n```\n sort-by {flags} <...comparator>\n```\n\n### Flags\n\n" +132+ " `-h`, `--help` - Display the help message for this command\\\n `-r`, `--reverse` - Sort in reverse order.\\\n" +133+ " `-i`, `--ignore-case` - Sort string-based data case-insensitively.\n\n### Parameters\n\n" +134+ " `...comparator: oneof<cell-path, closure>` - The cell path(s) or closure(s) to compare elements by.\n\n\n" +135+ "### Input/output types\n\n```\n list<any> | list<any>\n table | table\n\n```\n### Example(s)\n```\n```\n" +136+ " Sort files by modified date.\n```\n ls | sort-by modified\n\n"137+138+// The caret resting on a command says its signature on the context139+// line; F1 opens its help, and Esc goes back to the cell being edited.140+// F1 where there's no command opens the shortcuts.141+func TestNotebookWordHelp(t *testing.T) {142+ nu := &ideNu{fakeNu: &fakeNu{out: map[string]string{}}, help: sortByHelp}143+ m := newModel()144+ m.SetShellRunner(nu)145+ m.SetMachine("here")146+ m.nb.still = true147+ m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})148+ run(m, m.runCommand("nb.open"))149+ press(t, m, "b", "<enter>", "ls | sort-by name -r", "<home>", "<right>", "<right>", "<right>", "<right>", "<right>", "<right>")150+ if got := line(m, contextLine); !strings.Contains(got, "sort-by <...comparator: cell-path|closure> --reverse") ||151+ !strings.Contains(got, "Sort by the given cell path or closure.") || !strings.Contains(got, "F1") {152+ t.Fatalf("context line %q", got)153+ }154+ press(t, m, "<end>")155+ if got := line(m, contextLine); !strings.Contains(got, "sort-by --reverse, -r") || !strings.Contains(got, "Sort in reverse order.") {156+ t.Errorf("on the flag: %q", got)157+ }158+ press(t, m, "<f1>")159+ if _, ok := m.overlay.(*cmdhelp.View); !ok {160+ t.Fatalf("F1 opened %T", m.overlay)161+ }162+ if s := screen(m); !strings.Contains(s, "https://www.nushell.sh/commands/docs/sort-by.html") || !strings.Contains(s, "-r, --reverse") ||163+ !strings.Contains(s, "ls | sort-by modified") || !strings.Contains(line(m, 0), "HELP") {164+ t.Errorf("help\n%s", s)165+ }166+ press(t, m, "<esc>")167+ if m.overlay != nil || !m.nbView().Editing() {168+ t.Errorf("Esc: overlay %T, editing %v", m.overlay, m.nbView().Editing())169+ }170+ press(t, m, " ", "<f1>")171+ if _, ok := m.overlay.(*shortcuts.View); !ok {172+ t.Errorf("F1 on no word opened %T", m.overlay)173+ }174+}@@ -268,6 +268,8 @@ func (m *Model) notebookMsg(msg tea.Msg) (tea.Cmd, bool) {
268268 return m.streamPolled(msg), true269269 case nbStreamTickMsg:270270 return m.streamTicked(), true271+ case nbview.HelpMsg:272+ return m.showWordHelp(msg), true271273 }272274 for _, v := range m.nb.views {273275 if cmd, ok := v.Update(msg); ok {@@ -32,6 +32,12 @@ type editor struct {
3232 comp []Completion3333 compSel int3434 stop context.CancelFunc // stops the questions about the text still out35+ // moves counts the caret's moves and the text's changes, so an36+ // answer about a word the caret has left is dropped.37+ moves int38+ hover Hover // what's said of the word at the caret39+ hoverFor string // the text it was said of40+ hoverStop context.CancelFunc3541 }3642 3743 func (e *editor) text() string { return e.area.Text() }@@ -185,7 +191,7 @@ func (v *View) Update(msg tea.Msg) (tea.Cmd, bool) {
185191 }186192 }187193 default:188- return nil, false194+ return v.updateHover(msg)189195 }190196 return nil, true191197 }@@ -197,11 +203,12 @@ func (v *View) changed() tea.Cmd {
197203 v.edit.version++198204 v.edit.comp = nil199205 v.edit.cancel()206+ rest := v.rest()200207 if !v.asksLater() {201- return nil208+ return rest202209 }203210 version := v.edit.version204- return tea.Tick(pause, func(time.Time) tea.Msg { return pausedMsg{view: v, version: version} })211+ return tea.Batch(rest, tea.Tick(pause, func(time.Time) tea.Msg { return pausedMsg{view: v, version: version} }))205212 }206213 207214 // cancel stops the questions about the text being edited that are@@ -211,6 +218,7 @@ func (e *editor) cancel() {
211218 e.stop()212219 e.stop = nil213220 }221+ e.stopHover()214222 }215223 216224 // check asks the highlighter and checker about the text being edited,@@ -0,0 +1,351 @@
1+package nbview2+3+import (4+ "context"5+ "slices"6+ "strings"7+ "time"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/notebook"13+ "github.com/FelineStateMachine/012/internal/nuon"14+ "github.com/FelineStateMachine/012/internal/nushell"15+ "github.com/FelineStateMachine/012/internal/ui/lineedit"16+ "github.com/FelineStateMachine/012/internal/ui/theme"17+)18+19+// The word at the caret, on the context line: once the caret rests on20+// a command or a flag, its signature and what it does, as nu's hover21+// says; on a $name the notebook binds, the cell it comes from and the22+// shape of its value, from the notebook's own outputs without asking23+// nu. A problem at the caret wins the line. F1 opens the command's24+// whole help (HelpMsg).25+26+// Hover is what the context line says of a word of a cell's source.27+type Hover struct {28+ From, To int // the word's bytes29+ Text string // sort-by <...comparator: cell-path|closure> --reverse, or $files: table, 12 rows × 4 columns from cell 330+ Desc string // what it does, or ""31+ // Brief is Text where there's no room for it: sort-by <...comparator> ….32+ Brief string33+ // Help is the command's whole help, for F1; nil for a variable.34+ Help *nushell.Help35+}36+37+// Hoverer says what the word at a byte of src is.38+type Hoverer interface {39+ Hover(ctx context.Context, src string, offset int) (Hover, bool)40+}41+42+// HelpMsg asks the UI to show a command's help, or its keyboard43+// shortcuts when Help is nil: F1 on a word nu has no help for.44+type HelpMsg struct{ Help *nushell.Help }45+46+type (47+ // restedMsg is the caret having rested since move moves.48+ restedMsg struct {49+ view *View50+ moves int51+ }52+ // hoveredMsg is the answer about the word at the caret after moves53+ // moves; open says F1 asked, and waits to show its help.54+ hoveredMsg struct {55+ view *View56+ moves int57+ src string58+ hover Hover59+ ok bool60+ open bool61+ }62+)63+64+// rest notes that the caret moved or the text changed: what was asked65+// about the word it was at is stale, and the word it's at is asked66+// about once it rests.67+func (v *View) rest() tea.Cmd {68+ e := &v.edit69+ e.moves++70+ e.stopHover()71+ moves := e.moves72+ return tea.Tick(pause, func(time.Time) tea.Msg { return restedMsg{view: v, moves: moves} })73+}74+75+func (e *editor) stopHover() {76+ if e.hoverStop != nil {77+ e.hoverStop()78+ e.hoverStop = nil79+ }80+}81+82+// updateHover takes the hover's messages.83+func (v *View) updateHover(msg tea.Msg) (tea.Cmd, bool) {84+ switch msg := msg.(type) {85+ case restedMsg:86+ if msg.view != v {87+ return nil, false88+ }89+ if v.edit.on && msg.moves == v.edit.moves {90+ return v.askHover(false), true91+ }92+ case hoveredMsg:93+ if msg.view != v {94+ return nil, false95+ }96+ if !v.edit.on || msg.moves != v.edit.moves {97+ return nil, true98+ }99+ v.edit.hover, v.edit.hoverFor = msg.hover, msg.src100+ if !msg.ok {101+ v.edit.hover = Hover{}102+ }103+ if msg.open {104+ return helpCmd(v.edit.hover.Help), true105+ }106+ default:107+ return nil, false108+ }109+ return nil, true110+}111+112+func helpCmd(h *nushell.Help) tea.Cmd { return func() tea.Msg { return HelpMsg{Help: h} } }113+114+// askHover asks about the word at the caret: a $name of the notebook's115+// at once, anything else in the background.116+func (v *View) askHover(open bool) tea.Cmd {117+ e := &v.edit118+ src, off, moves := e.text(), e.area.Offset(), e.moves119+ at, ok := wordAt(src, off)120+ if !ok || !v.editingCode() {121+ e.hover = Hover{}122+ if open {123+ return helpCmd(nil)124+ }125+ return nil126+ }127+ if h, ok := v.cellHover(src, at); ok {128+ e.hover, e.hoverFor = h, src129+ if open {130+ return helpCmd(nil)131+ }132+ return nil133+ }134+ hv := v.Providers.Hoverer135+ if hv == nil {136+ return helpCmdIf(open)137+ }138+ e.stopHover()139+ ctx, stop := context.WithCancel(context.Background())140+ e.hoverStop = stop141+ return func() tea.Msg {142+ h, ok := hv.Hover(ctx, src, at)143+ if ctx.Err() != nil {144+ return nil145+ }146+ return hoveredMsg{view: v, moves: moves, src: src, hover: h, ok: ok, open: open}147+ }148+}149+150+func helpCmdIf(open bool) tea.Cmd {151+ if open {152+ return helpCmd(nil)153+ }154+ return nil155+}156+157+// editingCode reports whether the cell being edited is code.158+func (v *View) editingCode() bool {159+ for _, c := range v.h.Cells() {160+ if c.ID == v.edit.id {161+ return c.Kind == notebook.Code162+ }163+ }164+ return false165+}166+167+// WordHelp is F1 in edit mode: the help of the command at the caret,168+// asked for if it hasn't been, or the shortcuts when there's none.169+func (v *View) WordHelp() tea.Cmd {170+ if h, ok := v.hovered(); ok && h.Help != nil {171+ return helpCmd(h.Help)172+ }173+ if !v.edit.on {174+ return helpCmd(nil)175+ }176+ return v.askHover(true)177+}178+179+// hovered is what's said of the word at the caret, while the text is180+// what it was said of.181+func (v *View) hovered() (Hover, bool) {182+ e := &v.edit183+ if !e.on || e.hover.Text == "" || e.hoverFor != e.text() {184+ return Hover{}, false185+ }186+ off := e.area.Offset()187+ return e.hover, off >= e.hover.From && off <= e.hover.To188+}189+190+// isWordByte reports whether b is part of a word: not a space, a191+// bracket, a pipe or a quote.192+func isWordByte(b byte) bool { return !strings.ContainsRune(" \t\n|;(){}[],'\"`", rune(b)) }193+194+// wordAt is the byte of src to ask about for the caret at off: the195+// word it's in, or the one it's just after.196+func wordAt(src string, off int) (int, bool) {197+ switch {198+ case off < len(src) && isWordByte(src[off]):199+ return off, true200+ case off > 0 && off <= len(src) && isWordByte(src[off-1]):201+ return off - 1, true202+ }203+ return 0, false204+}205+206+// wordBounds are the bytes of the word holding byte at.207+func wordBounds(src string, at int) (int, int) {208+ from, to := at, at209+ for from > 0 && isWordByte(src[from-1]) {210+ from--211+ }212+ for to < len(src) && isWordByte(src[to]) {213+ to++214+ }215+ return from, to216+}217+218+// varName is the name $name at byte from of src reads, and where it219+// ends: files of $files.name.220+func varName(src string, from int) (string, int) {221+ if from >= len(src) || src[from] != '$' {222+ return "", from223+ }224+ to := from + 1225+ for to < len(src) && (isNameByte(src[to])) {226+ to++227+ }228+ return src[from+1 : to], to229+}230+231+func isNameByte(b byte) bool {232+ return b == '_' || b == '-' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= 0x80233+}234+235+// cellHover is what the notebook knows of the $name at byte at: the236+// cell whose output or variable it is, and its value's shape.237+func (v *View) cellHover(src string, at int) (Hover, bool) {238+ from, _ := wordBounds(src, at)239+ name, to := varName(src, from)240+ if name == "" || at >= to {241+ return Hover{}, false242+ }243+ for i, c := range v.h.Cells() {244+ if c.Kind != notebook.Code || c.ID == v.edit.id {245+ continue246+ }247+ var shape string248+ switch {249+ case c.Name() == name:250+ shape = shapeWords(v.shown(c), v.h.Output(c.ID))251+ case slices.Contains(c.Parse().Assigned(), name):252+ shape = varShape(v.h.Output(c.ID), name)253+ default:254+ continue255+ }256+ text := "$" + name + ": " + shape + " from cell " + itoa(i+1)257+ if shape == "" {258+ text = "$" + name + ": from cell " + itoa(i+1) + ", not run yet"259+ }260+ return Hover{From: from, To: to, Text: text}, true261+ }262+ return Hover{}, false263+}264+265+// varShape is the shape of variable name of a cell's last run.266+func varShape(o *notebook.Output, name string) string {267+ if o == nil || o.Vars[name] == nil {268+ return ""269+ }270+ val, err := nuon.Parse(o.Vars[name])271+ if err != nil {272+ return "a value"273+ }274+ sh := shownOf(val)275+ if sh.kind == outValue {276+ return val.Kind.String()277+ }278+ return shapeWords(sh, nil)279+}280+281+// shapeWords is an output's shape, "table, 12 rows × 4 columns", or ""282+// before it has run.283+func shapeWords(sh *shown, o *notebook.Output) string {284+ switch sh.kind {285+ case outTable:286+ return "table, " + more(sh.total, "row") + " × " + more(sh.cols, "column")287+ case outRecord:288+ return "record, " + more(sh.total, "field")289+ case outList:290+ return "list, " + more(sh.total, "item")291+ case outText:292+ if sh.total > 1 {293+ return "string, " + more(sh.total, "line")294+ }295+ return "string"296+ case outError:297+ return "an error"298+ case outValue:299+ if o != nil {300+ if val, err := nuon.Parse(o.NUON); err == nil {301+ return val.Kind.String()302+ }303+ }304+ return "a value"305+ case outNone:306+ if o != nil && o.Count > 0 {307+ return "nothing"308+ }309+ }310+ return ""311+}312+313+// hoverLine draws h in room columns: the signature, then what it does;314+// flags are left out, then the types, then the description cut, before315+// the rest is.316+func hoverLine(th *theme.Theme, h Hover, room int) string {317+ text, desc := h.Text, h.Desc318+ width := func() int {319+ w := ansi.StringWidth(text)320+ if desc != "" {321+ w += 2 + ansi.StringWidth(desc)322+ }323+ return w324+ }325+ for width() > room && desc != "" {326+ i := strings.LastIndex(text, " --")327+ if i < 0 {328+ break329+ }330+ text = strings.TrimSuffix(text[:i], " …") + " …"331+ }332+ if width() > room && h.Brief != "" {333+ text = h.Brief334+ }335+ if desc == "" {336+ return th.Hint.Render(ansi.Truncate(text, max(room, 1), "…"))337+ }338+ out := th.Hint.Render(ansi.Truncate(text, max(room, 1), "…"))339+ if left := room - ansi.StringWidth(text) - 2; left > 3 {340+ out += " " + th.Muted.Render(ansi.Truncate(desc, left, "…"))341+ }342+ return out343+}344+345+// Highlighted draws a line of nushell in its syntax roles, as 012's346+// own highlighter has them: a command's usage and examples in its help.347+func Highlighted(th *theme.Theme, src string) string {348+ buf := []rune(src)349+ roles := rolesOf(src, Tokens{}.Highlight(context.Background(), src))350+ return drawRow(th, buf, lineedit.Row{Start: 0, End: len(buf)}, roles, nil)351+}@@ -0,0 +1,179 @@
1+package nbview2+3+import (4+ "context"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/notebook"12+)13+14+// nu's hover, as testdata/nu records it: a command's signature and15+// summary, a flag in its command's help, a variable's type, and the16+// notebook's own word for a name it binds.17+func TestNuHovers(t *testing.T) {18+ _, n := nuFor(recordedNu(t))19+ for _, c := range []struct{ word, text, desc string }{20+ {"sort-by", "sort-by <...comparator: cell-path|closure> --reverse --ignore-case --natural --custom", "Sort by the given cell path or closure."},21+ {"-r", "sort-by --reverse, -r", "Sort in reverse order."},22+ {"--decimals", "into string --decimals <int>, -d", "Decimal digits to which to round."},23+ {"$n", "$n: int", ""},24+ {"$files", "$files: a cell's output", ""},25+ } {26+ at := strings.Index(hoverSrc, c.word+" ")27+ if c.word == "$n" {28+ at = strings.LastIndex(hoverSrc, c.word)29+ }30+ h, ok := n.Hover(context.Background(), hoverSrc, at+1)31+ if !ok || h.Text != c.text || h.Desc != c.desc || hoverSrc[h.From:h.To] != c.word {32+ t.Errorf("%s: %+v %v", c.word, h, ok)33+ }34+ if wantHelp := c.desc != ""; (h.Help != nil) != wantHelp {35+ t.Errorf("%s: help %v", c.word, h.Help)36+ }37+ }38+ n.SetWords([]string{"files"}, []Word{{Text: "$sheet.", Desc: "a range of a sheet: $sheet.A1:C9"}})39+ if h, ok := n.Hover(context.Background(), "$sheet.A1:C9 | first", 3); !ok || h.Text != "$sheet.A1:C9: a range of a sheet" {40+ t.Errorf("a range: %+v", h)41+ }42+}43+44+func TestCommandStart(t *testing.T) {45+ for _, c := range []struct{ src, flag, want string }{46+ {"ls | sort-by size -r", "-r", "sort-by"},47+ {"ls | each {|f| $f | sort-by x -r }", "-r", "sort-by"},48+ {"let big = ls -a", "-a", "ls"},49+ {"ls\n | where ('a|b' | str contains -i x)", "-i", "str"},50+ {"^git log --oneline", "--oneline", "git"},51+ } {52+ src := c.src53+ at, ok := commandStart(src, notebook.Parse(src).Strings, strings.Index(src, c.flag))54+ if !ok || !strings.HasPrefix(src[at:], c.want) {55+ t.Errorf("%q: %d %q", src, at, src[at:])56+ }57+ }58+}59+60+// settle runs cmd and hands the view the messages it makes, and theirs.61+func settle(v *View, cmd tea.Cmd) []tea.Msg {62+ var out []tea.Msg63+ for _, msg := range drain(cmd) {64+ out = append(out, msg)65+ if next, ok := v.Update(msg); ok {66+ out = append(out, settle(v, next)...)67+ }68+ }69+ return out70+}71+72+// press types keys, then lets the caret rest.73+func press(v *View, keys ...string) {74+ var cmd tea.Cmd75+ for _, k := range keys {76+ cmd, _ = v.Key(key(k))77+ }78+ settle(v, cmd)79+}80+81+// The caret resting on a command shows its signature on the context82+// line, F1 asks for its help, and a problem at the caret wins the line.83+func TestHoverOnTheContextLine(t *testing.T) {84+ f := recordedNu(t)85+ h := newHost(hoverSrc)86+ v := newView(h, 120, 20)87+ v.EditKeys["f1"] = "nb.word_help"88+ _, n := nuFor(f)89+ v.Providers = Providers{Highlighter: n, Checker: n, Hoverer: n}90+ settle(v, v.StartEdit())91+ press(v, "home", "right", "right", "right", "right", "right", "right", "right", "right", "right", "right")92+ left, right := v.ContextLine()93+ if got := ansi.Strip(left); !strings.HasPrefix(got, "sort-by <...comparator: cell-path|closure>") || !strings.Contains(got, "Sort by the given") {94+ t.Errorf("context line %q", got)95+ }96+ if !strings.Contains(ansi.Strip(right), "F1 help") {97+ t.Errorf("keys %q", ansi.Strip(right))98+ }99+ msgs := settle(v, v.WordHelp())100+ if len(msgs) != 1 || msgs[0].(HelpMsg).Help == nil || msgs[0].(HelpMsg).Help.Name != "sort-by" {101+ t.Errorf("F1: %v", msgs)102+ }103+104+ v.edit.diags, v.edit.diagFor = []Diagnostic{{From: 19, To: 26, Msg: "Something's wrong."}}, v.edit.text()105+ if left, _ := v.ContextLine(); ansi.Strip(left) != "Something's wrong." {106+ t.Errorf("the problem gives way: %q", ansi.Strip(left))107+ }108+ v.edit.diags = nil109+110+ cmd, _ := v.Key(key("home")) // the answer about the word left is dropped111+ if _, ok := v.Update(hoveredMsg{view: v, moves: v.edit.moves - 1, src: v.edit.text(), hover: Hover{Text: "stale", To: 99}, ok: true}); !ok || v.edit.hover.Text == "stale" {112+ t.Error("a stale answer was kept")113+ }114+ settle(v, cmd)115+ if left, _ := v.ContextLine(); strings.Contains(ansi.Strip(left), "sort-by") {116+ t.Errorf("the caret left the word: %q", ansi.Strip(left))117+ }118+}119+120+// F1 on no command asks for the shortcuts.121+func TestWordHelpWithoutAWord(t *testing.T) {122+ h := newHost("ls ")123+ v := newView(h, 80, 20)124+ settle(v, v.StartEdit())125+ msgs := settle(v, v.WordHelp())126+ if len(msgs) != 1 || msgs[0].(HelpMsg).Help != nil {127+ t.Errorf("F1 after a space: %v", msgs)128+ }129+}130+131+// A $name the notebook binds says its cell and its value's shape,132+// without asking nu.133+func TestCellHover(t *testing.T) {134+ h := newHost("files = ls", "x = 4\ny = [1 2 3]", "$files | append $x | append $y | append $gone")135+ h.outs[1] = ¬ebook.Output{Count: 1, NUON: []byte("[{name: a, size: 1, type: file}, {name: b, size: 2, type: dir}]")}136+ h.outs[2] = ¬ebook.Output{Count: 2, NUON: []byte("[1, 2, 3]"), Vars: map[string][]byte{"x": []byte("4")}}137+ v := newView(h, 100, 30)138+ v.Select(2, false)139+ settle(v, v.StartEdit())140+ src := v.edit.text()141+ for word, want := range map[string]string{142+ "$files": "$files: table, 2 rows × 3 columns from cell 1",143+ "$x": "$x: int from cell 2",144+ "$y": "$y: list, 3 items from cell 2",145+ } {146+ v.edit.area.Pos = len([]rune(src[:strings.Index(src, word)+2]))147+ settle(v, v.rest())148+ if left, _ := v.ContextLine(); ansi.Strip(left) != want {149+ t.Errorf("%s: %q", word, ansi.Strip(left))150+ }151+ }152+ h.outs[1] = nil153+ v.edit.area.Pos = 2154+ settle(v, v.rest())155+ if left, _ := v.ContextLine(); ansi.Strip(left) != "$files: from cell 1, not run yet" {156+ t.Errorf("not run: %q", ansi.Strip(left))157+ }158+ v.edit.area.Pos = len([]rune(src)) - 2159+ settle(v, v.rest())160+ if left, _ := v.ContextLine(); ansi.Strip(left) != "" {161+ t.Errorf("a name no cell binds: %q", ansi.Strip(left))162+ }163+}164+165+// A long signature gives up its flags, then its description, to fit.166+func TestHoverLineFits(t *testing.T) {167+ th := newHost().Theme()168+ h := Hover{Text: "sort-by <...comparator: cell-path> --reverse --ignore-case", Brief: "sort-by <...comparator> …", Desc: "Sort by the given cell path or closure."}169+ for room, want := range map[int]string{170+ 200: "sort-by <...comparator: cell-path> --reverse --ignore-case Sort by the given cell path or closure.",171+ 90: "sort-by <...comparator: cell-path> --reverse … Sort by the given cell path or closure.",172+ 60: "sort-by <...comparator> … Sort by the given cell path or c…",173+ 40: "sort-by <...comparator> … Sort by the …",174+ } {175+ if got := ansi.Strip(hoverLine(th, h, room)); got != want || ansi.StringWidth(got) > room {176+ t.Errorf("%d: %q", room, got)177+ }178+ }179+}@@ -219,7 +219,7 @@ func (v *View) editKey(k tea.KeyPressMsg) (tea.Cmd, bool) {
219219 if e.text() != before {220220 return v.changed(), true221221 }222- return nil, true222+ return v.rest(), true223223 }224224 225225 // Paste types text into the cell being edited.@@ -68,7 +68,7 @@ func (v *View) Click(x, y int, shift bool) tea.Cmd {
6868 if v.edit.on && h.cell == v.sel && h.kind == rowSrc && boxed && v.inBox(x) {6969 v.edit.area.Place(v.content(), h.r, x-textX)7070 v.follow()71- return nil71+ return v.rest()7272 }7373 double := !shift && v.lastTap.cell == h.cell && v.lastTap.kind == h.kind && time.Since(v.lastTap.at) < 400*time.Millisecond7474 v.lastTap = tap{at: time.Now(), cell: h.cell, kind: h.kind}@@ -29,7 +29,7 @@ import (
2929 type fakeRunner struct {3030 mu sync.Mutex3131 version string32- answers map[string]string // flag + text asked about → answer32+ answers map[string]string // flags, a line, the text asked about → answer3333 err error // every question fails so3434 block bool // questions wait until they're stopped3535 asked []string@@ -49,7 +49,7 @@ func (f *fakeRunner) Run(ctx context.Context, job nushell.Job, script string, st
4949 <-ctx.Done()5050 return context.Cause(ctx)5151 }52- out, ok := f.answers[job.IDE[0]+script]52+ out, ok := f.answers[asked(job.IDE, script)]5353 if !ok {5454 return &nushell.Error{Msg: "not recorded: " + script}5555 }@@ -57,6 +57,9 @@ func (f *fakeRunner) Run(ctx context.Context, job nushell.Job, script string, st
5757 return err5858 }5959 60+// asked is how fakeRunner files a question.61+func asked(flags []string, script string) string { return strings.Join(flags, " ") + "\n" + script }62+6063 func (f *fakeRunner) questions() []string {6164 f.mu.Lock()6265 defer f.mu.Unlock()@@ -97,15 +100,26 @@ func recordings(t *testing.T) []recording {
97100 }98101 out = append(out, recording{flags: flags, script: script, answer: read(r.answer)})99102 }103+ script := read("hover.nu")104+ for _, at := range hoverAsked {105+ out = append(out, recording{flags: []string{"--ide-hover", fmt.Sprint(at)}, script: script, answer: read(fmt.Sprintf("hover-%d.json", at))})106+ }100107 return out101108 }102109 110+// hoverAsked are the bytes of hover.nu nu's hover is recorded at: the111+// sort-by, -r, into string, --decimals and $n of hoverSrc.112+var hoverAsked = []int{83, 96, 101, 113, 135}113+114+// hoverSrc is the cell hover.nu asks about.115+const hoverSrc = "let n = 4\n$files | sort-by size -r | into string --decimals 2 | append $n"116+103117 // recordedNu is a fake nu answering what testdata/nu records.104118 func recordedNu(t *testing.T) *fakeRunner {105119 t.Helper()106120 f := &fakeRunner{version: "0.116.0\n", answers: map[string]string{}}107121 for _, r := range recordings(t) {108- f.answers[r.flags[0]+r.script] = r.answer122+ f.answers[asked(r.flags, r.script)] = r.answer109123 }110124 return f111125 }@@ -224,6 +238,9 @@ func TestNuFallsBack(t *testing.T) {
224238 }225239 asked := len(f.questions())226240 n.Highlight(context.Background(), src)241+ if _, ok := n.Hover(context.Background(), src, 6); ok {242+ t.Errorf("%s: a hover", name)243+ }227244 if got := n.Complete(context.Background(), "$fi", 3); len(got) != 1 || got[0].Text != "$files" || len(f.questions()) != asked {228245 t.Errorf("%s: completions %v, asked %v", name, got, f.questions())229246 }@@ -234,6 +251,7 @@ func TestNuFallsBack(t *testing.T) {
234251 n.Highlight(context.Background(), src)235252 n.Check(context.Background(), src)236253 n.Complete(context.Background(), src, 2)254+ n.Hover(context.Background(), src, 6)237255 if q := f.questions(); len(q) != 0 {238256 t.Errorf("asked nu while it isn't allowed: %v", q)239257 }@@ -0,0 +1,152 @@
1+package nbview2+3+import (4+ "context"5+ "slices"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/notebook"9+ "github.com/FelineStateMachine/012/internal/nushell"10+)11+12+// Hover implements Hoverer: for a command, its signature and help from13+// nu's --ide-hover; for a flag, the flag in its command's help; for a14+// variable, the notebook's word for it ($selection, a linked file), or15+// the type nu gives a variable the cell binds itself.16+func (n *Nu) Hover(ctx context.Context, src string, at int) (Hover, bool) {17+ names, words := n.known()18+ from, to := wordBounds(src, at)19+ if h, ok := wordHover(words, src, from, to); ok {20+ return h, true21+ }22+ p := prepare(src, names)23+ nh, ok := n.hoverAt(ctx, p, from) // the word's start, whichever byte of it the caret is at24+ if !ok {25+ return Hover{}, false26+ }27+ from, to = nh.From-p.at, nh.To-p.at28+ if from < 0 || to > len(src) || from >= to || p.inHead(from, to) {29+ return Hover{}, false30+ }31+ if nh.Text == "flag" {32+ return n.flagHover(ctx, src, p, from, to)33+ }34+ if help, ok := nushell.ParseHelp(nh.Text); ok {35+ return Hover{From: from, To: to, Text: help.Signature(), Brief: help.Brief(), Desc: help.Summary(), Help: &help}, true36+ }37+ word := src[from:to]38+ if strings.HasPrefix(word, "$") && nh.Text != "any" && !strings.ContainsAny(nh.Text, "\n ") {39+ return Hover{From: from, To: to, Text: word + ": " + nh.Text}, true40+ }41+ return Hover{}, false42+}43+44+// hoverAt is nu's hover at byte at of the source p was prepared from.45+func (n *Nu) hoverAt(ctx context.Context, p prepared, at int) (h nushell.Hover, ok bool) {46+ err := n.s.ask(ctx, func(ctx context.Context) (err error) {47+ h, ok, err = nushell.HoverAt(ctx, n.s.Runner, p.text, p.at+at)48+ return err49+ })50+ return h, err == nil && ok51+}52+53+// flagHover is what the help of the command a flag belongs to says of54+// the flag at src[from:to].55+func (n *Nu) flagHover(ctx context.Context, src string, p prepared, from, to int) (Hover, bool) {56+ cmd, ok := commandStart(p.text[p.at:], notebook.Parse(src).Strings, from)57+ if !ok {58+ return Hover{}, false59+ }60+ nh, ok := n.hoverAt(ctx, p, cmd)61+ if !ok {62+ return Hover{}, false63+ }64+ help, ok := nushell.ParseHelp(nh.Text)65+ if !ok {66+ return Hover{}, false67+ }68+ f, ok := help.Flag(src[from:to])69+ if !ok {70+ return Hover{}, false71+ }72+ text := help.Name + " " + f.Long73+ if f.Arg != "" {74+ text += " <" + f.Arg + ">"75+ }76+ if f.Short != "" {77+ text += ", " + f.Short78+ }79+ return Hover{From: from, To: to, Text: strings.TrimSpace(text), Desc: f.Desc, Help: &help}, true80+}81+82+// commandStart is the first byte of the command whose argument begins83+// at byte at of src: after the |, ;, line or bracket opening the84+// pipeline's element it's in, strings in strs left alone, and after a85+// let's =.86+func commandStart(src string, strs [][2]int, at int) (int, bool) {87+ depth, i := 0, at88+scan:89+ for i > 0 {90+ i--91+ if s, ok := stringAround(strs, i); ok {92+ i = s93+ continue94+ }95+ switch src[i] {96+ case ')', '}', ']':97+ depth++98+ case '(', '{', '[':99+ if depth == 0 {100+ i++101+ break scan102+ }103+ depth--104+ case '|', ';', '\n':105+ if depth == 0 {106+ i++107+ break scan108+ }109+ }110+ }111+ for i < at && (src[i] == ' ' || src[i] == '\t' || src[i] == '^') {112+ i++113+ }114+ if _, end := wordBounds(src, i); slices.Contains([]string{"let", "mut", "const"}, src[i:end]) {115+ if eq := strings.IndexByte(src[end:at], '='); eq >= 0 {116+ i = end + eq + 1117+ for i < at && (src[i] == ' ' || src[i] == '\t') {118+ i++119+ }120+ }121+ }122+ return i, i < at123+}124+125+// stringAround is where the string holding byte i starts.126+func stringAround(strs [][2]int, i int) (int, bool) {127+ for _, s := range strs {128+ if i >= s[0] && i < s[1] {129+ return s[0], true130+ }131+ }132+ return 0, false133+}134+135+// wordHover is the notebook's own word for the $name at src[from:to]:136+// $selection, a linked file, a range of a sheet.137+func wordHover(words []Word, src string, from, to int) (Hover, bool) {138+ name, end := varName(src, from)139+ if name == "" {140+ return Hover{}, false141+ }142+ for _, w := range words {143+ switch {144+ case w.Text == "$"+name:145+ return Hover{From: from, To: end, Text: w.Text + ": " + w.Desc}, true146+ case strings.HasSuffix(w.Text, ".") && strings.HasPrefix(src[from:to], w.Text):147+ desc, _, _ := strings.Cut(w.Desc, ":") // a range of a sheet: $sheet.A1:C9148+ return Hover{From: from, To: to, Text: src[from:to] + ": " + desc}, true149+ }150+ }151+ return Hover{}, false152+}@@ -70,6 +70,12 @@ func (v *View) ContextLine() (string, string) {
7070 if d := v.Diagnostic(); d != "" {7171 return th.Warning.Render(d), right7272 }73+ if h, ok := v.hovered(); ok {74+ if h.Help != nil {75+ right = v.hints(v.EditKeys, "nb.word_help", "help")76+ }77+ return hoverLine(th, h, v.width-ansi.StringWidth(right)-6), right78+ }7379 return "", right7480 case v.pending == "d":7581 return th.Hint.Render("d again deletes " + v.selectedWords()), th.KeyHints("Esc", "cancel")@@ -0,0 +1 @@
1+{"hover":"flag","span":{"start":113,"end":123}}@@ -0,0 +1 @@
1+{"hover":"int","span":{"start":135,"end":137}}@@ -0,0 +1 @@
1+{"hover":"flag","span":{"start":96,"end":98}}@@ -129,7 +129,8 @@ func text(v *View) string { return ansi.Strip(strings.Join(v.Lines(), "\n")) }
129129 130130 func key(s string) tea.KeyPressMsg {131131 named := map[string]tea.Key{"enter": {Code: tea.KeyEnter}, "esc": {Code: tea.KeyEscape}, "up": {Code: tea.KeyUp},132- "down": {Code: tea.KeyDown}, "tab": {Code: tea.KeyTab}, "home": {Code: tea.KeyHome}, "end": {Code: tea.KeyEnd}}132+ "down": {Code: tea.KeyDown}, "tab": {Code: tea.KeyTab}, "home": {Code: tea.KeyHome}, "end": {Code: tea.KeyEnd},133+ "left": {Code: tea.KeyLeft}, "right": {Code: tea.KeyRight}}133134 if k, ok := named[s]; ok {134135 return tea.KeyPressMsg(k)135136 }@@ -61,6 +61,7 @@ var nbEditKeys = map[string]string{
6161 "shift+enter": "nb.run_next",6262 "ctrl+enter": "nb.run",6363 "alt+enter": "nb.run_insert",64+ "f1": "nb.word_help",6465 }6566 6667 // nbShortcut is the notebook's key for command id, as shown: its@@ -97,7 +98,7 @@ func nbKeyLabels(id string) []string {
9798 func nbHelpRows(listed map[string]bool) []helpRow {9899 rows := []helpRow{{keys: []string{"Up", "Down", "j", "k"}, action: "Move between cells and outputs"},99100 {keys: []string{"Shift+Up", "Shift+Down", "K", "J"}, action: "Select several cells"}}100- for _, it := range append([]menuItem{{cmd: "nb.edit"}, {cmd: "nb.command_mode"}, {cmd: "nb.add_code"}}, notebookItems...) {101+ for _, it := range append([]menuItem{{cmd: "nb.edit"}, {cmd: "nb.command_mode"}, {cmd: "nb.add_code"}, {cmd: "nb.word_help"}}, notebookItems...) {101102 keys := nbKeyLabels(it.cmd)102103 if it.sep || keys[0] == "" || listed[it.cmd] || commands[it.cmd] == nil {103104 continue@@ -190,7 +191,7 @@ func (m *Model) viewOf(s *sheet.Sheet) *nbview.View {
190191 v = nbview.New(nbHost{m, s})191192 v.Keys, v.EditKeys = nbKeys, nbEditKeys192193 lang := m.nbLang().For()193- v.Providers = nbview.Providers{Highlighter: lang, Completer: lang, Checker: lang}194+ v.Providers = nbview.Providers{Highlighter: lang, Completer: lang, Checker: lang, Hoverer: lang}194195 m.nb.views[s] = v195196 }196197 return v