diff --git a/go.mod b/go.mod index 5246ebe70e..e69efef3ec 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( github.com/moloch--/memmod v0.0.0-20211120144554-8b37cc654945 github.com/ncruces/go-sqlite3 v0.7.2 github.com/reeflective/console v0.1.6 - github.com/reeflective/readline v1.0.8 + github.com/reeflective/readline v1.0.10 github.com/rsteube/carapace v0.36.3 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 diff --git a/go.sum b/go.sum index 307c4f882f..71ed939158 100644 --- a/go.sum +++ b/go.sum @@ -351,8 +351,8 @@ github.com/reeflective/carapace v0.25.2-0.20230602202234-e8d757e458ca h1:tD797h1 github.com/reeflective/carapace v0.25.2-0.20230602202234-e8d757e458ca/go.mod h1:jkLt41Ne2TD2xPuMdX/2O05Smhy8vMgG7O2TYvC0yOc= github.com/reeflective/console v0.1.6 h1:BhhvQU/m8QOpaIRzrXfwcbtkGQJX9jVR5qIqAy/Mcuw= github.com/reeflective/console v0.1.6/go.mod h1:7owTBE9k2lg2QpVw7g4DrK1HxEgr/5DQCmA3O2Znoek= -github.com/reeflective/readline v1.0.8 h1:VuDGI82lAwl1H5by+hpW4OQgM+9ikh6EuOySQUGP3sI= -github.com/reeflective/readline v1.0.8/go.mod h1:5JgnHb/ZCvp/6RUA59HEansPBxWTkyBO4hJ5LL9Fp1Y= +github.com/reeflective/readline v1.0.10 h1:neAdbArjB1f5LZ2JBh/9TZ9ibFUrjjvBS8xuhLbx5do= +github.com/reeflective/readline v1.0.10/go.mod h1:mcD0HxNVJVteVwDm9caXKg52nQACVyfh8EyuBmgVlzY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -446,8 +446,6 @@ golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220208050332-20e1d8d225ab/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= diff --git a/vendor/github.com/reeflective/readline/completions.go b/vendor/github.com/reeflective/readline/completions.go index 4ed2c108d9..099a4fd276 100644 --- a/vendor/github.com/reeflective/readline/completions.go +++ b/vendor/github.com/reeflective/readline/completions.go @@ -22,6 +22,7 @@ type Completions struct { noSort map[string]bool listSep map[string]string pad map[string]bool + escapes map[string]bool // Initially this will be set to the part of the current word // from the beginning of the word up to the position of the cursor. @@ -48,7 +49,7 @@ func CompleteValues(values ...string) Completions { // CompleteStyledValues is like CompleteValues but also accepts a style. func CompleteStyledValues(values ...string) Completions { if length := len(values); length%2 != 0 { - return Message("invalid amount of arguments [CompleteStyledValues]: %v", length) + return CompleteMessage("invalid amount of arguments [CompleteStyledValues]: %v", length) } vals := make([]Completion, 0, len(values)/2) @@ -62,7 +63,7 @@ func CompleteStyledValues(values ...string) Completions { // CompleteValuesDescribed completes arbitrary key (values) with an additional description (value, description pairs). func CompleteValuesDescribed(values ...string) Completions { if length := len(values); length%2 != 0 { - return Message("invalid amount of arguments [CompleteValuesDescribed]: %v", length) + return CompleteMessage("invalid amount of arguments [CompleteValuesDescribed]: %v", length) } vals := make([]Completion, 0, len(values)/2) @@ -76,7 +77,7 @@ func CompleteValuesDescribed(values ...string) Completions { // CompleteStyledValuesDescribed is like CompleteValues but also accepts a style. func CompleteStyledValuesDescribed(values ...string) Completions { if length := len(values); length%3 != 0 { - return Message("invalid amount of arguments [CompleteStyledValuesDescribed]: %v", length) + return CompleteMessage("invalid amount of arguments [CompleteStyledValuesDescribed]: %v", length) } vals := make([]Completion, 0, len(values)/3) @@ -87,13 +88,27 @@ func CompleteStyledValuesDescribed(values ...string) Completions { return Completions{values: vals} } +// CompleteMessage ads a help message to display along with +// or in places where no completions can be generated. +func CompleteMessage(msg string, args ...any) Completions { + comps := Completions{} + + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + + comps.messages.Add(msg) + + return comps +} + // CompleteRaw directly accepts a list of prepared Completion values. func CompleteRaw(values []Completion) Completions { return Completions{values: completion.RawValues(values)} } // Message displays a help messages in places where no completions can be generated. -func Message(msg string, args ...interface{}) Completions { +func Message(msg string, args ...any) Completions { comps := Completions{} if len(args) > 0 { @@ -108,7 +123,7 @@ func Message(msg string, args ...interface{}) Completions { // Suppress suppresses specific error messages using regular expressions. func (c Completions) Suppress(expr ...string) Completions { if err := c.messages.Suppress(expr...); err != nil { - return Message(err.Error()) + return CompleteMessage(err.Error()) } return c @@ -153,7 +168,7 @@ func (c Completions) Suffix(suffix string) Completions { } // Usage sets the usage. -func (c Completions) Usage(usage string, args ...interface{}) Completions { +func (c Completions) Usage(usage string, args ...any) Completions { return c.UsageF(func() string { return fmt.Sprintf(usage, args...) }) @@ -255,7 +270,7 @@ func (c Completions) ListSeparator(seps ...string) Completions { } if length := len(seps); len(seps) > 1 && length%2 != 0 { - return Message("invalid amount of arguments (ListSeparator): %v", length) + return CompleteMessage("invalid amount of arguments (ListSeparator): %v", length) } if len(seps) == 1 { @@ -322,6 +337,35 @@ func (c Completions) JustifyDescriptions(tags ...string) Completions { return c } +// PreserveEscapes forces the completion engine to keep all escaped characters in +// the inserted completion (c.Value of the Completion type). By default, those are +// stripped out and only kept in the completion.Display. If no arguments are given, +// escape sequence preservation will apply to all tags. +// +// This has very few use cases: one of them might be when you want to read a string +// from the readline shell that might include color sequences to be preserved. +// In such cases, this function gives a double advantage: the resulting completion +// is still "color-displayed" in the input line, and returned to the readline with +// them. A classic example is where you want to read a prompt string configuration. +// +// Note that this option might have various undefined behaviors when it comes to +// completion prefix matching, insertion, removal and related things. +func (c Completions) PreserveEscapes(tags ...string) Completions { + if c.escapes == nil { + c.escapes = make(map[string]bool) + } + + if len(tags) == 0 { + c.escapes["*"] = true + } + + for _, tag := range tags { + c.escapes[tag] = true + } + + return c +} + // Merge merges Completions (existing values are overwritten) // // a := CompleteValues("A", "B").Invoke(c) @@ -400,6 +444,10 @@ func (c *Completions) convert() completion.Values { comps.NoSort = c.noSort comps.ListSep = c.listSep comps.Pad = c.pad + comps.Escapes = c.escapes + + comps.PREFIX = c.PREFIX + comps.SUFFIX = c.SUFFIX return comps } diff --git a/vendor/github.com/reeflective/readline/internal/color/color.go b/vendor/github.com/reeflective/readline/internal/color/color.go index 0ab48c1d45..91afc219ff 100644 --- a/vendor/github.com/reeflective/readline/internal/color/color.go +++ b/vendor/github.com/reeflective/readline/internal/color/color.go @@ -3,6 +3,8 @@ package color import ( "os" "regexp" + "strconv" + "strings" ) // Base text effects. @@ -82,6 +84,44 @@ func Fmt(color string) string { return SGRStart + color + SGREnd } +// Trim accepts a string including arbitrary escaped sequences at arbitrary +// index positions, and returns the first 'n' printable characters in this +// string, including all escape codes found between and immediately around +// those characters (including surrounding 1st and 80th ones). +func Trim(input string, maxPrintableLength int) string { + if len(input) < maxPrintableLength { + return input + } + + // Find all escape sequences in the input + escapeIndices := re.FindAllStringIndex(input, -1) + + // Iterate over escape sequences to find the + // last escape index within maxPrintableLength + for _, indices := range escapeIndices { + if indices[0] <= maxPrintableLength { + maxPrintableLength += indices[1] - indices[0] + } else { + break + } + } + + // Determine the end index for limiting printable content + return input[:maxPrintableLength] +} + +// UnquoteRC removes the `\e` escape used in readline .inputrc +// configuration values and replaces it with the printable escape. +func UnquoteRC(color string) string { + color = strings.ReplaceAll(color, `\e`, "\x1b") + + if unquoted, err := strconv.Unquote(color); err == nil { + return unquoted + } + + return color +} + // HasEffects returns true if colors and effects are supported // on the current terminal. func HasEffects() bool { @@ -159,14 +199,3 @@ var re = regexp.MustCompile(ansi) func Strip(str string) string { return re.ReplaceAllString(str, "") } - -// wrong: reapplies fg/bg escapes regardless of the string passed. -// Users should be in charge of applying any effect as they wish. -// func SGR(color string, fg bool) string { -// if fg { -// return SGRStart + FgColorStart + color + SGREnd -// // return SGRStart + color + SGREnd -// } -// -// return SGRStart + BgColorStart + color + SGREnd -// } diff --git a/vendor/github.com/reeflective/readline/internal/completion/completion.go b/vendor/github.com/reeflective/readline/internal/completion/completion.go index 5b80a21be7..dd46657d9c 100644 --- a/vendor/github.com/reeflective/readline/internal/completion/completion.go +++ b/vendor/github.com/reeflective/readline/internal/completion/completion.go @@ -17,6 +17,9 @@ type Candidate struct { // inserted immediately after the completion. This is used for slash-autoremoval in path // completions, comma-separated completions, etc. noSpace SuffixMatcher + + displayLen int // Real length of the displayed candidate, that is not counting escaped sequences. + descLen int } // Values is used internally to hold all completion candidates and their associated data. @@ -29,6 +32,7 @@ type Values struct { NoSort map[string]bool ListSep map[string]string Pad map[string]bool + Escapes map[string]bool // Initially this will be set to the part of the current word // from the beginning of the word up to the position of the cursor. diff --git a/vendor/github.com/reeflective/readline/internal/completion/display.go b/vendor/github.com/reeflective/readline/internal/completion/display.go index 46f13e745a..e4b2d9b632 100644 --- a/vendor/github.com/reeflective/readline/internal/completion/display.go +++ b/vendor/github.com/reeflective/readline/internal/completion/display.go @@ -3,6 +3,7 @@ package completion import ( "bufio" "fmt" + "regexp" "strings" "github.com/reeflective/readline/internal/color" @@ -30,7 +31,7 @@ func Display(eng *Engine, maxRows int) { completions := term.ClearLineAfter for _, group := range eng.groups { - completions += group.writeComps(eng) + completions += eng.renderCompletions(group) } // Crop the completions so that it fits within our terminal @@ -47,6 +48,129 @@ func Coordinates(e *Engine) int { return e.usedY } +// renderCompletions renders all completions in a given list (with aliases or not). +// The descriptions list argument is optional. +func (e *Engine) renderCompletions(grp *group) string { + var builder strings.Builder + + if len(grp.rows) == 0 { + return "" + } + + if grp.tag != "" { + tag := fmt.Sprintf("%s%s%s %s", color.Bold, color.FgYellow, grp.tag, color.Reset) + builder.WriteString(tag + term.ClearLineAfter + term.NewlineReturn) + } + + for rowIndex, row := range grp.rows { + for columnIndex := range grp.columnsWidth { + var value Candidate + + // If there are aliases, we might have no completions at the current + // coordinates, so just print the corresponding padding and return. + if len(row) > columnIndex { + value = row[columnIndex] + } + + // Apply all highlightings to the displayed value: + // selection, prefixes, styles and other things, + padding := grp.getPad(value, columnIndex, false) + isSelected := rowIndex == grp.posY && columnIndex == grp.posX && grp.isCurrent + display := e.highlightDisplay(grp, value, padding, columnIndex, isSelected) + + builder.WriteString(display) + + // Add description if no aliases, or if done with them. + onLast := columnIndex == len(grp.columnsWidth)-1 + if grp.aliased && onLast && value.Description == "" { + value = row[0] + } + + if !grp.aliased || onLast { + grp.maxDescAllowed = grp.setMaximumSizes(columnIndex) + + descPad := grp.getPad(value, columnIndex, true) + desc := e.highlightDesc(grp, value, descPad, rowIndex, columnIndex, isSelected) + builder.WriteString(desc) + } + } + + // We're done for this line. + builder.WriteString(term.ClearLineAfter + term.NewlineReturn) + } + + return builder.String() +} + +func (e *Engine) highlightDisplay(grp *group, val Candidate, pad, col int, selected bool) (candidate string) { + // An empty display value means padding. + if val.Display == "" { + return padSpace(pad) + } + + reset := color.Fmt(val.Style) + candidate, padded := grp.trimDisplay(val, pad, col) + + if e.IsearchRegex != nil && e.isearchBuf.Len() > 0 && !selected { + match := e.IsearchRegex.FindString(candidate) + match = color.Fmt(color.Bg+"244") + match + color.Reset + reset + candidate = e.IsearchRegex.ReplaceAllLiteralString(candidate, match) + } + + if selected { + // If the comp is currently selected, overwrite any highlighting already applied. + userStyle := color.UnquoteRC(e.config.GetString("completion-selection-style")) + selectionHighlightStyle := color.Fmt(color.Bg+"255") + userStyle + candidate = selectionHighlightStyle + candidate + + if grp.aliased { + candidate += color.Reset + } + } else { + // Highlight the prefix if any and configured for it. + if e.config.GetBool("colored-completion-prefix") && e.prefix != "" { + if prefixMatch, err := regexp.Compile(fmt.Sprintf("^%s", e.prefix)); err == nil { + prefixColored := color.Bold + color.FgBlue + e.prefix + color.BoldReset + color.FgDefault + reset + candidate = prefixMatch.ReplaceAllString(candidate, prefixColored) + } + } + + candidate = reset + candidate + color.Reset + } + + return candidate + padded +} + +func (e *Engine) highlightDesc(grp *group, val Candidate, pad, row, col int, selected bool) (desc string) { + if val.Description == "" { + return color.Reset + } + + desc, padded := grp.trimDesc(val, pad) + + // If the next row has the same completions, replace the description with our hint. + if len(grp.rows) > row+1 && grp.rows[row+1][0].Description == val.Description { + desc = "|" + } else if e.IsearchRegex != nil && e.isearchBuf.Len() > 0 && !selected { + match := e.IsearchRegex.FindString(desc) + match = color.Fmt(color.Bg+"244") + match + color.Reset + color.Dim + desc = e.IsearchRegex.ReplaceAllLiteralString(desc, match) + } + + // If the comp is currently selected, overwrite any highlighting already applied. + // Replace all background reset escape sequences in it, to ensure correct display. + if row == grp.posY && col == grp.posX && grp.isCurrent && !grp.aliased { + userDescStyle := color.UnquoteRC(e.config.GetString("completion-selection-style")) + selectionHighlightStyle := color.Fmt(color.Bg+"255") + userDescStyle + desc = strings.ReplaceAll(desc, color.BgDefault, userDescStyle) + desc = selectionHighlightStyle + desc + } + + compDescStyle := color.UnquoteRC(e.config.GetString("completion-description-style")) + + return compDescStyle + desc + color.Reset + padded +} + // 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. diff --git a/vendor/github.com/reeflective/readline/internal/completion/engine.go b/vendor/github.com/reeflective/readline/internal/completion/engine.go index 54de45ab3d..4532cf58d3 100644 --- a/vendor/github.com/reeflective/readline/internal/completion/engine.go +++ b/vendor/github.com/reeflective/readline/internal/completion/engine.go @@ -116,7 +116,7 @@ func (e *Engine) SkipDisplay() { func (e *Engine) Select(row, column int) { grp := e.currentGroup() - if grp == nil || len(grp.values) == 0 { + if grp == nil || len(grp.rows) == 0 { return } diff --git a/vendor/github.com/reeflective/readline/internal/completion/group.go b/vendor/github.com/reeflective/readline/internal/completion/group.go index 7d218e65ea..2298baf79f 100644 --- a/vendor/github.com/reeflective/readline/internal/completion/group.go +++ b/vendor/github.com/reeflective/readline/internal/completion/group.go @@ -1,11 +1,12 @@ package completion import ( - "fmt" - "regexp" + "math" "sort" + "strconv" "strings" - "unicode/utf8" + + "golang.org/x/exp/slices" "github.com/reeflective/readline/internal/color" "github.com/reeflective/readline/internal/term" @@ -14,19 +15,21 @@ import ( // group is used to structure different types of completions with different // display types, autosuffix removal matchers, under their tag heading. type group struct { - tag string // Printed on top of the group's completions - values [][]Candidate // Values are grouped by aliases/rows, with computed paddings. - noSpace SuffixMatcher // Suffixes to remove if a space or non-nil character is entered after the completion. - columnsWidth []int // Computed width for each column of completions, when aliases - listSeparator string // This is used to separate completion candidates from their descriptions. - list bool // Force completions to be listed instead of grided - noSort bool // Don't sort completions - aliased bool // Are their aliased completions - isCurrent bool // Currently cycling through this group, for highlighting choice - maxLength int // Each group can be limited in the number of comps offered - tcMaxLength int // Used when display is map/list, for determining message width - maxDescWidth int - maxCellLength int + tag string // Printed on top of the group's completions + rows [][]Candidate // Values are grouped by aliases/rows, with computed paddings. + noSpace SuffixMatcher // Suffixes to remove if a space or non-nil character is entered after the completion. + columnsWidth []int // Computed width for each column of completions, when aliases + descriptionsWidth []int // Computed width for each column of completions, when aliases + listSeparator string // This is used to separate completion candidates from their descriptions. + list bool // Force completions to be listed instead of grided + noSort bool // Don't sort completions + aliased bool // Are their aliased completions + preserveEscapes bool // Preserve escape sequences in the completion inserted values. + isCurrent bool // Currently cycling through this group, for highlighting choice + longestValue int // Used when display is map/list, for determining message width + longestDesc int // Used to know how much descriptions can use when there are aliases. + maxDescAllowed int // Maximum ALLOWED description width. + termWidth int // Term size queried at beginning of computes by the engine. // Selectors (position/bounds) management posX int @@ -35,334 +38,402 @@ type group struct { maxY int } -func (e *Engine) group(comps Values) { - // Compute hints once we found either any errors, - // or if we have no completions but usage strings. - defer func() { - e.hintCompletions(comps) - }() - - // Nothing else to do if no completions - if len(comps.values) == 0 { - return +// newCompletionGroup initializes a group of completions to be displayed in the same area/header. +func (e *Engine) newCompletionGroup(comps Values, tag string, vals RawValues, descriptions []string) { + grp := &group{ + tag: tag, + noSpace: comps.NoSpace, + posX: -1, + posY: -1, + columnsWidth: []int{0}, + termWidth: term.GetWidth(), + longestDesc: longest(descriptions, true), } - // Apply the prefix to the completions, and filter out any - // completions that don't match, optionally ignoring case. - matchCase := e.config.GetBool("completion-ignore-case") - comps.values = comps.values.FilterPrefix(e.prefix, !matchCase) + // Initialize all options for the group. + grp.initOptions(e, &comps, tag, vals) - comps.values.EachTag(func(tag string, values RawValues) { - // Separate the completions that have a description and - // those which don't, and devise if there are aliases. - vals, noDescVals, aliased := e.groupValues(&comps, values) + // Global actions to take on all values. + if !grp.noSort { + sort.Stable(vals) + } - // Create a "first" group with the "first" grouped values - e.newGroup(comps, tag, vals, aliased) + // Initial processing of our assigned values: + // Compute color/no-color sizes, some max/min, etc. + grp.prepareValues(vals) - // If we have a remaining group of values without descriptions, - // we will print and use them in a separate, anonymous group. - if len(noDescVals) > 0 { - e.newGroup(comps, "", noDescVals, false) - } - }) + // Generate the full grid of completions. + // Special processing is needed when some values + // share a common description, they are "aliased". + if completionsAreAliases(vals) { + grp.initCompletionAliased(vals) + } else { + grp.initCompletionsGrid(vals) + } - e.justifyGroups(comps) + e.groups = append(e.groups, grp) } -// groupValues separates values based on whether they have descriptions, or are aliases of each other. -func (e *Engine) groupValues(comps *Values, values RawValues) (vals, noDescVals RawValues, aliased bool) { - var descriptions []string +// initOptions checks for global or group-specific options (display, behavior, grouping, etc). +func (g *group) initOptions(eng *Engine, comps *Values, tag string, vals RawValues) { + // Override grid/list displays + _, g.list = comps.ListLong[tag] + if _, all := comps.ListLong["*"]; all && len(comps.ListLong) == 1 { + g.list = true + } - prefix := "" - if e.prefix != "\"\"" && e.prefix != "''" { - prefix = e.prefix + // Description list separator + listSep, err := strconv.Unquote(eng.config.GetString("completion-list-separator")) + if err != nil { + g.listSeparator = "--" + } else { + g.listSeparator = listSep } - for _, val := range values { - // Ensure all values have a display string. - if val.Display == "" { - val.Display = val.Value - } + // Strip escaped characters in the value component. + g.preserveEscapes = comps.Escapes[g.tag] + if !g.preserveEscapes { + g.preserveEscapes = comps.Escapes["*"] + } - // Currently this is because errors are passed as completions. - if strings.HasPrefix(val.Value, prefix+"ERR") && val.Value == prefix+"_" { - if val.Description != "" && comps != nil { - comps.Messages.Add(color.FgRed + val.Description) - } + // Always list long commands when they have descriptions. + if strings.HasSuffix(g.tag, "commands") && len(vals) > 0 && vals[0].Description != "" { + g.list = true + } - continue + // Description list separator + listSep, found := comps.ListSep[tag] + if !found { + if allSep, found := comps.ListSep["*"]; found { + g.listSeparator = allSep } + } else { + g.listSeparator = listSep + } - // Grid completions - if val.Description == "" { - noDescVals = append(noDescVals, val) - - continue - } + // Override sorting or sort if needed + g.noSort = comps.NoSort[tag] + if noSort, all := comps.NoSort["*"]; noSort && all && len(comps.NoSort) == 1 { + g.noSort = true + } +} - // List/map completions. - if stringInSlice(val.Description, descriptions) { - aliased = true - } +// initCompletionsGrid arranges completions when there are no aliases. +func (g *group) initCompletionsGrid(comps RawValues) { + if len(comps) == 0 { + return + } - descriptions = append(descriptions, val.Description) - vals = append(vals, val) + pairLength := g.longestValueDescribed(comps) + if pairLength > g.termWidth { + pairLength = g.termWidth } - // if no candidates have a description, swap - if len(vals) == 0 { - vals = noDescVals - noDescVals = make(RawValues, 0) + maxColumns := g.termWidth / pairLength + if g.list || maxColumns < 0 { + maxColumns = 1 } - return vals, noDescVals, aliased + rowCount := int(math.Ceil(float64(len(comps)) / (float64(maxColumns)))) + + g.rows = createGrid(comps, rowCount, maxColumns) + g.calculateMaxColumnWidths(g.rows) } -func (e *Engine) justifyGroups(values Values) { - commandGroups := make([]*group, 0) - maxCellLength := 0 +// initCompletionsGrid arranges completions when some of them share the same description. +func (g *group) initCompletionAliased(domains []Candidate) { + g.aliased = true - for _, group := range e.groups { - // Skip groups that are not to be justified - if _, justify := values.Pad[group.tag]; !justify { - if _, all := values.Pad["*"]; !all { - continue - } - } - - // Skip groups that are aliased or have more than one column - if group.aliased || len(group.columnsWidth) > 1 { - continue - } + // Filter out all duplicates: group aliased completions together. + grid, descriptions := g.createDescribedRows(domains) + g.calculateMaxColumnWidths(grid) + g.wrapExcessAliases(grid, descriptions) - commandGroups = append(commandGroups, group) + g.maxY = len(g.rows) + g.maxX = len(g.columnsWidth) +} - if group.tcMaxLength > maxCellLength { - maxCellLength = group.tcMaxLength +// This createDescribedRows function takes a list of values, a list of descriptions, and the +// terminal width as input, and returns a list of rows based on the provided requirements:. +func (g *group) createDescribedRows(values []Candidate) ([][]Candidate, []string) { + descriptionMap := make(map[string][]Candidate) + uniqueDescriptions := make([]string, 0) + rows := make([][]Candidate, 0) + + // Separate duplicates and store them. + for i, description := range values { + if slices.Contains(uniqueDescriptions, description.Description) { + descriptionMap[description.Description] = append(descriptionMap[description.Description], values[i]) + } else { + uniqueDescriptions = append(uniqueDescriptions, description.Description) + descriptionMap[description.Description] = []Candidate{values[i]} } } - for _, group := range commandGroups { - group.tcMaxLength = maxCellLength + // Sorting helps with easier grids. + for _, description := range uniqueDescriptions { + row := descriptionMap[description] + // slices.Sort(row) + // slices.Reverse(row) + rows = append(rows, row) } + + return rows, uniqueDescriptions } -func (e *Engine) newGroup(comps Values, tag string, vals RawValues, aliased bool) { - grp := &group{ - tag: tag, - noSpace: comps.NoSpace, - listSeparator: "--", - posX: -1, - posY: -1, - aliased: aliased, - columnsWidth: []int{0}, +// Wraps all rows for which there are too many aliases to be displayed on a single one. +func (g *group) wrapExcessAliases(grid [][]Candidate, descriptions []string) { + breakeven := 0 + maxColumns := len(g.columnsWidth) + + for i, width := range g.columnsWidth { + if (breakeven + width + 1) > g.termWidth/2 { + maxColumns = i + break + } + + breakeven += width + 1 } - // Check that all comps have a display value, - // and begin computing some parameters. - vals = grp.checkDisplays(vals) + var rows [][]Candidate - // Set sorting options, various display styles, etc. - grp.setOptions(comps, tag, vals) + for rowIndex := range grid { + row := grid[rowIndex] - // Keep computing/devising some parameters and constraints. - // This does not do much when we have aliased completions. - grp.computeCells(e, vals) + for len(row) > maxColumns { + rows = append(rows, row[:maxColumns]) + row = row[maxColumns:] + } - // Rearrange all candidates into a matrix of completions, - // based on most parameters computed above. - grp.makeMatrix(vals) + rows = append(rows, row) + } - e.groups = append(e.groups, grp) + g.rows = rows + g.columnsWidth = g.columnsWidth[:maxColumns] } -func (g *group) checkDisplays(vals RawValues) RawValues { - if g.aliased { - return vals - } +// prepareValues ensures all of them have a display, and starts +// gathering information on longest/shortest values, etc. +func (g *group) prepareValues(vals RawValues) RawValues { + for pos, value := range vals { + if value.Display == "" { + value.Display = value.Value + } - if len(vals) == 0 { - g.columnsWidth[0] = term.GetWidth() - 1 - } + // Only pass for colors regex should be here. + value.displayLen = len(color.Strip(value.Display)) + value.descLen = len(color.Strip(value.Description)) - // Otherwise update the size of the longest candidate - for _, val := range vals { - valLen := utf8.RuneCountInString(val.Display) - if valLen > g.columnsWidth[0] { - g.columnsWidth[0] = valLen + if value.displayLen > g.longestValue { + g.longestValue = value.displayLen } + + if value.descLen > g.longestDesc { + g.longestDesc = value.descLen + } + + vals[pos] = value } return vals } -func (g *group) setOptions(comps Values, tag string, vals RawValues) { - // Override grid/list displays - _, g.list = comps.ListLong[tag] - if _, all := comps.ListLong["*"]; all && len(comps.ListLong) == 1 { - g.list = true - } +func (g *group) setMaximumSizes(col int) int { + // Get the length of the longest description in the same column. + maxDescLen := g.descriptionsWidth[col] + valuesRealLen := sum(g.columnsWidth) + len(g.columnsWidth) + len(g.listSep()) - // Always list long commands when they have descriptions. - if strings.HasSuffix(g.tag, "commands") && len(vals) > 0 && vals[0].Description != "" { - g.list = true + if valuesRealLen+maxDescLen > g.termWidth { + maxDescLen = g.termWidth - valuesRealLen + } else if valuesRealLen+maxDescLen < g.termWidth { + maxDescLen = g.termWidth - valuesRealLen } - // Description list separator - listSep, found := comps.ListSep[tag] - if !found { - if allSep, found := comps.ListSep["*"]; found { - g.listSeparator = allSep + return maxDescLen +} + +// calculateMaxColumnWidths is in charge of optimizing the sizes of rows/columns. +func (g *group) calculateMaxColumnWidths(grid [][]Candidate) { + var numColumns int + + // Get the row with the greatest number of columns. + for _, row := range grid { + if len(row) > numColumns { + numColumns = len(row) } - } else { - g.listSeparator = listSep } - // Override sorting or sort if needed - _, g.noSort = comps.NoSort[tag] - if _, all := comps.NoSort["*"]; all && len(comps.NoSort) == 1 { - g.noSort = true - } + // First, all columns are as wide as the longest of their elements, + // regardless of if this longest element is longer than terminal + values := make([]int, numColumns) + descriptions := make([]int, numColumns) + + for _, row := range grid { + for columnIndex, value := range row { + if value.displayLen+1 > values[columnIndex] { + values[columnIndex] = value.displayLen + 1 + } - if !g.noSort { - sort.Slice(vals, func(i, j int) bool { - return vals[i].Display < vals[j].Display - }) + if value.descLen+1 > descriptions[columnIndex] { + descriptions[columnIndex] = value.descLen + 1 + } + } } -} -func (g *group) computeCells(eng *Engine, vals RawValues) { - // Aliases will compute themselves individually, later. - if g.aliased { - return + // If we have only one row, it means that the number of columns + // multiplied by the size on the longest one will fit into the + // terminal, so we can just + if len(grid) == 1 && len(grid[0]) <= numColumns && sum(descriptions) == 0 { + for i := range values { + values[i] = g.longestValue + } } - if len(vals) == 0 { - return + // Last time adjustment: try to reallocate any space modulo to each column. + shouldPad := len(grid) > 1 && numColumns > 1 && sum(descriptions) == 0 + intraColumnSpace := (numColumns * 2) + totalSpaceUsed := sum(values) + sum(descriptions) + intraColumnSpace + freeSpace := g.termWidth - totalSpaceUsed + + if shouldPad && !g.aliased && freeSpace >= numColumns { + each := freeSpace / numColumns + + for i := range values { + values[i] += each + } } - g.tcMaxLength = g.columnsWidth[0] + // The group is mostly ready to print and select its values for completion. + g.maxY = len(g.rows) + g.maxX = len(values) + g.columnsWidth = values + g.descriptionsWidth = descriptions +} + +func (g *group) longestValueDescribed(vals []Candidate) int { + var longestDesc, longestVal int + + // Equivalent to ` -- `, + // asuuming no trailing spaces in the completion + // nor leading spaces in the description. + descSeparatorLen := 1 + len(g.listSeparator) + 1 - // Each value first computes the total amount of space - // it is going to take in a row (including the description) + // Get the length of the longest value + // and the length of the longest description. for _, val := range vals { - candidate := g.displayTrimmed(color.Strip(val.Display)) - pad := strings.Repeat(" ", g.tcMaxLength-len(candidate)) - desc := g.descriptionTrimmed(val.Description) - display := fmt.Sprintf("%s%s%s", candidate, pad+" ", desc) - valLen := utf8.RuneCountInString(display) - - if valLen > g.maxCellLength { - g.maxCellLength = valLen + if val.displayLen > longestVal { + longestVal = val.displayLen } - } - // Adapt the maximum cell size based on inputrc settings. - compDisplayWidth := g.setMaxCellLength(eng) + if val.descLen > longestDesc { + longestDesc = val.descLen - // We now have the length of the longest completion for this group, - // so we devise how many columns we can use to display completions. - g.setColumnsWidth(&vals, compDisplayWidth) -} + } -func (g *group) setMaxCellLength(eng *Engine) int { - termWidth := term.GetWidth() + if val.descLen > longestDesc { + longestDesc = val.descLen + } + } - compDisplayWidth := eng.config.GetInt("completion-display-width") - if compDisplayWidth > termWidth { - compDisplayWidth = -1 + if longestDesc > 0 { + longestDesc += descSeparatorLen } - if compDisplayWidth > 0 && compDisplayWidth < termWidth { - if g.maxCellLength < compDisplayWidth { - g.maxCellLength = compDisplayWidth - } else { - g.maxCellLength = termWidth - } + if longestDesc > 0 { + longestDesc += descSeparatorLen } - return compDisplayWidth + // Always add one: there is at least one space between each column. + return longestVal + longestDesc + 1 } -func (g *group) setColumnsWidth(vals *RawValues, compDisplayWidth int) { - g.maxX = term.GetWidth() / (g.maxCellLength) - if g.maxX < 1 { - g.maxX = 1 // avoid a divide by zero error - } +func (g *group) trimDisplay(comp Candidate, pad, col int) (candidate, padded string) { + val := comp.Display - if g.maxX > len(*vals) { - g.maxX = len(*vals) + // No display value means padding. + if val == "" { + return "", padSpace(pad) } - if g.list || compDisplayWidth == 0 { - g.maxX = 1 - } + // Get the allowed length for this column. + // The display can never be longer than terminal width. + maxDisplayWidth := g.columnsWidth[col] + 1 - if g.maxX > compDisplayWidth && compDisplayWidth > 0 { - g.maxX = compDisplayWidth + if maxDisplayWidth > g.termWidth { + maxDisplayWidth = g.termWidth } - // We also have the width for each column - g.columnsWidth = make([]int, g.maxX) - for i := 0; i < g.maxX; i++ { - g.columnsWidth[i] = g.maxCellLength + val = sanitizer.Replace(val) + + if comp.displayLen > maxDisplayWidth { + val = color.Trim(val, maxDisplayWidth-trailingValueLen) + val += "..." // 3 dots + 1 safety space = -3 + + return val, " " } + + return val, padSpace(pad) } -func (g *group) makeMatrix(vals RawValues) { -nextValue: - for _, val := range vals { - valLen := utf8.RuneCountInString(val.Display) +func (g *group) trimDesc(val Candidate, pad int) (desc, padded string) { + desc = val.Description + if desc == "" { + return desc, padSpace(pad) + } - // If we have an alias, and we must get the right - // column and the right padding for this column. - if g.aliased { - for i, row := range g.values { - if row[0].Description == val.Description { - g.values[i] = append(row, val) - g.columnsWidth = getColumnPad(g.columnsWidth, valLen, len(g.values[i])) + // We don't compare against the terminal width: + // the correct padding should have been computed + // based on the space taken by all candidates + // described by our current string. + if pad > g.maxDescAllowed { + pad = g.maxDescAllowed - val.descLen + } - continue nextValue - } - } - } + desc = sanitizer.Replace(desc) - // Else, either add it to the current row if there is still room - // on it for this candidate, or add a new one. We only do that when - // we know we don't have aliases, or when we don't have to display list. - if !g.aliased && g.canFitInRow(term.GetWidth()) && !g.list { - g.values[len(g.values)-1] = append(g.values[len(g.values)-1], val) - } else { - // Else create a new row, and update the row pad. - g.values = append(g.values, []Candidate{val}) - if g.columnsWidth[0] < valLen+1 { - g.columnsWidth[0] = valLen + 1 - } - } - } + // Trim the description accounting for escapes. + if val.descLen > g.maxDescAllowed && g.maxDescAllowed > 0 { + desc = color.Trim(desc, g.maxDescAllowed-trailingDescLen) + desc += "..." // 3 dots = -3 - if g.aliased { - g.maxX = len(g.columnsWidth) - g.tcMaxLength = sum(g.columnsWidth) + len(g.columnsWidth) + 1 + return g.listSep() + desc, "" } - g.maxY = len(g.values) - if g.maxY > g.maxLength && g.maxLength != 0 { - g.maxY = g.maxLength + if val.descLen+pad > g.maxDescAllowed { + pad = g.maxDescAllowed - val.descLen } + + return g.listSep() + desc, padSpace(pad) } -func (g *group) canFitInRow(termWidth int) bool { - if len(g.values) == 0 { - return false +func (g *group) getPad(value Candidate, columnIndex int, desc bool) int { + columns := g.columnsWidth + valLen := value.displayLen - 1 + + if desc { + columns = g.descriptionsWidth + valLen = value.descLen + } + + // Ensure we never longer or even fully equal + // to the terminal size: we are not really sure + // of where offsets might be set in the code... + column := columns[columnIndex] + if column > g.termWidth-1 { + column = g.termWidth - 1 } - if termWidth/(g.maxCellLength)-1 < len(g.values[len(g.values)-1]) { - return false + padding := column - valLen + + if padding < 0 { + return 0 } - return true + return padding +} + +func (g *group) listSep() string { + return g.listSeparator + " " } // @@ -379,8 +450,8 @@ func (g *group) updateIsearch(eng *Engine) { suggs := make([]Candidate, 0) - for i := range g.values { - row := g.values[i] + for i := range g.rows { + row := g.rows[i] for _, val := range row { if eng.IsearchRegex.MatchString(val.Value) { @@ -392,49 +463,36 @@ func (g *group) updateIsearch(eng *Engine) { } // Reset the group parameters - g.values = make([][]Candidate, 0) + g.rows = make([][]Candidate, 0) g.posX = -1 g.posY = -1 - g.columnsWidth = []int{0} - - // Assign the filtered values: we don't need to check - // for a separate set of non-described values, as the - // completions have already been triaged when generated. - vals, _, aliased := eng.groupValues(nil, suggs) - g.aliased = aliased - - if len(vals) == 0 { - return - } - - // And perform the usual initialization routines. - vals = g.checkDisplays(vals) - g.computeCells(eng, vals) - g.makeMatrix(vals) -} - -func (g *group) firstCell() { - g.posX = 0 - g.posY = 0 -} -func (g *group) lastCell() { - g.posY = len(g.values) - 1 - g.posX = len(g.columnsWidth) - 1 + // Initial processing of our assigned values: + // Compute color/no-color sizes, some max/min, etc. + suggs = g.prepareValues(suggs) - if g.aliased { - g.findFirstCandidate(0, -1) + // Generate the full grid of completions. + // Special processing is needed when some values + // share a common description, they are "aliased". + if completionsAreAliases(suggs) { + g.initCompletionAliased(suggs) } else { - g.posX = len(g.values[g.posY]) - 1 + g.initCompletionsGrid(suggs) } } func (g *group) selected() (comp Candidate) { + defer func() { + if !g.preserveEscapes { + comp.Value = color.Strip(comp.Value) + } + }() + if g.posY == -1 || g.posX == -1 { - return g.values[0][0] + return g.rows[0][0] } - return g.values[g.posY][g.posX] + return g.rows[g.posY][g.posX] } func (g *group) moveSelector(x, y int) (done, next bool) { @@ -462,7 +520,7 @@ func (g *group) moveSelector(x, y int) (done, next bool) { } g.posY-- - g.posX = len(g.values[g.posY]) - 1 + g.posX = len(g.rows[g.posY]) - 1 } // 2) If we are reverse-cycling and currently on the first candidate, @@ -475,7 +533,7 @@ func (g *group) moveSelector(x, y int) (done, next bool) { return true, false } - g.posY = len(g.values) - 1 + g.posY = len(g.rows) - 1 g.posX-- } @@ -491,7 +549,7 @@ func (g *group) moveSelector(x, y int) (done, next bool) { } // 4) If we are on the last column, go to next row or next group - if g.posX > len(g.values[g.posY])-1 { + if g.posX > len(g.rows[g.posY])-1 { if g.aliased { return g.findFirstCandidate(x, y) } @@ -513,7 +571,7 @@ func (g *group) moveSelector(x, y int) (done, next bool) { // otherwise loop in the direction wished until one is found, or go next/ // previous column, and so on. func (g *group) findFirstCandidate(x, y int) (done, next bool) { - for g.posX > len(g.values[g.posY])-1 { + for g.posX > len(g.rows[g.posY])-1 { g.posY += y g.posY += x @@ -526,7 +584,7 @@ func (g *group) findFirstCandidate(x, y int) (done, next bool) { return true, false } - g.posY = len(g.values) - 1 + g.posY = len(g.rows) - 1 g.posX-- } @@ -544,198 +602,69 @@ func (g *group) findFirstCandidate(x, y int) (done, next bool) { return } -func (g *group) writeComps(eng *Engine) (comp string) { - if len(g.values) == 0 { - return - } - - if g.tag != "" { - comp += fmt.Sprintf("%s%s%s %s", color.Bold, color.FgYellow, g.tag, color.Reset) + term.ClearLineAfter + term.NewlineReturn - eng.usedY++ - } - - // Base parameters - var columns, rows int - - for range g.values { - // Generate the completion string for this row (comp/aliases - // and/or descriptions), and apply any styles and isearch - // highlighting with pattern replacement, - comp += g.writeRow(eng, columns) - - columns++ - rows++ - - if rows > g.maxY { - break - } - } - - eng.usedY += rows - - return comp +func (g *group) firstCell() { + g.posX = 0 + g.posY = 0 } -func (g *group) writeRow(eng *Engine, row int) (comp string) { - current := g.values[row] - - writeDesc := func(val Candidate, x, y, pad int) string { - desc := g.highlightDescription(eng, val, y, x) - descPad := g.padDescription(current, val, pad) - - if descPad > 0 { - desc += strings.Repeat(" ", descPad) - } - - return desc - } - - for col, val := range current { - // Generate the highlighted candidate with its padding - isSelected := row == g.posY && col == g.posX && g.isCurrent - cell, pad := g.padCandidate(current, val, col) - comp += g.highlightCandidate(eng, val, cell, pad, isSelected) + " " +func (g *group) lastCell() { + g.posY = len(g.rows) - 1 + g.posX = len(g.columnsWidth) - 1 - // And append the description only if at the end of the row, - // or if there are no aliases, in which case all comps are described. - if !g.aliased || col == len(current)-1 { - comp += writeDesc(val, col, row, len(cell)+len(pad)) - } + if g.aliased { + g.findFirstCandidate(0, -1) + } else { + g.posX = len(g.rows[g.posY]) - 1 } - - comp += term.ClearLineAfter + term.NewlineReturn - - return } -func (g *group) highlightCandidate(eng *Engine, val Candidate, cell, pad string, selected bool) (candidate string) { - reset := color.Fmt(val.Style) - candidate = g.displayTrimmed(val.Display) - - if eng.IsearchRegex != nil && eng.isearchBuf.Len() > 0 { - match := eng.IsearchRegex.FindString(candidate) - match = color.Fmt(color.Bg+"244") + match + color.Reset + reset - candidate = eng.IsearchRegex.ReplaceAllLiteralString(candidate, match) - } - - switch { - // If the comp is currently selected, overwrite any highlighting already applied. - case selected: - candidate = color.Fmt(color.Bg+"255") + color.FgBlackBright + g.displayTrimmed(color.Strip(val.Display)) - if g.aliased { - candidate += cell + color.Reset - } +func completionsAreAliases(values []Candidate) bool { + oddValueMap := make(map[string]bool) - default: - // Highlight the prefix if any and configured for it. - if eng.config.GetBool("colored-completion-prefix") && eng.prefix != "" { - if prefixMatch, err := regexp.Compile(fmt.Sprintf("^%s", eng.prefix)); err == nil { - candidate = prefixMatch.ReplaceAllString(candidate, color.Bold+color.FgBlue+eng.prefix+color.BoldReset+color.FgDefault+reset) - } + for _, value := range values { + if value.Description == "" { + continue } - candidate = reset + candidate + color.Reset + cell - } - - return candidate + pad -} - -func (g *group) highlightDescription(eng *Engine, val Candidate, row, col int) (desc string) { - if val.Description == "" { - return color.Reset - } - - desc = g.descriptionTrimmed(val.Description) - - if eng.IsearchRegex != nil && eng.isearchBuf.Len() > 0 { - match := eng.IsearchRegex.FindString(desc) - match = color.Fmt(color.Bg+"244") + match + color.Reset + color.Dim - desc = eng.IsearchRegex.ReplaceAllLiteralString(desc, match) - } - - // If the comp is currently selected, overwrite any highlighting already applied. - if row == g.posY && col == g.posX && g.isCurrent && !g.aliased { - desc = color.Fmt(color.Bg+"255") + color.FgBlackBright + g.descriptionTrimmed(val.Description) - } - - return color.Dim + desc + color.Reset -} - -func (g *group) padCandidate(row []Candidate, val Candidate, col int) (cell, pad string) { - var cellLen, padLen int - valLen := utf8.RuneCountInString(val.Display) - - if !g.aliased { - padLen = g.tcMaxLength - valLen - if padLen < 0 { - padLen = 0 + if _, found := oddValueMap[value.Description]; found { + return true } - return "", strings.Repeat(" ", padLen) - } - - cellLen = g.columnsWidth[col] - valLen - - if len(row) == 1 { - padLen = sum(g.columnsWidth) + len(g.columnsWidth) - g.columnsWidth[col] - 1 + oddValueMap[value.Description] = true } - return strings.Repeat(" ", cellLen), strings.Repeat(" ", padLen) + return false } -func (g *group) padDescription(row []Candidate, val Candidate, valPad int) (pad int) { - if g.aliased { - return 1 +func createGrid(values []Candidate, rowCount, maxColumns int) [][]Candidate { + if rowCount < 0 { + rowCount = 0 } - candidateLen := len(g.displayTrimmed(val.Display)) + valPad + 1 - individualRest := (term.GetWidth() % g.maxCellLength) / (g.maxX + len(row)) - pad = g.maxCellLength - candidateLen - len(g.descriptionTrimmed(val.Description)) + individualRest + grid := make([][]Candidate, rowCount) - if pad > 1 { - pad-- + for i := 0; i < rowCount; i++ { + grid[i] = createRow(values, maxColumns, i) } - return pad + return grid } -func (g *group) displayTrimmed(val string) string { - termWidth := term.GetWidth() - if g.tcMaxLength > termWidth-1 { - g.tcMaxLength = termWidth - 1 - } +func createRow(domains []Candidate, maxColumns, rowIndex int) []Candidate { + rowStart := rowIndex * maxColumns + rowEnd := (rowIndex + 1) * maxColumns - if len(val) > g.tcMaxLength { - val = val[:g.tcMaxLength-3] + "..." + if rowEnd > len(domains) { + rowEnd = len(domains) } - val = sanitizer.Replace(val) - - return val + return domains[rowStart:rowEnd] } -func (g *group) descriptionTrimmed(desc string) string { - if desc == "" { - return desc - } - - termWidth := term.GetWidth() - if g.tcMaxLength > termWidth { - g.tcMaxLength = termWidth +func padSpace(times int) string { + if times > 0 { + return strings.Repeat(" ", times) } - g.maxDescWidth = termWidth - g.tcMaxLength - len(g.listSeparator) - 1 - - if len(desc) >= g.maxDescWidth { - offset := 4 - if !g.aliased { - offset++ - } - - desc = desc[:g.maxDescWidth-offset] + "..." - } - - desc = g.listSeparator + " " + sanitizer.Replace(desc) - - return desc + return "" } diff --git a/vendor/github.com/reeflective/readline/internal/completion/hint.go b/vendor/github.com/reeflective/readline/internal/completion/hint.go index 0aeeb979f4..42dec696d3 100644 --- a/vendor/github.com/reeflective/readline/internal/completion/hint.go +++ b/vendor/github.com/reeflective/readline/internal/completion/hint.go @@ -18,10 +18,18 @@ func (e *Engine) hintCompletions(comps Values) { } } - // And all further messages - hint += strings.Join(comps.Messages.Get(), term.NewlineReturn) + // Add application-specific messages. + // There is full support for color in them, but in case those messages + // don't include any, we tame the color a little bit first, like hints. + messages := strings.Join(comps.Messages.Get(), term.NewlineReturn) + messages = strings.TrimSuffix(messages, term.NewlineReturn) - if e.Matches() == 0 && hint == "" && !e.auto { + if messages != "" { + hint = hint + color.Dim + messages + } + + // If we don't have any completions, and no messages, let's say it. + if e.Matches() == 0 && hint == color.Dim+term.NewlineReturn && !e.auto { hint = e.hintNoMatches() } diff --git a/vendor/github.com/reeflective/readline/internal/completion/utils.go b/vendor/github.com/reeflective/readline/internal/completion/utils.go index a07223e49a..a80a5104eb 100644 --- a/vendor/github.com/reeflective/readline/internal/completion/utils.go +++ b/vendor/github.com/reeflective/readline/internal/completion/utils.go @@ -4,12 +4,15 @@ import ( "strings" "unicode" + "github.com/reeflective/readline/internal/color" "github.com/reeflective/readline/internal/keymap" "github.com/reeflective/readline/internal/term" ) -// Maximum ratio of the screen that described values can have. -var maxValuesAreaRatio = 0.5 +const ( + trailingDescLen = 3 + trailingValueLen = 4 +) var sanitizer = strings.NewReplacer( "\n", ``, @@ -21,16 +24,38 @@ var sanitizer = strings.NewReplacer( // and prefix/suffix strings, but does not attempt any candidate // insertion/abortion on the line. func (e *Engine) prepare(completions Values) { + e.prefix = "" e.groups = make([]*group, 0) e.setPrefix(completions) e.setSuffix(completions) + e.generate(completions) +} + +func (e *Engine) generate(completions Values) { + // Compute hints once we found either any errors, + // or if we have no completions but usage strings. + defer func() { + e.hintCompletions(completions) + }() + + // Nothing else to do if no completions + if len(completions.values) == 0 { + return + } + + // Apply the prefix to the completions, and filter out any + // completions that don't match, optionally ignoring case. + matchCase := e.config.GetBool("completion-ignore-case") + completions.values = completions.values.FilterPrefix(e.prefix, !matchCase) - e.group(completions) + // Classify, group together and initialize completions. + completions.values.EachTag(e.generateGroup(completions)) + e.justifyGroups(completions) } -func (e *Engine) setPrefix(comps Values) { - switch comps.PREFIX { +func (e *Engine) setPrefix(completions Values) { + switch completions.PREFIX { case "": // Select the character just before the cursor. cpos := e.cursor.Pos() - 1 @@ -49,15 +74,18 @@ func (e *Engine) setPrefix(comps Values) { cpos++ } + // You might wonder why we trim spaces here: + // in practice we don't really ever want to + // consider "how many spaces are somewhere". e.prefix = strings.TrimSpace(string((*e.line)[bpos:cpos])) default: - e.prefix = comps.PREFIX + e.prefix = completions.PREFIX } } -func (e *Engine) setSuffix(comps Values) { - switch comps.SUFFIX { +func (e *Engine) setSuffix(completions Values) { + switch completions.SUFFIX { case "": cpos := e.cursor.Pos() _, epos := e.line.SelectBlankWord(cpos) @@ -81,8 +109,68 @@ func (e *Engine) setSuffix(comps Values) { e.suffix = strings.TrimSpace(string((*e.line)[cpos:epos])) default: - e.suffix = comps.SUFFIX + e.suffix = completions.SUFFIX + } +} + +// Returns a function to run on each completio group tag. +func (e *Engine) generateGroup(comps Values) func(tag string, values RawValues) { + return func(tag string, values RawValues) { + // Separate the completions that have a description and + // those which don't, and devise if there are aliases. + vals, noDescVals, descriptions := e.groupNonDescribed(&comps, values) + + // Create a "first" group with the "first" grouped values + e.newCompletionGroup(comps, tag, vals, descriptions) + + // If we have a remaining group of values without descriptions, + // we will print and use them in a separate, anonymous group. + if len(noDescVals) > 0 { + e.newCompletionGroup(comps, "", noDescVals, descriptions) + } + } +} + +// groupNonDescribed separates values based on whether they have descriptions, or are aliases of each other. +func (e *Engine) groupNonDescribed(comps *Values, values RawValues) (vals, noDescVals RawValues, descs []string) { + var descriptions []string + + prefix := "" + if e.prefix != "\"\"" && e.prefix != "''" { + prefix = e.prefix + } + + for _, val := range values { + // Ensure all values have a display string. + if val.Display == "" { + val.Display = val.Value + } + + // Currently this is because errors are passed as completions. + if strings.HasPrefix(val.Value, prefix+"ERR") && val.Value == prefix+"_" { + comps.Messages.Add(color.FgRed + val.Display + val.Description) + + continue + } + + // Grid completions + if val.Description == "" { + noDescVals = append(noDescVals, val) + + continue + } + + descriptions = append(descriptions, val.Description) + vals = append(vals, val) + } + + // if no candidates have a description, swap + if len(vals) == 0 { + vals = noDescVals + noDescVals = make(RawValues, 0) } + + return vals, noDescVals, descriptions } func (e *Engine) currentGroup() (grp *group) { @@ -95,7 +183,7 @@ func (e *Engine) currentGroup() (grp *group) { // If there are groups but no current, make first one the king. if len(e.groups) > 0 { for _, g := range e.groups { - if len(g.values) > 0 { + if len(g.rows) > 0 { g.isCurrent = true return g } @@ -124,7 +212,7 @@ func (e *Engine) cycleNextGroup() { for { next := e.currentGroup() - if len(next.values) == 0 { + if len(next.rows) == 0 { e.cycleNextGroup() continue } @@ -151,7 +239,7 @@ func (e *Engine) cyclePreviousGroup() { for { prev := e.currentGroup() - if len(prev.values) == 0 { + if len(prev.rows) == 0 { e.cyclePreviousGroup() continue } @@ -160,6 +248,40 @@ func (e *Engine) cyclePreviousGroup() { } } +func (e *Engine) justifyGroups(values Values) { + commandGroups := make([]*group, 0) + maxCellLength := 0 + + for _, group := range e.groups { + // Skip groups that are not to be justified + justify := values.Pad[group.tag] + if !justify { + justify = values.Pad["*"] + } + + if !justify { + continue + } + + // Skip groups that are aliased or have more than one column + if group.aliased || len(group.columnsWidth) > 1 { + continue + } + + // Else this group should be justified-padded globally. + commandGroups = append(commandGroups, group) + + if group.longestValue > maxCellLength { + maxCellLength = group.longestValue + } + } + + for _, group := range commandGroups { + group.columnsWidth[0] = maxCellLength + group.longestValue = maxCellLength + } +} + func (e *Engine) adjustCycleKeys(row, column int) (int, int) { cur := e.currentGroup() @@ -190,23 +312,25 @@ func (e *Engine) adjustSelectKeymap() { } } +// completionCount returns the number of completions for a given group, +// as well as the number of real terminal lines it spans on, including +// the group name if there is one. func (e *Engine) completionCount() (comps int, used int) { for _, group := range e.groups { - groupComps := 0 - - for _, row := range group.values { - groupComps += len(row) - comps += groupComps + // First, agree on the number of comps. + for _, row := range group.rows { + comps += len(row) } - if group.maxY > len(group.values) { - used += len(group.values) - } else { - used += group.maxY + // One line for the group name + if group.tag != "" { + used++ } - if groupComps > 0 { - used++ + if group.maxY > len(group.rows) { + used += group.maxY + } else { + used += len(group.rows) } } @@ -224,18 +348,18 @@ func (e *Engine) hasUniqueCandidate() bool { return false } - if len(cur.values) == 1 { - return len(cur.values[0]) == 1 + if len(cur.rows) == 1 { + return len(cur.rows[0]) == 1 } - return len(cur.values) == 1 + return len(cur.rows) == 1 default: var count int GROUPS: for _, group := range e.groups { - for _, row := range group.values { + for _, row := range group.rows { count++ for range row { count++ @@ -252,7 +376,7 @@ func (e *Engine) hasUniqueCandidate() bool { func (e *Engine) noCompletions() bool { for _, group := range e.groups { - if len(group.values) > 0 { + if len(group.rows) > 0 { return false } } @@ -314,7 +438,7 @@ func (e *Engine) getAbsPos() int { for _, grp := range e.groups { groupComps := 0 - for _, row := range grp.values { + for _, row := range grp.rows { groupComps += len(row) } @@ -346,35 +470,6 @@ func (e *Engine) getAbsPos() int { return prev } -// getColumnPad either updates or adds a new column for an alias. -func getColumnPad(columns []int, valLen int, numAliases int) []int { - switch { - case (float64(sum(columns) + valLen)) > - (float64(term.GetWidth()) * maxValuesAreaRatio): - columnX := numAliases % len(columns) - - if columns[columnX] < valLen { - columns[columnX] = valLen - } - case numAliases > len(columns): - columns = append(columns, valLen) - case columns[numAliases-1] < valLen: - columns[numAliases-1] = valLen - } - - return columns -} - -func stringInSlice(s string, sl []string) bool { - for _, str := range sl { - if s == str { - return true - } - } - - return false -} - func sum(vals []int) (sum int) { for _, val := range vals { sum += val @@ -392,3 +487,18 @@ func hasUpper(line []rune) bool { return false } + +func longest(vals []string, trimEscapes bool) int { + var length int + for _, val := range vals { + if trimEscapes { + val = color.Strip(val) + } + + if len(val) > length { + length = len(val) + } + } + + return length +} diff --git a/vendor/github.com/reeflective/readline/internal/completion/values.go b/vendor/github.com/reeflective/readline/internal/completion/values.go index 7bbfaa97f1..5db020e3d5 100644 --- a/vendor/github.com/reeflective/readline/internal/completion/values.go +++ b/vendor/github.com/reeflective/readline/internal/completion/values.go @@ -1,6 +1,8 @@ package completion -import "strings" +import ( + "strings" +) // RawValues is a list of completion candidates. type RawValues []Candidate @@ -62,22 +64,37 @@ func (c RawValues) EachTag(tagF func(tag string, values RawValues)) { // FilterPrefix filters values with given prefix. // If matchCase is false, the filtering is made case-insensitive. +// This function ensures that all spaces are correctly. func (c RawValues) FilterPrefix(prefix string, matchCase bool) RawValues { + if prefix == "" { + return c + } + filtered := make(RawValues, 0) if !matchCase { prefix = strings.ToLower(prefix) } - for _, r := range c { - val := r.Value + for _, raw := range c { + val := raw.Value + if !matchCase { val = strings.ToLower(val) } if strings.HasPrefix(val, prefix) { - filtered = append(filtered, r) + filtered = append(filtered, raw) } } + return filtered } + +func (c RawValues) Len() int { return len(c) } + +func (c RawValues) Swap(i, j int) { c[i], c[j] = c[j], c[i] } + +func (c RawValues) Less(i, j int) bool { + return strings.ToLower(c[i].Value) < strings.ToLower(c[j].Value) +} diff --git a/vendor/github.com/reeflective/readline/internal/display/engine.go b/vendor/github.com/reeflective/readline/internal/display/engine.go index 5cd907e73c..8b0c374194 100644 --- a/vendor/github.com/reeflective/readline/internal/display/engine.go +++ b/vendor/github.com/reeflective/readline/internal/display/engine.go @@ -84,7 +84,7 @@ func (e *Engine) Refresh() { // Get all positions required for the redisplay to come: // prompt end (thus indentation), cursor positions, etc. - e.computeCoordinates() + e.computeCoordinates(true) // Print the line, right prompt, hints and completions. e.displayLine() @@ -129,7 +129,7 @@ func (e *Engine) ResetHelpers() { func (e *Engine) AcceptLine() { e.CursorToLineStart() - e.computeCoordinates() + e.computeCoordinates(false) // Go back to the end of the non-suggested line. term.MoveCursorBackwards(term.GetWidth()) @@ -197,7 +197,7 @@ func (e *Engine) cursorHintToLineStart() { e.CursorToLineStart() } -func (e *Engine) computeCoordinates() { +func (e *Engine) computeCoordinates(suggested bool) { // Get the new input line and auto-suggested one. e.line, e.cursor = e.completer.Line() if e.completer.IsInserting() { @@ -222,7 +222,7 @@ func (e *Engine) computeCoordinates() { e.cursorCol, e.cursorRow = core.CoordinatesCursor(e.cursor, e.startCols) // Get the number of rows used by the line, and the end line X pos. - if e.opts.GetBool("history-autosuggest") { + if e.opts.GetBool("history-autosuggest") && suggested { e.lineCol, e.lineRows = core.CoordinatesLine(&e.suggested, e.startCols) } else { e.lineCol, e.lineRows = core.CoordinatesLine(e.line, e.startCols) diff --git a/vendor/github.com/reeflective/readline/internal/display/highlight.go b/vendor/github.com/reeflective/readline/internal/display/highlight.go index c45ebb8c71..5da978ca3f 100644 --- a/vendor/github.com/reeflective/readline/internal/display/highlight.go +++ b/vendor/github.com/reeflective/readline/internal/display/highlight.go @@ -174,7 +174,7 @@ func (e *Engine) hlAdd(regions []core.Selection, newHl core.Selection, line []ru // Update the highlighting with inputrc settings if any. if bg != "" && !matcher { - background := strings.ReplaceAll(e.opts.GetString("active-region-start-color"), `\e`, "\x1b") + background := color.UnquoteRC("active-region-start-color") if bg, _ = strconv.Unquote(background); bg == "" { bg = color.Reverse } diff --git a/vendor/github.com/reeflective/readline/internal/keymap/config.go b/vendor/github.com/reeflective/readline/internal/keymap/config.go index e787e2f978..fb23fbcde9 100644 --- a/vendor/github.com/reeflective/readline/internal/keymap/config.go +++ b/vendor/github.com/reeflective/readline/internal/keymap/config.go @@ -12,11 +12,18 @@ import ( // readline global options specific to this library. var readlineOptions = map[string]interface{}{ - "autocomplete": false, - "autopairs": false, + // General edition + "autopairs": false, + + // Completion + "autocomplete": false, + "completion-list-separator": "--", + "completion-selection-style": "\x1b[1;30m", + + // Prompt & General UI "transient-prompt": false, - "history-autosuggest": false, "usage-hint-always": false, + "history-autosuggest": false, } // ReloadConfig parses all valid .inputrc configurations and immediately diff --git a/vendor/github.com/reeflective/readline/internal/keymap/cursor.go b/vendor/github.com/reeflective/readline/internal/keymap/cursor.go index 07b6dee4cc..2f7d476f30 100644 --- a/vendor/github.com/reeflective/readline/internal/keymap/cursor.go +++ b/vendor/github.com/reeflective/readline/internal/keymap/cursor.go @@ -1,6 +1,9 @@ package keymap -import "fmt" +import ( + "fmt" + "strings" +) // CursorStyle is the style of the cursor // in a given input mode/submode. @@ -46,18 +49,25 @@ var defaultCursors = map[Mode]CursorStyle{ // PrintCursor prints the cursor for the given keymap mode, // either default value or the one specified in inputrc file. +// TODO: I've been quite vicious here, I need to admit: the logic +// is not made to use the default user cursor in insert-mode. +// It didn't bother. And if that can help some getting to use +// .inputrc, so be it. func (m *Engine) PrintCursor(keymap Mode) { var cursor CursorStyle // Check for a configured cursor in .inputrc file. - modeSet := m.config.GetString(string(keymap)) - if modeSet != "" { - cursor = defaultCursors[Mode(modeSet)] + cursorOptname := fmt.Sprintf("cursor-%s", string(keymap)) + modeSet := strings.TrimSpace(m.config.GetString(cursorOptname)) + + if _, valid := cursors[CursorStyle(modeSet)]; valid { + fmt.Print(cursors[CursorStyle(modeSet)]) + return } - // If the configured one was invalid, use default one. - if cursor == "" { - cursor = defaultCursors[keymap] + if cursor, valid := defaultCursors[keymap]; valid { + fmt.Print(cursors[cursor]) + return } fmt.Print(cursors[cursor]) diff --git a/vendor/github.com/reeflective/readline/internal/term/term.go b/vendor/github.com/reeflective/readline/internal/term/term.go index fcf730696a..e9d8512fa4 100644 --- a/vendor/github.com/reeflective/readline/internal/term/term.go +++ b/vendor/github.com/reeflective/readline/internal/term/term.go @@ -31,7 +31,7 @@ func GetWidth() (termWidth int) { fd := int(stdoutTerm.Fd()) termWidth, _, err = GetSize(fd) - if err != nil { + if err != nil || termWidth == 0 { termWidth = defaultTermWidth } diff --git a/vendor/modules.txt b/vendor/modules.txt index 23516986a6..a17d907bdc 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -545,8 +545,8 @@ github.com/pmezard/go-difflib/difflib ## explicit; go 1.20 github.com/reeflective/console github.com/reeflective/console/commands/readline -# github.com/reeflective/readline v1.0.8 -## explicit; go 1.20 +# github.com/reeflective/readline v1.0.10 +## explicit; go 1.21 github.com/reeflective/readline github.com/reeflective/readline/inputrc github.com/reeflective/readline/internal/color