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 }