github.com/elves/elvish@v0.15.0/pkg/cli/addons/navigation/navigation.go (about)

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