1package nbview23import (4 "context"5 "strings"6 "time"78 tea "charm.land/bubbletea/v2"9 "github.com/charmbracelet/x/ansi"1011 "src.rest/cat/012/internal/ui/lineedit"12 "src.rest/cat/012/internal/ui/theme"13)1415// Edit mode: the selected cell's source in a multi-line editor16// (lineedit.Area), soft-wrapped to the cell's width with the caret17// always on screen. What the language says of it (highlighting,18// diagnostics) is asked for in the background once typing pauses;19// completions when Tab asks.2021// pause is how long typing pauses before the language is asked.22const pause = 80 * time.Millisecond2324// afterPause gives msg once typing has paused, on the view's clock.25func (v *View) afterPause(msg tea.Msg) tea.Cmd {26 if v.After != nil {27 return v.After(pause, msg)28 }29 return tea.Tick(pause, func(time.Time) tea.Msg { return msg })30}3132// editor is the cell being edited.33type editor struct {34 on bool35 id int36 area lineedit.Area37 version int // counts changes, so answers to older text are dropped38 diags []Diagnostic39 diagFor string // the text the diagnostics are of40 comp []Completion41 compSel int42 stop context.CancelFunc // stops the questions about the text still out43 // moves counts the caret's moves and the text's changes, so an44 // answer about a word the caret has left is dropped.45 moves int46 hover Hover // what's said of the word at the caret47 hoverFor string // the text it was said of48 hoverStop context.CancelFunc49}5051func (e *editor) text() string { return e.area.Text() }5253func (e *editor) rows(width int) []lineedit.Row { return lineedit.Wrap(e.area.Buf, width) }5455// caret is the caret's row and column at width.56func (e *editor) caret(width int) (int, int) {57 return lineedit.Caret(e.rows(width), e.area.Buf, e.area.Pos)58}5960// line draws row r of the text being edited.61func (e *editor) line(th *theme.Theme, r, width int, roles []int8) string {62 rows := e.rows(width)63 if r >= len(rows) {64 return ""65 }66 return drawRow(th, e.area.Buf, rows[r], roles, e.marks())67}6869// marks are the runes the diagnostics underline: those of the text they70// were of, where the text is unchanged since, so the underline doesn't71// blink off as keys are typed.72func (e *editor) marks() []bool {73 if len(e.diags) == 0 {74 return nil75 }76 text := e.text()77 marks := diagMarks(e.diagFor, e.diags)78 if e.diagFor == text {79 return marks80 }81 return blend(e.diagFor, text, marks, make([]bool, len(e.area.Buf)))82}8384// diagMarks marks the runes a diagnostic covers.85func diagMarks(src string, diags []Diagnostic) []bool {86 marks := make([]bool, len([]rune(src)))87 ri := 088 for bi := range src {89 for _, d := range diags {90 if bi >= d.From && bi < max(d.To, d.From+1) {91 marks[ri] = true92 }93 }94 ri++95 }96 return marks97}9899// drawRow draws a row of buf in its syntax roles (a role per rune, -1100// for none), marked runes curly-underlined, a continuation row indented.101func drawRow(th *theme.Theme, buf []rune, row lineedit.Row, roles []int8, marks []bool) string {102 var b strings.Builder103 if row.Cont {104 b.WriteString(strings.Repeat(" ", lineedit.Indent))105 }106 i := row.Start107 for i < row.End {108 j := i + 1109 for j < row.End && roleAt(roles, j) == roleAt(roles, i) && markAt(marks, j) == markAt(marks, i) {110 j++111 }112 text := string(buf[i:j])113 r, marked := roleAt(roles, i), markAt(marks, i)114 switch {115 case r < 0 && !marked:116 b.WriteString(text)117 case r < 0:118 b.WriteString(th.ErrorMark.Render(text))119 case marked:120 b.WriteString(th.Code[r].Inherit(th.ErrorMark).Render(text))121 default:122 b.WriteString(th.Code[r].Render(text))123 }124 i = j125 }126 return b.String()127}128129func roleAt(roles []int8, i int) int8 {130 if i < len(roles) {131 return roles[i]132 }133 return -1134}135136func markAt(marks []bool, i int) bool { return i < len(marks) && marks[i] }137138// Messages the view sends itself.139type (140 // highlightMsg is the highlighter's answer about src; edited says141 // src was the text being edited.142 highlightMsg struct {143 view *View144 src string145 spans []Span146 edited bool147 }148 // pausedMsg is typing having paused at version.149 pausedMsg struct {150 view *View151 version int152 }153 // checkedMsg is the checker's answer about src.154 checkedMsg struct {155 view *View156 src string157 diags []Diagnostic158 }159 // completedMsg is the completer's answer for version.160 completedMsg struct {161 view *View162 version int163 comp []Completion164 }165)166167// Update takes the view's own messages, reporting whether msg was one.168func (v *View) Update(msg tea.Msg) (tea.Cmd, bool) {169 switch msg := msg.(type) {170 case highlightMsg:171 if msg.view != v {172 return nil, false173 }174 v.keepSpans(msg.src, msg.spans)175 delete(v.syntax.asked, msg.src)176 if msg.edited {177 v.syntax.last = msg.src178 }179 case pausedMsg:180 if msg.view != v {181 return nil, false182 }183 if v.edit.on && msg.version == v.edit.version {184 return v.check(), true185 }186 case checkedMsg:187 if msg.view != v {188 return nil, false189 }190 v.edit.diags, v.edit.diagFor = msg.diags, msg.src191 case completedMsg:192 if msg.view != v {193 return nil, false194 }195 if v.edit.on && msg.version == v.edit.version {196 v.edit.comp, v.edit.compSel = msg.comp, 0197 if len(msg.comp) == 1 {198 v.complete()199 }200 }201 default:202 return v.updateHover(msg)203 }204 return nil, true205}206207// changed notes that the text changed: what the language was asked208// about the text before is stale, and it's asked about this text once209// typing pauses.210func (v *View) changed() tea.Cmd {211 v.edit.version++212 v.edit.comp = nil213 v.edit.cancel()214 rest := v.rest()215 if !v.asksLater() {216 return rest217 }218 version := v.edit.version219 return tea.Batch(rest, v.afterPause(pausedMsg{view: v, version: version}))220}221222// cancel stops the questions about the text being edited that are223// still out.224func (e *editor) cancel() {225 if e.stop != nil {226 e.stop()227 e.stop = nil228 }229 e.stopHover()230}231232// check asks the highlighter and checker about the text being edited,233// in the background; answers that come once it has changed are dropped.234func (v *View) check() tea.Cmd {235 src := v.edit.text()236 v.edit.cancel()237 ctx, stop := context.WithCancel(context.Background())238 v.edit.stop = stop239 var cmds []tea.Cmd240 if hl := v.Providers.Highlighter; hl != nil && !isInstant(hl) {241 cmds = append(cmds, func() tea.Msg {242 spans := hl.Highlight(ctx, src)243 if ctx.Err() != nil {244 return nil245 }246 return highlightMsg{view: v, src: src, spans: spans, edited: true}247 })248 }249 if ck := v.Providers.Checker; ck != nil && !isInstant(ck) {250 cmds = append(cmds, func() tea.Msg {251 diags := ck.Check(ctx, src)252 if ctx.Err() != nil {253 return nil254 }255 return checkedMsg{view: v, src: src, diags: diags}256 })257 }258 return tea.Batch(cmds...)259}260261// askCompletions asks what can go at the caret.262func (v *View) askCompletions() tea.Cmd {263 cp := v.Providers.Completer264 if cp == nil {265 return nil266 }267 src, off, version := v.edit.text(), v.edit.area.Offset(), v.edit.version268 return func() tea.Msg {269 return completedMsg{view: v, version: version, comp: cp.Complete(context.Background(), src, off)}270 }271}272273// complete puts the highlighted completion in place.274func (v *View) complete() {275 e := &v.edit276 if len(e.comp) == 0 {277 return278 }279 c := e.comp[e.compSel]280 src := e.text()281 from, to := min(c.From, len(src)), min(c.To, len(src))282 e.area.SetText(src[:from] + c.Text)283 tail := src[to:]284 pos := e.area.Pos285 e.area.InsertText(tail)286 e.area.Pos = pos287 e.comp = nil288 e.version++289}290291// Diagnostic is the problem the caret is on, for the context line, or292// "" when it is on none.293func (v *View) Diagnostic() string {294 e := &v.edit295 if !e.on || e.diagFor != e.text() {296 return ""297 }298 off := e.area.Offset()299 for _, d := range e.diags {300 if off >= d.From && off <= max(d.To, d.From+1) {301 return d.Msg302 }303 }304 return ""305}306307// Completions are the completions shown, and the highlighted one.308func (v *View) Completions() ([]Completion, int) { return v.edit.comp, v.edit.compSel }309310// completionBox draws the completions under the caret's row: at most311// eight, the highlighted one in reverse video.312func (v *View) completionBox(th *theme.Theme) []string {313 comp, sel := v.edit.comp, v.edit.compSel314 if len(comp) == 0 {315 return nil316 }317 first := max(0, min(sel-7, len(comp)-8))318 shown := comp[first:min(first+8, len(comp))]319 tw, dw := 0, 0320 for _, c := range shown {321 tw = max(tw, ansi.StringWidth(c.Text))322 dw = max(dw, ansi.StringWidth(c.Desc))323 }324 inner := min(tw+2+dw+2, max(v.width-gutter-4, 10))325 lines := make([]string, len(shown))326 for i, c := range shown {327 text := ansi.Truncate(" "+c.Text+" "+c.Desc, inner, "…")328 text += strings.Repeat(" ", max(inner-ansi.StringWidth(text), 0))329 if first+i == sel {330 lines[i] = th.MenuSelected.Render(text)331 } else {332 lines[i] = th.MenuBar.Render(text)333 }334 }335 return th.Frame(inner, "Completions", "", lines)336}