github.com/kolbycrouch/elvish@v0.14.1-0.20210614162631-215b9ac1c423/pkg/cli/mode/navigation.go (about)

     1  package mode
     2  
     3  import (
     4  	"sort"
     5  	"strings"
     6  	"sync"
     7  	"unicode"
     8  
     9  	"src.elv.sh/pkg/cli"
    10  	"src.elv.sh/pkg/cli/term"
    11  	"src.elv.sh/pkg/cli/tk"
    12  	"src.elv.sh/pkg/ui"
    13  )
    14  
    15  type Navigation interface {
    16  	tk.Widget
    17  	// SelectedName returns the currently selected name. It returns an empty
    18  	// string if there is no selected name, which can happen if the current
    19  	// directory is empty.
    20  	SelectedName() string
    21  	// Select changes the selection.
    22  	Select(f func(tk.ListBoxState) int)
    23  	// ScrollPreview scrolls the preview.
    24  	ScrollPreview(delta int)
    25  	// Ascend ascends to the parent directory.
    26  	Ascend()
    27  	// Descend descends into the currently selected child directory.
    28  	Descend()
    29  	// MutateFiltering changes the filtering status.
    30  	MutateFiltering(f func(bool) bool)
    31  	// MutateShowHidden changes whether hidden files - files whose names start
    32  	// with ".", should be shown.
    33  	MutateShowHidden(f func(bool) bool)
    34  }
    35  
    36  // NavigationSpec specifieis the configuration for the navigation mode.
    37  type NavigationSpec struct {
    38  	// Key bindings.
    39  	Bindings tk.Bindings
    40  	// Underlying filesystem.
    41  	Cursor NavigationCursor
    42  	// A function that returns the relative weights of the widths of the 3
    43  	// columns. If unspecified, the ratio is 1:3:4.
    44  	WidthRatio func() [3]int
    45  	// Configuration for the filter.
    46  	Filter FilterSpec
    47  }
    48  
    49  type navigationState struct {
    50  	Filtering  bool
    51  	ShowHidden bool
    52  }
    53  
    54  type navigation struct {
    55  	NavigationSpec
    56  	app        cli.App
    57  	codeArea   tk.CodeArea
    58  	colView    tk.ColView
    59  	lastFilter string
    60  	stateMutex sync.RWMutex
    61  	state      navigationState
    62  }
    63  
    64  func (w *navigation) MutateState(f func(*navigationState)) {
    65  	w.stateMutex.Lock()
    66  	defer w.stateMutex.Unlock()
    67  	f(&w.state)
    68  }
    69  
    70  func (w *navigation) CopyState() navigationState {
    71  	w.stateMutex.RLock()
    72  	defer w.stateMutex.RUnlock()
    73  	return w.state
    74  }
    75  
    76  func (w *navigation) Handle(event term.Event) bool {
    77  	if w.colView.Handle(event) {
    78  		return true
    79  	}
    80  	if w.CopyState().Filtering {
    81  		if w.codeArea.Handle(event) {
    82  			filter := w.codeArea.CopyState().Buffer.Content
    83  			if filter != w.lastFilter {
    84  				w.lastFilter = filter
    85  				updateState(w, "")
    86  			}
    87  			return true
    88  		}
    89  		return false
    90  	}
    91  	return w.app.CodeArea().Handle(event)
    92  }
    93  
    94  func (w *navigation) Render(width, height int) *term.Buffer {
    95  	buf := w.codeArea.Render(width, height)
    96  	bufColView := w.colView.Render(width, height-len(buf.Lines))
    97  	buf.Extend(bufColView, false)
    98  	return buf
    99  }
   100  
   101  func (w *navigation) Focus() bool {
   102  	return w.CopyState().Filtering
   103  }
   104  
   105  func (w *navigation) ascend() {
   106  	// Remember the name of the current directory before ascending.
   107  	currentName := ""
   108  	current, err := w.Cursor.Current()
   109  	if err == nil {
   110  		currentName = current.Name()
   111  	}
   112  
   113  	err = w.Cursor.Ascend()
   114  	if err != nil {
   115  		w.app.Notify(err.Error())
   116  	} else {
   117  		w.codeArea.MutateState(func(s *tk.CodeAreaState) {
   118  			s.Buffer = tk.CodeBuffer{}
   119  		})
   120  		updateState(w, currentName)
   121  	}
   122  }
   123  
   124  func (w *navigation) descend() {
   125  	currentCol, ok := w.colView.CopyState().Columns[1].(tk.ListBox)
   126  	if !ok {
   127  		return
   128  	}
   129  	state := currentCol.CopyState()
   130  	if state.Items.Len() == 0 {
   131  		return
   132  	}
   133  	selected := state.Items.(fileItems)[state.Selected]
   134  	if !selected.IsDirDeep() {
   135  		return
   136  	}
   137  	err := w.Cursor.Descend(selected.Name())
   138  	if err != nil {
   139  		w.app.Notify(err.Error())
   140  	} else {
   141  		w.codeArea.MutateState(func(s *tk.CodeAreaState) {
   142  			s.Buffer = tk.CodeBuffer{}
   143  		})
   144  		updateState(w, "")
   145  	}
   146  }
   147  
   148  // NewNavigation creates a new navigation mode.
   149  func NewNavigation(app cli.App, spec NavigationSpec) Navigation {
   150  	if spec.Cursor == nil {
   151  		spec.Cursor = NewOSNavigationCursor()
   152  	}
   153  	if spec.WidthRatio == nil {
   154  		spec.WidthRatio = func() [3]int { return [3]int{1, 3, 4} }
   155  	}
   156  
   157  	var w *navigation
   158  	w = &navigation{
   159  		NavigationSpec: spec,
   160  		app:            app,
   161  		codeArea: tk.NewCodeArea(tk.CodeAreaSpec{
   162  			Prompt: func() ui.Text {
   163  				if w.CopyState().ShowHidden {
   164  					return modeLine(" NAVIGATING (show hidden) ", true)
   165  				}
   166  				return modeLine(" NAVIGATING ", true)
   167  			},
   168  			Highlighter: spec.Filter.Highlighter,
   169  		}),
   170  		colView: tk.NewColView(tk.ColViewSpec{
   171  			Bindings: spec.Bindings,
   172  			Weights: func(int) []int {
   173  				a := spec.WidthRatio()
   174  				return a[:]
   175  			},
   176  			OnLeft:  func(tk.ColView) { w.ascend() },
   177  			OnRight: func(tk.ColView) { w.descend() },
   178  		}),
   179  	}
   180  	updateState(w, "")
   181  	return w
   182  }
   183  
   184  func (w *navigation) SelectedName() string {
   185  	col, ok := w.colView.CopyState().Columns[1].(tk.ListBox)
   186  	if !ok {
   187  		return ""
   188  	}
   189  	state := col.CopyState()
   190  	if 0 <= state.Selected && state.Selected < state.Items.Len() {
   191  		return state.Items.(fileItems)[state.Selected].Name()
   192  	}
   193  	return ""
   194  }
   195  
   196  func updateState(w *navigation, selectName string) {
   197  	colView := w.colView
   198  	cursor := w.Cursor
   199  	filter := w.lastFilter
   200  	showHidden := w.CopyState().ShowHidden
   201  
   202  	var parentCol, currentCol tk.Widget
   203  
   204  	colView.MutateState(func(s *tk.ColViewState) {
   205  		*s = tk.ColViewState{
   206  			Columns: []tk.Widget{
   207  				tk.Empty{}, tk.Empty{}, tk.Empty{}},
   208  			FocusColumn: 1,
   209  		}
   210  	})
   211  
   212  	parent, err := cursor.Parent()
   213  	if err == nil {
   214  		parentCol = makeCol(parent, showHidden)
   215  	} else {
   216  		parentCol = makeErrCol(err)
   217  	}
   218  
   219  	current, err := cursor.Current()
   220  	if err == nil {
   221  		currentCol = makeColInner(
   222  			current,
   223  			w.Filter.makePredicate(filter),
   224  			showHidden,
   225  			func(it tk.Items, i int) {
   226  				previewCol := makeCol(it.(fileItems)[i], showHidden)
   227  				colView.MutateState(func(s *tk.ColViewState) {
   228  					s.Columns[2] = previewCol
   229  				})
   230  			})
   231  		tryToSelectName(parentCol, current.Name())
   232  		if selectName != "" {
   233  			tryToSelectName(currentCol, selectName)
   234  		}
   235  	} else {
   236  		currentCol = makeErrCol(err)
   237  		tryToSelectNothing(parentCol)
   238  	}
   239  
   240  	colView.MutateState(func(s *tk.ColViewState) {
   241  		s.Columns[0] = parentCol
   242  		s.Columns[1] = currentCol
   243  	})
   244  }
   245  
   246  // Selects nothing if the widget is a listbox.
   247  func tryToSelectNothing(w tk.Widget) {
   248  	list, ok := w.(tk.ListBox)
   249  	if !ok {
   250  		return
   251  	}
   252  	list.Select(func(tk.ListBoxState) int { return -1 })
   253  }
   254  
   255  // Selects the item with the given name, if the widget is a listbox with
   256  // fileItems and has such an item.
   257  func tryToSelectName(w tk.Widget, name string) {
   258  	list, ok := w.(tk.ListBox)
   259  	if !ok {
   260  		// Do nothing
   261  		return
   262  	}
   263  	list.Select(func(state tk.ListBoxState) int {
   264  		items, ok := state.Items.(fileItems)
   265  		if !ok {
   266  			return 0
   267  		}
   268  		for i, file := range items {
   269  			if file.Name() == name {
   270  				return i
   271  			}
   272  		}
   273  		return 0
   274  	})
   275  }
   276  
   277  func makeCol(f NavigationFile, showHidden bool) tk.Widget {
   278  	return makeColInner(f, func(string) bool { return true }, showHidden, nil)
   279  }
   280  
   281  func makeColInner(f NavigationFile, filter func(string) bool, showHidden bool, onSelect func(tk.Items, int)) tk.Widget {
   282  	files, content, err := f.Read()
   283  	if err != nil {
   284  		return makeErrCol(err)
   285  	}
   286  
   287  	if files != nil {
   288  		var filtered []NavigationFile
   289  		for _, file := range files {
   290  			name := file.Name()
   291  			hidden := len(name) > 0 && name[0] == '.'
   292  			if filter(name) && (showHidden || !hidden) {
   293  				filtered = append(filtered, file)
   294  			}
   295  		}
   296  		files = filtered
   297  		sort.Slice(files, func(i, j int) bool {
   298  			return files[i].Name() < files[j].Name()
   299  		})
   300  		return tk.NewListBox(tk.ListBoxSpec{
   301  			Padding: 1, ExtendStyle: true, OnSelect: onSelect,
   302  			State: tk.ListBoxState{Items: fileItems(files)},
   303  		})
   304  	}
   305  
   306  	lines := strings.Split(sanitize(string(content)), "\n")
   307  	return tk.NewTextView(tk.TextViewSpec{
   308  		State:      tk.TextViewState{Lines: lines},
   309  		Scrollable: true,
   310  	})
   311  }
   312  
   313  func makeErrCol(err error) tk.Widget {
   314  	return tk.Label{Content: ui.T(err.Error(), ui.FgRed)}
   315  }
   316  
   317  type fileItems []NavigationFile
   318  
   319  func (it fileItems) Show(i int) ui.Text {
   320  	return it[i].ShowName()
   321  }
   322  
   323  func (it fileItems) Len() int { return len(it) }
   324  
   325  func sanitize(content string) string {
   326  	// Remove unprintable characters, and replace tabs with 4 spaces.
   327  	var sb strings.Builder
   328  	for _, r := range content {
   329  		if r == '\t' {
   330  			sb.WriteString("    ")
   331  		} else if r == '\n' || unicode.IsGraphic(r) {
   332  			sb.WriteRune(r)
   333  		}
   334  	}
   335  	return sb.String()
   336  }
   337  
   338  func (w *navigation) Select(f func(tk.ListBoxState) int) {
   339  	if listBox, ok := w.colView.CopyState().Columns[1].(tk.ListBox); ok {
   340  		listBox.Select(f)
   341  	}
   342  }
   343  
   344  func (w *navigation) ScrollPreview(delta int) {
   345  	if textView, ok := w.colView.CopyState().Columns[2].(tk.TextView); ok {
   346  		textView.ScrollBy(delta)
   347  	}
   348  }
   349  
   350  func (w *navigation) Ascend() {
   351  	w.colView.Left()
   352  }
   353  
   354  func (w *navigation) Descend() {
   355  	w.colView.Right()
   356  }
   357  
   358  func (w *navigation) MutateFiltering(f func(bool) bool) {
   359  	w.MutateState(func(s *navigationState) { s.Filtering = f(s.Filtering) })
   360  }
   361  
   362  func (w *navigation) MutateShowHidden(f func(bool) bool) {
   363  	w.MutateState(func(s *navigationState) { s.ShowHidden = f(s.ShowHidden) })
   364  	updateState(w, w.SelectedName())
   365  }