src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/cli/modes/navigation.go (about)

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