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 }