src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/cli/app.go (about)

     1  // Package cli implements a generic interactive line editor.
     2  package cli
     3  
     4  import (
     5  	"io"
     6  	"os"
     7  	"sort"
     8  	"sync"
     9  	"syscall"
    10  
    11  	"src.elv.sh/pkg/cli/term"
    12  	"src.elv.sh/pkg/cli/tk"
    13  	"src.elv.sh/pkg/sys"
    14  	"src.elv.sh/pkg/ui"
    15  )
    16  
    17  // App represents a CLI app.
    18  type App interface {
    19  	// ReadCode requests the App to read code from the terminal by running an
    20  	// event loop. This function is not re-entrant.
    21  	ReadCode() (string, error)
    22  
    23  	// MutateState mutates the state of the app.
    24  	MutateState(f func(*State))
    25  	// CopyState returns a copy of the a state.
    26  	CopyState() State
    27  
    28  	// PushAddon pushes a widget to the addon stack.
    29  	PushAddon(w tk.Widget)
    30  	// PopAddon pops the last widget from the addon stack. If the widget
    31  	// implements interface{ Dismiss() }, the Dismiss method is called
    32  	// first. This method does nothing if the addon stack is empty.
    33  	PopAddon()
    34  
    35  	// ActiveWidget returns the currently active widget. If the addon stack is
    36  	// non-empty, it returns the last addon. Otherwise it returns the main code
    37  	// area widget.
    38  	ActiveWidget() tk.Widget
    39  	// FocusedWidget returns the currently focused widget. It is searched like
    40  	// ActiveWidget, but skips widgets that implement interface{ Focus() bool }
    41  	// and return false when .Focus() is called.
    42  	FocusedWidget() tk.Widget
    43  
    44  	// CommitEOF causes the main loop to exit with EOF. If this method is called
    45  	// when an event is being handled, the main loop will exit after the handler
    46  	// returns.
    47  	CommitEOF()
    48  	// CommitCode causes the main loop to exit with the current code content. If
    49  	// this method is called when an event is being handled, the main loop will
    50  	// exit after the handler returns.
    51  	CommitCode()
    52  
    53  	// Redraw requests a redraw. It never blocks and can be called regardless of
    54  	// whether the App is active or not.
    55  	Redraw()
    56  	// RedrawFull requests a full redraw. It never blocks and can be called
    57  	// regardless of whether the App is active or not.
    58  	RedrawFull()
    59  	// Notify adds a note and requests a redraw.
    60  	Notify(note ui.Text)
    61  }
    62  
    63  type app struct {
    64  	loop    *loop
    65  	reqRead chan struct{}
    66  
    67  	TTY               TTY
    68  	MaxHeight         func() int
    69  	RPromptPersistent func() bool
    70  	BeforeReadline    []func()
    71  	AfterReadline     []func(string)
    72  	Highlighter       Highlighter
    73  	Prompt            Prompt
    74  	RPrompt           Prompt
    75  	GlobalBindings    tk.Bindings
    76  
    77  	StateMutex sync.RWMutex
    78  	State      State
    79  
    80  	codeArea tk.CodeArea
    81  }
    82  
    83  // State represents mutable state of an App.
    84  type State struct {
    85  	// Notes that have been added since the last redraw.
    86  	Notes []ui.Text
    87  	// The addon stack. All widgets are shown under the codearea widget. The
    88  	// last widget handles terminal events.
    89  	Addons []tk.Widget
    90  }
    91  
    92  // NewApp creates a new App from the given specification.
    93  func NewApp(spec AppSpec) App {
    94  	lp := newLoop()
    95  	a := app{
    96  		loop:              lp,
    97  		TTY:               spec.TTY,
    98  		MaxHeight:         spec.MaxHeight,
    99  		RPromptPersistent: spec.RPromptPersistent,
   100  		BeforeReadline:    spec.BeforeReadline,
   101  		AfterReadline:     spec.AfterReadline,
   102  		Highlighter:       spec.Highlighter,
   103  		Prompt:            spec.Prompt,
   104  		RPrompt:           spec.RPrompt,
   105  		GlobalBindings:    spec.GlobalBindings,
   106  		State:             spec.State,
   107  	}
   108  	if a.TTY == nil {
   109  		a.TTY = NewTTY(os.Stdin, os.Stderr)
   110  	}
   111  	if a.MaxHeight == nil {
   112  		a.MaxHeight = func() int { return -1 }
   113  	}
   114  	if a.RPromptPersistent == nil {
   115  		a.RPromptPersistent = func() bool { return false }
   116  	}
   117  	if a.Highlighter == nil {
   118  		a.Highlighter = dummyHighlighter{}
   119  	}
   120  	if a.Prompt == nil {
   121  		a.Prompt = NewConstPrompt(nil)
   122  	}
   123  	if a.RPrompt == nil {
   124  		a.RPrompt = NewConstPrompt(nil)
   125  	}
   126  	if a.GlobalBindings == nil {
   127  		a.GlobalBindings = tk.DummyBindings{}
   128  	}
   129  	lp.HandleCb(a.handle)
   130  	lp.RedrawCb(a.redraw)
   131  
   132  	a.codeArea = tk.NewCodeArea(tk.CodeAreaSpec{
   133  		Bindings:    spec.CodeAreaBindings,
   134  		Highlighter: a.Highlighter.Get,
   135  		Prompt:      a.Prompt.Get,
   136  		RPrompt:     a.RPrompt.Get,
   137  		QuotePaste:  spec.QuotePaste,
   138  		OnSubmit:    a.CommitCode,
   139  		State:       spec.CodeAreaState,
   140  
   141  		SimpleAbbreviations:    spec.SimpleAbbreviations,
   142  		CommandAbbreviations:   spec.CommandAbbreviations,
   143  		SmallWordAbbreviations: spec.SmallWordAbbreviations,
   144  	})
   145  
   146  	return &a
   147  }
   148  
   149  func (a *app) MutateState(f func(*State)) {
   150  	a.StateMutex.Lock()
   151  	defer a.StateMutex.Unlock()
   152  	f(&a.State)
   153  }
   154  
   155  func (a *app) CopyState() State {
   156  	a.StateMutex.RLock()
   157  	defer a.StateMutex.RUnlock()
   158  	return State{
   159  		append([]ui.Text(nil), a.State.Notes...),
   160  		append([]tk.Widget(nil), a.State.Addons...),
   161  	}
   162  }
   163  
   164  type dismisser interface {
   165  	Dismiss()
   166  }
   167  
   168  func (a *app) PushAddon(w tk.Widget) {
   169  	a.StateMutex.Lock()
   170  	defer a.StateMutex.Unlock()
   171  	a.State.Addons = append(a.State.Addons, w)
   172  }
   173  
   174  func (a *app) PopAddon() {
   175  	a.StateMutex.Lock()
   176  	defer a.StateMutex.Unlock()
   177  	if len(a.State.Addons) == 0 {
   178  		return
   179  	}
   180  	if d, ok := a.State.Addons[len(a.State.Addons)-1].(dismisser); ok {
   181  		d.Dismiss()
   182  	}
   183  	a.State.Addons = a.State.Addons[:len(a.State.Addons)-1]
   184  }
   185  
   186  func (a *app) ActiveWidget() tk.Widget {
   187  	a.StateMutex.Lock()
   188  	defer a.StateMutex.Unlock()
   189  	if len(a.State.Addons) > 0 {
   190  		return a.State.Addons[len(a.State.Addons)-1]
   191  	}
   192  	return a.codeArea
   193  }
   194  
   195  func (a *app) FocusedWidget() tk.Widget {
   196  	a.StateMutex.Lock()
   197  	defer a.StateMutex.Unlock()
   198  	addons := a.State.Addons
   199  	for i := len(addons) - 1; i >= 0; i-- {
   200  		if hasFocus(addons[i]) {
   201  			return addons[i]
   202  		}
   203  	}
   204  	return a.codeArea
   205  }
   206  
   207  func (a *app) resetAllStates() {
   208  	a.MutateState(func(s *State) { *s = State{} })
   209  	a.codeArea.MutateState(
   210  		func(s *tk.CodeAreaState) { *s = tk.CodeAreaState{} })
   211  }
   212  
   213  func (a *app) handle(e event) {
   214  	switch e := e.(type) {
   215  	case os.Signal:
   216  		switch e {
   217  		case syscall.SIGHUP:
   218  			a.loop.Return("", io.EOF)
   219  		case syscall.SIGINT:
   220  			a.resetAllStates()
   221  			a.triggerPrompts(true)
   222  		case sys.SIGWINCH:
   223  			a.RedrawFull()
   224  		}
   225  	case term.Event:
   226  		target := a.ActiveWidget()
   227  		handled := target.Handle(e)
   228  		if !handled {
   229  			handled = a.GlobalBindings.Handle(target, e)
   230  		}
   231  		if !handled {
   232  			if k, ok := e.(term.KeyEvent); ok {
   233  				a.Notify(ui.T("Unbound key: " + ui.Key(k).String()))
   234  			}
   235  		}
   236  		if !a.loop.HasReturned() {
   237  			a.triggerPrompts(false)
   238  			a.reqRead <- struct{}{}
   239  		}
   240  	}
   241  }
   242  
   243  func (a *app) triggerPrompts(force bool) {
   244  	a.Prompt.Trigger(force)
   245  	a.RPrompt.Trigger(force)
   246  }
   247  
   248  func (a *app) redraw(flag redrawFlag) {
   249  	// Get the dimensions available.
   250  	height, width := a.TTY.Size()
   251  	if maxHeight := a.MaxHeight(); maxHeight > 0 && maxHeight < height {
   252  		height = maxHeight
   253  	}
   254  
   255  	var notes []ui.Text
   256  	var addons []tk.Widget
   257  	a.MutateState(func(s *State) {
   258  		notes = s.Notes
   259  		s.Notes = nil
   260  		addons = append([]tk.Widget(nil), s.Addons...)
   261  	})
   262  
   263  	bufNotes := renderNotes(notes, width)
   264  	isFinalRedraw := flag&finalRedraw != 0
   265  	if isFinalRedraw {
   266  		hideRPrompt := !a.RPromptPersistent()
   267  		a.codeArea.MutateState(func(s *tk.CodeAreaState) {
   268  			s.HideTips = true
   269  			s.HideRPrompt = hideRPrompt
   270  		})
   271  		bufMain := renderApp([]tk.Widget{a.codeArea /* no addon */}, width, height)
   272  		a.codeArea.MutateState(func(s *tk.CodeAreaState) {
   273  			s.HideTips = false
   274  			s.HideRPrompt = false
   275  		})
   276  		// Insert a newline after the buffer and position the cursor there.
   277  		bufMain.Extend(term.NewBuffer(width), true)
   278  
   279  		a.TTY.UpdateBuffer(bufNotes, bufMain, flag&fullRedraw != 0)
   280  		a.TTY.ResetBuffer()
   281  	} else {
   282  		bufMain := renderApp(append([]tk.Widget{a.codeArea}, addons...), width, height)
   283  		a.TTY.UpdateBuffer(bufNotes, bufMain, flag&fullRedraw != 0)
   284  	}
   285  }
   286  
   287  // Renders notes. This does not respect height so that overflow notes end up in
   288  // the scrollback buffer.
   289  func renderNotes(notes []ui.Text, width int) *term.Buffer {
   290  	if len(notes) == 0 {
   291  		return nil
   292  	}
   293  	bb := term.NewBufferBuilder(width)
   294  	for i, note := range notes {
   295  		if i > 0 {
   296  			bb.Newline()
   297  		}
   298  		bb.WriteStyled(note)
   299  	}
   300  	return bb.Buffer()
   301  }
   302  
   303  // Renders the codearea, and uses the rest of the height for the listing.
   304  func renderApp(widgets []tk.Widget, width, height int) *term.Buffer {
   305  	heights, focus := distributeHeight(widgets, width, height)
   306  	var buf *term.Buffer
   307  	for i, w := range widgets {
   308  		if heights[i] == 0 {
   309  			continue
   310  		}
   311  		buf2 := w.Render(width, heights[i])
   312  		if buf == nil {
   313  			buf = buf2
   314  		} else {
   315  			buf.Extend(buf2, i == focus)
   316  		}
   317  	}
   318  	return buf
   319  }
   320  
   321  // Distributes the height among all the widgets. Returns the height for each
   322  // widget, and the index of the widget currently focused.
   323  func distributeHeight(widgets []tk.Widget, width, height int) ([]int, int) {
   324  	var focus int
   325  	for i, w := range widgets {
   326  		if hasFocus(w) {
   327  			focus = i
   328  		}
   329  	}
   330  	n := len(widgets)
   331  	heights := make([]int, n)
   332  	if height <= n {
   333  		// Not enough (or just enough) height to render every widget with a
   334  		// height of 1.
   335  		remain := height
   336  		// Start from the focused widget, and extend downwards as much as
   337  		// possible.
   338  		for i := focus; i < n && remain > 0; i++ {
   339  			heights[i] = 1
   340  			remain--
   341  		}
   342  		// If there is still space remaining, start from the focused widget
   343  		// again, and extend upwards as much as possible.
   344  		for i := focus - 1; i >= 0 && remain > 0; i-- {
   345  			heights[i] = 1
   346  			remain--
   347  		}
   348  		return heights, focus
   349  	}
   350  
   351  	maxHeights := make([]int, n)
   352  	for i, w := range widgets {
   353  		maxHeights[i] = w.MaxHeight(width, height)
   354  	}
   355  
   356  	// The algorithm below achieves the following goals:
   357  	//
   358  	// 1. If maxHeights[u] > maxHeights[v], heights[u] >= heights[v];
   359  	//
   360  	// 2. While achieving goal 1, have as many widgets s.t. heights[u] ==
   361  	//    maxHeights[u].
   362  	//
   363  	// This is done by allocating the height among the widgets following an
   364  	// non-decreasing order of maxHeights. At each step:
   365  	//
   366  	// - If it's possible to allocate maxHeights[u] to all remaining widgets,
   367  	//   then allocate maxHeights[u] to widget u;
   368  	//
   369  	// - If not, allocate the remaining budget evenly - rounding down at each
   370  	//   step, so the widgets with smaller maxHeights gets smaller heights.
   371  
   372  	// TODO: Add a test for this.
   373  
   374  	indices := make([]int, n)
   375  	for i := range indices {
   376  		indices[i] = i
   377  	}
   378  	sort.Slice(indices, func(i, j int) bool {
   379  		return maxHeights[indices[i]] < maxHeights[indices[j]]
   380  	})
   381  
   382  	remain := height
   383  	for rank, idx := range indices {
   384  		if remain >= maxHeights[idx]*(n-rank) {
   385  			heights[idx] = maxHeights[idx]
   386  		} else {
   387  			heights[idx] = remain / (n - rank)
   388  		}
   389  		remain -= heights[idx]
   390  	}
   391  
   392  	return heights, focus
   393  }
   394  
   395  func hasFocus(w any) bool {
   396  	if f, ok := w.(interface{ Focus() bool }); ok {
   397  		return f.Focus()
   398  	}
   399  	return true
   400  }
   401  
   402  func (a *app) ReadCode() (string, error) {
   403  	for _, f := range a.BeforeReadline {
   404  		f()
   405  	}
   406  	defer func() {
   407  		content := a.codeArea.CopyState().Buffer.Content
   408  		for _, f := range a.AfterReadline {
   409  			f(content)
   410  		}
   411  		a.resetAllStates()
   412  	}()
   413  
   414  	restore, err := a.TTY.Setup()
   415  	if err != nil {
   416  		return "", err
   417  	}
   418  	defer restore()
   419  
   420  	var wg sync.WaitGroup
   421  	defer wg.Wait()
   422  
   423  	// Relay input events.
   424  	a.reqRead = make(chan struct{}, 1)
   425  	a.reqRead <- struct{}{}
   426  	defer close(a.reqRead)
   427  	defer a.TTY.CloseReader()
   428  	wg.Add(1)
   429  	go func() {
   430  		defer wg.Done()
   431  		for range a.reqRead {
   432  			event, err := a.TTY.ReadEvent()
   433  			if err == nil {
   434  				a.loop.Input(event)
   435  			} else if err == term.ErrStopped {
   436  				return
   437  			} else if term.IsReadErrorRecoverable(err) {
   438  				a.loop.Input(term.NonfatalErrorEvent{Err: err})
   439  			} else {
   440  				a.loop.Input(term.FatalErrorEvent{Err: err})
   441  				return
   442  			}
   443  		}
   444  	}()
   445  
   446  	// Relay signals.
   447  	sigCh := a.TTY.NotifySignals()
   448  	defer a.TTY.StopSignals()
   449  	wg.Add(1)
   450  	go func() {
   451  		for sig := range sigCh {
   452  			a.loop.Input(sig)
   453  		}
   454  		wg.Done()
   455  	}()
   456  
   457  	// Relay late updates from prompt, rprompt and highlighter.
   458  	stopRelayLateUpdates := make(chan struct{})
   459  	defer close(stopRelayLateUpdates)
   460  	relayLateUpdates := func(ch <-chan struct{}) {
   461  		if ch == nil {
   462  			return
   463  		}
   464  		wg.Add(1)
   465  		go func() {
   466  			defer wg.Done()
   467  			for {
   468  				select {
   469  				case <-ch:
   470  					a.Redraw()
   471  				case <-stopRelayLateUpdates:
   472  					return
   473  				}
   474  			}
   475  		}()
   476  	}
   477  
   478  	relayLateUpdates(a.Prompt.LateUpdates())
   479  	relayLateUpdates(a.RPrompt.LateUpdates())
   480  	relayLateUpdates(a.Highlighter.LateUpdates())
   481  
   482  	// Trigger an initial prompt update.
   483  	a.triggerPrompts(true)
   484  
   485  	return a.loop.Run()
   486  }
   487  
   488  func (a *app) Redraw() {
   489  	a.loop.Redraw(false)
   490  }
   491  
   492  func (a *app) RedrawFull() {
   493  	a.loop.Redraw(true)
   494  }
   495  
   496  func (a *app) CommitEOF() {
   497  	a.loop.Return("", io.EOF)
   498  }
   499  
   500  func (a *app) CommitCode() {
   501  	code := a.codeArea.CopyState().Buffer.Content
   502  	a.loop.Return(code, nil)
   503  }
   504  
   505  func (a *app) Notify(note ui.Text) {
   506  	a.MutateState(func(s *State) { s.Notes = append(s.Notes, note) })
   507  	a.Redraw()
   508  }