| package readline |
| |
| import ( |
| "errors" |
| "io" |
| "sync" |
| "sync/atomic" |
| "time" |
| |
| "github.com/ergochat/readline/internal/platform" |
| "github.com/ergochat/readline/internal/runes" |
| ) |
| |
| var ( |
| ErrInterrupt = errors.New("Interrupt") |
| ) |
| |
| type operation struct { |
| m sync.Mutex |
| t *terminal |
| buf *runeBuffer |
| wrapOut atomic.Pointer[wrapWriter] |
| wrapErr atomic.Pointer[wrapWriter] |
| |
| isPrompting bool // true when prompt written and waiting for input |
| |
| // dsrUnsupported is set once a cursor position query has timed out. The |
| // terminal is then assumed not to implement DSR and no further queries |
| // are made, so the timeout is paid at most once per session rather than |
| // before every prompt. Read and written under m, via getAndSetOffset. |
| dsrUnsupported bool |
| |
| history *opHistory |
| search *opSearch |
| completer *opCompleter |
| vim *opVim |
| undo *opUndo |
| } |
| |
| func (o *operation) SetBuffer(what string) { |
| o.buf.SetNoRefresh([]rune(what)) |
| } |
| |
| type wrapWriter struct { |
| o *operation |
| target io.Writer |
| } |
| |
| func (w *wrapWriter) Write(b []byte) (int, error) { |
| return w.o.write(w.target, b) |
| } |
| |
| func (o *operation) write(target io.Writer, b []byte) (int, error) { |
| o.m.Lock() |
| defer o.m.Unlock() |
| |
| if !o.isPrompting { |
| return target.Write(b) |
| } |
| |
| var ( |
| n int |
| err error |
| ) |
| o.buf.Refresh(func() { |
| n, err = target.Write(b) |
| // Adjust the prompt start position by b |
| rout := runes.ColorFilter([]rune(string(b[:]))) |
| tWidth, _ := o.t.GetWidthHeight() |
| sp := runes.SplitByLine(rout, []rune{}, o.buf.ppos, tWidth, 1) |
| if len(sp) > 1 { |
| o.buf.ppos = len(sp[len(sp)-1]) |
| } else { |
| o.buf.ppos += len(rout) |
| } |
| }) |
| |
| o.search.RefreshIfNeeded() |
| if o.completer.IsInCompleteMode() { |
| o.completer.CompleteRefresh() |
| } |
| return n, err |
| } |
| |
| func newOperation(t *terminal) *operation { |
| cfg := t.GetConfig() |
| op := &operation{ |
| t: t, |
| buf: newRuneBuffer(t), |
| } |
| op.SetConfig(cfg) |
| op.vim = newVimMode(op) |
| op.completer = newOpCompleter(op.buf.w, op) |
| cfg.FuncOnWidthChanged(t.OnSizeChange) |
| return op |
| } |
| |
| func (o *operation) GetConfig() *Config { |
| return o.t.GetConfig() |
| } |
| |
| func (o *operation) readline(deadline chan struct{}) ([]rune, error) { |
| isTyping := false // don't add new undo entries during normal typing |
| |
| for { |
| keepInSearchMode := false |
| keepInCompleteMode := false |
| r, err := o.t.GetRune(deadline) |
| |
| if cfg := o.GetConfig(); cfg.FuncFilterInputRune != nil && err == nil { |
| var process bool |
| r, process = cfg.FuncFilterInputRune(r) |
| if !process { |
| o.buf.Refresh(nil) // to refresh the line |
| continue // ignore this rune |
| } |
| } |
| |
| if err == io.EOF { |
| if o.buf.Len() == 0 { |
| o.buf.Clean() |
| return nil, io.EOF |
| } else { |
| // if stdin got io.EOF and there is something left in buffer, |
| // let's flush them by sending CharEnter. |
| // And we will got io.EOF int next loop. |
| r = CharEnter |
| } |
| } else if err != nil { |
| return nil, err |
| } |
| isUpdateHistory := true |
| |
| if o.completer.IsInCompleteSelectMode() { |
| keepInCompleteMode = o.completer.HandleCompleteSelect(r) |
| if keepInCompleteMode { |
| continue |
| } |
| |
| o.buf.Refresh(nil) |
| switch r { |
| case CharEnter, CharCtrlJ: |
| o.history.Update(o.buf.Runes(), false) |
| fallthrough |
| case CharInterrupt: |
| fallthrough |
| case CharBell: |
| continue |
| } |
| } |
| |
| if o.vim.IsEnableVimMode() { |
| r = o.vim.HandleVim(r, func() rune { |
| r, err := o.t.GetRune(deadline) |
| if err == nil { |
| return r |
| } else { |
| return 0 |
| } |
| }) |
| if r == 0 { |
| continue |
| } |
| } |
| |
| var result []rune |
| |
| isTypingRune := false |
| |
| switch r { |
| case CharBell: |
| if o.search.IsSearchMode() { |
| o.search.ExitSearchMode(true) |
| o.buf.Refresh(nil) |
| } |
| if o.completer.IsInCompleteMode() { |
| o.completer.ExitCompleteMode(true) |
| o.buf.Refresh(nil) |
| } |
| case CharBckSearch: |
| if !o.search.SearchMode(searchDirectionBackward) { |
| o.t.Bell() |
| break |
| } |
| keepInSearchMode = true |
| case CharCtrlU: |
| o.undo.add() |
| o.buf.KillFront() |
| case CharFwdSearch: |
| if !o.search.SearchMode(searchDirectionForward) { |
| o.t.Bell() |
| break |
| } |
| keepInSearchMode = true |
| case CharKill: |
| o.undo.add() |
| o.buf.Kill() |
| keepInCompleteMode = true |
| case MetaForward: |
| o.buf.MoveToNextWord() |
| case CharTranspose: |
| o.undo.add() |
| o.buf.Transpose() |
| case MetaBackward: |
| o.buf.MoveToPrevWord() |
| case MetaDelete: |
| o.undo.add() |
| o.buf.DeleteWord() |
| case CharLineStart: |
| o.buf.MoveToLineStart() |
| case CharLineEnd: |
| o.buf.MoveToLineEnd() |
| case CharBackspace, CharCtrlH: |
| o.undo.add() |
| if o.search.IsSearchMode() { |
| o.search.SearchBackspace() |
| keepInSearchMode = true |
| break |
| } |
| |
| if o.buf.Len() == 0 { |
| o.t.Bell() |
| break |
| } |
| o.buf.Backspace() |
| case CharCtrlZ: |
| if !platform.IsWindows { |
| o.buf.Clean() |
| o.t.SleepToResume() |
| o.Refresh() |
| } |
| case CharCtrlL: |
| clearScreen(o.t) |
| o.buf.SetOffset(cursorPosition{1, 1}) |
| o.Refresh() |
| case MetaBackspace, CharCtrlW: |
| o.undo.add() |
| o.buf.BackEscapeWord() |
| case MetaShiftTab: |
| // no-op |
| case CharCtrlY: |
| o.buf.Yank() |
| case CharCtrl_: |
| o.undo.undo() |
| case CharEnter, CharCtrlJ: |
| if o.search.IsSearchMode() { |
| o.search.ExitSearchMode(false) |
| } |
| if o.completer.IsInCompleteMode() { |
| o.completer.ExitCompleteMode(true) |
| o.buf.Refresh(nil) |
| } |
| o.buf.MoveToLineEnd() |
| var data []rune |
| o.buf.WriteRune('\n') |
| data = o.buf.Reset() |
| data = data[:len(data)-1] // trim \n |
| result = data |
| if !o.GetConfig().DisableAutoSaveHistory { |
| // ignore IO error |
| _ = o.history.New(data) |
| } else { |
| isUpdateHistory = false |
| } |
| o.undo.init() |
| case CharBackward: |
| o.buf.MoveBackward() |
| case CharForward: |
| o.buf.MoveForward() |
| case CharPrev: |
| buf := o.history.Prev() |
| if buf != nil { |
| o.buf.Set(buf) |
| o.undo.init() |
| } else { |
| o.t.Bell() |
| } |
| case CharNext: |
| buf, ok := o.history.Next() |
| if ok { |
| o.buf.Set(buf) |
| o.undo.init() |
| } else { |
| o.t.Bell() |
| } |
| case MetaDeleteKey, CharEOT: |
| o.undo.add() |
| // on Delete key or Ctrl-D, attempt to delete a character: |
| if o.buf.Len() > 0 || !o.IsNormalMode() { |
| if !o.buf.Delete() { |
| o.t.Bell() |
| } |
| break |
| } |
| if r != CharEOT { |
| break |
| } |
| // Ctrl-D on an empty buffer: treated as EOF |
| o.buf.WriteString(o.GetConfig().EOFPrompt + "\n") |
| o.buf.Reset() |
| isUpdateHistory = false |
| o.history.Revert() |
| o.buf.Clean() |
| return nil, io.EOF |
| case CharInterrupt: |
| if o.search.IsSearchMode() { |
| o.search.ExitSearchMode(true) |
| break |
| } |
| if o.completer.IsInCompleteMode() { |
| o.completer.ExitCompleteMode(true) |
| o.buf.Refresh(nil) |
| break |
| } |
| o.buf.MoveToLineEnd() |
| o.buf.Refresh(nil) |
| hint := o.GetConfig().InterruptPrompt + "\n" |
| o.buf.WriteString(hint) |
| remain := o.buf.Reset() |
| remain = remain[:len(remain)-len([]rune(hint))] |
| isUpdateHistory = false |
| o.history.Revert() |
| return nil, ErrInterrupt |
| case CharTab: |
| if o.GetConfig().AutoComplete != nil { |
| if o.completer.OnComplete() { |
| if o.completer.IsInCompleteMode() { |
| keepInCompleteMode = true |
| continue // redraw is done, loop |
| } |
| } else { |
| o.t.Bell() |
| } |
| o.buf.Refresh(nil) |
| break |
| } // else: process as a normal input character |
| fallthrough |
| default: |
| isTypingRune = true |
| if !isTyping { |
| o.undo.add() |
| } |
| if o.search.IsSearchMode() { |
| o.search.SearchChar(r) |
| keepInSearchMode = true |
| break |
| } |
| o.buf.WriteRune(r) |
| if o.completer.IsInCompleteMode() { |
| o.completer.OnComplete() |
| if o.completer.IsInCompleteMode() { |
| keepInCompleteMode = true |
| } else { |
| o.buf.Refresh(nil) |
| } |
| } |
| } |
| |
| isTyping = isTypingRune |
| |
| // suppress the Listener callback if we received Enter or similar and are |
| // submitting the result, since the buffer has already been cleared: |
| if result == nil { |
| if listener := o.GetConfig().Listener; listener != nil { |
| newLine, newPos, ok := listener(o.buf.Runes(), o.buf.Pos(), r) |
| if ok { |
| o.buf.SetWithIdx(newPos, newLine) |
| } |
| } |
| } |
| |
| o.m.Lock() |
| if !keepInSearchMode && o.search.IsSearchMode() { |
| o.search.ExitSearchMode(false) |
| o.buf.Refresh(nil) |
| o.undo.init() |
| } else if o.completer.IsInCompleteMode() { |
| if !keepInCompleteMode { |
| o.completer.ExitCompleteMode(false) |
| o.refresh() |
| o.undo.init() |
| } else { |
| o.buf.Refresh(nil) |
| o.completer.CompleteRefresh() |
| } |
| } |
| if isUpdateHistory && !o.search.IsSearchMode() { |
| // it will cause null history |
| o.history.Update(o.buf.Runes(), false) |
| } |
| o.m.Unlock() |
| |
| if result != nil { |
| return result, nil |
| } |
| } |
| } |
| |
| func (o *operation) Stderr() io.Writer { |
| return o.wrapErr.Load() |
| } |
| |
| func (o *operation) Stdout() io.Writer { |
| return o.wrapOut.Load() |
| } |
| |
| func (o *operation) String() (string, error) { |
| r, err := o.Runes() |
| return string(r), err |
| } |
| |
| func (o *operation) Runes() ([]rune, error) { |
| o.t.EnterRawMode() |
| defer o.t.ExitRawMode() |
| |
| cfg := o.GetConfig() |
| listener := cfg.Listener |
| if listener != nil { |
| listener(nil, 0, 0) |
| } |
| |
| // Before writing the prompt and starting to read, get a lock |
| // so we don't race with wrapWriter trying to write and refresh. |
| o.m.Lock() |
| o.isPrompting = true |
| // Query cursor position before printing the prompt as there |
| // may be existing text on the same line that ideally we don't |
| // want to overwrite and cause prompt to jump left. |
| // Bound the query: a pty that never answers the DSR request (expect, |
| // pexpect, Ansible with a pty, some CI runners) would otherwise block |
| // here forever and no prompt would ever be printed. |
| o.getAndSetOffset(newDSRDeadline()) |
| o.buf.Print() // print prompt & buffer contents |
| // Prompt written safely, unlock until read completes and then |
| // lock again to unset. |
| o.m.Unlock() |
| |
| if cfg.Undo { |
| o.undo = newOpUndo(o) |
| } |
| |
| defer func() { |
| o.m.Lock() |
| o.isPrompting = false |
| o.buf.SetOffset(cursorPosition{1, 1}) |
| o.m.Unlock() |
| }() |
| |
| return o.readline(nil) |
| } |
| |
| func (o *operation) getAndSetOffset(deadline chan struct{}) { |
| if !o.GetConfig().isInteractive || o.dsrUnsupported { |
| return |
| } |
| |
| // Handle lineedge cases where existing text before before |
| // the prompt is printed would leave us at the right edge of |
| // the screen but the next character would actually be printed |
| // at the beginning of the next line. |
| // TODO ??? |
| o.t.Write([]byte(" \b")) |
| |
| offset, err := o.t.GetCursorPosition(deadline) |
| switch { |
| case err == nil: |
| o.buf.SetOffset(offset) |
| case errors.Is(err, deadlineExceeded): |
| // The terminal did not answer in time; assume it never will and |
| // stop querying, so the timeout is not paid before every prompt. |
| o.dsrUnsupported = true |
| } |
| } |
| |
| // newDSRDeadline returns a channel that closes once dsrTimeout has elapsed, |
| // bounding how long a cursor position query will wait for the terminal to |
| // answer. On expiry GetCursorPosition returns an error, the offset is left |
| // unchanged and the prompt is printed anyway. |
| func newDSRDeadline() chan struct{} { |
| deadline := make(chan struct{}) |
| time.AfterFunc(dsrTimeout, func() { close(deadline) }) |
| return deadline |
| } |
| |
| func (o *operation) GenPasswordConfig() *Config { |
| baseConfig := o.GetConfig() |
| return &Config{ |
| EnableMask: true, |
| InterruptPrompt: "\n", |
| EOFPrompt: "\n", |
| HistoryLimit: -1, |
| |
| Stdin: baseConfig.Stdin, |
| Stdout: baseConfig.Stdout, |
| Stderr: baseConfig.Stderr, |
| |
| FuncIsTerminal: baseConfig.FuncIsTerminal, |
| FuncMakeRaw: baseConfig.FuncMakeRaw, |
| FuncExitRaw: baseConfig.FuncExitRaw, |
| FuncOnWidthChanged: baseConfig.FuncOnWidthChanged, |
| } |
| } |
| |
| func (o *operation) ReadLineWithConfig(cfg *Config) (string, error) { |
| backupCfg, err := o.SetConfig(cfg) |
| if err != nil { |
| return "", err |
| } |
| defer func() { |
| o.SetConfig(backupCfg) |
| }() |
| return o.String() |
| } |
| |
| func (o *operation) SetTitle(t string) { |
| o.t.Write([]byte("\033[2;" + t + "\007")) |
| } |
| |
| func (o *operation) Slice() ([]byte, error) { |
| r, err := o.Runes() |
| if err != nil { |
| return nil, err |
| } |
| return []byte(string(r)), nil |
| } |
| |
| func (o *operation) Close() { |
| o.history.Close() |
| } |
| |
| func (o *operation) IsNormalMode() bool { |
| return !o.completer.IsInCompleteMode() && !o.search.IsSearchMode() |
| } |
| |
| func (op *operation) SetConfig(cfg *Config) (*Config, error) { |
| op.m.Lock() |
| defer op.m.Unlock() |
| old := op.t.GetConfig() |
| if err := cfg.init(); err != nil { |
| return old, err |
| } |
| |
| // install the config in its canonical location (inside terminal): |
| op.t.SetConfig(cfg) |
| |
| op.wrapOut.Store(&wrapWriter{target: cfg.Stdout, o: op}) |
| op.wrapErr.Store(&wrapWriter{target: cfg.Stderr, o: op}) |
| |
| if op.history == nil { |
| op.history = newOpHistory(op) |
| } |
| if op.search == nil { |
| op.search = newOpSearch(op.buf.w, op.buf, op.history) |
| } |
| |
| if cfg.AutoComplete != nil && op.completer == nil { |
| op.completer = newOpCompleter(op.buf.w, op) |
| } |
| |
| return old, nil |
| } |
| |
| func (o *operation) ResetHistory() { |
| o.history.Reset() |
| } |
| |
| func (o *operation) SaveToHistory(content string) error { |
| return o.history.New([]rune(content)) |
| } |
| |
| func (o *operation) Refresh() { |
| o.m.Lock() |
| defer o.m.Unlock() |
| o.refresh() |
| } |
| |
| func (o *operation) refresh() { |
| if o.isPrompting { |
| o.buf.Refresh(nil) |
| } |
| } |