github.com/markusbkk/elvish@v0.0.0-20231204143114-91dc52438621/pkg/cli/modes/navigation.go (about)

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