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  }