| package readline |
| |
| import ( |
| "bufio" |
| "bytes" |
| "fmt" |
| "strings" |
| "sync/atomic" |
| |
| "github.com/ergochat/readline/internal/platform" |
| "github.com/ergochat/readline/internal/runes" |
| ) |
| |
| type AutoCompleter interface { |
| // Readline will pass the whole line and current offset to it |
| // Completer need to pass all the candidates, and how long they shared the same characters in line |
| // Example: |
| // [go, git, git-shell, grep] |
| // Do("g", 1) => ["o", "it", "it-shell", "rep"], 1 |
| // Do("gi", 2) => ["t", "t-shell"], 2 |
| // Do("git", 3) => ["", "-shell"], 3 |
| Do(line []rune, pos int) (newLine [][]rune, length int) |
| } |
| |
| type opCompleter struct { |
| w *terminal |
| op *operation |
| |
| inCompleteMode atomic.Uint32 // this is read asynchronously from wrapWriter |
| inSelectMode bool |
| |
| candidate [][]rune // list of candidates |
| candidateSource []rune // buffer string when tab was pressed |
| candidateOff int // num runes in common from buf where candidate start |
| candidateChoice int // absolute index of the chosen candidate (indexing the candidate array which might not all display in current page) |
| candidateColNum int // num columns candidates take 0..wraps, 1 col, 2 cols etc. |
| candidateColWidth int // width of candidate columns |
| linesAvail int // number of lines available below the user's prompt which could be used for rendering the completion |
| pageStartIdx []int // start index in the candidate array on each page (candidatePageStart[i] = absolute idx of the first candidate on page i) |
| curPage int // index of the current page |
| } |
| |
| func newOpCompleter(w *terminal, op *operation) *opCompleter { |
| return &opCompleter{ |
| w: w, |
| op: op, |
| } |
| } |
| |
| func (o *opCompleter) truncateBufferAfterLastEqual(completion []rune) { |
| bufRunes := o.op.buf.Runes() |
| for i := len(bufRunes) - 1; i >= 0; i-- { |
| if bufRunes[i] == '=' { |
| prefix := bufRunes[i+1:] // part after '=' in buffer |
| if len(prefix) > 0 && len(completion) >= len(prefix) && string(completion[:len(prefix)]) == string(prefix) { |
| o.op.buf.Set(bufRunes[:i+1]) // Keep content till '=' |
| } |
| break |
| } |
| } |
| } |
| |
| func (o *opCompleter) writeRunes(candidate []rune) { |
| selected := candidate |
| spaceFound := false |
| for idx, r := range candidate { |
| if r == ' ' { |
| spaceFound = true |
| } |
| if spaceFound && r == '(' { |
| o.truncateBufferAfterLastEqual(candidate[idx+1:]) |
| selected = candidate[:idx] |
| break |
| } |
| } |
| o.op.buf.WriteRunes(selected) |
| } |
| |
| func (o *opCompleter) doSelect() { |
| if len(o.candidate) == 1 { |
| o.writeRunes(o.candidate[0]) |
| o.ExitCompleteMode(false) |
| return |
| } |
| o.nextCandidate() |
| o.CompleteRefresh() |
| } |
| |
| // Convert absolute index of the chosen candidate to a page-relative index |
| func (o *opCompleter) candidateChoiceWithinPage() int { |
| return o.candidateChoice - o.pageStartIdx[o.curPage] |
| } |
| |
| // Given a page relative index of the chosen candidate, update the absolute index |
| func (o *opCompleter) updateAbsolutechoice(choiceWithinPage int) { |
| o.candidateChoice = choiceWithinPage + o.pageStartIdx[o.curPage] |
| } |
| |
| // Move selection to the next candidate, updating page if necessary |
| // Note: we don't allow passing arbitrary offset to this function because, e.g., |
| // we don't have the 3rd page offset initialized when the user is just seeing the first page, |
| // so we only allow users to navigate into the 2nd page but not to an arbirary page as a result |
| // of calling this method |
| func (o *opCompleter) nextCandidate() { |
| o.candidateChoice = (o.candidateChoice + 1) % len(o.candidate) |
| // Wrapping around |
| if o.candidateChoice == 0 { |
| o.curPage = 0 |
| return |
| } |
| // Going to next page |
| if o.candidateChoice == o.pageStartIdx[o.curPage+1] { |
| o.curPage += 1 |
| } |
| } |
| |
| // Move selection to the next ith col in the current line, wrapping to the line start/end if needed |
| func (o *opCompleter) nextCol(i int) { |
| // If o.candidateColNum == 1 or 0, there is only one col per line and this is a noop |
| if o.candidateColNum > 1 { |
| idxWithinPage := o.candidateChoiceWithinPage() |
| curLine := idxWithinPage / o.candidateColNum |
| offsetInLine := idxWithinPage % o.candidateColNum |
| nextOffset := offsetInLine + i |
| nextOffset %= o.candidateColNum |
| if nextOffset < 0 { |
| nextOffset += o.candidateColNum |
| } |
| |
| nextIdxWithinPage := curLine*o.candidateColNum + nextOffset |
| o.updateAbsolutechoice(nextIdxWithinPage) |
| } |
| } |
| |
| // Move selection to the line below |
| func (o *opCompleter) nextLine() { |
| colNum := 1 |
| if o.candidateColNum > 1 { |
| colNum = o.candidateColNum |
| } |
| |
| idxWithinPage := o.candidateChoiceWithinPage() |
| |
| idxWithinPage += colNum |
| if idxWithinPage >= o.getMatrixSize() { |
| idxWithinPage -= o.getMatrixSize() |
| } else if idxWithinPage >= o.numCandidateCurPage() { |
| idxWithinPage += colNum |
| idxWithinPage -= o.getMatrixSize() |
| } |
| |
| o.updateAbsolutechoice(idxWithinPage) |
| } |
| |
| // Move selection to the line above |
| func (o *opCompleter) prevLine() { |
| colNum := 1 |
| if o.candidateColNum > 1 { |
| colNum = o.candidateColNum |
| } |
| |
| idxWithinPage := o.candidateChoiceWithinPage() |
| |
| idxWithinPage -= colNum |
| if idxWithinPage < 0 { |
| idxWithinPage += o.getMatrixSize() |
| if idxWithinPage >= o.numCandidateCurPage() { |
| idxWithinPage -= colNum |
| } |
| } |
| |
| o.updateAbsolutechoice(idxWithinPage) |
| } |
| |
| // Move selection to the start of the current line |
| func (o *opCompleter) lineStart() { |
| if o.candidateColNum > 1 { |
| idxWithinPage := o.candidateChoiceWithinPage() |
| lineOffset := idxWithinPage % o.candidateColNum |
| idxWithinPage -= lineOffset |
| o.updateAbsolutechoice(idxWithinPage) |
| } |
| } |
| |
| // Move selection to the end of the current line |
| func (o *opCompleter) lineEnd() { |
| if o.candidateColNum > 1 { |
| idxWithinPage := o.candidateChoiceWithinPage() |
| offsetToLineEnd := o.candidateColNum - idxWithinPage%o.candidateColNum - 1 |
| idxWithinPage += offsetToLineEnd |
| o.updateAbsolutechoice(idxWithinPage) |
| if o.candidateChoice >= len(o.candidate) { |
| o.candidateChoice = len(o.candidate) - 1 |
| } |
| } |
| } |
| |
| // Move to the next page if possible, returning selection to the first item in the page |
| func (o *opCompleter) nextPage() { |
| // Check that this is not the last page already |
| nextPageStart := o.pageStartIdx[o.curPage+1] |
| if nextPageStart < len(o.candidate) { |
| o.curPage += 1 |
| o.candidateChoice = o.pageStartIdx[o.curPage] |
| } |
| } |
| |
| // Move to the previous page if possible, returning selection to the first item in the page |
| func (o *opCompleter) prevPage() { |
| if o.curPage > 0 { |
| o.curPage -= 1 |
| o.candidateChoice = o.pageStartIdx[o.curPage] |
| } |
| } |
| |
| // OnComplete returns true if complete mode is available. Used to ring bell |
| // when tab pressed if cannot do complete for reason such as width unknown |
| // or no candidates available. |
| func (o *opCompleter) OnComplete() (ringBell bool) { |
| tWidth, tHeight := o.w.GetWidthHeight() |
| if tWidth == 0 || tHeight < 3 { |
| return false |
| } |
| if o.IsInCompleteSelectMode() { |
| o.doSelect() |
| return true |
| } |
| |
| buf := o.op.buf |
| rs := buf.Runes() |
| |
| // If in complete mode and nothing else typed then we must be entering select mode |
| if o.IsInCompleteMode() && o.candidateSource != nil && runes.Equal(rs, o.candidateSource) { |
| if len(o.candidate) > 1 { |
| same, size := runes.Aggregate(o.candidate) |
| if size > 0 { |
| buf.WriteRunes(same) |
| o.ExitCompleteMode(false) |
| return false // partial completion so ring the bell |
| } |
| } |
| o.EnterCompleteSelectMode() |
| o.doSelect() |
| return true |
| } |
| |
| newLines, offset := o.op.GetConfig().AutoComplete.Do(rs, buf.idx) |
| if len(newLines) == 0 || (len(newLines) == 1 && len(newLines[0]) == 0) { |
| o.ExitCompleteMode(false) |
| return false // will ring bell on initial tab press |
| } |
| if o.candidateOff > offset { |
| // part of buffer we are completing has changed. Example might be that we were completing "ls" and |
| // user typed space so we are no longer completing "ls" but now we are completing an argument of |
| // the ls command. Instead of continuing in complete mode, we exit. |
| o.ExitCompleteMode(false) |
| return true |
| } |
| o.candidateSource = rs |
| |
| // only Aggregate candidates in non-complete mode |
| if !o.IsInCompleteMode() { |
| if len(newLines) == 1 { |
| // not yet in complete mode but only 1 candidate so complete it |
| o.writeRunes(newLines[0]) |
| o.ExitCompleteMode(false) |
| return true |
| } |
| |
| // check if all candidates have common prefix and return it and its size |
| same, size := runes.Aggregate(newLines) |
| if size > 0 { |
| buf.WriteRunes(same) |
| o.ExitCompleteMode(false) |
| return false // partial completion so ring the bell |
| } |
| } |
| |
| // otherwise, we just enter complete mode (which does a refresh) |
| o.EnterCompleteMode(offset, newLines) |
| return true |
| } |
| |
| func (o *opCompleter) IsInCompleteSelectMode() bool { |
| return o.inSelectMode |
| } |
| |
| func (o *opCompleter) IsInCompleteMode() bool { |
| return o.inCompleteMode.Load() == 1 |
| } |
| |
| func (o *opCompleter) HandleCompleteSelect(r rune) (stayInMode bool) { |
| next := true |
| switch r { |
| case CharEnter, CharCtrlJ: |
| next = false |
| o.writeRunes(o.candidate[o.candidateChoice]) |
| o.ExitCompleteMode(false) |
| case CharLineStart: |
| o.lineStart() |
| case CharLineEnd: |
| o.lineEnd() |
| case CharBackspace: |
| o.ExitCompleteSelectMode() |
| next = false |
| case CharTab: |
| o.nextCandidate() |
| case CharForward: |
| o.nextCol(1) |
| case CharBell, CharInterrupt: |
| o.ExitCompleteMode(true) |
| next = false |
| case CharNext: |
| o.nextLine() |
| case CharBackward, MetaShiftTab: |
| o.nextCol(-1) |
| case CharPrev: |
| o.prevLine() |
| case 'j', 'J': |
| o.prevPage() |
| case 'k', 'K': |
| o.nextPage() |
| default: |
| next = false |
| o.ExitCompleteSelectMode() |
| } |
| if next { |
| o.CompleteRefresh() |
| return true |
| } |
| return false |
| } |
| |
| func (o *opCompleter) getMatrixSize() int { |
| colNum := 1 |
| if o.candidateColNum > 1 { |
| colNum = o.candidateColNum |
| } |
| line := o.getMatrixNumRows() |
| return line * colNum |
| } |
| |
| // Number of candidate that could fit on current page |
| func (o *opCompleter) numCandidateCurPage() int { |
| // Safety: we will always render the first page, and whenever we finished rendering page i, |
| // we always populate o.candidatePageStart through at least i + 1, so when this is called, we |
| // always know the start of the next page |
| return o.pageStartIdx[o.curPage+1] - o.pageStartIdx[o.curPage] |
| } |
| |
| // Get number of rows of current page viewed as a matrix of candidates |
| func (o *opCompleter) getMatrixNumRows() int { |
| candidateCurPage := o.numCandidateCurPage() |
| // Normal case where there is no wrap |
| if o.candidateColNum > 1 { |
| numLine := candidateCurPage / o.candidateColNum |
| if candidateCurPage%o.candidateColNum != 0 { |
| numLine++ |
| } |
| return numLine |
| } |
| |
| // Now since there are wraps, each candidate will be put on its own line, so the number of lines is just the number of candidate |
| return candidateCurPage |
| } |
| |
| // setColumnInfo calculates column width and number of columns required |
| // to present the list of candidates on the terminal. |
| func (o *opCompleter) setColumnInfo() { |
| same := o.op.buf.RuneSlice(-o.candidateOff) |
| sameWidth := runes.WidthAll(same) |
| |
| colWidth := 0 |
| for _, c := range o.candidate { |
| w := sameWidth + runes.WidthAll(c) |
| if w > colWidth { |
| colWidth = w |
| } |
| } |
| colWidth++ // whitespace between cols |
| |
| tWidth, _ := o.w.GetWidthHeight() |
| |
| // -1 to avoid end of line issues |
| width := tWidth - 1 |
| colNum := width / colWidth |
| if colNum != 0 { |
| colWidth += (width - (colWidth * colNum)) / colNum |
| } |
| |
| o.candidateColNum = colNum |
| o.candidateColWidth = colWidth |
| } |
| |
| // CompleteRefresh is used for completemode and selectmode |
| func (o *opCompleter) CompleteRefresh() { |
| if !o.IsInCompleteMode() { |
| return |
| } |
| |
| buf := bufio.NewWriter(o.w) |
| // calculate num lines from cursor pos to where choices should be written |
| lineCnt := o.op.buf.CursorLineCount() |
| buf.Write(bytes.Repeat([]byte("\n"), lineCnt)) // move down from cursor to start of candidates |
| buf.WriteString("\033[J") |
| |
| same := o.op.buf.RuneSlice(-o.candidateOff) |
| tWidth, _ := o.w.GetWidthHeight() |
| |
| colIdx := 0 |
| lines := 0 |
| sameWidth := runes.WidthAll(same) |
| |
| // Show completions for the current page |
| idx := o.pageStartIdx[o.curPage] |
| for ; idx < len(o.candidate); idx++ { |
| // If writing the current candidate would overflow the page, |
| // we know that it is the start of the next page. |
| if colIdx == 0 && lines == o.linesAvail { |
| if o.curPage == len(o.pageStartIdx)-1 { |
| o.pageStartIdx = append(o.pageStartIdx, idx) |
| } |
| break |
| } |
| |
| c := o.candidate[idx] |
| inSelect := idx == o.candidateChoice && o.IsInCompleteSelectMode() |
| cWidth := sameWidth + runes.WidthAll(c) |
| cLines := 1 |
| if tWidth > 0 { |
| sWidth := 0 |
| if platform.IsWindows && inSelect { |
| sWidth = 1 // adjust for hightlighting on Windows |
| } |
| cLines = (cWidth + sWidth) / tWidth |
| if (cWidth+sWidth)%tWidth > 0 { |
| cLines++ |
| } |
| } |
| |
| if lines > 0 && colIdx == 0 { |
| // After line 1, if we're printing to the first column |
| // goto a new line. We do it here, instead of at the end |
| // of the loop, to avoid the last \n taking up a blank |
| // line at the end and stealing realestate. |
| buf.WriteString("\n") |
| } |
| |
| if inSelect { |
| buf.WriteString("\033[30;47m") |
| } |
| |
| buf.WriteString(string(same)) |
| buf.WriteString(string(c)) |
| if o.candidateColNum >= 1 { |
| // only output spaces between columns if everything fits |
| buf.Write(bytes.Repeat([]byte(" "), o.candidateColWidth-cWidth)) |
| } |
| |
| if inSelect { |
| buf.WriteString("\033[0m") |
| } |
| |
| colIdx++ |
| if colIdx >= o.candidateColNum { |
| lines += cLines |
| colIdx = 0 |
| if platform.IsWindows { |
| // Windows EOL edge-case. |
| buf.WriteString("\b") |
| } |
| } |
| } |
| |
| if idx == len(o.candidate) { |
| // Book-keeping for the last page. |
| o.pageStartIdx = append(o.pageStartIdx, len(o.candidate)) |
| } |
| |
| if colIdx > 0 { |
| lines++ // mid-line so count it. |
| } |
| |
| // Show the guidance if there are more pages |
| if idx != len(o.candidate) || o.curPage > 0 { |
| buf.WriteString("\n-- (j: prev page) (k: next page) --") |
| lines++ |
| } |
| |
| // wrote out choices over "lines", move back to cursor (positioned at index) |
| fmt.Fprintf(buf, "\033[%dA", lines) |
| |
| // Redraw the prompt and buffer since \033[J may have cleared them |
| // Calculate which line the cursor is on (0-indexed, where 0 is the prompt line) |
| cursorLine := o.op.buf.IdxLine(tWidth) |
| |
| // Move to the beginning of the prompt line (line 0) |
| if cursorLine > 0 { |
| fmt.Fprintf(buf, "\033[%dA", cursorLine) |
| } |
| // Move to column 1 to redraw from the start |
| buf.WriteString("\033[1G") |
| // Redraw prompt and buffer content |
| cfg := o.op.GetConfig() |
| buf.WriteString(cfg.Prompt) |
| buf.WriteString("\x1b[0K") // Clear line from cursor right |
| rs := o.op.buf.Runes() |
| for _, e := range cfg.Painter(rs, o.op.buf.Pos()) { |
| if e == '\t' { |
| buf.WriteString(strings.Repeat(" ", runes.TabWidth)) |
| } else { |
| buf.WriteRune(e) |
| } |
| } |
| // Now position cursor correctly - move back down to the cursor line |
| if cursorLine > 0 { |
| fmt.Fprintf(buf, "\033[%dB", cursorLine) |
| } |
| buf.Write(o.op.buf.getBackspaceSequence()) |
| buf.Flush() |
| } |
| |
| func (o *opCompleter) EnterCompleteSelectMode() { |
| o.inSelectMode = true |
| o.candidateChoice = -1 |
| } |
| |
| func (o *opCompleter) EnterCompleteMode(offset int, candidate [][]rune) { |
| o.inCompleteMode.Store(1) |
| o.candidate = candidate |
| o.candidateOff = offset |
| o.setColumnInfo() |
| o.initPage() |
| o.CompleteRefresh() |
| } |
| |
| func (o *opCompleter) initPage() { |
| _, tHeight := o.w.GetWidthHeight() |
| buflineCnt := o.op.buf.LineCount() // lines taken by buffer content |
| o.linesAvail = tHeight - buflineCnt - 1 // lines available without scrolling buffer off screen, reserve one line for the guidance message |
| o.pageStartIdx = []int{0} // first page always start at 0 |
| o.curPage = 0 |
| } |
| |
| func (o *opCompleter) ExitCompleteSelectMode() { |
| o.inSelectMode = false |
| o.candidateChoice = -1 |
| } |
| |
| func (o *opCompleter) ExitCompleteMode(revent bool) { |
| o.inCompleteMode.Store(0) |
| o.candidate = nil |
| o.candidateOff = -1 |
| o.candidateSource = nil |
| o.ExitCompleteSelectMode() |
| } |