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