Skip to content

Commit

Permalink
Autocomplete directories in new document text field
Browse files Browse the repository at this point in the history
  • Loading branch information
wedaly committed Dec 23, 2023
1 parent 9959b5b commit d64594c
Show file tree
Hide file tree
Showing 10 changed files with 314 additions and 20 deletions.
2 changes: 1 addition & 1 deletion display/editor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func TestDrawEditor(t *testing.T) {
s, err := newEditorStateWithPath("test.txt")
require.NoError(t, err)
emptyAction := func(_ *state.EditorState, _ string) error { return nil }
state.ShowTextField(s, "Test:", emptyAction)
state.ShowTextField(s, "Test:", emptyAction, nil)
state.AppendRuneToTextField(s, 'a')
state.AppendRuneToTextField(s, 'b')
state.AppendRuneToTextField(s, 'c')
Expand Down
8 changes: 6 additions & 2 deletions display/textfield.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ func DrawTextField(screen tcell.Screen, palette *Palette, textfield *state.TextF
drawStringNoWrap(sr, promptText, 0, 0, palette.StyleForTextFieldPrompt())

// Draw the user input on the second row, with the cursor at the end.
inputText := textfield.InputText()
col := drawStringNoWrap(sr, inputText, 0, 1, palette.StyleForTextFieldInputText())
col := drawStringNoWrap(sr, textfield.InputText(), 0, 1, palette.StyleForTextFieldInputText())

// Append autocomplete suffix (could be empty).
col = drawStringNoWrap(sr, textfield.AutocompleteSuffix(), col, 1, palette.StyleForTextFieldInputText())

// Cursor the end of user input + autocomplete suffix.
sr.ShowCursor(col, 1)

// Draw bottom border, unless it would overlap the status bar in last row.
Expand Down
2 changes: 1 addition & 1 deletion display/textfield_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func buildTextFieldState(t *testing.T, promptText, inputText string) *state.Text
require.NoError(t, err)

emptyAction := func(_ *state.EditorState, _ string) error { return nil }
state.ShowTextField(s, promptText, emptyAction)
state.ShowTextField(s, promptText, emptyAction, nil)
for _, r := range inputText {
state.AppendRuneToTextField(s, r)
}
Expand Down
32 changes: 32 additions & 0 deletions file/autocomplete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package file

import (
"os"
"path/filepath"
"strings"
)

// AutocompleteDirectory autocompletes the last subdirectory in a path.
func AutocompleteDirectory(path string) ([]string, error) {
baseDir, subdirPrefix := filepath.Split(path)
if baseDir == "" {
baseDir = "."
}

entries, err := os.ReadDir(baseDir)
if err != nil {
return nil, err
}

var suffixes []string
for _, e := range entries {
if e.IsDir() {
name := e.Name()
if strings.HasPrefix(name, subdirPrefix) && len(subdirPrefix) < len(name) {
suffixes = append(suffixes, name[len(subdirPrefix):])
}
}
}

return suffixes, nil
}
82 changes: 82 additions & 0 deletions file/autocomplete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package file

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAutocompleteDirectory(t *testing.T) {
tmpDir := t.TempDir()

for _, subdir := range []string{"aaa", "aab", "aac", "aba", "abb", "abc", "xyz"} {
path := filepath.Join(tmpDir, subdir)
err := os.Mkdir(path, 0755)
require.NoError(t, err)
}

testCases := []struct {
name string
prefix string
chdir string
expectedSuffixes []string
}{
{
name: "empty prefix",
prefix: "",
chdir: tmpDir,
expectedSuffixes: []string{"aaa", "aab", "aac", "aba", "abb", "abc", "xyz"},
},
{
name: "base directory, no trailing slash",
prefix: tmpDir,
expectedSuffixes: nil,
},
{
name: "base directory with trailing slash",
prefix: fmt.Sprintf("%s%c", tmpDir, filepath.Separator),
expectedSuffixes: []string{"aaa", "aab", "aac", "aba", "abb", "abc", "xyz"},
},
{
name: "first character matches",
prefix: filepath.Join(tmpDir, "x"),
expectedSuffixes: []string{"yz"},
},
{
name: "first two characters match",
prefix: filepath.Join(tmpDir, "ab"),
expectedSuffixes: []string{"a", "b", "c"},
},
{
name: "all characters match",
prefix: filepath.Join(tmpDir, "aac"),
expectedSuffixes: nil,
},
{
name: "no characters match",
prefix: filepath.Join(tmpDir, "m"),
expectedSuffixes: nil,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.chdir != "" {
cwd, err := os.Getwd()
require.NoError(t, err)
defer func() { os.Chdir(cwd) }()

err = os.Chdir(tc.chdir)
require.NoError(t, err)
}

suffixes, err := AutocompleteDirectory(tc.prefix)
require.NoError(t, err)
assert.Equal(t, tc.expectedSuffixes, suffixes)
})
}
}
6 changes: 5 additions & 1 deletion input/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package input

import (
"github.com/aretext/aretext/clipboard"
"github.com/aretext/aretext/file"
"github.com/aretext/aretext/locate"
"github.com/aretext/aretext/selection"
"github.com/aretext/aretext/state"
Expand Down Expand Up @@ -907,7 +908,10 @@ func SearchWordUnderCursor(direction state.SearchDirection, count uint64) Action

func ShowNewFileTextField(s *state.EditorState) {
state.AbortIfUnsavedChanges(s, state.DefaultUnsavedChangesAbortMsg, func(s *state.EditorState) {
state.ShowTextField(s, "New document file path:", state.NewDocument)
state.ShowTextField(s,
"New document file path:",
state.NewDocument,
file.AutocompleteDirectory)
})
}

Expand Down
9 changes: 9 additions & 0 deletions input/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2110,5 +2110,14 @@ func TextFieldCommands() []Command {
return state.ExecuteTextFieldAction
},
},
{
Name: "autocomplete",
BuildExpr: func() engine.Expr {
return keyExpr(tcell.KeyTab)
},
BuildAction: func(ctx Context, p CommandParams) Action {
return state.AutocompleteTextField
},
},
}
}
Binary file modified input/generated/textfield.bin
Binary file not shown.
87 changes: 78 additions & 9 deletions state/textfield.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
package state

import "github.com/aretext/aretext/text"
import (
"fmt"

"github.com/aretext/aretext/text"
)

// Same as Linux PATH_MAX.
const maxTextFieldLen = 4096

// TextFieldAction is the action to perform with the text input by the user.
type TextFieldAction func(*EditorState, string) error

// TextFieldAutocompleteFunc retrieves autocomplete suffixes for a given prefix.
// It is acceptable to return an empty slice if there are no autocompletions,
// but every string in the slice must have non-zero length.
type TextFieldAutocompleteFunc func(prefix string) ([]string, error)

// TextFieldState represents the state of the text field.
// This is used to enter text such as the file path
// when creating a new file from within the editor.
type TextFieldState struct {
promptText string
inputText text.RuneStack
action TextFieldAction
prevInputMode InputMode
promptText string
inputText text.RuneStack
action TextFieldAction
prevInputMode InputMode
autocompleteFunc TextFieldAutocompleteFunc // Set to nil to disable autocompletion.
autocompleteSuffixes []string
autocompleteSuffixIdx int
}

func (s *TextFieldState) PromptText() string {
Expand All @@ -26,11 +38,28 @@ func (s *TextFieldState) InputText() string {
return s.inputText.String()
}

func ShowTextField(state *EditorState, promptText string, action TextFieldAction) {
func (s *TextFieldState) AutocompleteSuffix() string {
if s.autocompleteSuffixIdx < len(s.autocompleteSuffixes) {
return s.autocompleteSuffixes[s.autocompleteSuffixIdx]
} else {
return ""
}
}

func (s *TextFieldState) applyAutocomplete() {
for _, r := range s.AutocompleteSuffix() {
s.inputText.Push(r)
}
s.autocompleteSuffixes = nil
s.autocompleteSuffixIdx = 0
}

func ShowTextField(state *EditorState, promptText string, action TextFieldAction, autocompleteFunc TextFieldAutocompleteFunc) {
state.textfield = &TextFieldState{
promptText: promptText,
action: action,
prevInputMode: state.inputMode,
promptText: promptText,
action: action,
prevInputMode: state.inputMode,
autocompleteFunc: autocompleteFunc,
}
setInputMode(state, InputModeTextField)
}
Expand All @@ -42,17 +71,22 @@ func HideTextField(state *EditorState) {
}

func AppendRuneToTextField(state *EditorState, r rune) {
state.textfield.applyAutocomplete()
inputText := &state.textfield.inputText
if inputText.Len() < maxTextFieldLen {
inputText.Push(r)
}
SetStatusMsg(state, StatusMsg{})
}

func DeleteRuneFromTextField(state *EditorState) {
state.textfield.applyAutocomplete()
state.textfield.inputText.Pop()
SetStatusMsg(state, StatusMsg{})
}

func ExecuteTextFieldAction(state *EditorState) {
state.textfield.applyAutocomplete()
action := state.textfield.action
inputText := state.textfield.InputText()
err := action(state, inputText)
Expand All @@ -69,3 +103,38 @@ func ExecuteTextFieldAction(state *EditorState) {
// The action completed successfully, so hide the text field.
HideTextField(state)
}

// AutocompleteTextField performs autocompletion on the text field input.
// If there are multiple matching suffixes, repeated invocations will cycle
// through the options (including the original input).
func AutocompleteTextField(state *EditorState) {
tf := state.textfield
if tf.autocompleteFunc == nil {
// Autocomplete disabled.
return
}

// If we already have autocomplete suffixes, cycle through them.
if len(tf.autocompleteSuffixes) > 0 {
tf.autocompleteSuffixIdx = (tf.autocompleteSuffixIdx + 1) % len(tf.autocompleteSuffixes)
return
}

// Otherwise, retrieve suffixes for the current prefix.
prefix := tf.inputText.String()
suffixes, err := tf.autocompleteFunc(prefix)
if err != nil {
SetStatusMsg(state, StatusMsg{
Style: StatusMsgStyleError,
Text: fmt.Sprintf("Error occurred during autocomplete: %s", err),
})
return
}

SetStatusMsg(state, StatusMsg{})

if len(suffixes) > 0 {
tf.autocompleteSuffixes = append(suffixes, "") // Last item is always "" to show just the prefix.
tf.autocompleteSuffixIdx = 0
}
}

0 comments on commit d64594c

Please sign in to comment.