src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/cli/tk/codearea.go (about) 1 package tk 2 3 import ( 4 "bytes" 5 "regexp" 6 "strings" 7 "sync" 8 "unicode" 9 "unicode/utf8" 10 11 "src.elv.sh/pkg/cli/term" 12 "src.elv.sh/pkg/parse" 13 "src.elv.sh/pkg/ui" 14 ) 15 16 // CodeArea is a Widget for displaying and editing code. 17 type CodeArea interface { 18 Widget 19 // CopyState returns a copy of the state. 20 CopyState() CodeAreaState 21 // MutateState calls the given the function while locking StateMutex. 22 MutateState(f func(*CodeAreaState)) 23 // Submit triggers the OnSubmit callback. 24 Submit() 25 } 26 27 // CodeAreaSpec specifies the configuration and initial state for CodeArea. 28 type CodeAreaSpec struct { 29 // Key bindings. 30 Bindings Bindings 31 // A function that highlights the given code and returns any tips it has 32 // found, such as errors and autofixes. If this function is not given, the 33 // Widget does not highlight the code nor show any tips. 34 Highlighter func(code string) (ui.Text, []ui.Text) 35 // Prompt callback. 36 Prompt func() ui.Text 37 // Right-prompt callback. 38 RPrompt func() ui.Text 39 // A function that calls the callback with string pairs for abbreviations 40 // and their expansions. If no function is provided the Widget does not 41 // expand any abbreviations of the specified type. 42 SimpleAbbreviations func(f func(abbr, full string)) 43 CommandAbbreviations func(f func(abbr, full string)) 44 SmallWordAbbreviations func(f func(abbr, full string)) 45 // A function that returns whether pasted texts (from bracketed pastes) 46 // should be quoted. If this function is not given, the Widget defaults to 47 // not quoting pasted texts. 48 QuotePaste func() bool 49 // A function that is called on the submit event. 50 OnSubmit func() 51 52 // State. When used in New, this field specifies the initial state. 53 State CodeAreaState 54 } 55 56 // CodeAreaState keeps the mutable state of the CodeArea widget. 57 type CodeAreaState struct { 58 Buffer CodeBuffer 59 Pending PendingCode 60 HideRPrompt bool 61 HideTips bool 62 } 63 64 // CodeBuffer represents the buffer of the CodeArea widget. 65 type CodeBuffer struct { 66 // Content of the buffer. 67 Content string 68 // Position of the dot (more commonly known as the cursor), as a byte index 69 // into Content. 70 Dot int 71 } 72 73 // PendingCode represents pending code, such as during completion. 74 type PendingCode struct { 75 // Beginning index of the text area that the pending code replaces, as a 76 // byte index into RawState.Code. 77 From int 78 // End index of the text area that the pending code replaces, as a byte 79 // index into RawState.Code. 80 To int 81 // The content of the pending code. 82 Content string 83 } 84 85 // ApplyPending applies pending code to the code buffer, and resets pending code. 86 func (s *CodeAreaState) ApplyPending() { 87 s.Buffer, _, _ = patchPending(s.Buffer, s.Pending) 88 s.Pending = PendingCode{} 89 } 90 91 func (c *CodeBuffer) InsertAtDot(text string) { 92 *c = CodeBuffer{ 93 Content: c.Content[:c.Dot] + text + c.Content[c.Dot:], 94 Dot: c.Dot + len(text), 95 } 96 } 97 98 type codeArea struct { 99 // Mutex for synchronizing access to State. 100 StateMutex sync.RWMutex 101 // Configuration and state. 102 CodeAreaSpec 103 104 // Consecutively inserted text. Used for expanding abbreviations. 105 inserts string 106 // Value of State.CodeBuffer when handleKeyEvent was last called. Used for 107 // detecting whether insertion has been interrupted. 108 lastCodeBuffer CodeBuffer 109 // Whether the widget is in the middle of bracketed pasting. 110 pasting bool 111 // Buffer for keeping Pasted text during bracketed pasting. 112 pasteBuffer bytes.Buffer 113 } 114 115 // NewCodeArea creates a new CodeArea from the given spec. 116 func NewCodeArea(spec CodeAreaSpec) CodeArea { 117 if spec.Bindings == nil { 118 spec.Bindings = DummyBindings{} 119 } 120 if spec.Highlighter == nil { 121 spec.Highlighter = func(s string) (ui.Text, []ui.Text) { return ui.T(s), nil } 122 } 123 if spec.Prompt == nil { 124 spec.Prompt = func() ui.Text { return nil } 125 } 126 if spec.RPrompt == nil { 127 spec.RPrompt = func() ui.Text { return nil } 128 } 129 if spec.SimpleAbbreviations == nil { 130 spec.SimpleAbbreviations = func(func(a, f string)) {} 131 } 132 if spec.CommandAbbreviations == nil { 133 spec.CommandAbbreviations = func(func(a, f string)) {} 134 } 135 if spec.SmallWordAbbreviations == nil { 136 spec.SmallWordAbbreviations = func(func(a, f string)) {} 137 } 138 if spec.QuotePaste == nil { 139 spec.QuotePaste = func() bool { return false } 140 } 141 if spec.OnSubmit == nil { 142 spec.OnSubmit = func() {} 143 } 144 return &codeArea{CodeAreaSpec: spec} 145 } 146 147 // Submit emits a submit event with the current code content. 148 func (w *codeArea) Submit() { 149 w.OnSubmit() 150 } 151 152 // Render renders the code area, including the prompt and rprompt, highlighted 153 // code, the cursor, and compilation errors in the code content. 154 func (w *codeArea) Render(width, height int) *term.Buffer { 155 b := w.render(width) 156 truncateToHeight(b, height) 157 return b 158 } 159 160 func (w *codeArea) MaxHeight(width, height int) int { 161 return len(w.render(width).Lines) 162 } 163 164 func (w *codeArea) render(width int) *term.Buffer { 165 view := getView(w) 166 bb := term.NewBufferBuilder(width) 167 renderView(view, bb) 168 return bb.Buffer() 169 } 170 171 // Handle handles KeyEvent's of non-function keys, as well as PasteSetting 172 // events. 173 func (w *codeArea) Handle(event term.Event) bool { 174 switch event := event.(type) { 175 case term.PasteSetting: 176 return w.handlePasteSetting(bool(event)) 177 case term.KeyEvent: 178 return w.handleKeyEvent(ui.Key(event)) 179 } 180 return false 181 } 182 183 func (w *codeArea) MutateState(f func(*CodeAreaState)) { 184 w.StateMutex.Lock() 185 defer w.StateMutex.Unlock() 186 f(&w.State) 187 } 188 189 func (w *codeArea) CopyState() CodeAreaState { 190 w.StateMutex.RLock() 191 defer w.StateMutex.RUnlock() 192 return w.State 193 } 194 195 func (w *codeArea) resetInserts() { 196 w.inserts = "" 197 w.lastCodeBuffer = CodeBuffer{} 198 } 199 200 func (w *codeArea) handlePasteSetting(start bool) bool { 201 w.resetInserts() 202 if start { 203 w.pasting = true 204 } else { 205 text := w.pasteBuffer.String() 206 if w.QuotePaste() { 207 text = parse.Quote(text) 208 } 209 w.MutateState(func(s *CodeAreaState) { s.Buffer.InsertAtDot(text) }) 210 211 w.pasting = false 212 w.pasteBuffer = bytes.Buffer{} 213 } 214 return true 215 } 216 217 // Tries to expand a simple abbreviation. This function assumes the state mutex is held. 218 func (w *codeArea) expandSimpleAbbr() { 219 var abbr, full string 220 // Find the longest matching abbreviation. 221 w.SimpleAbbreviations(func(a, f string) { 222 if strings.HasSuffix(w.inserts, a) && len(a) > len(abbr) { 223 abbr, full = a, f 224 } 225 }) 226 if len(abbr) > 0 { 227 buf := &w.State.Buffer 228 *buf = CodeBuffer{ 229 Content: buf.Content[:buf.Dot-len(abbr)] + full + buf.Content[buf.Dot:], 230 Dot: buf.Dot - len(abbr) + len(full), 231 } 232 w.resetInserts() 233 } 234 } 235 236 var commandRegex = regexp.MustCompile(`(?:^|[^^]\n|\||;|{\s|\()\s*([\p{L}\p{M}\p{N}!%+,\-./:@\\_<>*]+)(\s)$`) 237 238 // Tries to expand a command abbreviation. This function assumes the state mutex 239 // is held. 240 // 241 // We use a regex rather than parse.Parse() because dealing with the latter 242 // requires a lot of code. A simple regex is far simpler and good enough for 243 // this use case. The regex essentially matches commands at the start of the 244 // line (with potential leading whitespace) and similarly after the opening 245 // brace of a lambda or pipeline char. 246 // 247 // This only handles bareword commands. 248 func (w *codeArea) expandCommandAbbr() { 249 buf := &w.State.Buffer 250 if buf.Dot < len(buf.Content) { 251 // Command abbreviations are only expanded when inserting at the end of the buffer. 252 return 253 } 254 255 // See if there is something that looks like a bareword at the end of the buffer. 256 matches := commandRegex.FindStringSubmatch(buf.Content) 257 if len(matches) == 0 { 258 return 259 } 260 261 // Find an abbreviation matching the command. 262 command, whitespace := matches[1], matches[2] 263 var expansion string 264 w.CommandAbbreviations(func(a, e string) { 265 if a == command { 266 expansion = e 267 } 268 }) 269 if expansion == "" { 270 return 271 } 272 273 // We found a matching abbreviation -- replace it with its expansion. 274 newContent := buf.Content[:buf.Dot-len(command)-1] + expansion + whitespace 275 *buf = CodeBuffer{ 276 Content: newContent, 277 Dot: len(newContent), 278 } 279 w.resetInserts() 280 } 281 282 // Try to expand a small word abbreviation. This function assumes the state mutex is held. 283 func (w *codeArea) expandSmallWordAbbr(trigger rune, categorizer func(rune) int) { 284 buf := &w.State.Buffer 285 if buf.Dot < len(buf.Content) { 286 // Word abbreviations are only expanded when inserting at the end of the buffer. 287 return 288 } 289 triggerLen := len(string(trigger)) 290 if triggerLen >= len(w.inserts) { 291 // Only the trigger has been inserted, or a simple abbreviation was just 292 // expanded. In either case, there is nothing to expand. 293 return 294 } 295 // The trigger is only used to determine word boundary; when considering 296 // what to expand, we only consider the part that was inserted before it. 297 inserts := w.inserts[:len(w.inserts)-triggerLen] 298 299 var abbr, full string 300 // Find the longest matching abbreviation. 301 w.SmallWordAbbreviations(func(a, f string) { 302 if len(a) <= len(abbr) { 303 // This abbreviation can't be the longest. 304 return 305 } 306 if !strings.HasSuffix(inserts, a) { 307 // This abbreviation was not inserted. 308 return 309 } 310 // Verify the trigger rune creates a word boundary. 311 r, _ := utf8.DecodeLastRuneInString(a) 312 if categorizer(trigger) == categorizer(r) { 313 return 314 } 315 // Verify the rune preceding the abbreviation, if any, creates a word 316 // boundary. 317 if len(buf.Content) > len(a)+triggerLen { 318 r1, _ := utf8.DecodeLastRuneInString(buf.Content[:len(buf.Content)-len(a)-triggerLen]) 319 r2, _ := utf8.DecodeRuneInString(a) 320 if categorizer(r1) == categorizer(r2) { 321 return 322 } 323 } 324 abbr, full = a, f 325 }) 326 if len(abbr) > 0 { 327 *buf = CodeBuffer{ 328 Content: buf.Content[:buf.Dot-len(abbr)-triggerLen] + full + string(trigger), 329 Dot: buf.Dot - len(abbr) + len(full), 330 } 331 w.resetInserts() 332 } 333 } 334 335 func (w *codeArea) handleKeyEvent(key ui.Key) bool { 336 isFuncKey := key.Mod != 0 || key.Rune < 0 337 if w.pasting { 338 if isFuncKey { 339 // TODO: Notify the user of the error, or insert the original 340 // character as is. 341 } else { 342 w.pasteBuffer.WriteRune(key.Rune) 343 } 344 return true 345 } 346 347 if w.Bindings.Handle(w, term.KeyEvent(key)) { 348 return true 349 } 350 351 // We only implement essential keybindings here. Other keybindings can be 352 // added via handler overlays. 353 switch key { 354 case ui.K('\n'): 355 w.resetInserts() 356 w.Submit() 357 return true 358 case ui.K(ui.Backspace), ui.K('H', ui.Ctrl): 359 w.resetInserts() 360 w.MutateState(func(s *CodeAreaState) { 361 c := &s.Buffer 362 // Remove the last rune. 363 _, chop := utf8.DecodeLastRuneInString(c.Content[:c.Dot]) 364 *c = CodeBuffer{ 365 Content: c.Content[:c.Dot-chop] + c.Content[c.Dot:], 366 Dot: c.Dot - chop, 367 } 368 }) 369 return true 370 default: 371 if isFuncKey || !unicode.IsGraphic(key.Rune) { 372 w.resetInserts() 373 return false 374 } 375 w.StateMutex.Lock() 376 defer w.StateMutex.Unlock() 377 if w.lastCodeBuffer != w.State.Buffer { 378 // Something has happened between the last insert and this one; 379 // reset the state. 380 w.resetInserts() 381 } 382 s := string(key.Rune) 383 w.State.Buffer.InsertAtDot(s) 384 w.inserts += s 385 w.lastCodeBuffer = w.State.Buffer 386 if parse.IsWhitespace(key.Rune) { 387 w.expandCommandAbbr() 388 } 389 w.expandSimpleAbbr() 390 w.expandSmallWordAbbr(key.Rune, CategorizeSmallWord) 391 return true 392 } 393 } 394 395 // IsAlnum determines if the rune is an alphanumeric character. 396 func IsAlnum(r rune) bool { 397 return unicode.IsLetter(r) || unicode.IsNumber(r) 398 } 399 400 // CategorizeSmallWord determines if the rune is whitespace, alphanum, or 401 // something else. 402 func CategorizeSmallWord(r rune) int { 403 switch { 404 case unicode.IsSpace(r): 405 return 0 406 case IsAlnum(r): 407 return 1 408 default: 409 return 2 410 } 411 }