elvish/pkg/cli/modes/navigation.go
Qi Xiao c46fd60270 Properly clear the filter when ascending or descending in nav mode.
Also add regression tests for this behavior.
2021-12-30 12:58:34 +00:00

379 lines
8.7 KiB
Go

package modes
import (
"os"
"sort"
"strings"
"sync"
"unicode"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
type Navigation interface {
tk.Widget
// SelectedName returns the currently selected name. It returns an empty
// string if there is no selected name, which can happen if the current
// directory is empty.
SelectedName() string
// Select changes the selection.
Select(f func(tk.ListBoxState) int)
// ScrollPreview scrolls the preview.
ScrollPreview(delta int)
// Ascend ascends to the parent directory.
Ascend()
// Descend descends into the currently selected child directory.
Descend()
// MutateFiltering changes the filtering status.
MutateFiltering(f func(bool) bool)
// MutateShowHidden changes whether hidden files - files whose names start
// with ".", should be shown.
MutateShowHidden(f func(bool) bool)
}
// NavigationSpec specifieis the configuration for the navigation mode.
type NavigationSpec struct {
// Key bindings.
Bindings tk.Bindings
// Underlying filesystem.
Cursor NavigationCursor
// A function that returns the relative weights of the widths of the 3
// columns. If unspecified, the ratio is 1:3:4.
WidthRatio func() [3]int
// Configuration for the filter.
Filter FilterSpec
}
type navigationState struct {
Filtering bool
ShowHidden bool
}
type navigation struct {
NavigationSpec
app cli.App
attachedTo tk.CodeArea
codeArea tk.CodeArea
colView tk.ColView
lastFilter string
stateMutex sync.RWMutex
state navigationState
}
func (w *navigation) MutateState(f func(*navigationState)) {
w.stateMutex.Lock()
defer w.stateMutex.Unlock()
f(&w.state)
}
func (w *navigation) CopyState() navigationState {
w.stateMutex.RLock()
defer w.stateMutex.RUnlock()
return w.state
}
func (w *navigation) Handle(event term.Event) bool {
if w.colView.Handle(event) {
return true
}
if w.CopyState().Filtering {
if w.codeArea.Handle(event) {
filter := w.codeArea.CopyState().Buffer.Content
if filter != w.lastFilter {
w.lastFilter = filter
updateState(w, "")
}
return true
}
return false
}
return w.attachedTo.Handle(event)
}
func (w *navigation) Render(width, height int) *term.Buffer {
buf := w.codeArea.Render(width, height)
bufColView := w.colView.Render(width, height-len(buf.Lines))
buf.Extend(bufColView, false)
return buf
}
func (w *navigation) MaxHeight(width, height int) int {
return w.codeArea.MaxHeight(width, height) + w.colView.MaxHeight(width, height)
}
func (w *navigation) Focus() bool {
return w.CopyState().Filtering
}
func (w *navigation) ascend() {
// Remember the name of the current directory before ascending.
currentName := ""
current, err := w.Cursor.Current()
if err == nil {
currentName = current.Name()
}
err = w.Cursor.Ascend()
if err != nil {
w.app.Notify(ErrorText(err))
} else {
w.codeArea.MutateState(func(s *tk.CodeAreaState) {
s.Buffer = tk.CodeBuffer{}
})
w.lastFilter = ""
updateState(w, currentName)
}
}
func (w *navigation) descend() {
currentCol, ok := w.colView.CopyState().Columns[1].(tk.ListBox)
if !ok {
return
}
state := currentCol.CopyState()
if state.Items.Len() == 0 {
return
}
selected := state.Items.(fileItems)[state.Selected]
if !selected.IsDirDeep() {
return
}
err := w.Cursor.Descend(selected.Name())
if err != nil {
w.app.Notify(ErrorText(err))
} else {
w.codeArea.MutateState(func(s *tk.CodeAreaState) {
s.Buffer = tk.CodeBuffer{}
})
w.lastFilter = ""
updateState(w, "")
}
}
// NewNavigation creates a new navigation mode.
func NewNavigation(app cli.App, spec NavigationSpec) (Navigation, error) {
codeArea, err := FocusedCodeArea(app)
if err != nil {
return nil, err
}
if spec.Cursor == nil {
spec.Cursor = NewOSNavigationCursor(os.Chdir)
}
if spec.WidthRatio == nil {
spec.WidthRatio = func() [3]int { return [3]int{1, 3, 4} }
}
var w *navigation
w = &navigation{
NavigationSpec: spec,
app: app,
attachedTo: codeArea,
codeArea: tk.NewCodeArea(tk.CodeAreaSpec{
Prompt: func() ui.Text {
if w.CopyState().ShowHidden {
return modeLine(" NAVIGATING (show hidden) ", true)
}
return modeLine(" NAVIGATING ", true)
},
Highlighter: spec.Filter.Highlighter,
}),
colView: tk.NewColView(tk.ColViewSpec{
Bindings: spec.Bindings,
Weights: func(int) []int {
a := spec.WidthRatio()
return a[:]
},
OnLeft: func(tk.ColView) { w.ascend() },
OnRight: func(tk.ColView) { w.descend() },
}),
}
updateState(w, "")
return w, nil
}
func (w *navigation) SelectedName() string {
col, ok := w.colView.CopyState().Columns[1].(tk.ListBox)
if !ok {
return ""
}
state := col.CopyState()
if 0 <= state.Selected && state.Selected < state.Items.Len() {
return state.Items.(fileItems)[state.Selected].Name()
}
return ""
}
func updateState(w *navigation, selectName string) {
colView := w.colView
cursor := w.Cursor
filter := w.lastFilter
showHidden := w.CopyState().ShowHidden
var parentCol, currentCol tk.Widget
colView.MutateState(func(s *tk.ColViewState) {
*s = tk.ColViewState{
Columns: []tk.Widget{
tk.Empty{}, tk.Empty{}, tk.Empty{}},
FocusColumn: 1,
}
})
parent, err := cursor.Parent()
if err == nil {
parentCol = makeCol(parent, showHidden)
} else {
parentCol = makeErrCol(err)
}
current, err := cursor.Current()
if err == nil {
currentCol = makeColInner(
current,
w.Filter.makePredicate(filter),
showHidden,
func(it tk.Items, i int) {
previewCol := makeCol(it.(fileItems)[i], showHidden)
colView.MutateState(func(s *tk.ColViewState) {
s.Columns[2] = previewCol
})
})
tryToSelectName(parentCol, current.Name())
if selectName != "" {
tryToSelectName(currentCol, selectName)
}
} else {
currentCol = makeErrCol(err)
tryToSelectNothing(parentCol)
}
colView.MutateState(func(s *tk.ColViewState) {
s.Columns[0] = parentCol
s.Columns[1] = currentCol
})
}
// Selects nothing if the widget is a listbox.
func tryToSelectNothing(w tk.Widget) {
list, ok := w.(tk.ListBox)
if !ok {
return
}
list.Select(func(tk.ListBoxState) int { return -1 })
}
// Selects the item with the given name, if the widget is a listbox with
// fileItems and has such an item.
func tryToSelectName(w tk.Widget, name string) {
list, ok := w.(tk.ListBox)
if !ok {
// Do nothing
return
}
list.Select(func(state tk.ListBoxState) int {
items, ok := state.Items.(fileItems)
if !ok {
return 0
}
for i, file := range items {
if file.Name() == name {
return i
}
}
return 0
})
}
func makeCol(f NavigationFile, showHidden bool) tk.Widget {
return makeColInner(f, func(string) bool { return true }, showHidden, nil)
}
func makeColInner(f NavigationFile, filter func(string) bool, showHidden bool, onSelect func(tk.Items, int)) tk.Widget {
files, content, err := f.Read()
if err != nil {
return makeErrCol(err)
}
if files != nil {
var filtered []NavigationFile
for _, file := range files {
name := file.Name()
hidden := len(name) > 0 && name[0] == '.'
if filter(name) && (showHidden || !hidden) {
filtered = append(filtered, file)
}
}
files = filtered
sort.Slice(files, func(i, j int) bool {
return files[i].Name() < files[j].Name()
})
return tk.NewListBox(tk.ListBoxSpec{
Padding: 1, ExtendStyle: true, OnSelect: onSelect,
State: tk.ListBoxState{Items: fileItems(files)},
})
}
lines := strings.Split(sanitize(string(content)), "\n")
return tk.NewTextView(tk.TextViewSpec{
State: tk.TextViewState{Lines: lines},
Scrollable: true,
})
}
func makeErrCol(err error) tk.Widget {
return tk.Label{Content: ui.T(err.Error(), ui.FgRed)}
}
type fileItems []NavigationFile
func (it fileItems) Show(i int) ui.Text {
return it[i].ShowName()
}
func (it fileItems) Len() int { return len(it) }
func sanitize(content string) string {
// Remove unprintable characters, and replace tabs with 4 spaces.
var sb strings.Builder
for _, r := range content {
if r == '\t' {
sb.WriteString(" ")
} else if r == '\n' || unicode.IsGraphic(r) {
sb.WriteRune(r)
}
}
return sb.String()
}
func (w *navigation) Select(f func(tk.ListBoxState) int) {
if listBox, ok := w.colView.CopyState().Columns[1].(tk.ListBox); ok {
listBox.Select(f)
}
}
func (w *navigation) ScrollPreview(delta int) {
if textView, ok := w.colView.CopyState().Columns[2].(tk.TextView); ok {
textView.ScrollBy(delta)
}
}
func (w *navigation) Ascend() {
w.colView.Left()
}
func (w *navigation) Descend() {
w.colView.Right()
}
func (w *navigation) MutateFiltering(f func(bool) bool) {
w.MutateState(func(s *navigationState) { s.Filtering = f(s.Filtering) })
}
func (w *navigation) MutateShowHidden(f func(bool) bool) {
w.MutateState(func(s *navigationState) { s.ShowHidden = f(s.ShowHidden) })
updateState(w, w.SelectedName())
}