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 }