github.com/oweisse/u-root@v0.0.0-20181109060735-d005ad25fef1/cmds/elvish/edit/navigation.go (about)

     1  package edit
     2  
     3  import (
     4  	"errors"
     5  	"os"
     6  	"path"
     7  	"sort"
     8  	"strings"
     9  	"unicode/utf8"
    10  
    11  	"github.com/u-root/u-root/cmds/elvish/edit/eddefs"
    12  	"github.com/u-root/u-root/cmds/elvish/edit/ui"
    13  	"github.com/u-root/u-root/cmds/elvish/eval"
    14  	"github.com/u-root/u-root/cmds/elvish/eval/vars"
    15  	"github.com/u-root/u-root/cmds/elvish/parse"
    16  	"github.com/u-root/u-root/cmds/elvish/util"
    17  )
    18  
    19  // Navigation subsystem.
    20  
    21  // Interface.
    22  
    23  type navigation struct {
    24  	binding eddefs.BindingMap
    25  	chdir   func(string) error
    26  	navigationState
    27  }
    28  
    29  type navigationState struct {
    30  	current    *navColumn
    31  	parent     *navColumn
    32  	preview    navPreview
    33  	showHidden bool
    34  	filtering  bool
    35  	filter     string
    36  }
    37  
    38  func init() { atEditorInit(initNavigation) }
    39  
    40  func initNavigation(ed *editor, ns eval.Ns) {
    41  	n := &navigation{
    42  		binding: emptyBindingMap,
    43  		chdir:   ed.Evaler().Chdir,
    44  	}
    45  	ed.navigation = n
    46  
    47  	subns := eval.Ns{
    48  		"binding": vars.FromPtr(&n.binding),
    49  	}
    50  	subns.AddBuiltinFns("edit:navigation:", map[string]interface{}{
    51  		"start":                    func() { n.start(ed) },
    52  		"up":                       n.prev,
    53  		"down":                     n.next,
    54  		"page-up":                  n.pageUp,
    55  		"page-down":                n.pageDown,
    56  		"left":                     n.ascend,
    57  		"right":                    n.descend,
    58  		"file-preview-up":          n.filePreviewUp,
    59  		"file-preview-down":        n.filePreviewDown,
    60  		"trigger-shown-hidden":     n.triggerShowHidden,
    61  		"trigger-filter":           n.triggerFilter,
    62  		"insert-selected":          func() { n.insertSelected(ed) },
    63  		"insert-selected-and-quit": func() { n.insertSelectedAndQuit(ed) },
    64  		"default":                  func() { n.defaultFn(ed) },
    65  	})
    66  	ns.AddNs("navigation", subns)
    67  }
    68  
    69  type navPreview interface {
    70  	FullWidth(int) int
    71  	List(int) ui.Renderer
    72  }
    73  
    74  func (n *navigation) Teardown() {
    75  	n.navigationState = navigationState{}
    76  }
    77  
    78  func (n *navigation) Binding(k ui.Key) eval.Callable {
    79  	return n.binding.GetOrDefault(k)
    80  }
    81  
    82  func (n *navigation) ModeLine() ui.Renderer {
    83  	title := " NAVIGATING "
    84  	if n.showHidden {
    85  		title += "(show hidden) "
    86  	}
    87  	return ui.NewModeLineRenderer(title, n.filter)
    88  }
    89  
    90  func (n *navigation) CursorOnModeLine() bool {
    91  	return n.filtering
    92  }
    93  
    94  func (n *navigation) start(ed *editor) {
    95  	n.refresh()
    96  	ed.SetMode(n)
    97  }
    98  
    99  func (n *navigation) pageUp() {
   100  	n.current.pageUp()
   101  	n.refresh()
   102  }
   103  
   104  func (n *navigation) pageDown() {
   105  	n.current.pageDown()
   106  	n.refresh()
   107  }
   108  
   109  func (n *navigation) filePreviewUp() {
   110  	fp, ok := n.preview.(*navFilePreview)
   111  	if ok {
   112  		if fp.beginLine > 0 {
   113  			fp.beginLine--
   114  		}
   115  	}
   116  }
   117  
   118  func (n *navigation) filePreviewDown() {
   119  	fp, ok := n.preview.(*navFilePreview)
   120  	if ok {
   121  		if fp.beginLine < len(fp.lines)-1 {
   122  			fp.beginLine++
   123  		}
   124  	}
   125  }
   126  
   127  func (n *navigation) triggerShowHidden() {
   128  	n.showHidden = !n.showHidden
   129  	n.refresh()
   130  }
   131  
   132  func (n *navigation) triggerFilter() {
   133  	n.filtering = !n.filtering
   134  }
   135  
   136  func (n *navigation) insertSelected(ed *editor) {
   137  	ed.InsertAtDot(parse.Quote(n.current.selectedName()) + " ")
   138  }
   139  
   140  func (n *navigation) insertSelectedAndQuit(ed *editor) {
   141  	ed.InsertAtDot(parse.Quote(n.current.selectedName()) + " ")
   142  	ed.SetModeInsert()
   143  }
   144  
   145  func (n *navigation) defaultFn(ed *editor) {
   146  	// Use key binding for insert mode without exiting nigation mode.
   147  	k := ed.lastKey
   148  	if n.filtering && likeChar(k) {
   149  		n.filter += k.String()
   150  		n.refreshCurrent()
   151  		n.refreshDirPreview()
   152  	} else if n.filtering && k == (ui.Key{ui.Backspace, 0}) {
   153  		_, size := utf8.DecodeLastRuneInString(n.filter)
   154  		if size > 0 {
   155  			n.filter = n.filter[:len(n.filter)-size]
   156  			n.refreshCurrent()
   157  			n.refreshDirPreview()
   158  		}
   159  	} else {
   160  		fn := ed.insert.binding.GetOrDefault(k)
   161  		if fn == nil {
   162  			ed.Notify("key %s unbound and no default binding", k)
   163  		} else {
   164  			ed.CallFn(fn)
   165  		}
   166  	}
   167  }
   168  
   169  // Implementation.
   170  // TODO(xiaq): Remember which file was selected in each directory.
   171  
   172  var errorEmptyCwd = errors.New("current directory is empty")
   173  
   174  func (n *navigation) maintainSelected(name string) {
   175  	n.current.selected = 0
   176  	for i, s := range n.current.candidates {
   177  		if s.Text > name {
   178  			break
   179  		}
   180  		n.current.selected = i
   181  	}
   182  }
   183  
   184  func (n *navigation) refreshCurrent() {
   185  	selectedName := n.current.selectedName()
   186  	all, err := n.loaddir(".")
   187  	if err != nil {
   188  		n.current = newErrNavColumn(err)
   189  		return
   190  	}
   191  	// Try to select the old selected file.
   192  	// XXX(xiaq): This would break when we support alternative ordering.
   193  	n.current = newNavColumn(all, func(i int) bool {
   194  		return i == 0 || all[i].Text <= selectedName
   195  	})
   196  	n.current.changeFilter(n.filter)
   197  	n.maintainSelected(selectedName)
   198  }
   199  
   200  func (n *navigation) refreshParent() {
   201  	wd, err := os.Getwd()
   202  	if err != nil {
   203  		n.parent = newErrNavColumn(err)
   204  		return
   205  	}
   206  	if wd == "/" {
   207  		n.parent = newNavColumn(nil, nil)
   208  	} else {
   209  		all, err := n.loaddir("..")
   210  		if err != nil {
   211  			n.parent = newErrNavColumn(err)
   212  			return
   213  		}
   214  		cwd, err := os.Stat(".")
   215  		if err != nil {
   216  			n.parent = newErrNavColumn(err)
   217  			return
   218  		}
   219  		n.parent = newNavColumn(all, func(i int) bool {
   220  			d, _ := os.Lstat("../" + all[i].Text)
   221  			return os.SameFile(d, cwd)
   222  		})
   223  	}
   224  }
   225  
   226  func (n *navigation) refreshDirPreview() {
   227  	if n.current.selected != -1 {
   228  		name := n.current.selectedName()
   229  		fi, err := os.Stat(name)
   230  		if err != nil {
   231  			n.preview = newErrNavColumn(err)
   232  			return
   233  		}
   234  		if fi.Mode().IsDir() {
   235  			all, err := n.loaddir(name)
   236  			if err != nil {
   237  				n.preview = newErrNavColumn(err)
   238  				return
   239  			}
   240  			n.preview = newNavColumn(all, func(int) bool { return false })
   241  		} else {
   242  			n.preview = makeNavFilePreview(name)
   243  		}
   244  	} else {
   245  		n.preview = nil
   246  	}
   247  }
   248  
   249  // refresh rereads files in current and parent directories and maintains the
   250  // selected file if possible.
   251  func (n *navigation) refresh() {
   252  	n.refreshCurrent()
   253  	n.refreshParent()
   254  	n.refreshDirPreview()
   255  }
   256  
   257  // ascend changes current directory to the parent.
   258  // TODO(xiaq): navigation.{ascend descend} bypasses the cd builtin. This can be
   259  // problematic if cd acquires more functionality (e.g. trigger a hook).
   260  func (n *navigation) ascend() error {
   261  	wd, err := os.Getwd()
   262  	if err != nil {
   263  		return err
   264  	}
   265  	if wd == "/" {
   266  		return nil
   267  	}
   268  
   269  	name := n.parent.selectedName()
   270  	err = os.Chdir("..")
   271  	if err != nil {
   272  		return err
   273  	}
   274  	n.filter = ""
   275  	n.refresh()
   276  	n.maintainSelected(name)
   277  	// XXX Refresh dir preview again. We should perhaps not have used refresh
   278  	// above.
   279  	n.refreshDirPreview()
   280  	return nil
   281  }
   282  
   283  // descend changes current directory to the selected file, if it is a
   284  // directory.
   285  func (n *navigation) descend() error {
   286  	if n.current.selected == -1 {
   287  		return errorEmptyCwd
   288  	}
   289  	name := n.current.selectedName()
   290  	err := n.chdir(name)
   291  	if err != nil {
   292  		return err
   293  	}
   294  	n.filter = ""
   295  	n.current.selected = -1
   296  	n.refresh()
   297  	n.refreshDirPreview()
   298  	return nil
   299  }
   300  
   301  // prev selects the previous file.
   302  func (n *navigation) prev() {
   303  	if n.current.selected > 0 {
   304  		n.current.selected--
   305  	}
   306  	n.refresh()
   307  }
   308  
   309  // next selects the next file.
   310  func (n *navigation) next() {
   311  	if n.current.selected != -1 && n.current.selected < len(n.current.candidates)-1 {
   312  		n.current.selected++
   313  	}
   314  	n.refresh()
   315  }
   316  
   317  func (n *navigation) loaddir(dir string) ([]ui.Styled, error) {
   318  	f, err := os.Open(dir)
   319  	if err != nil {
   320  		return nil, err
   321  	}
   322  	names, err := f.Readdirnames(-1)
   323  	if err != nil {
   324  		return nil, err
   325  	}
   326  	sort.Strings(names)
   327  
   328  	var all []ui.Styled
   329  	for _, name := range names {
   330  		if n.showHidden || name[0] != '.' {
   331  			all = append(all, ui.Styled{name,
   332  				ui.StylesFromString(path.Join(dir, name))})
   333  		}
   334  	}
   335  
   336  	return all, nil
   337  }
   338  
   339  func (n *navigation) List(maxHeight int) ui.Renderer {
   340  	return makeNavRenderer(
   341  		maxHeight,
   342  		n.parent.FullWidth(maxHeight),
   343  		n.current.FullWidth(maxHeight),
   344  		n.preview.FullWidth(maxHeight),
   345  		n.parent.List(maxHeight),
   346  		n.current.List(maxHeight),
   347  		n.preview.List(maxHeight),
   348  	)
   349  }
   350  
   351  // navColumn is a column in the navigation layout.
   352  type navColumn struct {
   353  	listingMode
   354  	all        []ui.Styled
   355  	candidates []ui.Styled
   356  	// selected int
   357  	err error
   358  }
   359  
   360  func newNavColumn(all []ui.Styled, sel func(int) bool) *navColumn {
   361  	nc := &navColumn{all: all, candidates: all}
   362  	nc.provider = nc
   363  	nc.selected = -1
   364  	for i := range all {
   365  		if sel(i) {
   366  			nc.selected = i
   367  		}
   368  	}
   369  	return nc
   370  }
   371  
   372  func newErrNavColumn(err error) *navColumn {
   373  	nc := &navColumn{err: err}
   374  	nc.provider = nc
   375  	return nc
   376  }
   377  
   378  func (nc *navColumn) Placeholder() string {
   379  	if nc.err != nil {
   380  		return nc.err.Error()
   381  	}
   382  	return ""
   383  }
   384  
   385  func (nc *navColumn) Len() int {
   386  	return len(nc.candidates)
   387  }
   388  
   389  func (nc *navColumn) Show(i int) (string, ui.Styled) {
   390  	cand := nc.candidates[i]
   391  	return "", ui.Styled{" " + cand.Text + " ", cand.Styles}
   392  }
   393  
   394  func (nc *navColumn) Filter(filter string) int {
   395  	nc.candidates = nc.candidates[:0]
   396  	for _, s := range nc.all {
   397  		if strings.Contains(s.Text, filter) {
   398  			nc.candidates = append(nc.candidates, s)
   399  		}
   400  	}
   401  	return 0
   402  }
   403  
   404  func (nc *navColumn) FullWidth(h int) int {
   405  	if nc == nil {
   406  		return 0
   407  	}
   408  	if nc.err != nil {
   409  		return util.Wcswidth(nc.err.Error())
   410  	}
   411  	maxw := 0
   412  	for _, s := range nc.candidates {
   413  		maxw = max(maxw, util.Wcswidth(s.Text)+2)
   414  	}
   415  	if len(nc.candidates) > h {
   416  		maxw++
   417  	}
   418  	return maxw
   419  }
   420  
   421  func (nc *navColumn) Accept(i int, ed eddefs.Editor) {
   422  	// TODO
   423  }
   424  
   425  func (nc *navColumn) ModeTitle(i int) string {
   426  	// Not used
   427  	return ""
   428  }
   429  
   430  func (nc *navColumn) selectedName() string {
   431  	if nc == nil || nc.selected == -1 || nc.selected >= len(nc.candidates) {
   432  		return ""
   433  	}
   434  	return nc.candidates[nc.selected].Text
   435  }