Hilbish/readline/tab.go

555 lines
15 KiB
Go

package readline
import (
"bufio"
"context"
"fmt"
"strings"
)
// TabDisplayType defines how the autocomplete suggestions display
type TabDisplayType int
const (
// TabDisplayGrid is the default. It's where the screen below the prompt is
// divided into a grid with each suggestion occupying an individual cell.
TabDisplayGrid = iota
// TabDisplayList is where suggestions are displayed as a list with a
// description. The suggestion gets highlighted but both are searchable (ctrl+f)
TabDisplayList
// TabDisplayMap is where suggestions are displayed as a list with a
// description however the description is what gets highlighted and only
// that is searchable (ctrl+f). The benefit of TabDisplayMap is when your
// autocomplete suggestions are IDs rather than human terms.
TabDisplayMap
)
// getTabCompletion - This root function sets up all completion items and engines,
// dealing with all search and completion modes. But it does not perform printing.
func (rl *Instance) getTabCompletion() {
// Populate registers if requested.
if rl.modeAutoFind && rl.searchMode == RegisterFind {
rl.getRegisterCompletion()
return
}
// Populate for completion search if in this mode
if rl.modeAutoFind && rl.searchMode == CompletionFind {
rl.getTabSearchCompletion()
return
}
// Populate for History search if in this mode
if rl.modeAutoFind && rl.searchMode == HistoryFind {
rl.getHistorySearchCompletion()
return
}
// Else, yield normal completions
rl.getNormalCompletion()
}
// getRegisterCompletion - Populates and sets up completion for Vim registers.
func (rl *Instance) getRegisterCompletion() {
rl.tcGroups = rl.completeRegisters()
if len(rl.tcGroups) == 0 {
return
}
rl.tcGroups = checkNilItems(rl.tcGroups) // Avoid nil maps in groups
// Adjust the index for each group after the first:
// this ensures no latency when we will move around them.
for i, group := range rl.tcGroups {
group.init(rl)
if i != 0 {
group.tcPosY = 1
}
}
// If there aren't ANY completion candidates, we
// escape the completion mode from here directly.
var items bool
for _, group := range rl.tcGroups {
if len(group.Suggestions) > 0 {
items = true
}
}
if !items {
rl.modeTabCompletion = false
}
}
// getTabSearchCompletion - Populates and sets up completion for completion search.
func (rl *Instance) getTabSearchCompletion() {
// Get completions from the engine, and make sure there is a current group.
rl.getCompletions()
if len(rl.tcGroups) == 0 {
return
}
rl.getCurrentGroup()
// Set the info for this completion mode
rl.infoText = append([]rune("Completion search: " + UNDERLINE + BOLD), rl.tfLine...)
for _, g := range rl.tcGroups {
g.updateTabFind(rl)
}
// If total number of matches is zero, we directly change the info, and return
if comps, _, _ := rl.getCompletionCount(); comps == 0 {
rl.infoText = append(rl.infoText, []rune(RESET+DIM+RED+" ! no matches (Ctrl-G/Esc to cancel)"+RESET)...)
}
}
// getHistorySearchCompletion - Populates and sets up completion for command history search
func (rl *Instance) getHistorySearchCompletion() {
// Refresh full list each time
rl.tcGroups = rl.completeHistory()
if len(rl.tcGroups) == 0 {
return
}
rl.tcGroups = checkNilItems(rl.tcGroups) // Avoid nil maps in groups
rl.getCurrentGroup() // Make sure there is a current group
// The history info is already set, but overwrite it if we don't have completions
if len(rl.tcGroups[0].Suggestions) == 0 {
rl.histInfo = []rune(fmt.Sprintf("%s%s%s %s", DIM, RED,
"No command history source, or empty (Ctrl-G/Esc to cancel)", RESET))
rl.infoText = rl.histInfo
return
}
// Set the info line with everything
rl.histInfo = append([]rune("\033[38;5;183m"+string(rl.histInfo)+RESET), rl.tfLine...)
rl.histInfo = append(rl.histInfo, []rune(RESET)...)
rl.infoText = rl.histInfo
// Refresh filtered candidates
rl.tcGroups[0].updateTabFind(rl)
// If no items matched history, add info text that we failed to search
if len(rl.tcGroups[0].Suggestions) == 0 {
rl.infoText = append(rl.histInfo, []rune(DIM+RED+" ! no matches (Ctrl-G/Esc to cancel)"+RESET)...)
return
}
}
// getNormalCompletion - Populates and sets up completion for normal comp mode.
// Will automatically cancel the completion mode if there are no candidates.
func (rl *Instance) getNormalCompletion() {
// Get completions groups, pass delayedTabContext and check nils
rl.getCompletions()
// Adjust the index for each group after the first:
// this ensures no latency when we will move around them.
for i, group := range rl.tcGroups {
group.init(rl)
if i != 0 {
group.tcPosY = 1
}
}
// If there aren't ANY completion candidates, we
// escape the completion mode from here directly.
var items bool
for _, group := range rl.tcGroups {
if len(group.Suggestions) > 1 {
items = true
}
}
if !items {
rl.modeTabCompletion = false
}
}
// getCompletions - Calls the completion engine/function to yield a list of 0 or more completion groups,
// sets up a delayed tab context and passes it on to the tab completion engine function, and ensure no
// nil groups/items will pass through. This function is called by different comp search/nav modes.
func (rl *Instance) getCompletions() {
// If there is no wired tab completion engine, nothing we can do.
if rl.TabCompleter == nil {
return
}
// Cancel any existing tab context first.
if rl.delayedTabContext.cancel != nil {
rl.delayedTabContext.cancel()
}
// Recreate a new context
rl.delayedTabContext = DelayedTabContext{rl: rl}
rl.delayedTabContext.Context, rl.delayedTabContext.cancel = context.WithCancel(context.Background())
// Get the correct line to be completed, and the current cursor position
compLine, compPos := rl.getCompletionLine()
// Call up the completion engine/function to yield completion groups
rl.tcPrefix, rl.tcGroups = rl.TabCompleter(compLine, compPos, rl.delayedTabContext)
// Avoid nil maps in groups. Maybe we could also pop any empty group.
rl.tcGroups = checkNilItems(rl.tcGroups)
// We have been loading fresh completion sin this function,
// so adjust the positions for each group, so that cycling
// correctly occurs in both directions (tab/shift+tab)
for i, group := range rl.tcGroups {
if i > 0 {
switch group.DisplayType {
case TabDisplayGrid:
group.tcPosX = 1
case TabDisplayList, TabDisplayMap:
group.tcPosY = 1
}
}
}
}
// moveTabCompletionHighlight - This function is in charge of
// computing the new position in the current completions liste.
func (rl *Instance) moveTabCompletionHighlight(x, y int) {
rl.completionOpen = true
g := rl.getCurrentGroup()
// If there is no current group, we leave any current completion mode.
if g == nil || g.Suggestions == nil {
rl.modeTabCompletion = false
return
}
// done means we need to find the next/previous group.
// next determines if we need to get the next OR previous group.
var done, next bool
// Depending on the display, we only keep track of x or (x and y)
switch g.DisplayType {
case TabDisplayGrid:
done, next = g.moveTabGridHighlight(rl, x, y)
case TabDisplayList:
done, next = g.moveTabListHighlight(rl, x, y)
case TabDisplayMap:
done, next = g.moveTabMapHighlight(rl, x, y)
}
// Cycle to next/previous group, if done with current one.
if done {
if next {
rl.cycleNextGroup()
nextGroup := rl.getCurrentGroup()
nextGroup.goFirstCell()
} else {
rl.cyclePreviousGroup()
prevGroup := rl.getCurrentGroup()
prevGroup.goLastCell()
}
}
}
// writeTabCompletion - Prints all completion groups and their items
func (rl *Instance) writeTabCompletion() {
// The final completions string to print.
var completions string
// This stablizes the completion printing just beyond the input line
rl.tcUsedY = 0
// Safecheck
if !rl.modeTabCompletion {
return
}
// In any case, we write the completions strings, trimmed for redundant
// newline occurences that have been put at the end of each group.
for _, group := range rl.tcGroups {
completions += group.writeCompletion(rl)
}
// Because some completion groups might have more suggestions
// than what their MaxLength allows them to, cycling sometimes occur,
// but does not fully clears itself: some descriptions are messed up with.
// We always clear the screen as a result, between writings.
print(seqClearScreenBelow)
// Crop the completions so that it fits within our MaxTabCompleterRows
completions, rl.tcUsedY = rl.cropCompletions(completions)
// Then we print all of them.
fmt.Printf(completions)
}
// cropCompletions - When the user cycles through a completion list longer
// than the console MaxTabCompleterRows value, we crop the completions string
// so that "global" cycling (across all groups) is printed correctly.
func (rl *Instance) cropCompletions(comps string) (cropped string, usedY int) {
// If we actually fit into the MaxTabCompleterRows, return the comps
if rl.tcUsedY < rl.MaxTabCompleterRows {
return comps, rl.tcUsedY
}
// Else we go on, but we have more comps than what allowed:
// we will add a line to the end of the comps, giving the actualized
// number of completions remaining and not printed
var moreComps = func(cropped string, offset int) (infoed string, noInfo bool) {
_, _, adjusted := rl.getCompletionCount()
remain := adjusted - offset
if remain == 0 {
return cropped, true
}
info := fmt.Sprintf(DIM+YELLOW+" %d more completions... (scroll down to show)"+RESET+"\n", remain)
infoed = cropped + info
return infoed, false
}
// Get the current absolute candidate position (prev groups x suggestions + curGroup.tcPosY)
var absPos = rl.getAbsPos()
// Get absPos - MaxTabCompleterRows for having the number of lines to cut at the top
// If the number is negative, that means we don't need to cut anything at the top yet.
var maxLines = absPos - rl.MaxTabCompleterRows
if maxLines < 0 {
maxLines = 0
}
// Scan the completions for cutting them at newlines
scanner := bufio.NewScanner(strings.NewReader(comps))
// If absPos < MaxTabCompleterRows, cut below MaxTabCompleterRows and return
if absPos <= rl.MaxTabCompleterRows {
var count int
for scanner.Scan() {
line := scanner.Text()
if count < rl.MaxTabCompleterRows {
cropped += line + "\n"
count++
} else {
count++
break
}
}
cropped, _ = moreComps(cropped, count)
return cropped, count
}
// If absolute > MaxTabCompleterRows, cut above and below and return
// -> This includes de facto when we tabCompletionReverse
if absPos > rl.MaxTabCompleterRows {
cutAbove := absPos - rl.MaxTabCompleterRows
var count int
for scanner.Scan() {
line := scanner.Text()
if count < cutAbove {
count++
continue
}
if count >= cutAbove && count < absPos {
cropped += line + "\n"
count++
} else {
count++
break
}
}
cropped, _ := moreComps(cropped, rl.MaxTabCompleterRows+cutAbove)
return cropped, count - cutAbove
}
return
}
func (rl *Instance) getAbsPos() int {
var prev int
var foundCurrent bool
for _, grp := range rl.tcGroups {
if grp.isCurrent {
prev += grp.tcPosY + 1 // + 1 for title
foundCurrent = true
break
} else {
prev += grp.tcMaxY + 1 // + 1 for title
}
}
// If there was no current group, it means
// we showed completions but there is no
// candidate selected yet, return 0
if !foundCurrent {
return 0
}
return prev
}
// We pass a special subset of the current input line, so that
// completions are available no matter where the cursor is.
func (rl *Instance) getCompletionLine() (line []rune, pos int) {
pos = rl.pos - len(rl.currentComp)
if pos < 0 {
pos = 0
}
switch {
case rl.pos == len(rl.line):
line = rl.line
case rl.pos < len(rl.line):
line = rl.line[:pos]
default:
line = rl.line
}
return
}
func (rl *Instance) getCurrentGroup() (group *CompletionGroup) {
for _, g := range rl.tcGroups {
if g.isCurrent && len(g.Suggestions) > 0 {
return g
}
}
// We might, for whatever reason, not find one.
// If there are groups but no current, make first one the king.
if len(rl.tcGroups) > 0 {
// Find first group that has list > 0, as another checkup
for _, g := range rl.tcGroups {
if len(g.Suggestions) > 0 {
g.isCurrent = true
return g
}
}
}
return
}
// cycleNextGroup - Finds either the first non-empty group,
// or the next non-empty group after the current one.
func (rl *Instance) cycleNextGroup() {
for i, g := range rl.tcGroups {
if g.isCurrent {
g.isCurrent = false
if i == len(rl.tcGroups)-1 {
rl.tcGroups[0].isCurrent = true
} else {
rl.tcGroups[i+1].isCurrent = true
// Here, we check if the cycled group is not empty.
// If yes, cycle to next one now.
new := rl.getCurrentGroup()
if len(new.Suggestions) == 0 {
rl.cycleNextGroup()
}
}
break
}
}
}
// cyclePreviousGroup - Same as cycleNextGroup but reverse
func (rl *Instance) cyclePreviousGroup() {
for i, g := range rl.tcGroups {
if g.isCurrent {
g.isCurrent = false
if i == 0 {
rl.tcGroups[len(rl.tcGroups)-1].isCurrent = true
} else {
rl.tcGroups[i-1].isCurrent = true
new := rl.getCurrentGroup()
if len(new.Suggestions) == 0 {
rl.cyclePreviousGroup()
}
}
break
}
}
}
// Check if we have a single completion candidate
func (rl *Instance) hasOneCandidate() bool {
if len(rl.tcGroups) == 0 {
return false
}
// If one group and one option, obvious
if len(rl.tcGroups) == 1 {
cur := rl.getCurrentGroup()
if cur == nil {
return false
}
if len(cur.Suggestions) == 1 {
return true
}
return false
}
// If many groups but only one option overall
if len(rl.tcGroups) > 1 {
var count int
for _, group := range rl.tcGroups {
for range group.Suggestions {
count++
}
}
if count == 1 {
return true
}
return false
}
return false
}
// When the completions are either longer than:
// - The user-specified max completion length
// - The terminal lengh
// we use this function to prompt for confirmation before printing comps.
func (rl *Instance) promptCompletionConfirm(sentence string) {
rl.infoText = []rune(sentence)
rl.compConfirmWait = true
rl.viUndoSkipAppend = true
rl.renderHelpers()
}
func (rl *Instance) getCompletionCount() (comps int, lines int, adjusted int) {
for _, group := range rl.tcGroups {
comps += len(group.Suggestions)
// if group.Name != "" {
adjusted++ // Title
// }
if group.tcMaxY > len(group.Suggestions) {
lines += len(group.Suggestions)
adjusted += len(group.Suggestions)
} else {
lines += group.tcMaxY
adjusted += group.tcMaxY
}
}
return
}
func (rl *Instance) resetTabCompletion() {
rl.modeTabCompletion = false
rl.tabCompletionSelect = false
rl.compConfirmWait = false
rl.tcUsedY = 0
rl.modeTabFind = false
rl.modeAutoFind = false
rl.tfLine = []rune{}
// Reset tab highlighting
if len(rl.tcGroups) > 0 {
for _, g := range rl.tcGroups {
g.isCurrent = false
}
rl.tcGroups[0].isCurrent = true
}
}