diff --git a/inline_test.go b/inline_test.go new file mode 100644 index 00000000..31699cf8 --- /dev/null +++ b/inline_test.go @@ -0,0 +1,17 @@ +package readline + +import "testing" + +func TestInlineSuggestionAPI(t *testing.T) { + rl := NewShell() + + rl.SetInlineSuggestion("status --verbose") + if got := rl.GetInlineSuggestion(); got != "status --verbose" { + t.Fatalf("inline suggestion = %q, want status --verbose", got) + } + + rl.ClearInlineSuggestion() + if got := rl.GetInlineSuggestion(); got != "" { + t.Fatalf("inline suggestion after clear = %q, want empty", got) + } +} diff --git a/internal/display/engine.go b/internal/display/engine.go index 79610d8c..fb0fd3db 100644 --- a/internal/display/engine.go +++ b/internal/display/engine.go @@ -2,6 +2,7 @@ package display import ( "regexp" + "strings" "github.com/reeflective/readline/inputrc" "github.com/reeflective/readline/internal/color" @@ -43,6 +44,7 @@ type Engine struct { line *core.Line suggested core.Line cursor *core.Cursor + inline string selection *core.Selection histories *history.Sources prompt *ui.Prompt @@ -71,6 +73,46 @@ func Init(e *Engine, highlighter func([]rune) string) { e.highlighter = highlighter } +// SetInlineSuggestion sets a suggestion to display after the cursor. +func (e *Engine) SetInlineSuggestion(suggestion string) { + e.inline = suggestion +} + +// ClearInlineSuggestion clears the current inline suggestion. +func (e *Engine) ClearInlineSuggestion() { + e.inline = "" +} + +// GetInlineSuggestion returns the current inline suggestion. +func (e *Engine) GetInlineSuggestion() string { + return e.inline +} + +func (e *Engine) inlineSuggestionApplies(currentLine string) bool { + if e.inline == "" { + return false + } + if e.cursor.Pos() != e.line.Len() { + return false + } + + return strings.HasPrefix(e.inline, currentLine) && len(e.inline) > len(currentLine) +} + +func (e *Engine) coordinatesLine(suggested bool) *core.Line { + currentLine := string(*e.line) + if e.opts.GetBool("history-autosuggest") && suggested && e.suggested.Len() > e.line.Len() { + return &e.suggested + } + if e.inlineSuggestionApplies(currentLine) { + inline := core.Line{} + inline.Set([]rune(e.inline)...) + return &inline + } + + return e.line +} + // PrintPrimaryPrompt redraws the primary prompt. // There are relatively few cases where you want to use this. // It is currently only used when using clear-screen commands. @@ -202,11 +244,7 @@ func (e *Engine) computeCoordinates(suggested bool) { 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") && suggested { - e.lineCol, e.lineRows = core.CoordinatesLine(&e.suggested, e.startCols) - } else { - e.lineCol, e.lineRows = core.CoordinatesLine(e.line, e.startCols) - } + e.lineCol, e.lineRows = core.CoordinatesLine(e.coordinatesLine(suggested), e.startCols) e.primaryPrinted = false } @@ -231,8 +269,15 @@ func (e *Engine) displayLine() { line = e.highlightLine([]rune(line), *e.selection) // Get the subset of the suggested line to print. + suggestionAdded := false if len(e.suggested) > e.line.Len() && e.opts.GetBool("history-autosuggest") { line += color.Dim + color.Fmt(color.Fg+"242") + string(e.suggested[e.line.Len():]) + color.Reset + suggestionAdded = true + } + + currentLine := string(*e.line) + if !suggestionAdded && e.inlineSuggestionApplies(currentLine) { + line += color.Dim + color.Fmt(color.Fg+"242") + e.inline[len(currentLine):] + color.Reset } // Format tabs as spaces, for consistent display diff --git a/internal/display/inline_test.go b/internal/display/inline_test.go new file mode 100644 index 00000000..9316f03e --- /dev/null +++ b/internal/display/inline_test.go @@ -0,0 +1,73 @@ +package display + +import ( + "testing" + + "github.com/reeflective/readline/inputrc" + "github.com/reeflective/readline/internal/core" +) + +func TestInlineSuggestionAppliesOnlyAtLineEndWithPrefix(t *testing.T) { + line := core.Line{} + line.Set([]rune("sta")...) + cursor := core.NewCursor(&line) + cursor.Set(line.Len()) + + engine := &Engine{line: &line, cursor: cursor} + engine.SetInlineSuggestion("status") + + if !engine.inlineSuggestionApplies("sta") { + t.Fatal("inline suggestion should apply when it extends the line at the cursor") + } + + cursor.Set(1) + if engine.inlineSuggestionApplies("sta") { + t.Fatal("inline suggestion should not apply when the cursor is not at the end") + } + + cursor.Set(line.Len()) + engine.SetInlineSuggestion("stop") + if engine.inlineSuggestionApplies("sta") { + t.Fatal("inline suggestion should not apply when it does not extend the current line") + } +} + +func TestCoordinatesLineUsesInlineSuggestion(t *testing.T) { + line := core.Line{} + line.Set([]rune("sta")...) + cursor := core.NewCursor(&line) + cursor.Set(line.Len()) + + engine := &Engine{ + line: &line, + cursor: cursor, + opts: inputrc.NewConfig(), + } + engine.SetInlineSuggestion("status --verbose") + + if got := engine.coordinatesLine(true); string(*got) != "status --verbose" { + t.Fatalf("coordinates line = %q, want inline suggestion", string(*got)) + } +} + +func TestCoordinatesLinePrefersHistorySuggestion(t *testing.T) { + line := core.Line{} + line.Set([]rune("sta")...) + cursor := core.NewCursor(&line) + cursor.Set(line.Len()) + + cfg := inputrc.NewConfig() + _ = cfg.Set("history-autosuggest", true) + + engine := &Engine{ + line: &line, + cursor: cursor, + opts: cfg, + } + engine.suggested.Set([]rune("status-from-history")...) + engine.SetInlineSuggestion("status-from-inline") + + if got := engine.coordinatesLine(true); string(*got) != "status-from-history" { + t.Fatalf("coordinates line = %q, want history suggestion", string(*got)) + } +} diff --git a/internal/display/refresh.go b/internal/display/refresh.go index a1210053..fdd333f1 100644 --- a/internal/display/refresh.go +++ b/internal/display/refresh.go @@ -41,7 +41,7 @@ func (e *Engine) Refresh() { // Recompute coordinates with the new indentation/cursor position. if e.line.Lines() > 0 { e.cursorCol, e.cursorRow = core.CoordinatesCursor(e.cursor, e.startCols) - e.lineCol, e.lineRows = core.CoordinatesLine(e.line, e.startCols) + e.lineCol, e.lineRows = core.CoordinatesLine(e.coordinatesLine(true), e.startCols) } // Ensure that we have enough space to print the line. @@ -268,8 +268,15 @@ func (e *Engine) displayLineRefactored() { // Apply visual selections highlighting if any line = e.highlightLine([]rune(line), *e.selection) // Get the subset of the suggested line to print. + suggestionAdded := false if len(e.suggested) > e.line.Len() && e.opts.GetBool("history-autosuggest") { line += color.Dim + color.Fmt(color.Fg+"242") + string(e.suggested[e.line.Len():]) + color.Reset + suggestionAdded = true + } + + currentLine := string(*e.line) + if !suggestionAdded && e.inlineSuggestionApplies(currentLine) { + line += color.Dim + color.Fmt(color.Fg+"242") + e.inline[len(currentLine):] + color.Reset } // Format tabs as spaces, for consistent display line = strutil.FormatTabs(line) + term.ClearLineAfter diff --git a/shell.go b/shell.go index 655dbb85..1e4a8404 100644 --- a/shell.go +++ b/shell.go @@ -176,3 +176,30 @@ func (rl *Shell) PrintTransientf(msg string, args ...any) (n int, err error) { return } + +// SetInlineSuggestion sets a suggestion to display after the cursor. +func (rl *Shell) SetInlineSuggestion(suggestion string) { + if rl == nil || rl.Display == nil { + return + } + + rl.Display.SetInlineSuggestion(suggestion) +} + +// ClearInlineSuggestion clears the current inline suggestion. +func (rl *Shell) ClearInlineSuggestion() { + if rl == nil || rl.Display == nil { + return + } + + rl.Display.ClearInlineSuggestion() +} + +// GetInlineSuggestion returns the current inline suggestion. +func (rl *Shell) GetInlineSuggestion() string { + if rl == nil || rl.Display == nil { + return "" + } + + return rl.Display.GetInlineSuggestion() +}